feat(charts): zero-dep chart family — $libs/plots engine + eidos components + demos

Net-new data-visualisation family on the in-house, zero-dependency $libs/plots
engine (scales · ticks · curves · shapes, all original). Compositional <Chart>
frame (context-shared scales, responsive, cursor-following tooltip) + primitives
Grid/XAxis/YAxis/Line/Area/Bar/Scatter/Tooltip/Legend + presets Line/Area/Bar/
Scatter/Sparkline (line + bars) + standalone Pie/Radar/BarList/BarSegment, each
with a themed hover tooltip. Themed via --chart-* recipe tokens + global colour
tokens; locale via eidos.format/langs; morfo `chart` (scope: eidos, role=img).
Point scales span edge-to-edge. Per-chart demos under web/routes/uix/components.

Roadmap + MUI X Charts comparison in components/chart/IMPROVEMENTS.md.

Note: recipes/base.ts + generated/base.css are shared files; this commit
unavoidably carries the (inert) metrics recipe block that co-lives there.

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

@ -0,0 +1,62 @@
# plots — charting math engine
Pure, **dependency-free** primitives for building charts: scales (data →
pixels), shape generators (points → SVG paths), tick generation and small
statistics helpers. No DOM, no framework, no state — just functions. The UIX
chart components consume this; the engine knows nothing about them.
Like the other `libs/` modules it depends on **nobody**: every algorithm here
(linear/band/time scales, 1-2-5 "nice" ticks, monotone cubic interpolation,
arc geometry) is implemented from the underlying mathematics, not ported from
any external library.
## Why a lib (and not an art)
The math is pure functions with no runtime state and no CSS emission — the exact
contract of `libs/`. Reactivity belongs in the consuming soma layer (a provider
recomputes a fresh scale when its inputs change), mirroring how `chronos/engine`
keeps pure layout modules separate from its reactive `state.svelte.ts`.
## Surface
| Module | Exports | Purpose |
| --------------- | ------------------------------------------------------------- | -------------------------------------------------- |
| `scale-linear` | `scaleLinear` | Continuous data → pixels (value axes) |
| `scale-band` | `scaleBand`, `scalePoint` | Categories → bands (bars) / points (line·scatter) |
| `scale-time` | `scaleTime` | Epoch-ms → pixels with calendar-aware ticks |
| `ticks` | `ticks`, `tickStep`, `nice` | "Nice" round tick values for an axis |
| `array` | `extent`, `min`, `max`, `sum`, `range`, `bisect*`, `bisector` | Stats + nearest-datum lookup (hover) |
| `shape-line` | `line`, `area` | Polyline / filled-band SVG paths |
| `shape-arc` | `arc`, `pie` | Pie / donut / gauge geometry + slice layout |
| `shape-stack` | `stack` | Stacked baselines (stacked bar / area) |
| `curve` | `curveLinear`, `curveMonotoneX`, `curveStep`, `curve(name)` | Point-connection strategies |
| `path` | `fmt`, `coord` | Coordinate formatting for custom shapes |
## Conventions
- **Pixel space.** Scales map data → pixels; shape generators take points that
are *already* in pixels. This decouples shapes from scales (compositional).
- **Immutable scales.** `scaleLinear(config)` returns a callable whose
`domain`/`range` are frozen and whose `.nice()` returns a *new* scale — derive
a fresh scale on input change, never mutate in place (suits Svelte runes).
- **Angles** (arc/pie): `0` points up (12 o'clock), increasing **clockwise**.
- **Time** works on epoch milliseconds; a `DateValue` consumer converts at its
edge so this module carries no date dependency.
## Example
```ts
import { line, scaleLinear } from '$libs/plots'
const x = scaleLinear({ domain: [0, 5], range: [0, 300] })
const y = scaleLinear({ domain: [0, 100], range: [150, 0] }) // y axis is inverted
const data = [10, 40, 30, 80, 60, 90]
const d = line(data.map((value, i) => ({ x: x(i), y: y(value) })))
// → "M0,135L60,90L120,105L180,30L240,60L300,15"
```
## Tests
`npx vitest run src/libs/plots` — 36 cases across `array` / `ticks` / `scale` /
`shape`, asserting the math against known values.

@ -0,0 +1,134 @@
/**
* Array and statistics helpers for the plots engine. Values in, scalars or
* small arrays out. NaN and nullish entries are skipped so a gap in the data
* never poisons an extent or a sum.
*/
/** Smallest finite value, or `undefined` when there are none. */
export function min(values: Iterable<number>): number | undefined {
let result: number | undefined
for (const value of values) {
if (value == null || Number.isNaN(value)) continue
if (result === undefined || value < result) result = value
}
return result
}
/** Largest finite value, or `undefined` when there are none. */
export function max(values: Iterable<number>): number | undefined {
let result: number | undefined
for (const value of values) {
if (value == null || Number.isNaN(value)) continue
if (result === undefined || value > result) result = value
}
return result
}
/** Minimum and maximum in a single pass; `undefined` when no finite values. */
export function extent(values: Iterable<number>): [number, number] | undefined {
let lo: number | undefined
let hi: number | undefined
for (const value of values) {
if (value == null || Number.isNaN(value)) continue
if (lo === undefined) {
lo = value
hi = value
} else {
if (value < lo) lo = value
if (value > (hi as number)) hi = value
}
}
return lo === undefined ? undefined : [lo, hi as number]
}
/** Sum of the finite values (empty / all-NaN sums to 0). */
export function sum(values: Iterable<number>): number {
let total = 0
for (const value of values) {
if (value == null || Number.isNaN(value)) continue
total += value
}
return total
}
/**
* Numeric sequence `[start, start + step, …)` stopping before `stop`. Called
* with one argument it counts `[0, n)`, matching the half-open convention of
* array indices.
*/
export function range(start: number, stop?: number, step = 1): number[] {
let from = start
let to = stop
if (to === undefined) {
to = start
from = 0
}
const count = step === 0 ? 0 : Math.max(0, Math.ceil((to - from) / step))
const result = new Array<number>(count)
for (let i = 0; i < count; i++) result[i] = from + i * step
return result
}
/**
* Index where `x` would insert into the ascending `array` to keep it sorted,
* placing `x` AFTER equal entries (the upper bound).
*/
export function bisectRight(array: ArrayLike<number>, x: number, lo = 0, hi = array.length): number {
let low = lo
let high = hi
while (low < high) {
const mid = (low + high) >>> 1
if (x < array[mid]) high = mid
else low = mid + 1
}
return low
}
/** As {@link bisectRight}, but placing `x` BEFORE equal entries (lower bound). */
export function bisectLeft(array: ArrayLike<number>, x: number, lo = 0, hi = array.length): number {
let low = lo
let high = hi
while (low < high) {
const mid = (low + high) >>> 1
if (array[mid] < x) low = mid + 1
else high = mid
}
return low
}
/**
* A bisector over arrays of arbitrary `T`, comparing by `accessor`. `center`
* returns the index of the entry nearest `x` — the lookup a chart needs to map
* a cursor position to the closest datum.
*/
export function bisector<T>(accessor: (datum: T) => number) {
function right(array: ArrayLike<T>, x: number, lo = 0, hi = array.length): number {
let low = lo
let high = hi
while (low < high) {
const mid = (low + high) >>> 1
if (x < accessor(array[mid])) high = mid
else low = mid + 1
}
return low
}
function left(array: ArrayLike<T>, x: number, lo = 0, hi = array.length): number {
let low = lo
let high = hi
while (low < high) {
const mid = (low + high) >>> 1
if (accessor(array[mid]) < x) low = mid + 1
else high = mid
}
return low
}
function center(array: ArrayLike<T>, x: number, lo = 0, hi = array.length): number {
if (array.length === 0) return -1
const i = right(array, x, lo, hi)
if (i <= lo) return lo
if (i >= hi) return hi - 1
// Pick whichever neighbour is closer in the accessor's units.
return x - accessor(array[i - 1]) <= accessor(array[i]) - x ? i - 1 : i
}
return { left, right, center }
}

@ -0,0 +1,96 @@
/**
* Curves: strategies for connecting a sequence of points into SVG commands.
*
* Each curve follows the {@link Curve} contract — it emits the commands linking
* `points[1..n-1]` assuming the pen already rests at `points[0]`. That lets the
* line and area generators reuse one curve for both edges of a shape.
*/
import type { Curve, Point } from './types'
import { coord } from './path'
/** Straight segments between consecutive points. */
export const curveLinear: Curve = (points) => {
let d = ''
for (let i = 1; i < points.length; i++) d += `L${coord(points[i].x, points[i].y)}`
return d
}
/**
* Step curve: holds each value, then jumps to the next at the midpoint between
* the two x positions — the classic staircase used for discrete/stepwise data.
*/
export const curveStep: Curve = (points) => {
let d = ''
for (let i = 1; i < points.length; i++) {
const a = points[i - 1]
const b = points[i]
const midX = (a.x + b.x) / 2
d += `L${coord(midX, a.y)}L${coord(midX, b.y)}L${coord(b.x, b.y)}`
}
return d
}
/**
* Monotone cubic curve: a smooth line that never overshoots the data. We fit a
* cubic Hermite spline whose tangent at each point is chosen so the
* interpolation stays monotone between samples — so a run of increasing values
* produces no spurious dip, and a local max stays the max.
*
* The tangent `m[i]` is the weighted harmonic mean of the neighbouring secant
* slopes, forced to 0 wherever the slope changes sign (a local extremum). Each
* segment then becomes a Bézier whose control points sit one third of the way
* along, lifted by the endpoint tangents.
*
* Requires x to be strictly increasing (which a sorted series guarantees).
*/
export const curveMonotoneX: Curve = (points) => {
const n = points.length
if (n < 2) return ''
if (n === 2) return `L${coord(points[1].x, points[1].y)}`
const dx = new Array<number>(n - 1)
const slope = new Array<number>(n - 1)
for (let i = 0; i < n - 1; i++) {
const h = points[i + 1].x - points[i].x
dx[i] = h
slope[i] = h === 0 ? 0 : (points[i + 1].y - points[i].y) / h
}
const tangent = new Array<number>(n)
tangent[0] = slope[0]
tangent[n - 1] = slope[n - 2]
for (let i = 1; i < n - 1; i++) {
if (slope[i - 1] * slope[i] <= 0) {
// Sign change (or a flat) ⇒ this point is a local extremum: flat tangent.
tangent[i] = 0
} else {
const w1 = 2 * dx[i] + dx[i - 1]
const w2 = dx[i] + 2 * dx[i - 1]
tangent[i] = (w1 + w2) / (w1 / slope[i - 1] + w2 / slope[i])
}
}
let d = ''
for (let i = 0; i < n - 1; i++) {
const p0 = points[i]
const p1 = points[i + 1]
const h = dx[i]
const c1x = p0.x + h / 3
const c1y = p0.y + (tangent[i] * h) / 3
const c2x = p1.x - h / 3
const c2y = p1.y - (tangent[i + 1] * h) / 3
d += `C${coord(c1x, c1y)},${coord(c2x, c2y)},${coord(p1.x, p1.y)}`
}
return d
}
const CURVES = { linear: curveLinear, step: curveStep, monotone: curveMonotoneX } as const
/** Named curve presets, for selecting a curve by string (e.g. from a prop). */
export type CurveName = keyof typeof CURVES
/** Resolve a curve by name; defaults to linear for an unknown name. */
export function curve(name: CurveName): Curve {
return CURVES[name] ?? curveLinear
}

@ -0,0 +1,33 @@
/**
* # plots — charting math engine
*
* Pure, dependency-free primitives for building charts: scales (data → pixels),
* shape generators (points → SVG paths), tick generation and small statistics
* helpers. No DOM, no framework, no state — just functions. The UIX chart
* components consume this; the engine knows nothing about them.
*
* Import the whole surface from `$libs/plots`, or a single module when you only
* need one slice (e.g. `$libs/plots/scale-linear`).
*/
export type { AreaPoint, Curve, Point } from './types'
export { bisectLeft, bisectRight, bisector, extent, max, min, range, sum } from './array'
export { nice, tickStep, ticks } from './ticks'
export { scaleLinear } from './scale-linear'
export type { LinearScale, LinearScaleConfig } from './scale-linear'
export { scaleBand, scalePoint } from './scale-band'
export type { BandScale, BandScaleConfig, PointScale, PointScaleConfig } from './scale-band'
export { scaleTime } from './scale-time'
export type { TimeScale, TimeScaleConfig } from './scale-time'
export { curve, curveLinear, curveMonotoneX, curveStep } from './curve'
export type { CurveName } from './curve'
export { coord, fmt } from './path'
export { area, line } from './shape-line'
export type { AreaOptions, LineOptions } from './shape-line'
export { arc, pie } from './shape-arc'
export type { ArcOptions, PieOptions, PieSlice } from './shape-arc'
export { stack } from './shape-stack'
export type { StackOptions, StackPoint, StackSeries } from './shape-stack'

@ -0,0 +1,20 @@
/**
* Path-building utilities. SVG accepts comma- or space-separated numbers, so
* every shape concatenates formatted coordinates directly — no DOM, no
* `CanvasPathMethods`, just strings.
*/
/**
* Round a coordinate to 3 decimals and collapse `-0` to `0`. Three decimals is
* sub-pixel precise for any realistic chart and keeps emitted paths compact and
* deterministic (stable test output).
*/
export function fmt(value: number): string {
const rounded = Math.round(value * 1000) / 1000
return Object.is(rounded, -0) ? '0' : String(rounded)
}
/** Format a single point as the SVG coordinate pair `"x,y"`. */
export function coord(x: number, y: number): string {
return `${fmt(x)},${fmt(y)}`
}

@ -0,0 +1,110 @@
/**
* Band and point scales: map a discrete list of categories to pixel positions.
*
* A **band** scale gives every category an equal-width slot (`bandwidth`) — the
* basis for bar charts. A **point** scale is the degenerate band with zero
* width: each category collapses to a single position, used to place markers in
* line and scatter charts with a categorical axis.
*
* Layout model: the range is divided into `n` equal *steps*. Each step holds
* one band plus an inner gap; the two ends carry an outer margin. Expressing
* every gap as a fraction of one step gives
*
* span = step · (n − paddingInner + 2 · paddingOuter)
*
* so `step = span / (n − paddingInner + 2·paddingOuter)` and
* `bandwidth = step · (1 − paddingInner)`.
*/
export interface BandScale {
/** Left/top pixel edge of a category's band, or `undefined` if unknown. */
(value: string): number | undefined
/** Width of each band in pixels. */
readonly bandwidth: number
/** Distance between the start of consecutive bands (band + inner gap). */
readonly step: number
readonly domain: readonly string[]
readonly range: readonly [number, number]
}
export interface BandScaleConfig {
domain: readonly string[]
range: readonly [number, number]
/** Shorthand setting both inner and outer padding (fraction of a step). */
padding?: number
/** Gap between adjacent bands, as a fraction of a step (0..1). */
paddingInner?: number
/** Margin before the first and after the last band, as a fraction (0..1). */
paddingOuter?: number
/** Where outer margin lands: 0 start, 1 end, 0.5 centred. Default 0.5. */
align?: number
}
export function scaleBand(config: BandScaleConfig): BandScale {
const domain = config.domain
const [r0, r1] = config.range
const paddingInner = config.paddingInner ?? config.padding ?? 0
const paddingOuter = config.paddingOuter ?? config.padding ?? 0
const align = config.align ?? 0.5
const n = domain.length
// Work in ascending pixel space, then reflect if the range descends.
const reverse = r1 < r0
const lo = reverse ? r1 : r0
const hi = reverse ? r0 : r1
const span = hi - lo
const step = n === 0 ? 0 : span / Math.max(1, n - paddingInner + paddingOuter * 2)
const bandwidth = step * (1 - paddingInner)
// `align` distributes the 2·paddingOuter·step of outer margin between ends.
const offset = lo + (span - step * (n - paddingInner)) * align
const index = new Map<string, number>()
for (let i = 0; i < n; i++) {
const ascending = offset + step * i
// Reflect within [lo, hi] so a descending range keeps bands in bounds.
index.set(domain[i], reverse ? hi - (ascending - lo) - bandwidth : ascending)
}
const scale = (value: string): number | undefined => index.get(value)
return Object.assign(scale, {
bandwidth,
step,
domain: Object.freeze([...domain]) as readonly string[],
range: Object.freeze<[number, number]>([r0, r1])
}) as BandScale
}
export interface PointScale {
/** Pixel position of a category, or `undefined` if unknown. */
(value: string): number | undefined
/** Distance between consecutive points. */
readonly step: number
readonly domain: readonly string[]
readonly range: readonly [number, number]
}
export interface PointScaleConfig {
domain: readonly string[]
range: readonly [number, number]
/** Outer margin before the first and after the last point (fraction). */
padding?: number
align?: number
}
/** A point scale is a band scale with `paddingInner = 1` (zero bandwidth). */
export function scalePoint(config: PointScaleConfig): PointScale {
const band = scaleBand({
domain: config.domain,
range: config.range,
paddingInner: 1,
paddingOuter: config.padding ?? 0,
align: config.align ?? 0.5
})
const scale = (value: string): number | undefined => band(value)
return Object.assign(scale, {
step: band.step,
domain: band.domain,
range: band.range
}) as PointScale
}

@ -0,0 +1,61 @@
/**
* Linear scale: a straight-line map from a numeric data `domain` to a pixel
* `range`. This is the workhorse behind value axes.
*
* The scale is **immutable** — `scaleLinear(config)` returns a callable whose
* `domain`/`range` are frozen and whose `nice()` returns a *new* scale. That
* suits a reactive (recompute-on-change) consumer better than the mutable
* getter/setter style: derive a fresh scale when inputs change, never patch one
* in place.
*/
import { nice as niceDomain, ticks as generateTicks } from './ticks'
export interface LinearScale {
/** Map a data value to its pixel position. */
(value: number): number
/** Map a pixel position back to a data value. */
invert(pixel: number): number
/** Nice tick values across the domain (about `count` of them). */
ticks(count?: number): number[]
/** A new scale whose domain is rounded outward to nice numbers. */
nice(count?: number): LinearScale
readonly domain: readonly [number, number]
readonly range: readonly [number, number]
}
export interface LinearScaleConfig {
/** `[min, max]` of the data. */
domain: readonly [number, number]
/** `[start, end]` in pixels; may be descending (e.g. `[height, 0]` for y). */
range: readonly [number, number]
/** Clamp outputs to the range (and inverts to the domain). Default `false`. */
clamp?: boolean
}
const clamp01 = (t: number): number => (t < 0 ? 0 : t > 1 ? 1 : t)
export function scaleLinear(config: LinearScaleConfig): LinearScale {
const [d0, d1] = config.domain
const [r0, r1] = config.range
const clamp = config.clamp ?? false
const domainSpan = d1 - d0
const rangeSpan = r1 - r0
const scale = (value: number): number => {
const t = domainSpan === 0 ? 0 : (value - d0) / domainSpan
return r0 + (clamp ? clamp01(t) : t) * rangeSpan
}
return Object.assign(scale, {
invert: (pixel: number): number => {
const t = rangeSpan === 0 ? 0 : (pixel - r0) / rangeSpan
return d0 + (clamp ? clamp01(t) : t) * domainSpan
},
ticks: (count = 10): number[] => generateTicks(d0, d1, count),
nice: (count = 10): LinearScale =>
scaleLinear({ ...config, domain: niceDomain(d0, d1, count) }),
domain: Object.freeze<[number, number]>([d0, d1]),
range: Object.freeze<[number, number]>([r0, r1])
}) as LinearScale
}

@ -0,0 +1,220 @@
/**
* Time scale: a linear map over **epoch milliseconds** whose ticks land on
* human time boundaries (every 15 min, every hour, every month, …) instead of
* arbitrary millisecond counts.
*
* The engine stays date-model-agnostic: the domain is plain numbers (ms since
* the epoch) and ticks come back as numbers. A chart that works in the
* framework's `DateValue` converts to/from ms at its edge — keeping this module
* pure and free of any calendar dependency. Tick boundaries are computed with
* the platform `Date` in local time.
*/
import { bisector } from './array'
import { tickStep, ticks as numericTicks } from './ticks'
export interface TimeScale {
/** Map an epoch-ms instant to its pixel position. */
(ms: number): number
/** Map a pixel position back to epoch ms. */
invert(pixel: number): number
/** Tick instants (epoch ms) on nice time boundaries. */
ticks(count?: number): number[]
/** A new scale whose domain is rounded outward to a nice time boundary. */
nice(count?: number): TimeScale
readonly domain: readonly [number, number]
readonly range: readonly [number, number]
}
export interface TimeScaleConfig {
/** `[start, end]` as epoch milliseconds. */
domain: readonly [number, number]
/** `[start, end]` in pixels. */
range: readonly [number, number]
clamp?: boolean
}
type TimeUnit = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'
const SECOND = 1000
const MINUTE = 60 * SECOND
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
const WEEK = 7 * DAY
const MONTH = 30 * DAY // nominal length, only used to pick an interval
const YEAR = 365 * DAY
interface TimeInterval {
unit: TimeUnit
step: number
/** Nominal length in ms — the sort key for choosing the closest interval. */
ms: number
}
// Candidate intervals in ascending duration. Steps are the ones that read
// cleanly on an axis (… 15s, 30s, 1m, 5m … 6h, 12h, 1d, 1 week, 1 month …).
const INTERVALS: readonly TimeInterval[] = [
{ unit: 'second', step: 1, ms: SECOND },
{ unit: 'second', step: 5, ms: 5 * SECOND },
{ unit: 'second', step: 15, ms: 15 * SECOND },
{ unit: 'second', step: 30, ms: 30 * SECOND },
{ unit: 'minute', step: 1, ms: MINUTE },
{ unit: 'minute', step: 5, ms: 5 * MINUTE },
{ unit: 'minute', step: 15, ms: 15 * MINUTE },
{ unit: 'minute', step: 30, ms: 30 * MINUTE },
{ unit: 'hour', step: 1, ms: HOUR },
{ unit: 'hour', step: 3, ms: 3 * HOUR },
{ unit: 'hour', step: 6, ms: 6 * HOUR },
{ unit: 'hour', step: 12, ms: 12 * HOUR },
{ unit: 'day', step: 1, ms: DAY },
{ unit: 'day', step: 2, ms: 2 * DAY },
{ unit: 'week', step: 1, ms: WEEK },
{ unit: 'month', step: 1, ms: MONTH },
{ unit: 'month', step: 3, ms: 3 * MONTH },
{ unit: 'year', step: 1, ms: YEAR }
]
const intervalBisect = bisector((interval: TimeInterval) => interval.ms)
/** Snap a date down to the start of its `unit`/`step` slot (local time). */
function floorTime(date: Date, unit: TimeUnit, step: number): void {
switch (unit) {
case 'second':
date.setMilliseconds(0)
date.setSeconds(Math.floor(date.getSeconds() / step) * step)
break
case 'minute':
date.setSeconds(0, 0)
date.setMinutes(Math.floor(date.getMinutes() / step) * step)
break
case 'hour':
date.setMinutes(0, 0, 0)
date.setHours(Math.floor(date.getHours() / step) * step)
break
case 'day':
date.setHours(0, 0, 0, 0)
break
case 'week':
date.setHours(0, 0, 0, 0)
date.setDate(date.getDate() - date.getDay()) // back to Sunday
break
case 'month':
date.setHours(0, 0, 0, 0)
date.setDate(1)
date.setMonth(Math.floor(date.getMonth() / step) * step)
break
case 'year':
date.setHours(0, 0, 0, 0)
date.setMonth(0, 1)
date.setFullYear(Math.floor(date.getFullYear() / step) * step)
break
}
}
/** Advance a date by one `unit`/`step` slot (local time, calendar-aware). */
function offsetTime(date: Date, unit: TimeUnit, step: number): void {
switch (unit) {
case 'second':
date.setSeconds(date.getSeconds() + step)
break
case 'minute':
date.setMinutes(date.getMinutes() + step)
break
case 'hour':
date.setHours(date.getHours() + step)
break
case 'day':
date.setDate(date.getDate() + step)
break
case 'week':
date.setDate(date.getDate() + step * 7)
break
case 'month':
date.setMonth(date.getMonth() + step)
break
case 'year':
date.setFullYear(date.getFullYear() + step)
break
}
}
/**
* The interval whose nominal length is closest to `target` ms. Returns `null`
* below one second, where the caller falls back to plain numeric ticks. For
* multi-year targets the year step is widened to a nice number.
*/
function chooseInterval(target: number, lo: number, hi: number, count: number): TimeInterval | null {
if (!(target > 0)) return null
const i = intervalBisect.right(INTERVALS, target)
if (i === 0) return null // sub-second
if (i >= INTERVALS.length) {
const years = Math.max(1, tickStep(new Date(lo).getFullYear(), new Date(hi).getFullYear(), count))
return { unit: 'year', step: years, ms: years * YEAR }
}
const lower = INTERVALS[i - 1]
const upper = INTERVALS[i]
// Closest on a ratio (log) scale, matching how durations compare.
return target / lower.ms < upper.ms / target ? lower : upper
}
const clamp01 = (t: number): number => (t < 0 ? 0 : t > 1 ? 1 : t)
export function scaleTime(config: TimeScaleConfig): TimeScale {
const [d0, d1] = config.domain
const [r0, r1] = config.range
const clamp = config.clamp ?? false
const domainSpan = d1 - d0
const rangeSpan = r1 - r0
const scale = (ms: number): number => {
const t = domainSpan === 0 ? 0 : (ms - d0) / domainSpan
return r0 + (clamp ? clamp01(t) : t) * rangeSpan
}
return Object.assign(scale, {
invert: (pixel: number): number => {
const t = rangeSpan === 0 ? 0 : (pixel - r0) / rangeSpan
return d0 + (clamp ? clamp01(t) : t) * domainSpan
},
ticks: (count = 10): number[] => {
const lo = Math.min(d0, d1)
const hi = Math.max(d0, d1)
if (lo === hi) return [lo]
const interval = chooseInterval((hi - lo) / count, lo, hi, count)
if (!interval) return numericTicks(lo, hi, count) // sub-second
const cursor = new Date(lo)
floorTime(cursor, interval.unit, interval.step)
if (cursor.getTime() < lo) offsetTime(cursor, interval.unit, interval.step)
const result: number[] = []
for (let guard = 0; cursor.getTime() <= hi && guard < 100000; guard++) {
result.push(cursor.getTime())
offsetTime(cursor, interval.unit, interval.step)
}
return d0 <= d1 ? result : result.reverse()
},
nice: (count = 10): TimeScale => {
const lo = Math.min(d0, d1)
const hi = Math.max(d0, d1)
const interval = chooseInterval((hi - lo) / count, lo, hi, count)
if (!interval) return scaleTime(config)
const start = new Date(lo)
floorTime(start, interval.unit, interval.step)
const end = new Date(hi)
floorTime(end, interval.unit, interval.step)
if (end.getTime() < hi) offsetTime(end, interval.unit, interval.step)
const niceLo = start.getTime()
const niceHi = end.getTime()
const domain: [number, number] = d0 <= d1 ? [niceLo, niceHi] : [niceHi, niceLo]
return scaleTime({ ...config, domain })
},
domain: Object.freeze<[number, number]>([d0, d1]),
range: Object.freeze<[number, number]>([r0, r1])
}) as TimeScale
}

@ -0,0 +1,119 @@
/**
* Arc geometry for pie, donut and gauge charts, plus the `pie` layout that
* turns a list of values into slice angles.
*
* Angle convention: 0 points up (12 o'clock) and increases clockwise, matching
* how a pie chart reads. On screen (y grows downward) a point at angle `a` and
* radius `r` is `(cx + r·sin a, cy − r·cos a)`.
*/
import { fmt } from './path'
const TAU = Math.PI * 2
export interface ArcOptions {
innerRadius: number
outerRadius: number
/** Start angle in radians (0 = up, clockwise positive). */
startAngle: number
endAngle: number
/** Centre, default `(0, 0)`. */
cx?: number
cy?: number
}
const pointOnArc = (cx: number, cy: number, r: number, a: number): string =>
`${fmt(cx + r * Math.sin(a))},${fmt(cy - r * Math.cos(a))}`
/**
* SVG path for an annular sector (or a pie slice when `innerRadius` is 0, or a
* full ring/disc when the sweep covers a full turn).
*/
export function arc(options: ArcOptions): string {
const r0 = Math.max(0, Math.min(options.innerRadius, options.outerRadius))
const r1 = Math.max(options.innerRadius, options.outerRadius)
const cx = options.cx ?? 0
const cy = options.cy ?? 0
const a0 = Math.min(options.startAngle, options.endAngle)
const a1 = Math.max(options.startAngle, options.endAngle)
const sweep = a1 - a0
// Full turn: SVG can't draw a 360° arc in one command, so use two halves.
if (sweep >= TAU - 1e-9) {
const mid = a0 + Math.PI
const outer =
`M${pointOnArc(cx, cy, r1, a0)}` +
`A${fmt(r1)},${fmt(r1)},0,1,1,${pointOnArc(cx, cy, r1, mid)}` +
`A${fmt(r1)},${fmt(r1)},0,1,1,${pointOnArc(cx, cy, r1, a0)}`
if (r0 === 0) return `${outer}Z`
// Inner ring wound the opposite way carves out the hole (even-odd fill).
const inner =
`M${pointOnArc(cx, cy, r0, a0)}` +
`A${fmt(r0)},${fmt(r0)},0,1,0,${pointOnArc(cx, cy, r0, mid)}` +
`A${fmt(r0)},${fmt(r0)},0,1,0,${pointOnArc(cx, cy, r0, a0)}`
return `${outer}${inner}Z`
}
const largeArc = sweep > Math.PI ? 1 : 0
if (r0 === 0) {
// Pie slice: centre → outer arc → centre.
return (
`M${fmt(cx)},${fmt(cy)}` +
`L${pointOnArc(cx, cy, r1, a0)}` +
`A${fmt(r1)},${fmt(r1)},0,${largeArc},1,${pointOnArc(cx, cy, r1, a1)}` +
'Z'
)
}
// Donut segment: outer arc (clockwise) then inner arc back (anticlockwise).
return (
`M${pointOnArc(cx, cy, r1, a0)}` +
`A${fmt(r1)},${fmt(r1)},0,${largeArc},1,${pointOnArc(cx, cy, r1, a1)}` +
`L${pointOnArc(cx, cy, r0, a1)}` +
`A${fmt(r0)},${fmt(r0)},0,${largeArc},0,${pointOnArc(cx, cy, r0, a0)}` +
'Z'
)
}
export interface PieOptions {
/** First slice's start angle. Default 0 (up). */
startAngle?: number
/** Last slice's end angle. Default `2π` (full turn). */
endAngle?: number
/** Gap between slices in radians. Default 0. */
padAngle?: number
}
export interface PieSlice {
/** Index into the input values. */
index: number
/** The original value. */
value: number
startAngle: number
endAngle: number
padAngle: number
}
/**
* Lay out values as slice angles, in input order (no implicit sort, so slices
* line up with their data). Negative values are treated as 0.
*/
export function pie(values: readonly number[], options: PieOptions = {}): PieSlice[] {
const start = options.startAngle ?? 0
const end = options.endAngle ?? TAU
const pad = options.padAngle ?? 0
const total = values.reduce((acc, v) => acc + (v > 0 ? v : 0), 0)
// Reserve one gap per slice; the rest is shared out by value.
const usable = end - start - pad * values.length
const slices: PieSlice[] = []
let angle = start
for (let i = 0; i < values.length; i++) {
const value = values[i] > 0 ? values[i] : 0
const span = total > 0 ? (value / total) * usable : 0
slices.push({ index: i, value: values[i], startAngle: angle, endAngle: angle + span, padAngle: pad })
angle += span + pad
}
return slices
}

@ -0,0 +1,43 @@
/**
* Line and area path generators. Inputs are already in pixel space (the caller
* maps data through scales first), so these are pure string builders.
*/
import type { AreaPoint, Curve, Point } from './types'
import { coord } from './path'
import { curveLinear } from './curve'
export interface LineOptions {
/** How to connect points. Default: straight segments. */
curve?: Curve
}
/**
* An open polyline through `points`. Returns an empty string for no points (a
* valid, invisible `d`), so binding it to a `<path>` is always safe.
*/
export function line(points: readonly Point[], options: LineOptions = {}): string {
if (points.length === 0) return ''
const link = options.curve ?? curveLinear
return `M${coord(points[0].x, points[0].y)}${link(points)}`
}
export interface AreaOptions {
curve?: Curve
}
/**
* A filled band between the `y0` (baseline) and `y1` (value) edges at each `x`.
* The outline runs along the top edge left→right, then back along the baseline
* right→left, and closes — so the same curve shapes both edges.
*/
export function area(points: readonly AreaPoint[], options: AreaOptions = {}): string {
if (points.length === 0) return ''
const link = options.curve ?? curveLinear
const tops: Point[] = points.map((p) => ({ x: p.x, y: p.y1 }))
const baseline: Point[] = points.map((p) => ({ x: p.x, y: p.y0 })).reverse()
return (
`M${coord(tops[0].x, tops[0].y)}${link(tops)}` +
`L${coord(baseline[0].x, baseline[0].y)}${link(baseline)}Z`
)
}

@ -0,0 +1,59 @@
/**
* Stack layout: turn parallel series into stacked `[y0, y1]` baselines, the
* basis for stacked bar and area charts. Output is in **data units** — the
* caller maps `y0`/`y1` through a scale.
*/
export interface StackPoint {
/** Index of the row in the input data. */
index: number
/** Lower edge (cumulative total below this series) in data units. */
y0: number
/** Upper edge (`y0` + this series' value) in data units. */
y1: number
/** This series' raw value at the row. */
value: number
}
export interface StackSeries {
/** The data key this series stacks. */
key: string
/** Index of the key in the input `keys`. */
index: number
points: StackPoint[]
}
export interface StackOptions {
/**
* `'none'` stacks cumulatively from 0 (default). `'expand'` normalises each
* row to sum to 1 — the basis for 100%-stacked charts.
*/
offset?: 'none' | 'expand'
}
/**
* Stack `keys` across `data` rows. For each row the keys accumulate in order:
* the first key sits on 0, each later key on top of the previous total.
*/
export function stack<T extends Record<string, number>>(
data: readonly T[],
keys: readonly string[],
options: StackOptions = {}
): StackSeries[] {
const expand = options.offset === 'expand'
const series: StackSeries[] = keys.map((key, index) => ({ key, index, points: [] }))
for (let row = 0; row < data.length; row++) {
const total = expand
? keys.reduce((acc, key) => acc + (Number(data[row][key]) || 0), 0)
: 0
let baseline = 0
for (let k = 0; k < keys.length; k++) {
const raw = Number(data[row][keys[k]]) || 0
const value = expand && total > 0 ? raw / total : raw
series[k].points.push({ index: row, y0: baseline, y1: baseline + value, value: raw })
baseline += value
}
}
return series
}

@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { bisectLeft, bisectRight, bisector, extent, max, min, range, sum } from '../array'
describe('array helpers', () => {
it('min/max/extent skip NaN and nullish', () => {
const values = [3, NaN, 1, 4, 1, 5]
expect(min(values)).toBe(1)
expect(max(values)).toBe(5)
expect(extent(values)).toEqual([1, 5])
})
it('extent/min of empty is undefined', () => {
expect(extent([])).toBeUndefined()
expect(min([])).toBeUndefined()
})
it('sum ignores NaN', () => {
expect(sum([1, 2, NaN, 3])).toBe(6)
})
it('range is half-open', () => {
expect(range(3)).toEqual([0, 1, 2])
expect(range(1, 4)).toEqual([1, 2, 3])
expect(range(0, 1, 0.25)).toEqual([0, 0.25, 0.5, 0.75])
})
it('bisectLeft/Right bracket equal entries', () => {
const sorted = [1, 2, 2, 3]
expect(bisectLeft(sorted, 2)).toBe(1)
expect(bisectRight(sorted, 2)).toBe(3)
})
it('bisector.center finds the nearest datum', () => {
const data = [{ t: 0 }, { t: 10 }, { t: 20 }]
const by = bisector((d: { t: number }) => d.t)
expect(by.center(data, 3)).toBe(0)
expect(by.center(data, 7)).toBe(1)
expect(by.center(data, 16)).toBe(2)
})
})

@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest'
import { scaleBand, scalePoint } from '../scale-band'
import { scaleLinear } from '../scale-linear'
import { scaleTime } from '../scale-time'
describe('scaleLinear', () => {
it('maps the domain onto the range', () => {
const s = scaleLinear({ domain: [0, 100], range: [0, 200] })
expect(s(0)).toBe(0)
expect(s(50)).toBe(100)
expect(s(100)).toBe(200)
})
it('inverts pixels back to data', () => {
const s = scaleLinear({ domain: [0, 100], range: [0, 200] })
expect(s.invert(100)).toBe(50)
})
it('supports a descending range (y axis)', () => {
const s = scaleLinear({ domain: [0, 10], range: [100, 0] })
expect(s(0)).toBe(100)
expect(s(5)).toBe(50)
expect(s(10)).toBe(0)
})
it('clamps when asked', () => {
const s = scaleLinear({ domain: [0, 10], range: [0, 100], clamp: true })
expect(s(-5)).toBe(0)
expect(s(15)).toBe(100)
})
it('exposes ticks and a niced domain', () => {
const s = scaleLinear({ domain: [0, 95], range: [0, 100] })
expect(s.ticks(5)).toEqual([0, 20, 40, 60, 80])
expect(s.nice(5).domain).toEqual([0, 100])
})
})
describe('scaleBand', () => {
it('splits the range into equal bands', () => {
const s = scaleBand({ domain: ['a', 'b', 'c', 'd'], range: [0, 100] })
expect(s.step).toBe(25)
expect(s.bandwidth).toBe(25)
expect(s('a')).toBe(0)
expect(s('b')).toBe(25)
expect(s('d')).toBe(75)
expect(s('missing')).toBeUndefined()
})
it('inner padding shrinks the bandwidth', () => {
const s = scaleBand({ domain: ['a', 'b'], range: [0, 100], paddingInner: 0.5 })
expect(s.step).toBeCloseTo(66.6667, 3)
expect(s.bandwidth).toBeCloseTo(33.3333, 3)
expect(s('a')).toBeCloseTo(0, 6)
})
})
describe('scalePoint', () => {
it('places zero-width points evenly', () => {
const s = scalePoint({ domain: ['a', 'b', 'c'], range: [0, 100] })
expect(s.step).toBe(50)
expect(s('a')).toBe(0)
expect(s('b')).toBe(50)
expect(s('c')).toBe(100)
})
})
describe('scaleTime', () => {
const day = 86_400_000
const t0 = Date.UTC(2026, 5, 1) // fixed instant; June avoids DST edges
it('maps and inverts over epoch ms', () => {
const s = scaleTime({ domain: [t0, t0 + day], range: [0, 100] })
expect(s(t0)).toBe(0)
expect(s(t0 + day)).toBe(100)
expect(s.invert(50)).toBe(t0 + day / 2)
})
it('emits sorted ticks inside the range', () => {
const s = scaleTime({ domain: [t0, t0 + day], range: [0, 100] })
const t = s.ticks(8)
expect(t.length).toBeGreaterThan(2)
for (let i = 1; i < t.length; i++) expect(t[i]).toBeGreaterThan(t[i - 1])
expect(t[0]).toBeGreaterThanOrEqual(t0)
expect(t[t.length - 1]).toBeLessThanOrEqual(t0 + day)
})
it('keeps multi-year tick counts sane', () => {
const s = scaleTime({ domain: [Date.UTC(2010, 0, 1), Date.UTC(2026, 0, 1)], range: [0, 100] })
const t = s.ticks(6)
expect(t.length).toBeGreaterThan(1)
expect(t.length).toBeLessThan(30)
for (let i = 1; i < t.length; i++) expect(t[i]).toBeGreaterThan(t[i - 1])
})
it('nice widens the domain to a boundary', () => {
const s = scaleTime({ domain: [t0 + 1000, t0 + day - 1000], range: [0, 100] }).nice(8)
expect(s.domain[0]).toBeLessThanOrEqual(t0 + 1000)
expect(s.domain[1]).toBeGreaterThanOrEqual(t0 + day - 1000)
})
})

@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import { curveStep } from '../curve'
import { arc, pie } from '../shape-arc'
import { area, line } from '../shape-line'
import { stack } from '../shape-stack'
describe('line / area', () => {
it('line emits a move then line-tos', () => {
expect(line([{ x: 0, y: 0 }, { x: 10, y: 20 }, { x: 20, y: 5 }])).toBe('M0,0L10,20L20,5')
})
it('an empty line is the empty path', () => {
expect(line([])).toBe('')
})
it('area traces the value edge then the baseline back', () => {
const d = area([
{ x: 0, y0: 100, y1: 50 },
{ x: 10, y0: 100, y1: 20 }
])
expect(d).toBe('M0,50L10,20L10,100L0,100Z')
})
it('step curve jumps at the midpoint', () => {
expect(line([{ x: 0, y: 0 }, { x: 10, y: 10 }], { curve: curveStep })).toBe('M0,0L5,0L5,10L10,10')
})
})
describe('arc / pie', () => {
it('lays out slices in input order over a full turn', () => {
const slices = pie([1, 1, 2])
expect(slices.length).toBe(3)
expect(slices[0].startAngle).toBe(0)
expect(slices[2].endAngle).toBeCloseTo(Math.PI * 2, 6)
// The value-2 slice spans half the circle.
expect(slices[2].endAngle - slices[2].startAngle).toBeCloseTo(Math.PI, 6)
})
it('reserves a gap per slice when padded', () => {
const slices = pie([1, 1], { padAngle: 0.1 })
expect(slices[0].endAngle - slices[0].startAngle).toBeCloseTo((Math.PI * 2 - 0.2) / 2, 6)
})
it('draws a pie slice from the centre', () => {
const d = arc({ innerRadius: 0, outerRadius: 100, startAngle: 0, endAngle: Math.PI / 2 })
expect(d).toBe('M0,0L0,-100A100,100,0,0,1,100,0Z')
})
it('draws a donut segment with two arcs', () => {
const d = arc({ innerRadius: 50, outerRadius: 100, startAngle: 0, endAngle: Math.PI / 2 })
expect(d.split('A').length).toBe(3)
})
it('draws a full ring with two arcs per edge', () => {
const d = arc({ innerRadius: 50, outerRadius: 100, startAngle: 0, endAngle: Math.PI * 2 })
expect(d.split('A').length).toBe(5)
})
})
describe('stack', () => {
it('accumulates keys per row', () => {
const data = [
{ a: 1, b: 2 },
{ a: 3, b: 1 }
]
const [sa, sb] = stack(data, ['a', 'b'])
expect(sa.points[0]).toMatchObject({ y0: 0, y1: 1 })
expect(sb.points[0]).toMatchObject({ y0: 1, y1: 3 })
expect(sb.points[1]).toMatchObject({ y0: 3, y1: 4 })
})
it('expand normalises each row to 1', () => {
const data = [{ a: 1, b: 3 }]
const [sa, sb] = stack(data, ['a', 'b'], { offset: 'expand' })
expect(sa.points[0].y1).toBeCloseTo(0.25, 6)
expect(sb.points[0].y1).toBeCloseTo(1, 6)
})
})

@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { nice, tickStep, ticks } from '../ticks'
describe('ticks', () => {
it('produces round multiples across a range', () => {
expect(ticks(0, 100, 5)).toEqual([0, 20, 40, 60, 80, 100])
expect(ticks(0, 10, 10)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
})
it('handles sub-unit steps without float drift', () => {
expect(ticks(0, 1, 10)).toEqual([0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1])
})
it('stays within the exact extent', () => {
const t = ticks(2, 97, 5)
expect(t[0]).toBeGreaterThanOrEqual(2)
expect(t[t.length - 1]).toBeLessThanOrEqual(97)
})
it('mirrors order when the range descends', () => {
expect(ticks(100, 0, 5)).toEqual([100, 80, 60, 40, 20, 0])
})
it('returns a single tick for a zero-width range', () => {
expect(ticks(5, 5, 10)).toEqual([5])
})
it('nice rounds the domain outward', () => {
expect(nice(0.1, 9.9, 10)).toEqual([0, 10])
expect(nice(2, 97, 5)).toEqual([0, 100])
})
it('tickStep returns the nice step', () => {
expect(tickStep(0, 100, 5)).toBe(20)
expect(tickStep(0, 1, 10)).toBeCloseTo(0.1, 10)
})
})

@ -0,0 +1,84 @@
/**
* Tick generation for continuous axes.
*
* The goal is "nice" numbers: an axis should be labelled at round values like
* 0, 20, 40, … rather than 0, 17.3, 34.6, …. A nice step is a value of the form
* `{1 | 2 | 5} × 10ⁿ` — the multiples humans read fluently. Given a desired
* tick count we compute the raw step (`range / count`) and snap it to the
* nearest nice value, measured on a logarithmic scale.
*/
// Geometric midpoints between adjacent nice values: √(1·2), √(2·5), √(5·10).
// Snapping at these boundaries chooses the closest of {1, 2, 5, 10} in log
// space, which is where "closeness" of multiplicative steps actually lives.
const SNAP_2 = Math.SQRT2 // ≈ 1.414
const SNAP_5 = Math.sqrt(10) // ≈ 3.162
const SNAP_10 = Math.sqrt(50) // ≈ 7.071
/** The nice step (`{1|2|5} × 10ⁿ`) closest to a positive raw step. */
function niceStep(raw: number): number {
if (!(raw > 0) || !Number.isFinite(raw)) return 0
const power = Math.floor(Math.log10(raw))
const base = Math.pow(10, power)
const fraction = raw / base // normalised into [1, 10)
const factor = fraction < SNAP_2 ? 1 : fraction < SNAP_5 ? 2 : fraction < SNAP_10 ? 5 : 10
return factor * base
}
/** The nice step that splits `[min, max]` into roughly `count` intervals. */
export function tickStep(min: number, max: number, count: number): number {
if (count <= 0) return 0
return niceStep(Math.abs(max - min) / count)
}
/**
* Evenly spaced nice tick values spanning `[min, max]` inclusive. Returns the
* round multiples of the nice step that fall within the range, so the first and
* last tick can sit slightly inside the exact extent. Order follows the inputs
* (descending when `max < min`).
*/
export function ticks(min: number, max: number, count = 10): number[] {
if (min === max) return [min]
if (count <= 0 || !Number.isFinite(min) || !Number.isFinite(max)) return []
const reverse = max < min
const lo = reverse ? max : min
const hi = reverse ? min : max
const step = niceStep((hi - lo) / count)
if (step === 0) return []
const result: number[] = []
if (step < 1) {
// Sub-unit steps drift under repeated multiplication (0.1 × 3 ≠ 0.3),
// so iterate with an integer reciprocal and divide instead.
const inv = Math.round(1 / step)
const first = Math.ceil(lo * inv)
const last = Math.floor(hi * inv)
for (let i = first; i <= last; i++) result.push(i / inv)
} else {
const first = Math.ceil(lo / step)
const last = Math.floor(hi / step)
for (let i = first; i <= last; i++) result.push(i * step)
}
return reverse ? result.reverse() : result
}
/**
* Round `[min, max]` outward to the nearest nice-step multiples, so an axis
* begins and ends on round numbers. Iterates because widening the range can
* change which step is nice; converges in a couple of passes.
*/
export function nice(min: number, max: number, count = 10): [number, number] {
if (min === max || !Number.isFinite(min) || !Number.isFinite(max)) return [min, max]
const reverse = max < min
let lo = reverse ? max : min
let hi = reverse ? min : max
let previous = 0
for (let guard = 0; guard < 100; guard++) {
const step = niceStep((hi - lo) / count)
if (step === 0 || step === previous) break
lo = Math.floor(lo / step) * step
hi = Math.ceil(hi / step) * step
previous = step
}
return reverse ? [hi, lo] : [lo, hi]
}

@ -0,0 +1,32 @@
/**
* Shared geometric primitives for the plots engine. Pure data types with no
* runtime behaviour — the scale and shape modules build on these.
*
* All coordinates are in **pixel space**: scales map data values to pixels,
* and shape generators consume the already-mapped points. Keeping shapes
* pixel-only decouples them from scales (the compositional model).
*/
/** A 2D point in pixel space. */
export interface Point {
x: number
y: number
}
/**
* A vertical span at a single `x`, used by area and stacked shapes: `y0` is
* the baseline edge and `y1` the value edge (both in pixel space).
*/
export interface AreaPoint {
x: number
y0: number
y1: number
}
/**
* A curve links a sequence of points into SVG path commands. It returns the
* commands that connect `points[1..n-1]` assuming the pen already sits at
* `points[0]` (the caller emits the leading `M`). This contract lets `line`
* and `area` reuse the same curve for their top and bottom edges.
*/
export type Curve = (points: readonly Point[]) => string

@ -0,0 +1,186 @@
# Chart — Improvement Roadmap (vs MUI X Charts)
> Hand-off doc to continue in a fresh session. It captures the analysis of
> **MUI X Charts** (`@mui/x-charts`, `packages/x-charts/src`, structure fetched
> from GitHub 2026-06-24) and a prioritized set of improvements that fit OUR
> model: compositional, **zero-dependency**, eidos-themed, Svelte-5-native.
> Read this + [`README.md`](./README.md) + [`$libs/plots/README.md`](../../../../libs/plots/README.md)
> before starting.
---
## 0. Where we are (snapshot 2026-06-24)
**Math** — `$libs/plots` (zero-dep, original): `scaleLinear/Band/Point/Time`,
`ticks` (1-2-5 nice), `curve` (linear/step/monotoneX), `shape-line`/`area`,
`shape-arc` (arc/pie), **`shape-stack`**, `bisector`, `path`. 36 tests.
**Components** — `chart/`:
- Compositional `<Chart>` frame: context-shared scales, responsive via
`bind:clientWidth`, cursor-following tooltip (`bisector.center`). Primitives:
`Chart.Grid`/`CartesianGrid`, `Chart.XAxis`/`YAxis`, `Chart.Line`/`Area`/`Bar`/`Scatter`,
`Chart.Tooltip`, `Chart.Legend`.
- Presets: `LineChart`, `AreaChart`, `BarChart`, `ScatterChart`, `Sparkline`
(line **and** `as="bars"`).
- Standalone: `PieChart` (pie/donut), `BarList`, `BarSegment`, `RadarChart` —
each with its own themed hover tooltip.
- `ChartContext` (context.ts): `data, x, series, xScale, yScale, plot, bandwidth,
baseline, px(), bandStart(), categoryX(), py(), color(), formatNumber(),
formatTime(), enableTooltip(), enableLegend()`.
- Themed by `--chart-*` recipe tokens (`lib/recipes/base.ts` → `generated/base.css`)
+ global color tokens. Locale via `eidos.format`/`eidos.langs`. morfo `chart`
`scope:['eidos']`, `role="img"`. Point scale spans **edge-to-edge** (line/area
touch the axes).
**GOTCHAs (don't relearn the hard way):**
- Config children (`<Chart.Tooltip>` / `<Chart.Legend>`) must be used
**statically** — a reactive `{#if}` toggle does NOT dispose their frame state
on unmount (empty-output Svelte components don't fire `$effect` teardown /
`onDestroy` on conditional unmount). For a reactive toggle, use the frame-level
`tooltip` **prop** instead (presets do).
- The frame is `data-chart-frame` (flex column) → `data-chart-plot` (fixed
height, tooltip anchor) → `<svg data-chart>`. Legend renders below the plot.
- Verify via `preview_eval` DOM measurements; `preview_screenshot` drifts (the
shared browser jumps to `/`); the preview `serverId` rotates per session.
---
## 1. How MUI X Charts is built (reference)
Confirmed first-hand from `packages/x-charts/src`:
- **Three tiers + headless core.** `ChartsDataProvider` (state) → `ChartsSurface`
(`<svg>`) → drawing children. A **plugin-store** holds one reactive state object;
components are dumb readers via selector hooks (`useDrawingArea`, `useXScale`,
`useSeries`). The all-in-one `<LineChart>` is just that child tree frozen.
- **Series = data-driven array** discriminated by `type` (`line|bar|scatter|pie|radar`),
`data` inline or `dataKey`+`dataset`, axis binding by `xAxisId`/`yAxisId`. Mixed
charts = heterogeneous `series` array.
- **Axes = arrays** (`xAxis`/`yAxis`) → multiple/secondary axes first-class;
`scaleType` ∈ linear·band·point·log·symlog·pow·sqrt·time·utc. Scales = **vendored d3**.
- ~25 composable sub-components (`ChartsGrid`, `ChartsTooltip`, `ChartsLegend`,
`ChartsAxisHighlight`, `ChartsReferenceLine`, `ChartsClipPath`, `ChartsText`…)
+ 14 feature-plugins (`useChartHighlight`, `useChartTooltip`, `useChartClosestPoint`,
`useChartKeyboardNavigation`, `useChartBrush`, `useChartCartesianAxis`(zoom), …).
- Community features: highlighting/fade, axis-vs-item tooltip, **stacking**,
interactive legend, reference lines, **animation** (react-spring), localization,
a11y (per-item focus + aria). Pro: zoom/pan, export, multi-axes, WebGL.
**Verdict vs ours:** philosophically aligned (composition) and we're MORE
compositional (each mark declares its series; MUI is still data-driven) and
zero-dep (their d3 vs our `$libs/plots`). We're behind on **interaction +
a few primitives**, not architecture. Do NOT adopt their plugin-store (React-y,
heavy) — our Svelte context (`setChartContext`) is the right-sized equivalent.
---
## 2. Improvements — prioritized
### Tier 1 — high value, fit our model now
**1. Stacking (bars + areas).** _The math already exists_ (`shape-stack` in
`$libs/plots`); it's just not wired into the marks.
- Where: `chart-bar.svelte`, `chart-area.svelte`, frame `chart.svelte` (y-domain
must sum stacked series), `types.ts` (`stack?: string` group key on the mark/preset).
- Approach: group series by `stack` key; compute cumulative baselines per datum
via `shape-stack`; each segment draws from its lower to upper offset instead of
from `baseline`. The frame's y-domain extent must use stacked totals.
- Cost: low-moderate. Highest value/effort ratio — **do this first.**
**2. Highlight / fade.** Hover a series (or legend item) → fade the others.
- Where: `chart.svelte` (track a `highlightedSeries` state in context),
`chart-line/area/bar/scatter.svelte` (read it, set `data-dim`), `chart.css`
(`[data-chart-*][data-dim='true'] { opacity: var(--opacity-scrim) }`).
- Approach: pure eidos — a `data-dim` attr + CSS, no JS painting. Add
`ctx.highlight(seriesKey | null)` and an `onpointerenter`/`leave` on each mark
(and on legend items). Respects the "eidos owns the visual" doctrine.
- Cost: low. Big perceived-quality win.
**3. Reference lines.** `<Chart.ReferenceLine y={target} label="Goal" />`
(and `x={…}`).
- Where: new `chart-reference-line.svelte` (reads `ctx.py`/`ctx.px`), `index.ts`
(`Chart.ReferenceLine`), `chart.css` + a `--chart-reference-*` token, `types.ts`.
- Approach: a dashed `<line>` across the plot + optional `<text>` label. Trivial.
- Cost: low.
**4. Secondary axis.** A right-hand Y axis bound to specific series
(`<Chart.YAxis side="right" series={['pct']} />`).
- Where: `chart.svelte` (model the axis as an object with an id + its own scale;
compute a second y-scale over the bound series), `chart-y-axis.svelte` (`side`),
the marks (pick which y-scale by series→axis binding), `context.ts` (expose
`pyFor(seriesKey)` or a per-axis scale lookup).
- Approach: this is the first step toward MUI-style "axes as objects with ids".
Keep it minimal: one optional secondary y-scale, opt-in. Don't build the full
array model.
- Cost: moderate. Common dashboard need (revenue vs %).
**5. Item tooltip (vs axis tooltip).** Today the tooltip is axis-mode (all series
at the nearest x). Add an item-mode (hover a specific bar/point → just that mark).
- Where: `chart.svelte` (tooltip already exists — add a `mode: 'axis' | 'item'`),
`chart-bar/scatter.svelte` (per-mark pointer target sets the active item).
- Approach: bars/scatter benefit most from item-mode; line/area keep axis-mode.
- Cost: moderate.
**6. Interactive legend.** Click a legend item to hide/show its series.
- Where: the preset legends + `Chart.Legend` (frame), `chart.svelte` (a
`hiddenSeries` set in context; marks skip hidden series).
- Approach: toggle on click, `data-hidden` on the legend swatch, marks read the set.
- Cost: low-moderate.
### Tier 2 — next wave
7. **Enter animation via `$motion`** — line draw (stroke-dashoffset), bars grow
(scaleY from baseline), arc sweep, with reduced-motion fallback. Animation is a
channel of the event/render, not a parallel engine — use `Presence` + `$motion`
per the motion doctrine. Mind the `prefers-reduced-motion` tracker in `$adom`.
8. **Log / symlog scale** in `$libs/plots` (`scale-log.ts`) + `xScale`/`yScale`
accepting `'log'`. Useful for wide-range data.
9. **Scatter nearest-point** — O(n) nearest-by-distance (NOT MUI's Flatbush; we're
"basic"). Replaces nearest-x for scatter tooltips.
10. **Gradient fills** (area fades to transparent via an `<linearGradient>` +
token), **clip-path** to the plot rect (prevents mark overflow), **rotated X
labels** (long categories) + **axis titles**.
### Tier 3 — out of "basic" scope (note + defer)
Zoom/pan, brush/range-select, keyboard data navigation, WebGL/progressive
rendering, new chart types (a simple **Gauge** could be worth it; Heatmap/Funnel/
Sankey/Candlestick are not). Per-item a11y + a hidden `<table>` data fallback is
the one accessibility gap worth a dedicated pass (MUI marks items focusable +
describable; we only give `role="img"` + `ariaLabel`).
---
## 3. Do NOT adopt (and why)
- **The plugin-store engine** — React-specific + heavy; our `setChartContext`
(Svelte context) is the correct, lighter equivalent.
- **`series` array as the primary API** — the user prefers composition
(see memory `feedback_compositional_not_data_driven`); ours is already more
compositional. At most offer it as an alternative for mixed charts (already covered).
- **Vendored d3** — zero-dependency mandate; `$libs/plots` is the original
equivalent. Never add d3.
- **WebGL / zoom / brush** — beyond a "basic" library.
---
## 4. Architecture reference (for the implementer)
- **Context**: marks call `getChartContext()` and read pre-scaled geometry
(`ctx.px(d)`, `ctx.py(v)`, `ctx.plot`, `ctx.series`, `ctx.color(i)`). The frame
(`chart.svelte`) owns the scales + pointer tracking; marks are dumb readers.
- **Add a primitive mark**: new `chart-{x}.svelte` reading the context →
register in `index.ts` namespace (`Chart.{X}`) → add `Chart{X}Props` to `types.ts`.
- **Add a recipe token**: edit the `chart` block in `lib/recipes/base.ts`, then
regenerate with `npm run generate:eidos-css` (keeps `generated/base.css` in sync;
the `recipe-css-contract` test enforces declared↔consumed parity).
- **Standalone charts** (pie/radar/bar-list/bar-segment) own their SVG/HTML +
tooltip; they don't use the `<Chart>` frame (angular / non-cartesian geometry).
## 5. Verification
- `npm run check` → **0 chart errors** (59 pre-existing, unrelated).
- Eidos contract tests **pass for chart** (recipe-css-contract, component-api,
component-visual-attrs, lint). The ~9 eidos-suite failures are pre-existing from
OTHER tracks (`palabras`, `spin-field`, `drawer`, `icon`) — not charts.
- Run a dev server and verify via `preview_eval` DOM measurements (not screenshots).

@ -0,0 +1,123 @@
# Chart
Data-visualisation family built on the in-house, **zero-dependency** plotting
engine [`$libs/plots`](../../../../libs/plots/README.md) (scales · shapes ·
ticks · curves). SVG, themeable via design tokens, composable primitives plus
ergonomic presets.
```svelte
<script lang="ts">
import { LineChart } from '$uix/eidos/components/chart';
</script>
<LineChart data={revenue} x="month" xScale="point" y={['revenue', 'expenses']} />
```
## Surface
- **`<Chart>`** — the compositional frame. Measures its width, computes the
scales from `data` + `x`/`y`, and shares them with SVG children through
context: `<Chart.Grid>` (alias `<Chart.CartesianGrid>`, x+y lines),
`<Chart.XAxis>`, `<Chart.YAxis>`, `<Chart.Line>`, `<Chart.Area>`, `<Chart.Bar>`,
`<Chart.Scatter>`, plus the config children `<Chart.Tooltip>` (cursor-following
readout) and `<Chart.Legend>` (HTML legend below the plot). Point scales span
**edge-to-edge** so the line/area touch the y-axis and the right edge.
- **Presets** (named exports) — `LineChart`, `AreaChart`, `BarChart`,
`ScatterChart`, `Sparkline` (line **or** `as="bars"`). They compose the frame +
axes + grid + marks + the HTML chrome (title + legend), with the hover tooltip
on by default (`tooltip` prop).
- **Standalone** (named exports, own SVG/HTML — non-cartesian geometry) —
`<PieChart>` (pie + donut via `donut={0..1}`), `<RadarChart>` (spider),
`<BarList>` (ranked rows), `<BarSegment>` (single stacked bar). Each owns its
own themed hover tooltip.
All live in this one family directory — new chart types are added here as named
exports, never as separate top-level component directories. The improvement
roadmap (incl. the MUI X Charts comparison) lives in
[`IMPROVEMENTS.md`](./IMPROVEMENTS.md).
## Baseline
**No Air baseline** — charts did not exist in the legacy `air/` layer. The
family is net-new, built on the in-house `$libs/plots` engine. The design
references are LayerCake / Pancake (compositional, Svelte) for the API shape and
d3-scale / d3-shape (algorithms, reimplemented) for the math — see
[`$libs/plots`](../../../../libs/plots/README.md).
## Passive justification (F-1.5)
`scope: ['eidos']`, **0 events**. A chart is a *derived render* of its data
(`data` → scales → SVG), like `Avatar`, `Image` and `QrCode`. It has no
interactive state machine and emits no perceptual signal, so there is no soma
behaviour and no sema projection. The opt-in hover **tooltip** is handled
entirely inside the eidos frame (local pointer tracking → `bisector.center` →
crosshair + readout); it stays a passive display because it neither mutates
component state nor projects a sema signal — no morfo event is involved.
## A2.3 exception — data marks
The mark parts (`line`, `area`, `bar`, `point`, `slice`) declare no `data-*`
beyond their part marker (`data-chart-{kebab}`). Their visual treatment is the
per-series colour, set inline from the theme palette
(`var(--color-{role}-solid)`) — there is no enum state to project as a
`data-*`. The axis carries `data-chart-axis="x|y"`.
## Subset (G-1.1)
Series colours cycle the canonical role palette via `seriesColor(index)`:
`primary · affirm · risk · secondary · fulfill · threat · loss · neutral`. A
mark's `color` prop accepts any `ColorRole`. Charts do not take an `intent`
prop — the palette is categorical (series identity), not evaluative.
## Comparativa
| Capability | Chart (UIX) | Chart.js | Recharts | unovis | LayerCake |
| --- | --- | --- | --- | --- | --- |
| Zero runtime deps | ✅ (own `$libs/plots`) | ❌ | ❌ (d3) | ❌ (d3) | ⚠️ (BYO d3) |
| Render | SVG | Canvas | SVG | SVG/Canvas/HTML | BYO |
| Compositional API | ✅ + presets | ❌ (config) | ✅ | ⚠️ | ✅ |
| Themed by tokens | ✅ (`--color-*`) | ⚠️ | ⚠️ | ⚠️ | ❌ |
| Svelte 5 runes-native | ✅ | ❌ | ❌ (React) | ⚠️ wrappers | ✅ |
Line/Bar/Area/Scatter/Pie/Sparkline cover the dashboard baseline. Heavy types
(candlestick, sankey, treemap, geo, 3D) are out of scope — see Gaps.
## Decisiones
- **One family, one morfo.** `chart` is a single eidos component (like a
layout family). The presets + pie share `chartMorfo` (provider + grid + axis
+ line/area/bar/point/slice + legend + title, all optional). New chart types
reuse the contract instead of inventing one each.
- **Provider = the `<svg role="img">`.** The svg is the labelled graphic
(`aria-label`); the responsive wrapper is the eidos-only `data-chart-frame`
shell. Matches the `QrCode` precedent.
- **Pixel-space marks.** Marks consume already-scaled points from the context,
so shapes stay decoupled from scales (the engine's compositional model).
- **`--chart-*` recipe for geometry, global tokens for colour.** Series colours
come from `--color-{role}-solid` and chrome from `--color-content-*` /
`--color-border-*` (categorical, theme-driven). Geometric constants (stroke
widths, point/bar radius, area opacity, tooltip surface, transition timing)
live in a `chart` recipe block (`lib/recipes/base.ts` → `--chart-*`), each
sourced from a canonical primitive — no magic numbers in `chart.css`.
- **SVG primitives in `MorfoElement`.** Charts are the first SVG-heavy
component; `g · rect · circle · line · figure · figcaption` were added to the
element union + schema (additive).
## Gaps
| Item | Disposition |
| --- | --- |
| Hover tooltip + crosshair (`bisector.center`) | ✅ **done** (cursor-following, opt-in `tooltip` prop) |
| Stacked bar / area (`shape-stack` ready in `$libs/plots`) | Tier 1 — [`IMPROVEMENTS.md`](./IMPROVEMENTS.md) |
| Highlight/fade · reference lines · secondary axis · interactive legend | Tier 1 — [`IMPROVEMENTS.md`](./IMPROVEMENTS.md) |
| Enter animation (`$motion`) · log scale · gradients | Tier 2 — [`IMPROVEMENTS.md`](./IMPROVEMENTS.md) |
| Keyboard data navigation + `<table>` data fallback | Tier 3 a11y — [`IMPROVEMENTS.md`](./IMPROVEMENTS.md) |
| Canvas/WebGL · zoom/pan · candlestick / sankey / treemap / geo / 3D | descartar — out of scope (SVG, "basic") |
## Theming
Series: `var(--color-{role}-solid)`. Axis lines: `--color-border-default`;
grid: `--color-border-subtle`; labels: `--color-content-secondary`; title:
`--color-content-primary`. Pie slice separators: `--color-surface-default`.
Geometry + tooltip surface: the `--chart-*` recipe tokens (`lib/recipes/base.ts`).
`chart.css` is code-split (imported from `chart.svelte`).

@ -0,0 +1,54 @@
<script lang="ts">
/** Ergonomic area chart — filled areas with a line edge per series. */
import Chart from './chart.svelte';
import Grid from './chart-grid.svelte';
import XAxis from './chart-x-axis.svelte';
import YAxis from './chart-y-axis.svelte';
import Line from './chart-line.svelte';
import Area from './chart-area.svelte';
import { seriesColor } from './context';
import type { AreaChartProps } from './types';
let {
data,
x,
y,
title,
legend = true,
noGrid = false,
xTicks,
yTicks,
formatX,
formatY,
curve = 'monotone',
opacity,
tooltip = true,
...frame
}: AreaChartProps = $props();
const seriesList = $derived(y === undefined ? [] : Array.isArray(y) ? [...y] : [y as string]);
const showLegend = $derived(legend && seriesList.length > 1);
</script>
<figure data-chart-figure>
{#if title}<figcaption data-chart-title>{title}</figcaption>{/if}
{#if showLegend}
<div data-chart-legend>
{#each seriesList as key, i}
<span data-chart-legend-item>
<span data-chart-swatch style="background: {seriesColor(i)}"></span>
{key}
</span>
{/each}
</div>
{/if}
<Chart {data} {x} {y} {tooltip} {...frame}>
{#if !noGrid}<Grid ticks={yTicks} />{/if}
<YAxis ticks={yTicks} format={formatY} />
<XAxis ticks={xTicks} format={formatX} />
{#each seriesList as key}
<Area series={key} {curve} {opacity} />
<Line series={key} {curve} />
{/each}
</Chart>
</figure>

@ -0,0 +1,49 @@
<script lang="ts">
/** Ergonomic bar chart — categorical x, grouped bars across series. */
import Chart from './chart.svelte';
import Grid from './chart-grid.svelte';
import XAxis from './chart-x-axis.svelte';
import YAxis from './chart-y-axis.svelte';
import Bar from './chart-bar.svelte';
import { seriesColor } from './context';
import type { BarChartProps } from './types';
let {
data,
x,
y,
title,
legend = true,
noGrid = false,
xTicks,
yTicks,
formatX,
formatY,
radius,
tooltip = true,
...frame
}: BarChartProps = $props();
const seriesList = $derived(y === undefined ? [] : Array.isArray(y) ? [...y] : [y as string]);
const showLegend = $derived(legend && seriesList.length > 1);
</script>
<figure data-chart-figure>
{#if title}<figcaption data-chart-title>{title}</figcaption>{/if}
{#if showLegend}
<div data-chart-legend>
{#each seriesList as key, i}
<span data-chart-legend-item>
<span data-chart-swatch style="background: {seriesColor(i)}"></span>
{key}
</span>
{/each}
</div>
{/if}
<Chart {data} {x} {y} {tooltip} {...frame} xScale="band">
{#if !noGrid}<Grid ticks={yTicks} />{/if}
<YAxis ticks={yTicks} format={formatY} />
<XAxis ticks={xTicks} format={formatX} />
<Bar {radius} />
</Chart>
</figure>

@ -0,0 +1,83 @@
<script lang="ts">
/**
* `<BarList>` — a ranked list of horizontal bars: label · proportional bar ·
* value, with a themed hover tooltip (label · value · % of total). Chakra
* bar-list style. Pure HTML/CSS (no SVG, no scales): each bar's width is its
* value relative to the max. Solid, opaque per-row colour on a neutral track;
* the label sits to the left so the bar stays fully saturated.
*/
import './chart.css';
import { ActiveEidos } from '$uix/eidos';
import { seriesColor } from './context';
import type { BarListProps } from './types';
let { data, color = 'primary', sort = true, limit, tooltip = true, format }: BarListProps = $props();
const eidos = ActiveEidos.require();
function fmt(v: number): string {
if (format) return format(v);
try {
return eidos.format?.numbers?.format?.(v) ?? String(v);
} catch {
return String(v);
}
}
const total = $derived(data.reduce((acc, d) => acc + (d.value > 0 ? d.value : 0), 0));
const items = $derived.by(() => {
let arr = data.map((d) => ({ label: d.label, value: d.value, fill: seriesColor(d.color ?? color) }));
if (sort) arr = [...arr].sort((a, b) => b.value - a.value);
if (limit !== undefined) arr = arr.slice(0, limit);
const max = Math.max(1, ...arr.map((d) => (d.value > 0 ? d.value : 0)));
return arr.map((d) => ({ ...d, pct: Math.max(0, Math.min(100, (d.value / max) * 100)) }));
});
// ── Themed hover tooltip ────────────────────────────────────────────────
let active = $state<number | null>(null);
let pointerX = $state(0);
let pointerY = $state(0);
let containerWidth = $state(0);
function onPointerMove(event: PointerEvent, i: number): void {
if (!tooltip) return;
active = i;
const box = (event.currentTarget as Element).closest('[data-chart-bar-list]') as Element;
const rect = box.getBoundingClientRect();
containerWidth = rect.width;
pointerX = event.clientX - rect.left;
pointerY = event.clientY - rect.top;
}
const flip = $derived(containerWidth > 0 && pointerX > containerWidth * 0.6);
const sharePct = $derived(
active === null || total <= 0 ? 0 : Math.round((items[active].value / total) * 100)
);
</script>
<div data-chart-bar-list onpointerleave={() => (active = null)}>
{#each items as item, i (item.label)}
<div
data-chart-bar-list-item
data-active={active === i ? 'true' : 'false'}
onpointermove={(e) => onPointerMove(e, i)}
>
<span data-chart-bar-list-label>{item.label}</span>
<div data-chart-bar-list-track>
<div data-chart-bar-list-bar style="inline-size: {item.pct}%; background: {item.fill};"></div>
</div>
<span data-chart-bar-list-value>{fmt(item.value)}</span>
</div>
{/each}
{#if tooltip && active !== null}
<div
data-chart-tooltip
data-flip={flip ? 'true' : 'false'}
style="left: {pointerX}px; top: {pointerY}px;"
>
<span data-chart-tooltip-x>{items[active].label}</span>
<span data-chart-tooltip-row>
<span data-chart-tooltip-swatch style="background: {items[active].fill};"></span>
value
<span data-chart-tooltip-value>{fmt(items[active].value)} · {sharePct}%</span>
</span>
</div>
{/if}
</div>

@ -0,0 +1,90 @@
<script lang="ts">
/**
* `<BarSegment>` — a single horizontal bar split into proportional segments
* (parts of a whole), with an optional legend and a themed hover tooltip.
* Chakra bar-segment style. Pure HTML/CSS: each segment's width is its share
* of the total.
*/
import './chart.css';
import { ActiveEidos } from '$uix/eidos';
import { seriesColor } from './context';
import type { BarSegmentProps } from './types';
let { data, legend = true, tooltip = true, format }: BarSegmentProps = $props();
const eidos = ActiveEidos.require();
function fmt(v: number): string {
if (format) return format(v);
try {
return eidos.format?.numbers?.format?.(v) ?? String(v);
} catch {
return String(v);
}
}
const total = $derived(data.reduce((acc, d) => acc + (d.value > 0 ? d.value : 0), 0));
const segments = $derived.by(() =>
data.map((d, i) => ({
label: d.label,
value: d.value,
fill: seriesColor(d.color ?? i),
pct: total > 0 ? ((d.value > 0 ? d.value : 0) / total) * 100 : 0
}))
);
// ── Themed hover tooltip ────────────────────────────────────────────────
let active = $state<number | null>(null);
let pointerX = $state(0);
let pointerY = $state(0);
let containerWidth = $state(0);
function onPointerMove(event: PointerEvent, i: number): void {
if (!tooltip) return;
active = i;
const box = (event.currentTarget as Element).closest('[data-chart-bar-segment]') as Element;
const rect = box.getBoundingClientRect();
containerWidth = rect.width;
pointerX = event.clientX - rect.left;
pointerY = event.clientY - rect.top;
}
const flip = $derived(containerWidth > 0 && pointerX > containerWidth * 0.6);
</script>
<div data-chart-bar-segment>
<div data-chart-bar-segment-track onpointerleave={() => (active = null)}>
{#each segments as seg, i (seg.label)}
<div
data-chart-bar-segment-fill
data-dim={active !== null && active !== i ? 'true' : 'false'}
style="inline-size: {seg.pct}%; background: {seg.fill};"
onpointermove={(e) => onPointerMove(e, i)}
></div>
{/each}
</div>
{#if legend}
<div data-chart-bar-segment-legend>
{#each segments as seg (seg.label)}
<span data-chart-bar-segment-legend-item>
<span data-chart-swatch style="background: {seg.fill};"></span>
{seg.label}
<span data-chart-bar-segment-legend-value>{fmt(seg.value)} · {Math.round(seg.pct)}%</span>
</span>
{/each}
</div>
{/if}
{#if tooltip && active !== null}
<div
data-chart-tooltip
data-flip={flip ? 'true' : 'false'}
style="left: {pointerX}px; top: {pointerY}px;"
>
<span data-chart-tooltip-x>{segments[active].label}</span>
<span data-chart-tooltip-row>
<span data-chart-tooltip-swatch style="background: {segments[active].fill};"></span>
value
<span data-chart-tooltip-value>
{fmt(segments[active].value)} · {Math.round(segments[active].pct)}%
</span>
</span>
</div>
{/if}
</div>

@ -0,0 +1,31 @@
<script lang="ts">
/** A filled area mark between the baseline and each series value. */
import { area as areaPath, curve as resolveCurve } from '$libs/plots';
import { getChartContext } from './context';
import type { ChartAreaProps } from './types';
let { series, color, curve = 'linear', opacity }: ChartAreaProps = $props();
const ctx = getChartContext();
const key = $derived(series ?? ctx.series[0]);
const index = $derived(key ? Math.max(0, ctx.series.indexOf(key)) : 0);
const fill = $derived(ctx.color(color ?? index));
const d = $derived.by(() => {
if (!key) return '';
const baseY = ctx.py(ctx.baseline);
const points = ctx.data
.map((row) => ({ x: ctx.px(row), y1: ctx.py(Number(row[key])), y0: baseY }))
.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y1));
return areaPath(points, { curve: resolveCurve(curve) });
});
</script>
<!-- fill-opacity comes from --chart-area-opacity (chart.css); `opacity` overrides. -->
<path
data-chart-area
d={d}
fill={fill}
stroke="none"
style={opacity !== undefined ? `fill-opacity: ${opacity}` : undefined}
/>

@ -0,0 +1,58 @@
<script lang="ts">
/**
* Bar mark for a band x scale. With `series` it fills the band; without it,
* every declared series renders grouped side-by-side within each band.
*/
import { getChartContext } from './context';
import type { ChartBarProps } from './types';
let { series, color, radius }: ChartBarProps = $props();
const ctx = getChartContext();
const keys = $derived(series ? [series] : [...ctx.series]);
interface Bar {
x: number;
y: number;
w: number;
h: number;
fill: string;
}
const bars = $derived.by<Bar[]>(() => {
const out: Bar[] = [];
const baseY = ctx.py(ctx.baseline);
const slots = Math.max(1, keys.length);
const slot = ctx.bandwidth / slots;
const w = slot * 0.86;
const inset = (slot - w) / 2;
for (const row of ctx.data) {
const start = ctx.bandStart(row);
keys.forEach((key, j) => {
const value = Number(row[key]);
if (Number.isNaN(value)) return;
const yValue = ctx.py(value);
const top = Math.min(yValue, baseY);
const h = Math.abs(yValue - baseY);
const roleIndex = series ? Math.max(0, ctx.series.indexOf(key)) : j;
out.push({ x: start + j * slot + inset, y: top, w, h, fill: ctx.color(color ?? roleIndex) });
});
}
return out;
});
</script>
<!-- Corner radius comes from --chart-bar-radius (chart.css); `radius` overrides. -->
<g data-chart-bars>
{#each bars as bar}
<rect
data-chart-bar
x={bar.x}
y={bar.y}
width={bar.w}
height={bar.h}
fill={bar.fill}
style={radius !== undefined ? `rx: ${radius}px` : undefined}
/>
{/each}
</g>

@ -0,0 +1,30 @@
<script lang="ts">
/**
* Cartesian grid lines — horizontal (y) by default, vertical (x) optional.
* Exposed as both `Chart.Grid` and `Chart.CartesianGrid`.
*/
import { getChartContext } from './context';
import type { ChartGridProps } from './types';
let { ticks = 5, x = false, y = true }: ChartGridProps = $props();
const ctx = getChartContext();
const yLines = $derived(y ? ctx.yScale.ticks(ticks).map((t) => ctx.yScale(t)) : []);
const xLines = $derived.by(() => {
if (!x) return [];
if (ctx.xKind === 'band' || ctx.xKind === 'point') {
return (ctx.xScale.domain as readonly string[]).map((category) => ctx.categoryX(category));
}
const scale = ctx.xScale as { ticks(count: number): number[] };
return scale.ticks(ticks).map((v) => (ctx.xScale as (value: number) => number)(v));
});
</script>
<g data-chart-grid>
{#each yLines as gy}
<line data-chart-grid-line x1={0} x2={ctx.plot.width} y1={gy} y2={gy} />
{/each}
{#each xLines as gx}
<line data-chart-grid-line x1={gx} x2={gx} y1={0} y2={ctx.plot.height} />
{/each}
</g>

@ -0,0 +1,13 @@
<script lang="ts">
/**
* `<Chart.Legend>` — a config child (renders nothing). It enables the frame's
* HTML legend, which lists the declared series with their themed swatches.
* Mirrors `<Chart.Tooltip>`: the frame owns the markup so the legend can sit
* below the fixed-height plot area. Enable on mount, dispose on unmount.
*/
import { onDestroy } from 'svelte';
import { getChartContext } from './context';
const ctx = getChartContext();
onDestroy(ctx.enableLegend());
</script>

@ -0,0 +1,32 @@
<script lang="ts">
/** A line mark. Maps each row's series value through the shared scales. */
import { curve as resolveCurve, line as linePath } from '$libs/plots';
import { getChartContext } from './context';
import type { ChartLineProps } from './types';
let { series, color, curve = 'linear', width }: ChartLineProps = $props();
const ctx = getChartContext();
const key = $derived(series ?? ctx.series[0]);
const index = $derived(key ? Math.max(0, ctx.series.indexOf(key)) : 0);
const stroke = $derived(ctx.color(color ?? index));
const d = $derived.by(() => {
if (!key) return '';
const points = ctx.data
.map((row) => ({ x: ctx.px(row), y: ctx.py(Number(row[key])) }))
.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y));
return linePath(points, { curve: resolveCurve(curve) });
});
</script>
<!-- stroke-width comes from --chart-line-width (chart.css); `width` overrides. -->
<path
data-chart-line
d={d}
fill="none"
stroke={stroke}
stroke-linejoin="round"
stroke-linecap="round"
style={width !== undefined ? `stroke-width: ${width}px` : undefined}
/>

@ -0,0 +1,32 @@
<script lang="ts">
/** A scatter mark — one circle per row at the series value. */
import { getChartContext } from './context';
import type { ChartScatterProps } from './types';
let { series, color, radius }: ChartScatterProps = $props();
const ctx = getChartContext();
const key = $derived(series ?? ctx.series[0]);
const index = $derived(key ? Math.max(0, ctx.series.indexOf(key)) : 0);
const fill = $derived(ctx.color(color ?? index));
const points = $derived.by(() => {
if (!key) return [];
return ctx.data
.map((row) => ({ cx: ctx.px(row), cy: ctx.py(Number(row[key])) }))
.filter((p) => Number.isFinite(p.cx) && Number.isFinite(p.cy));
});
</script>
<!-- Radius comes from --chart-point-radius (chart.css); `radius` overrides. -->
<g data-chart-scatter>
{#each points as p}
<circle
data-chart-point
cx={p.cx}
cy={p.cy}
fill={fill}
style={radius !== undefined ? `r: ${radius}px` : undefined}
/>
{/each}
</g>

@ -0,0 +1,15 @@
<script lang="ts">
/**
* `<Chart.Tooltip>` — enables the hover tooltip on the parent chart. A config
* component (renders nothing itself): on mount it registers with the frame,
* which owns the pointer tracking, crosshair + value readout; it disposes on
* unmount. Mirrors the Recharts / Chakra `<Tooltip>` composition element.
*/
import { onDestroy } from 'svelte';
import { getChartContext } from './context';
import type { ChartTooltipProps } from './types';
let { format }: ChartTooltipProps = $props();
const ctx = getChartContext();
onDestroy(ctx.enableTooltip({ format }));
</script>

@ -0,0 +1,39 @@
<script lang="ts">
/** The x axis: a baseline, tick marks and labels (categories or values). */
import { getChartContext } from './context';
import type { ChartAxisProps } from './types';
let { ticks = 6, format }: ChartAxisProps = $props();
const ctx = getChartContext();
const fmt = $derived(
format ?? ((v: number) => (ctx.xKind === 'time' ? ctx.formatTime(v) : ctx.formatNumber(v)))
);
interface Tick {
pos: number;
label: string;
}
const items = $derived.by<Tick[]>(() => {
if (ctx.xKind === 'band' || ctx.xKind === 'point') {
return (ctx.xScale.domain as readonly string[]).map((cat) => ({
pos: ctx.categoryX(cat),
label: cat
}));
}
const scale = ctx.xScale as { ticks(count: number): number[] };
return scale.ticks(ticks).map((v) => ({
pos: (ctx.xScale as (value: number) => number)(v),
label: fmt(v)
}));
});
</script>
<g data-chart-axis="x" transform="translate(0, {ctx.plot.height})">
<line data-chart-axis-line x1={0} y1={0} x2={ctx.plot.width} y2={0} />
{#each items as it}
<g transform="translate({it.pos}, 0)">
<line data-chart-axis-tick x1={0} x2={0} y1={0} y2={4} />
<text data-chart-axis-label y={16} text-anchor="middle">{it.label}</text>
</g>
{/each}
</g>

@ -0,0 +1,22 @@
<script lang="ts">
/** The y axis: a vertical line, tick marks and value labels. */
import { getChartContext } from './context';
import type { ChartAxisProps } from './types';
let { ticks = 5, format }: ChartAxisProps = $props();
const ctx = getChartContext();
const fmt = $derived(format ?? ((v: number) => ctx.formatNumber(v)));
const items = $derived(ctx.yScale.ticks(ticks).map((t) => ({ y: ctx.yScale(t), label: fmt(t) })));
</script>
<g data-chart-axis="y">
<line data-chart-axis-line x1={0} y1={0} x2={0} y2={ctx.plot.height} />
{#each items as it}
<g transform="translate(0, {it.y})">
<line data-chart-axis-tick x1={-4} x2={0} y1={0} y2={0} />
<text data-chart-axis-label x={-8} y={0} text-anchor="end" dominant-baseline="middle">
{it.label}
</text>
</g>
{/each}
</g>

@ -0,0 +1,283 @@
/**
* Chart recipe — styling for the SVG frame, axes, grid, marks and the hover
* tooltip, plus the HTML chrome (title + legend) the presets compose. Every
* geometric constant (stroke widths, radii, opacities, transition timing,
* tooltip surface) comes from a `--chart-*` recipe token sourced from canonical
* primitives — see `lib/recipes/base.ts`. Series fills/strokes are themed per
* series inline (`var(--color-{role}-solid)`); colours for chrome come from the
* global content/border/surface tokens.
*/
[data-chart-frame] {
display: flex;
flex-direction: column;
gap: var(--space-2);
inline-size: 100%;
}
[data-chart-plot] {
position: relative; /* anchor for the absolutely-positioned hover tooltip */
inline-size: 100%;
}
/* The plotting surface (morfo Provider — role="img"). */
[data-chart] {
display: block;
inline-size: 100%;
block-size: auto;
overflow: visible;
font-family: inherit;
}
/* Localised empty state when there is no data. */
[data-chart-empty] {
display: grid;
place-items: center;
block-size: 100%;
color: var(--color-content-secondary);
font-size: var(--font-size-sm);
}
/* ── Axes ─────────────────────────────────────────────────────────────── */
[data-chart-axis-line],
[data-chart-axis-tick] {
stroke: var(--color-border-default);
stroke-width: var(--chart-axis-width);
}
[data-chart-axis-label] {
fill: var(--color-content-secondary);
font-size: var(--font-size-xs);
}
/* ── Grid ─────────────────────────────────────────────────────────────── */
[data-chart-grid-line] {
stroke: var(--color-border-subtle);
stroke-width: var(--chart-grid-width);
}
/* ── Marks ────────────────────────────────────────────────────────────── */
[data-chart-line] {
stroke-width: var(--chart-line-width);
transition: opacity var(--chart-transition-duration) var(--chart-transition-ease);
}
[data-chart-area] {
fill-opacity: var(--chart-area-opacity);
transition: opacity var(--chart-transition-duration) var(--chart-transition-ease);
}
[data-chart-bar] {
rx: var(--chart-bar-radius);
transition: opacity var(--chart-transition-duration) var(--chart-transition-ease);
}
[data-chart-point] {
r: var(--chart-point-radius);
transition: opacity var(--chart-transition-duration) var(--chart-transition-ease);
}
/* Pie / donut slices, separated by a hairline of the page surface. */
[data-chart-slice] {
stroke: var(--color-surface-default);
stroke-width: var(--chart-slice-stroke-width);
transition: opacity var(--chart-transition-duration) var(--chart-transition-ease);
}
/* ── Hover crosshair + active markers ─────────────────────────────────── */
[data-chart-crosshair] {
stroke: var(--chart-crosshair-color);
stroke-width: var(--chart-crosshair-width);
stroke-dasharray: 3 3;
pointer-events: none;
}
[data-chart-active-dot] {
r: var(--chart-point-radius);
stroke: var(--color-surface-default);
stroke-width: var(--chart-axis-width);
pointer-events: none;
}
/* ── Hover tooltip ────────────────────────────────────────────────────── */
[data-chart-tooltip] {
position: absolute;
z-index: var(--z-index-tooltip);
pointer-events: none;
/* Anchored at the active datum's x (inline `left`) + cursor y (`top`);
offset to the side and vertically centred. Flips left near the edge. */
transform: translate(var(--space-3), -50%);
/* Glide between data points + flip side smoothly (the abrupt jump otherwise). */
transition:
left var(--chart-transition-duration) var(--chart-transition-ease),
transform var(--chart-transition-duration) var(--chart-transition-ease);
display: flex;
flex-direction: column;
gap: var(--chart-tooltip-gap);
padding: var(--chart-tooltip-padding);
background: var(--chart-tooltip-bg);
border: var(--chart-axis-width) solid var(--chart-tooltip-border);
border-radius: var(--chart-tooltip-radius);
box-shadow: var(--chart-tooltip-shadow);
font-size: var(--font-size-xs);
color: var(--color-content-primary);
min-inline-size: max-content;
}
[data-chart-tooltip][data-flip='true'] {
transform: translate(calc(-100% - var(--space-3)), -50%);
}
[data-chart-tooltip-x] {
font-weight: var(--font-weight-medium);
color: var(--color-content-primary);
}
[data-chart-tooltip-row] {
display: flex;
align-items: center;
gap: var(--chart-tooltip-gap);
color: var(--color-content-secondary);
}
[data-chart-tooltip-swatch] {
inline-size: 0.6em;
block-size: 0.6em;
border-radius: var(--radius-sm);
flex: none;
}
[data-chart-tooltip-value] {
margin-inline-start: var(--space-4);
font-variant-numeric: tabular-nums;
color: var(--color-content-primary);
}
/* ── Figure chrome (presets) ──────────────────────────────────────────── */
[data-chart-figure] {
/* Reset the browser's default <figure> margin (1em 40px) so the chart fills
and aligns with its container instead of sitting inset / off-centre. */
margin: 0;
position: relative; /* anchor for the pie's hover tooltip */
display: flex;
flex-direction: column;
gap: var(--space-2);
inline-size: 100%;
}
[data-chart-title] {
margin: 0;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-content-primary);
}
[data-chart-legend] {
display: flex;
flex-wrap: wrap;
gap: var(--space-1-5) var(--space-4);
align-items: center;
}
[data-chart-legend-item] {
display: inline-flex;
align-items: center;
gap: var(--space-1-5);
font-size: var(--font-size-xs);
color: var(--color-content-secondary);
}
[data-chart-swatch] {
inline-size: 0.75em;
block-size: 0.75em;
border-radius: var(--radius-sm);
flex: none;
}
/* ── Bar list (ranked horizontal bars) ────────────────────────────────── */
/* ONE grid for the whole list so the label / track / value columns line up
across every row — the track (the grey "100%" rail) is then identical-width
on every row, so the filled bars are directly comparable. */
[data-chart-bar-list] {
position: relative; /* anchor for the hover tooltip */
display: grid;
grid-template-columns: minmax(0, max-content) 1fr auto;
align-items: center;
gap: var(--space-2) var(--space-3);
inline-size: 100%;
}
[data-chart-bar-list-item] {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
}
[data-chart-bar-list-label] {
font-size: var(--font-size-sm);
color: var(--color-content-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-chart-bar-list-track] {
block-size: var(--control-height-sm, var(--space-7));
background: var(--color-surface-muted);
border-radius: var(--radius-sm);
overflow: hidden;
}
[data-chart-bar-list-bar] {
block-size: 100%;
border-radius: var(--radius-sm);
transition: inline-size var(--chart-transition-duration) var(--chart-transition-ease);
}
[data-chart-bar-list-value] {
font-size: var(--font-size-sm);
color: var(--color-content-secondary);
font-variant-numeric: tabular-nums;
text-align: end;
}
/* ── Bar segment (single stacked bar) ─────────────────────────────────── */
[data-chart-bar-segment] {
position: relative; /* anchor for the hover tooltip */
display: flex;
flex-direction: column;
gap: var(--space-3);
inline-size: 100%;
}
[data-chart-bar-segment-track] {
display: flex;
gap: var(--border-width-medium);
block-size: var(--control-height-sm, var(--space-7));
border-radius: var(--radius-md);
overflow: hidden;
}
[data-chart-bar-segment-fill] {
block-size: 100%;
min-inline-size: var(--border-width-thick);
transition:
inline-size var(--chart-transition-duration) var(--chart-transition-ease),
opacity var(--chart-transition-duration) var(--chart-transition-ease);
}
[data-chart-bar-segment-fill][data-dim='true'] {
opacity: var(--opacity-scrim);
}
[data-chart-bar-segment-legend] {
display: flex;
flex-wrap: wrap;
gap: var(--space-2) var(--space-4);
}
[data-chart-bar-segment-legend-item] {
display: inline-flex;
align-items: center;
gap: var(--space-1-5);
font-size: var(--font-size-xs);
color: var(--color-content-secondary);
}
[data-chart-bar-segment-legend-value] {
color: var(--color-content-primary);
font-variant-numeric: tabular-nums;
}
/* ── Radar / spider chart ─────────────────────────────────────────────── */
[data-chart-radar-ring],
[data-chart-radar-spoke] {
fill: none;
stroke: var(--color-border-subtle);
stroke-width: var(--chart-grid-width);
}
[data-chart-radar-area] {
fill-opacity: var(--chart-area-opacity);
stroke-width: var(--chart-line-width);
stroke-linejoin: round;
}

@ -0,0 +1,300 @@
<script lang="ts">
/**
* Eidos `<Chart>` — compositional chart frame. Measures its own width,
* computes the x/y scales from the data via `$libs/plots`, and shares them
* with its SVG children (grid, axes, marks) through context. It also owns the
* hover tooltip (enabled by `<Chart.Tooltip>` or the `tooltip` prop): pointer
* tracking, nearest-datum lookup (`bisector.center`), crosshair + active dots
* (SVG) and the value readout (HTML), all formatted through the locale-aware
* format engine. Titles/legends are composed around it (see presets).
*/
import './chart.css';
import { bisector, extent, nice, scaleBand, scaleLinear, scalePoint, scaleTime } from '$libs/plots';
import { ActiveEidos } from '$uix/eidos';
import {
seriesColor,
setChartContext,
type ChartContext,
type ChartTooltipConfig,
type ChartXScale
} from './context';
import type { ChartProps } from './types';
let {
data,
x,
y,
xScale: xKind = 'linear',
xDomain,
yDomain,
height = 260,
width: fixedWidth,
margin,
niceY = true,
padding = 0.2,
tooltip = false,
ariaLabel,
children
}: ChartProps = $props();
const eidos = ActiveEidos.require();
// Locale-aware default formatters (react to prefs.locale via the format
// engine). Optional-chained + guarded so a chart still renders if format
// was not wired into this ActiveEidos.
function formatNumber(value: number): string {
try {
return eidos.format?.numbers?.format?.(value) ?? String(value);
} catch {
return String(value);
}
}
function formatTime(ms: number): string {
try {
return (
eidos.format?.dates?.formatDate?.(new Date(ms), { month: 'short', day: 'numeric' }) ??
String(ms)
);
} catch {
return String(ms);
}
}
// Localised empty state (reacts to prefs.language), guarded for langs-less runtimes.
const emptyText = $derived.by(() => {
try {
return eidos.langs.ts('#?components.chart.empty|No data');
} catch {
return 'No data';
}
});
let measured = $state(0);
const width = $derived(fixedWidth ?? measured);
const margins = $derived({
top: margin?.top ?? 12,
right: margin?.right ?? 16,
bottom: margin?.bottom ?? 28,
left: margin?.left ?? 44
});
const series = $derived(y === undefined ? [] : Array.isArray(y) ? [...y] : [y as string]);
const isBandLike = $derived(xKind === 'band' || xKind === 'point');
const plot = $derived({
left: margins.left,
top: margins.top,
width: Math.max(0, width - margins.left - margins.right),
height: Math.max(0, height - margins.top - margins.bottom)
});
// y domain across every declared series, anchored at 0 unless overridden.
const yValues = $derived.by(() => {
const out: number[] = [];
for (const row of data) {
for (const key of series) {
const v = Number(row[key]);
if (!Number.isNaN(v)) out.push(v);
}
}
return out;
});
const yDom = $derived.by((): [number, number] => {
if (yDomain) return [yDomain[0], yDomain[1]];
const e = extent(yValues) ?? [0, 1];
const lo = Math.min(0, e[0]);
const hi = Math.max(0, e[1]);
const span: [number, number] = lo === hi ? [lo, lo + 1] : [lo, hi];
return niceY ? nice(span[0], span[1], 5) : span;
});
const yScale = $derived(scaleLinear({ domain: yDom, range: [plot.height, 0] }));
const baseValue = $derived(yDom[0] <= 0 && yDom[1] >= 0 ? 0 : yDom[0]);
const xScale = $derived.by<ChartXScale>(() => {
if (isBandLike) {
const domain = data.map((d) => String(d[x]));
return xKind === 'point'
? // point scale (line / area): span edge-to-edge so the line touches the
// y-axis and the right edge — the canonical look. `padding` is band-only.
scalePoint({ domain, range: [0, plot.width], padding: 0 })
: scaleBand({ domain, range: [0, plot.width], padding });
}
const nums = data.map((d) => Number(d[x]));
const dom = xDomain ? [xDomain[0], xDomain[1]] : (extent(nums) ?? [0, 1]);
return xKind === 'time'
? scaleTime({ domain: [dom[0], dom[1]], range: [0, plot.width] })
: scaleLinear({ domain: [dom[0], dom[1]], range: [0, plot.width] });
});
const bandwidth = $derived(
'bandwidth' in xScale ? (xScale as { bandwidth: number }).bandwidth : 0
);
function categoryX(category: string): number {
const pos = (xScale as (v: string) => number | undefined)(category) ?? 0;
return pos + (xKind === 'band' ? bandwidth / 2 : 0);
}
function pointX(datum: Record<string, unknown>): number {
if (isBandLike) return categoryX(String(datum[x]));
return (xScale as (v: number) => number)(Number(datum[x]));
}
// ── Hover tooltip ───────────────────────────────────────────────────────
let tooltipConfig = $state<ChartTooltipConfig | null>(null);
const tooltipOn = $derived(tooltip || tooltipConfig !== null);
let legendToken = $state<object | null>(null);
const legendOn = $derived(legendToken !== null);
let activeIndex = $state<number | null>(null);
let pointerX = $state(0);
let pointerY = $state(0);
const pxByIndex = $derived(data.map((d) => pointX(d)));
const xBisect = bisector((n: number) => n);
function onPointerMove(event: PointerEvent): void {
if (!tooltipOn || data.length === 0 || plot.width <= 0) return;
const rect = (event.currentTarget as Element).getBoundingClientRect();
const localX = event.clientX - rect.left - plot.left;
activeIndex = xBisect.center(pxByIndex, localX);
pointerX = event.clientX - rect.left;
pointerY = event.clientY - rect.top;
}
const anchorX = $derived(activeIndex === null ? 0 : plot.left + pxByIndex[activeIndex]);
const tooltipTop = $derived(Math.max(0, Math.min(pointerY, height)));
const tooltipFlip = $derived(width > 0 && anchorX > width * 0.6);
function xLabel(row: Record<string, unknown>): string {
if (isBandLike) return String(row[x]);
const value = Number(row[x]);
return xKind === 'time' ? formatTime(value) : formatNumber(value);
}
function valueLabel(value: number, key: string): string {
return tooltipConfig?.format?.(value, key) ?? formatNumber(value);
}
const ctx: ChartContext = {
get data() {
return data;
},
get x() {
return x;
},
get series() {
return series;
},
get xKind() {
return xKind;
},
get xScale() {
return xScale;
},
get yScale() {
return yScale;
},
get plot() {
return plot;
},
get bandwidth() {
return bandwidth;
},
get baseline() {
return baseValue;
},
px: (d) => pointX(d),
bandStart: (d) => pointX(d) - (xKind === 'band' ? bandwidth / 2 : 0),
categoryX,
py: (v) => yScale(v),
color: (i) => seriesColor(i),
formatNumber,
formatTime,
enableTooltip: (config = {}) => {
tooltipConfig = config;
return () => {
if (tooltipConfig === config) tooltipConfig = null;
};
},
enableLegend: () => {
const token = {};
legendToken = token;
return () => {
if (legendToken === token) legendToken = null;
};
}
};
setChartContext(ctx);
</script>
<div data-chart-frame bind:clientWidth={measured}>
<div data-chart-plot style="block-size: {height}px">
{#if data.length === 0}
<div data-chart-empty>{emptyText}</div>
{:else if plot.width > 0 && plot.height > 0}
<svg
data-chart
role="img"
aria-label={ariaLabel}
width={width}
height={height}
viewBox="0 0 {width} {height}"
onpointermove={onPointerMove}
onpointerleave={() => (activeIndex = null)}
>
<g transform="translate({plot.left}, {plot.top})">
{@render children?.()}
{#if tooltipOn && activeIndex !== null}
<line
data-chart-crosshair
x1={pxByIndex[activeIndex]}
x2={pxByIndex[activeIndex]}
y1={0}
y2={plot.height}
/>
{#each series as key, i (key)}
{@const v = Number(data[activeIndex][key])}
{#if Number.isFinite(v)}
<circle
data-chart-active-dot
cx={pxByIndex[activeIndex]}
cy={yScale(v)}
fill={seriesColor(i)}
/>
{/if}
{/each}
{/if}
</g>
</svg>
{#if tooltipOn && activeIndex !== null}
<div
data-chart-tooltip
data-flip={tooltipFlip ? 'true' : 'false'}
style="left: {anchorX}px; top: {tooltipTop}px;"
>
<span data-chart-tooltip-x>{xLabel(data[activeIndex])}</span>
{#each series as key, i (key)}
{@const v = Number(data[activeIndex][key])}
{#if Number.isFinite(v)}
<span data-chart-tooltip-row>
<span data-chart-tooltip-swatch style="background: {seriesColor(i)}"></span>
{key}
<span data-chart-tooltip-value>{valueLabel(v, key)}</span>
</span>
{/if}
{/each}
</div>
{/if}
{/if}
</div>
{#if legendOn && series.length > 0}
<div data-chart-legend>
{#each series as key, i (key)}
<span data-chart-legend-item>
<span data-chart-swatch style="background: {seriesColor(i)};"></span>
{key}
</span>
{/each}
</div>
{/if}
</div>

@ -0,0 +1,97 @@
import { getContext, setContext } from 'svelte';
import type { BandScale, LinearScale, PointScale, TimeScale } from '$libs/plots';
import type { ColorRole } from '$uix/eidos/lib/types';
import type { ChartXScaleKind } from './types';
const CHART_KEY = Symbol('chart');
export type ChartXScale = LinearScale | TimeScale | BandScale | PointScale;
/** Ordered role palette for multi-series colour assignment. */
export const SERIES_ROLES: readonly ColorRole[] = [
'primary',
'affirm',
'risk',
'secondary',
'fulfill',
'threat',
'loss',
'neutral'
];
/** Themed solid colour for a series by index (cycles) or explicit role. */
export function seriesColor(indexOrRole: number | ColorRole): string {
const role =
typeof indexOrRole === 'number'
? SERIES_ROLES[((indexOrRole % SERIES_ROLES.length) + SERIES_ROLES.length) % SERIES_ROLES.length]
: indexOrRole;
return `var(--color-${role}-solid)`;
}
/** Themed soft surface for a category fill (`var(--color-{role}-surface)`). */
export function seriesSurface(indexOrRole: number | ColorRole): string {
const role =
typeof indexOrRole === 'number'
? SERIES_ROLES[((indexOrRole % SERIES_ROLES.length) + SERIES_ROLES.length) % SERIES_ROLES.length]
: indexOrRole;
return `var(--color-${role}-surface)`;
}
export interface ChartPlot {
top: number;
left: number;
width: number;
height: number;
}
/** Tooltip configuration registered by `<Chart.Tooltip>`. */
export interface ChartTooltipConfig {
/** Format a series value for the tooltip. Defaults to the locale-aware number format. */
format?: (value: number, seriesKey: string) => string;
}
/**
* Shared frame state. The root computes scales + geometry and exposes them
* through getters so child marks stay reactive to data / size changes.
*/
export interface ChartContext {
readonly data: readonly Record<string, unknown>[];
readonly x: string;
readonly series: readonly string[];
readonly xKind: ChartXScaleKind;
readonly xScale: ChartXScale;
readonly yScale: LinearScale;
readonly plot: ChartPlot;
/** Band width in pixels (0 for non-band scales). */
readonly bandwidth: number;
/** Baseline value for area/bar fills — 0 when the domain crosses it, else the domain floor. */
readonly baseline: number;
/** Centre x (pixels) for a datum — band centre or the scaled value. */
px(datum: Record<string, unknown>): number;
/** Left edge x (pixels) for a bar at a datum. */
bandStart(datum: Record<string, unknown>): number;
/** Centre x (pixels) for a category label. */
categoryX(category: string): number;
/** y pixel for a value. */
py(value: number): number;
/** Themed series colour by index or role. */
color(indexOrRole: number | ColorRole): string;
/** Locale-aware default number formatter (axes + tooltip). */
formatNumber(value: number): string;
/** Locale-aware default formatter for an epoch-ms instant (time axis + tooltip). */
formatTime(ms: number): string;
/** Enable the hover tooltip (called by `<Chart.Tooltip>`). Returns a disposer. */
enableTooltip(config?: ChartTooltipConfig): () => void;
/** Enable the HTML legend (called by `<Chart.Legend>`). Returns a disposer. */
enableLegend(): () => void;
}
export function setChartContext(ctx: ChartContext): void {
setContext(CHART_KEY, ctx);
}
export function getChartContext(): ChartContext {
const ctx = getContext<ChartContext | undefined>(CHART_KEY);
if (!ctx) throw new Error('Chart parts must be used inside <Chart>');
return ctx;
}

@ -0,0 +1,73 @@
import ChartComponent from './chart.svelte';
import Grid from './chart-grid.svelte';
import XAxis from './chart-x-axis.svelte';
import YAxis from './chart-y-axis.svelte';
import Line from './chart-line.svelte';
import Area from './chart-area.svelte';
import Bar from './chart-bar.svelte';
import Scatter from './chart-scatter.svelte';
import Tooltip from './chart-tooltip.svelte';
import Legend from './chart-legend.svelte';
type ChartNamespace = typeof ChartComponent & {
Grid: typeof Grid;
CartesianGrid: typeof Grid;
XAxis: typeof XAxis;
YAxis: typeof YAxis;
Line: typeof Line;
Area: typeof Area;
Bar: typeof Bar;
Scatter: typeof Scatter;
Tooltip: typeof Tooltip;
Legend: typeof Legend;
};
const Chart = ChartComponent as ChartNamespace;
Chart.Grid = Grid;
Chart.XAxis = XAxis;
Chart.YAxis = YAxis;
Chart.Line = Line;
Chart.Area = Area;
Chart.Bar = Bar;
Chart.Scatter = Scatter;
Chart.CartesianGrid = Grid;
Chart.Tooltip = Tooltip;
Chart.Legend = Legend;
export { Chart };
export default Chart;
export { default as LineChart } from './line-chart.svelte';
export { default as AreaChart } from './area-chart.svelte';
export { default as BarChart } from './bar-chart.svelte';
export { default as ScatterChart } from './scatter-chart.svelte';
export { default as Sparkline } from './sparkline.svelte';
export { default as PieChart } from './pie-chart.svelte';
export { default as BarList } from './bar-list.svelte';
export { default as BarSegment } from './bar-segment.svelte';
export { default as RadarChart } from './radar-chart.svelte';
export { SERIES_ROLES, seriesColor } from './context';
export type { ChartContext } from './context';
export type {
AreaChartProps,
BarChartProps,
BarListProps,
BarSegmentProps,
ChartAreaProps,
ChartCategory,
ChartAxisProps,
ChartBarProps,
ChartGridProps,
ChartLineProps,
ChartProps,
ChartScatterProps,
ChartTooltipProps,
ChartXScaleKind,
LineChartProps,
PieChartProps,
PieDatum,
RadarChartProps,
ScatterChartProps,
SparklineProps
} from './types';

@ -0,0 +1,57 @@
<script lang="ts">
/** Ergonomic line chart — composes the frame, axes, grid and line marks. */
import Chart from './chart.svelte';
import Grid from './chart-grid.svelte';
import XAxis from './chart-x-axis.svelte';
import YAxis from './chart-y-axis.svelte';
import Line from './chart-line.svelte';
import Area from './chart-area.svelte';
import Scatter from './chart-scatter.svelte';
import { seriesColor } from './context';
import type { LineChartProps } from './types';
let {
data,
x,
y,
title,
legend = true,
noGrid = false,
xTicks,
yTicks,
formatX,
formatY,
curve = 'monotone',
area = false,
dots = false,
tooltip = true,
...frame
}: LineChartProps = $props();
const seriesList = $derived(y === undefined ? [] : Array.isArray(y) ? [...y] : [y as string]);
const showLegend = $derived(legend && seriesList.length > 1);
</script>
<figure data-chart-figure>
{#if title}<figcaption data-chart-title>{title}</figcaption>{/if}
{#if showLegend}
<div data-chart-legend>
{#each seriesList as key, i}
<span data-chart-legend-item>
<span data-chart-swatch style="background: {seriesColor(i)}"></span>
{key}
</span>
{/each}
</div>
{/if}
<Chart {data} {x} {y} {tooltip} {...frame}>
{#if !noGrid}<Grid ticks={yTicks} />{/if}
<YAxis ticks={yTicks} format={formatY} />
<XAxis ticks={xTicks} format={formatX} />
{#each seriesList as key}
{#if area}<Area series={key} {curve} />{/if}
<Line series={key} {curve} />
{#if dots}<Scatter series={key} radius={3} />{/if}
{/each}
</Chart>
</figure>

@ -0,0 +1,124 @@
<script lang="ts">
/**
* Standalone pie / donut chart. Angular geometry doesn't fit the x/y frame,
* so this owns its own SVG and consumes `arc` + `pie` from the engine. It
* also owns its own hover tooltip (per slice → label · value · percentage),
* since there's no Chart frame to delegate to.
*/
import './chart.css';
import { arc, pie } from '$libs/plots';
import { ActiveEidos } from '$uix/eidos';
import { seriesColor } from './context';
import type { PieChartProps } from './types';
let {
data,
size = 220,
donut = 0,
padAngle = 0.02,
legend = true,
tooltip = true,
title,
ariaLabel
}: PieChartProps = $props();
const eidos = ActiveEidos.require();
// Locale-aware number formatting (reacts to prefs.locale), guarded.
const formatNumber = (v: number): string => {
try {
return eidos.format?.numbers?.format?.(v) ?? String(v);
} catch {
return String(v);
}
};
const radius = $derived(size / 2);
const inner = $derived(radius * Math.min(0.95, Math.max(0, donut)));
const total = $derived(data.reduce((acc, d) => acc + (d.value > 0 ? d.value : 0), 0));
const slices = $derived.by(() =>
pie(
data.map((d) => d.value),
{ padAngle }
).map((slice, i) => ({
d: arc({
innerRadius: inner,
outerRadius: radius,
startAngle: slice.startAngle,
endAngle: slice.endAngle,
cx: radius,
cy: radius
}),
fill: seriesColor(data[i].color ?? i),
label: data[i].label,
value: data[i].value
}))
);
// ── Hover tooltip ───────────────────────────────────────────────────────
let active = $state<number | null>(null);
let pointerX = $state(0);
let pointerY = $state(0);
function onPointerMove(event: PointerEvent, i: number): void {
if (!tooltip) return;
active = i;
const figure = (event.currentTarget as Element).closest('[data-chart-figure]');
const rect = (figure ?? (event.currentTarget as Element)).getBoundingClientRect();
pointerX = event.clientX - rect.left;
pointerY = event.clientY - rect.top;
}
const percent = $derived(
active === null || total <= 0 ? 0 : Math.round((slices[active].value / total) * 100)
);
const tooltipFlip = $derived(pointerX > size * 0.6);
</script>
<figure data-chart-figure>
{#if title}<figcaption data-chart-title>{title}</figcaption>{/if}
<svg
data-chart
role="img"
aria-label={ariaLabel ?? title}
width={size}
height={size}
viewBox="0 0 {size} {size}"
style="inline-size: {size}px; flex: none; align-self: center;"
onpointerleave={() => (active = null)}
>
{#each slices as slice, i (slice.label)}
<path
data-chart-slice
d={slice.d}
fill={slice.fill}
opacity={active === null || active === i ? 1 : 0.45}
onpointermove={(e) => onPointerMove(e, i)}
/>
{/each}
</svg>
{#if legend}
<div data-chart-legend>
{#each slices as slice (slice.label)}
<span data-chart-legend-item>
<span data-chart-swatch style="background: {slice.fill}"></span>
{slice.label}
</span>
{/each}
</div>
{/if}
{#if tooltip && active !== null}
<div
data-chart-tooltip
data-flip={tooltipFlip ? 'true' : 'false'}
style="left: {pointerX}px; top: {pointerY}px;"
>
<span data-chart-tooltip-x>{slices[active].label}</span>
<span data-chart-tooltip-row>
<span data-chart-tooltip-swatch style="background: {slices[active].fill}"></span>
value
<span data-chart-tooltip-value>{formatNumber(slices[active].value)} · {percent}%</span>
</span>
</div>
{/if}
</figure>

@ -0,0 +1,175 @@
<script lang="ts">
/**
* `<RadarChart>` — a radar / spider chart. Categories sit around a circle;
* each series is a polygon whose vertices are the value at each category,
* placed by a radial `scaleLinear` from the engine. N-gon grid rings + spokes,
* category labels, legend and a hover tooltip (nearest category by cursor
* angle). Standalone (angular geometry doesn't fit the x/y frame).
*/
import './chart.css';
import { extent, nice, scaleLinear } from '$libs/plots';
import { ActiveEidos } from '$uix/eidos';
import { seriesColor } from './context';
import type { RadarChartProps } from './types';
let {
data,
axis,
series,
max,
size = 300,
levels = 4,
legend = true,
tooltip = true,
format,
title,
ariaLabel
}: RadarChartProps = $props();
const eidos = ActiveEidos.require();
function fmt(v: number): string {
if (format) return format(v);
try {
return eidos.format?.numbers?.format?.(v) ?? String(v);
} catch {
return String(v);
}
}
const seriesKeys = $derived(Array.isArray(series) ? [...series] : [series as string]);
const categories = $derived(data.map((d) => String(d[axis])));
const center = $derived(size / 2);
const radius = $derived(center - size * 0.16);
const domainMax = $derived.by(() => {
if (max !== undefined) return max;
const vals: number[] = [];
for (const row of data) for (const k of seriesKeys) {
const v = Number(row[k]);
if (!Number.isNaN(v)) vals.push(v);
}
const e = extent(vals) ?? [0, 1];
return nice(0, Math.max(1, e[1]), levels)[1];
});
const rScale = $derived(scaleLinear({ domain: [0, domainMax], range: [0, radius] }));
const angleFor = (i: number): number =>
-Math.PI / 2 + (i * 2 * Math.PI) / Math.max(1, categories.length);
function vertex(i: number, r: number): [number, number] {
const a = angleFor(i);
return [center + r * Math.cos(a), center + r * Math.sin(a)];
}
const ptsFrom = (mapper: (i: number) => [number, number]): string =>
categories
.map((_, i) => {
const [x, y] = mapper(i);
return `${Math.round(x * 100) / 100},${Math.round(y * 100) / 100}`;
})
.join(' ');
const rings = $derived(
Array.from({ length: levels }, (_, l) => ptsFrom((i) => vertex(i, (radius * (l + 1)) / levels)))
);
const spokes = $derived(categories.map((_, i) => vertex(i, radius)));
const polygons = $derived(
seriesKeys.map((key, s) => ({
key,
fill: seriesColor(s),
points: ptsFrom((i) => vertex(i, rScale(Number(data[i][key]) || 0)))
}))
);
const labels = $derived(
categories.map((c, i) => {
const [x, y] = vertex(i, radius + size * 0.06);
const cos = Math.cos(angleFor(i));
return { c, x, y, anchor: cos > 0.3 ? 'start' : cos < -0.3 ? 'end' : 'middle' };
})
);
// ── Hover tooltip — nearest category by cursor angle ────────────────────
let active = $state<number | null>(null);
let pointerX = $state(0);
let pointerY = $state(0);
let figureWidth = $state(0);
function onPointerMove(event: PointerEvent): void {
if (!tooltip || categories.length === 0) return;
const svgRect = (event.currentTarget as Element).getBoundingClientRect();
const figure = (event.currentTarget as Element).closest('[data-chart-figure]') as Element;
const figRect = figure.getBoundingClientRect();
figureWidth = figRect.width;
pointerX = event.clientX - figRect.left;
pointerY = event.clientY - figRect.top;
const a = Math.atan2(event.clientY - svgRect.top - center, event.clientX - svgRect.left - center);
const step = (2 * Math.PI) / categories.length;
let i = Math.round((a + Math.PI / 2) / step) % categories.length;
if (i < 0) i += categories.length;
active = i;
}
const flip = $derived(figureWidth > 0 && pointerX > figureWidth * 0.6);
</script>
<figure data-chart-figure>
{#if title}<figcaption data-chart-title>{title}</figcaption>{/if}
<svg
data-chart
role="img"
aria-label={ariaLabel ?? title ?? 'Radar chart'}
width={size}
height={size}
viewBox="0 0 {size} {size}"
style="inline-size: {size}px; flex: none; align-self: center;"
onpointermove={onPointerMove}
onpointerleave={() => (active = null)}
>
{#each rings as ring}<polygon data-chart-radar-ring points={ring} />{/each}
{#each spokes as [x, y]}
<line data-chart-radar-spoke x1={center} y1={center} x2={x} y2={y} />
{/each}
{#each polygons as poly (poly.key)}
<polygon data-chart-radar-area points={poly.points} fill={poly.fill} stroke={poly.fill} />
{/each}
{#if active !== null}
{#each seriesKeys as key, s (key)}
{@const v = Number(data[active][key])}
{#if Number.isFinite(v)}
{@const [cx, cy] = vertex(active, rScale(v))}
<circle data-chart-active-dot {cx} {cy} fill={seriesColor(s)} />
{/if}
{/each}
{/if}
{#each labels as lab}
<text data-chart-axis-label x={lab.x} y={lab.y} text-anchor={lab.anchor} dominant-baseline="middle">
{lab.c}
</text>
{/each}
</svg>
{#if legend && seriesKeys.length > 1}
<div data-chart-legend>
{#each seriesKeys as key, s (key)}
<span data-chart-legend-item>
<span data-chart-swatch style="background: {seriesColor(s)};"></span>
{key}
</span>
{/each}
</div>
{/if}
{#if tooltip && active !== null}
<div
data-chart-tooltip
data-flip={flip ? 'true' : 'false'}
style="left: {pointerX}px; top: {pointerY}px;"
>
<span data-chart-tooltip-x>{categories[active]}</span>
{#each seriesKeys as key, s (key)}
{@const v = Number(data[active][key])}
{#if Number.isFinite(v)}
<span data-chart-tooltip-row>
<span data-chart-tooltip-swatch style="background: {seriesColor(s)};"></span>
{key}
<span data-chart-tooltip-value>{fmt(v)}</span>
</span>
{/if}
{/each}
</div>
{/if}
</figure>

@ -0,0 +1,51 @@
<script lang="ts">
/** Ergonomic scatter chart — points per series over a continuous x. */
import Chart from './chart.svelte';
import Grid from './chart-grid.svelte';
import XAxis from './chart-x-axis.svelte';
import YAxis from './chart-y-axis.svelte';
import Scatter from './chart-scatter.svelte';
import { seriesColor } from './context';
import type { ScatterChartProps } from './types';
let {
data,
x,
y,
title,
legend = true,
noGrid = false,
xTicks,
yTicks,
formatX,
formatY,
radius,
tooltip = true,
...frame
}: ScatterChartProps = $props();
const seriesList = $derived(y === undefined ? [] : Array.isArray(y) ? [...y] : [y as string]);
const showLegend = $derived(legend && seriesList.length > 1);
</script>
<figure data-chart-figure>
{#if title}<figcaption data-chart-title>{title}</figcaption>{/if}
{#if showLegend}
<div data-chart-legend>
{#each seriesList as key, i}
<span data-chart-legend-item>
<span data-chart-swatch style="background: {seriesColor(i)}"></span>
{key}
</span>
{/each}
</div>
{/if}
<Chart {data} {x} {y} {tooltip} {...frame}>
{#if !noGrid}<Grid ticks={yTicks} />{/if}
<YAxis ticks={yTicks} format={formatY} />
<XAxis ticks={xTicks} format={formatX} />
{#each seriesList as key}
<Scatter series={key} {radius} />
{/each}
</Chart>
</figure>

@ -0,0 +1,33 @@
<script lang="ts">
/** A compact, axis-less trend — line or bars — for inline use in tables, cards, feeds. */
import Chart from './chart.svelte';
import Area from './chart-area.svelte';
import Line from './chart-line.svelte';
import Bar from './chart-bar.svelte';
import type { SparklineProps } from './types';
let {
data,
x,
y,
as = 'line',
curve = 'monotone',
area = true,
color,
height = 40,
...frame
}: SparklineProps = $props();
const margin = { top: 3, right: 3, bottom: 3, left: 3 };
</script>
{#if as === 'bars'}
<Chart {data} {x} {y} {height} {...frame} xScale="band" padding={0.25} {margin}>
<Bar {color} />
</Chart>
{:else}
<Chart {data} {x} {y} {height} {...frame} {margin}>
{#if area}<Area {curve} {color} />{/if}
<Line {curve} {color} />
</Chart>
{/if}

@ -0,0 +1,223 @@
import type { Snippet } from 'svelte';
import type { CurveName } from '$libs/plots';
import type { ColorRole } from '$uix/eidos/lib/types';
export type ChartData = readonly Record<string, unknown>[];
export type ChartXScaleKind = 'linear' | 'time' | 'band' | 'point';
export interface ChartMargin {
top?: number;
right?: number;
bottom?: number;
left?: number;
}
export interface ChartProps {
/** Rows of data; each mark reads its series key off these rows. */
data: ChartData;
/** Key on each row for the x position. */
x: string;
/** Series key(s) used to compute the y domain. */
y?: string | readonly string[];
/** Map x continuously (`linear`/`time`) or categorically (`band`/`point`). Default `linear`. */
xScale?: ChartXScaleKind;
/** Override the x domain (continuous scales only). */
xDomain?: readonly [number, number];
/** Override the y domain. Defaults to a 0-anchored, niced extent. */
yDomain?: readonly [number, number];
/** Plot height in pixels (width fills the container). Default 260. */
height?: number;
/** Fixed width in pixels. Omit to measure the container. */
width?: number;
/** Inner margins reserving room for axes. */
margin?: ChartMargin;
/** Round the y domain to nice numbers. Default true. */
niceY?: boolean;
/** Band (bar) padding (0..1). Default 0.2. Point scales span edge-to-edge. */
padding?: number;
/** Enable the hover tooltip (crosshair + value readout). Default false; presets default true. */
tooltip?: boolean;
/** Accessible label for the chart image. */
ariaLabel?: string;
children?: Snippet;
}
export interface ChartSeriesProps {
/** Series key. Defaults to the first declared series. */
series?: string;
/** Override colour by role; defaults to the series' palette slot. */
color?: ColorRole;
}
export interface ChartLineProps extends ChartSeriesProps {
curve?: CurveName;
/** Stroke width in pixels. Default 2. */
width?: number;
}
export interface ChartAreaProps extends ChartSeriesProps {
curve?: CurveName;
/** Fill opacity. Default 0.15. */
opacity?: number;
}
export interface ChartBarProps extends ChartSeriesProps {
/** Corner radius in pixels. Default 2. */
radius?: number;
}
export interface ChartScatterProps extends ChartSeriesProps {
/** Point radius in pixels. Default 3.5. */
radius?: number;
}
export interface ChartAxisProps {
/** Approximate tick count for continuous axes. */
ticks?: number;
/** Format a tick value to a label. */
format?: (value: number) => string;
}
export interface ChartGridProps {
/** Approximate grid-line count. Default 5. */
ticks?: number;
/** Draw vertical grid lines (at x ticks / categories). Default false. */
x?: boolean;
/** Draw horizontal grid lines (at y ticks). Default true. */
y?: boolean;
}
export interface ChartTooltipProps {
/** Format a series value for the tooltip. Defaults to the locale-aware number format. */
format?: (value: number, seriesKey: string) => string;
}
// ── Ergonomic presets ──────────────────────────────────────────────────────
interface PresetBase extends Omit<ChartProps, 'children'> {
/** Optional title rendered above the plot. */
title?: string;
/** Show a legend when there is more than one series. Default true. */
legend?: boolean;
/** Approximate x tick count. */
xTicks?: number;
/** Approximate y tick count. */
yTicks?: number;
/** Format x axis labels. */
formatX?: (value: number) => string;
/** Format y axis labels. */
formatY?: (value: number) => string;
/** Hide the grid. Default false. */
noGrid?: boolean;
/** Show the hover tooltip. Default true. */
tooltip?: boolean;
}
export interface LineChartProps extends PresetBase {
curve?: CurveName;
/** Also fill the area under each line. Default false. */
area?: boolean;
/** Show a dot at each point. Default false. */
dots?: boolean;
}
export interface AreaChartProps extends PresetBase {
curve?: CurveName;
/** Area fill opacity. Default 0.18. */
opacity?: number;
}
export interface BarChartProps extends PresetBase {
/** Bar corner radius. Default 2. */
radius?: number;
}
export interface ScatterChartProps extends PresetBase {
/** Point radius. Default 3.5. */
radius?: number;
}
export interface SparklineProps extends Omit<ChartProps, 'children' | 'y'> {
/** Single series key. */
y: string;
/** Render as a line (default) or vertical bars. */
as?: 'line' | 'bars';
curve?: CurveName;
/** Fill the area under the line (line mode only). Default true. */
area?: boolean;
color?: ColorRole;
}
export interface ChartCategory {
label: string;
value: number;
color?: ColorRole;
}
/** Alias kept for the pie API; same shape as ChartCategory. */
export type PieDatum = ChartCategory;
export interface RadarChartProps {
/** Rows; one per axis/category. */
data: readonly Record<string, unknown>[];
/** Key for the axis (category) label on each row. */
axis: string;
/** Series key(s) — one polygon each. */
series: string | readonly string[];
/** Domain max. Default: niced max across all series. */
max?: number;
/** Diameter in pixels. Default 300. */
size?: number;
/** Number of concentric grid rings. Default 4. */
levels?: number;
/** Show the legend (multi-series). Default true. */
legend?: boolean;
/** Show the themed hover tooltip. Default true. */
tooltip?: boolean;
/** Format values in the tooltip. Defaults to the locale-aware number format. */
format?: (value: number) => string;
/** Optional title above the chart. */
title?: string;
ariaLabel?: string;
}
export interface BarListProps {
data: readonly ChartCategory[];
/** Default bar colour (role) for items without their own `color`. Default 'primary'. */
color?: ColorRole;
/** Sort descending by value. Default true. */
sort?: boolean;
/** Show only the top N items. Default: all. */
limit?: number;
/** Show the themed hover tooltip (label · value · % of total). Default true. */
tooltip?: boolean;
/** Format the value. Defaults to the locale-aware number format. */
format?: (value: number) => string;
}
export interface BarSegmentProps {
data: readonly ChartCategory[];
/** Show the legend below the bar. Default true. */
legend?: boolean;
/** Show the themed hover tooltip (label · value · %). Default true. */
tooltip?: boolean;
/** Format values in the legend + tooltip. Defaults to the locale-aware number format. */
format?: (value: number) => string;
}
export interface PieChartProps {
data: readonly PieDatum[];
/** Diameter in pixels. Default 220. */
size?: number;
/** Inner radius as a fraction of the outer (0 = pie, 0.6 = donut). Default 0. */
donut?: number;
/** Gap between slices in radians. Default 0.02. */
padAngle?: number;
/** Show the legend. Default true. */
legend?: boolean;
/** Show the hover tooltip (label · value · percentage). Default true. */
tooltip?: boolean;
/** Optional title above the chart. */
title?: string;
ariaLabel?: string;
}

@ -826,7 +826,6 @@
--breadcrumb-ellipsis-bg-hover: var(--color-surface-raised);
--breadcrumb-ellipsis-color: var(--color-content-muted);
--breadcrumb-ellipsis-color-hover: var(--color-content-primary);
--breadcrumb-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--breadcrumb-transition-duration: var(--duration-fast);
--breadcrumb-transition-ease: var(--ease-default);
--card-px-xs: var(--space-3);
@ -945,11 +944,6 @@
--checkbox-size-xl-indicator-stroke: 2px;
--checkbox-radius: var(--radius-sm);
--checkbox-border-width: var(--border-width);
--checkbox-focus-ring-offset: var(--focus-ring-offset);
--checkbox-focus-ring-width: var(--focus-ring-width);
--checkbox-focus-ring-color: var(--focus-ring-color);
--checkbox-focus-ring-surface: var(--color-surface-default);
--checkbox-focus-ring: 0 0 0 var(--checkbox-focus-ring-offset) var(--checkbox-focus-ring-surface), 0 0 0 calc(var(--checkbox-focus-ring-offset) + var(--checkbox-focus-ring-width)) var(--checkbox-focus-ring-color);
--checkbox-transition-duration: var(--duration-fast);
--checkbox-transition-ease: var(--ease-default);
--checkbox-stroke-duration: var(--duration-fast);
@ -1075,7 +1069,6 @@
--field-control-bg-disabled: var(--color-surface-muted);
--field-control-color: var(--color-content-primary);
--field-control-placeholder-color: var(--color-content-muted);
--field-control-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--field-affix-color: var(--color-content-secondary);
--field-message-line-height: var(--leading-ui);
--field-helper-color: var(--color-content-secondary);
@ -1278,7 +1271,6 @@
--spin-field-border-invalid: var(--color-risk-border);
--spin-field-bg: var(--color-surface-default);
--spin-field-color: var(--color-content-primary);
--spin-field-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--spin-field-control-radius: 0;
--spin-field-control-border-width: var(--border-width);
--spin-field-control-border: transparent;
@ -1296,7 +1288,6 @@
--spin-field-control-decrement-glyph: '\2212';
--spin-field-control-increment-glyph-stacked: '\25B2';
--spin-field-control-decrement-glyph-stacked: '\25BC';
--spin-field-control-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--spin-field-control-disabled-opacity: 0.48;
--spin-field-transition-duration: var(--duration-fast);
--spin-field-transition-ease: var(--ease-default);
@ -1403,11 +1394,10 @@
--date-field-border-invalid: var(--color-risk-border);
--date-field-bg: var(--color-surface-default);
--date-field-color: var(--color-content-primary);
--date-field-label-color: var(--color-content-secondary);
--date-field-label-color: var(--color-content-primary);
--date-field-label-font-weight: var(--font-weight-regular);
--date-field-placeholder-color: var(--color-content-muted);
--date-field-literal-color: var(--color-content-muted);
--date-field-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--date-field-segment-height: calc(var(--_date-field-height) - var(--space-2));
--date-field-segment-radius: var(--radius-sm);
--date-field-segment-color: var(--color-content-primary);
@ -1452,11 +1442,10 @@
--time-field-border-invalid: var(--color-risk-border);
--time-field-bg: var(--color-surface-default);
--time-field-color: var(--color-content-primary);
--time-field-label-color: var(--color-content-secondary);
--time-field-label-color: var(--color-content-primary);
--time-field-label-font-weight: var(--font-weight-regular);
--time-field-placeholder-color: var(--color-content-muted);
--time-field-literal-color: var(--color-content-muted);
--time-field-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--time-field-segment-height: calc(var(--_time-field-height) - var(--space-2));
--time-field-segment-radius: var(--radius-sm);
--time-field-segment-color: var(--color-content-primary);
@ -1501,11 +1490,10 @@
--color-field-border-invalid: var(--color-risk-border);
--color-field-bg: var(--color-surface-default);
--color-field-color: var(--color-content-primary);
--color-field-label-color: var(--color-content-secondary);
--color-field-label-color: var(--color-content-primary);
--color-field-label-font-weight: var(--font-weight-regular);
--color-field-placeholder-color: var(--color-content-muted);
--color-field-literal-color: var(--color-content-muted);
--color-field-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--color-field-segment-height: calc(var(--_color-field-height) - var(--space-2));
--color-field-segment-radius: var(--radius-sm);
--color-field-segment-color: var(--color-content-primary);
@ -1526,10 +1514,6 @@
--color-field-transition-duration: var(--duration-fast);
--color-field-transition-ease: var(--ease-default);
--color-field-disabled-opacity: var(--opacity-disabled);
--color-picker-content-gap: var(--space-3);
--color-picker-content-padding-sm: var(--space-3);
--color-picker-content-padding-md: var(--space-3-5);
--color-picker-content-padding-lg: var(--space-4);
--color-picker-content-width-sm: 17rem;
--color-picker-content-width-md: 19.5rem;
--color-picker-content-width-lg: 22rem;
@ -1552,7 +1536,6 @@
--color-picker-trigger-swatch-size-lg: calc(var(--control-height-lg) - var(--space-2));
--color-picker-trigger-swatch-radius: var(--radius-full);
--color-picker-trigger-swatch-border: var(--color-border-subtle);
--color-picker-trigger-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--color-picker-area-height: 11rem;
--color-picker-area-radius: var(--radius-md);
--color-picker-area-border-width: var(--border-width);
@ -1751,7 +1734,6 @@
--calendar-day-weekend-color: var(--color-content-secondary);
--calendar-day-holiday-shadow: inset 0 -2px 0 var(--_calendar-accent-border);
--calendar-day-unavailable-decoration: var(--color-risk-border);
--calendar-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--calendar-event-shadow: 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--_calendar-accent-border);
--calendar-transition-duration: var(--duration-fast);
--calendar-transition-ease: var(--ease-default);
@ -1798,7 +1780,6 @@
--pagination-selected-bg-hover: var(--color-primary-solid-hover);
--pagination-selected-color: var(--color-content-on-solid);
--pagination-ellipsis-color: var(--color-content-muted);
--pagination-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--pagination-transition-duration: var(--duration-fast);
--pagination-transition-ease: var(--ease-default);
--pagination-disabled-opacity: var(--opacity-disabled);
@ -1923,11 +1904,6 @@
--radio-group-indicator-bg-checked-disabled: color-mix( in srgb, var(--color-content-disabled) 40%, var(--color-surface-overlay) );
--radio-group-dot-color: var(--color-content-on-solid);
--radio-group-dot-color-disabled: color-mix( in srgb, var(--color-content-on-solid) 82%, var(--color-surface-default) );
--radio-group-focus-ring-offset: var(--focus-ring-offset);
--radio-group-focus-ring-width: var(--focus-ring-width);
--radio-group-focus-ring-color: var(--focus-ring-color);
--radio-group-focus-ring-surface: var(--color-surface-default);
--radio-group-focus-ring: 0 0 0 var(--radio-group-focus-ring-offset) var(--radio-group-focus-ring-surface), 0 0 0 calc(var(--radio-group-focus-ring-offset) + var(--radio-group-focus-ring-width)) var(--radio-group-focus-ring-color);
--radio-group-transition-duration: var(--duration-fast);
--radio-group-transition-ease: var(--ease-default);
--radio-group-disabled-opacity: var(--opacity-disabled);
@ -1944,6 +1920,23 @@
--radio-group-label-font-weight: var(--font-weight-medium);
--radio-group-label-line-height: var(--leading-ui);
--radio-group-label-color: var(--color-content-primary);
--chart-line-width: var(--border-width-medium);
--chart-axis-width: var(--border-width-thin);
--chart-grid-width: var(--border-width-thin);
--chart-bar-radius: var(--radius-sm);
--chart-point-radius: 3.5px;
--chart-area-opacity: 0.15;
--chart-slice-stroke-width: var(--border-width-thin);
--chart-transition-duration: var(--duration-fast);
--chart-transition-ease: var(--ease-out);
--chart-tooltip-bg: var(--color-surface-overlay);
--chart-tooltip-border: var(--color-border-default);
--chart-tooltip-radius: var(--radius-md);
--chart-tooltip-shadow: var(--shadow-3);
--chart-tooltip-padding: var(--space-2);
--chart-tooltip-gap: var(--space-1-5);
--chart-crosshair-color: var(--color-border-strong);
--chart-crosshair-width: var(--border-width-thin);
--qr-code-fg: #18181b;
--qr-code-bg: #ffffff;
--qr-code-size-xs: 96px;
@ -1991,7 +1984,6 @@
--radio-cards-icon-size-lg: var(--icon-size-md);
--radio-cards-icon-size-xl: var(--icon-size-lg);
--radio-cards-icon-color: var(--color-content-secondary);
--radio-cards-focus-ring: var(--radio-group-focus-ring);
--radio-cards-transition-duration: var(--duration-fast);
--radio-cards-transition-ease: var(--ease-default);
--radio-cards-disabled-opacity: var(--opacity-disabled);
@ -2017,7 +2009,6 @@
--rating-group-item-color-partial: var(--color-fulfill-solid);
--rating-group-item-hover-offset: -1px;
--rating-group-focus-radius: var(--radius-md);
--rating-group-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--rating-group-transition-duration: var(--duration-fast);
--rating-group-transition-ease: var(--ease-default);
--rating-group-disabled-opacity: var(--opacity-disabled);
@ -2051,7 +2042,6 @@
--search-field-bg: var(--color-surface-default);
--search-field-color: var(--color-content-primary);
--search-field-placeholder-color: var(--color-content-muted);
--search-field-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--search-field-clear-hidden-opacity: 0;
--search-field-icon-size-xs: var(--icon-size-xs);
--search-field-icon-size-sm: var(--icon-size-sm);
@ -2102,11 +2092,9 @@
--password-field-bg: var(--color-surface-default);
--password-field-color: var(--color-content-primary);
--password-field-placeholder-color: var(--color-content-muted);
--password-field-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--password-field-trigger-radius: var(--radius-sm);
--password-field-trigger-bg: transparent;
--password-field-trigger-color: var(--color-content-secondary);
--password-field-trigger-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--password-field-icon-size-xs: var(--control-height-xs);
--password-field-icon-size-sm: var(--control-height-xs);
--password-field-icon-size-md: var(--control-height-sm);
@ -2173,7 +2161,6 @@
--select-trigger-indicator-color: var(--color-content-secondary);
--select-trigger-indicator-size: var(--icon-size-sm);
--select-placeholder-color: var(--color-content-muted);
--select-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--select-disabled-border: var(--color-border-subtle);
--select-disabled-bg: var(--color-surface-raised);
--select-disabled-color: var(--color-content-disabled);
@ -2277,7 +2264,6 @@
--toolbar-control-color: var(--color-content-secondary);
--toolbar-control-color-hover: var(--color-content-primary);
--toolbar-control-color-active: var(--color-primary-text);
--toolbar-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--toolbar-separator-bg: var(--color-border-subtle);
--toolbar-separator-thickness: var(--border-width);
--toolbar-separator-length: var(--control-height-md);
@ -2312,7 +2298,6 @@
--file-upload-dropzone-radius: var(--radius-lg);
--file-upload-dropzone-bg: var(--color-surface-raised);
--file-upload-dropzone-color: var(--color-content-secondary);
--file-upload-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--file-upload-button-gap: var(--space-1-5);
--file-upload-button-px: var(--space-3);
--file-upload-button-border-width: var(--border-width);
@ -2419,7 +2404,6 @@
--tag-group-item-shadow: var(--shadow-subtle);
--tag-group-item-color: var(--color-content-secondary);
--tag-group-item-color-hover: var(--color-content-primary);
--tag-group-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--tag-group-solid-fg: var(--color-content-on-solid);
--tag-group-remove-size: var(--control-height-xs);
--tag-group-remove-radius: var(--radius-full);
@ -2517,7 +2501,6 @@
--editable-control-border-hover: var(--color-border-default);
--editable-control-bg: var(--color-surface-default);
--editable-control-shadow: var(--shadow-subtle);
--editable-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--editable-placeholder-color: var(--color-content-muted);
--editable-trigger-gap: var(--space-1);
--editable-trigger-px: var(--space-2-5);
@ -2674,7 +2657,6 @@
--tags-input-control-border-hover: var(--color-border-strong);
--tags-input-control-bg: var(--color-surface-default);
--tags-input-control-shadow: var(--shadow-subtle);
--tags-input-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--tags-input-input-min-inline-size: 8ch;
--tags-input-placeholder-color: var(--color-content-muted);
--tags-input-item-radius: var(--radius-full);
@ -2721,6 +2703,68 @@
--tags-input-risk-border: var(--color-risk-border);
--tags-input-threat-track: var(--color-threat-track);
--tags-input-threat-border: var(--color-threat-border);
--metrics-gap-xs: var(--space-1);
--metrics-gap-sm: var(--space-1);
--metrics-gap-md: var(--space-1-5);
--metrics-gap-lg: var(--space-2);
--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-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-border-width: var(--border-width);
--metrics-icon-bg: var(--color-surface-muted);
--metrics-icon-color: var(--color-content-secondary);
--metrics-label-font-family: var(--font-ui);
--metrics-label-font-size-xs: var(--font-size-xxs);
--metrics-label-font-size-sm: var(--font-size-xs);
--metrics-label-font-size-md: var(--font-size-sm);
--metrics-label-font-size-lg: var(--font-size-md);
--metrics-label-font-size-xl: var(--font-size-lg);
--metrics-label-font-size-xxl: var(--font-size-xl);
--metrics-label-font-weight: var(--font-weight-medium);
--metrics-label-color: var(--color-content-secondary);
--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-weight: var(--font-weight-semibold);
--metrics-value-color: var(--color-content-primary);
--metrics-value-line-height: var(--leading-tight);
--metrics-value-letter-spacing: var(--tracking-tight);
--metrics-value-font-feature: var(--font-feature-tabular);
--metrics-description-font-family: var(--font-ui);
--metrics-description-font-size-xs: var(--font-size-xxs);
--metrics-description-font-size-sm: var(--font-size-xs);
--metrics-description-font-size-md: var(--font-size-sm);
--metrics-description-font-size-lg: var(--font-size-md);
--metrics-description-font-size-xl: var(--font-size-lg);
--metrics-description-font-size-xxl: var(--font-size-xl);
--metrics-description-color: var(--color-content-secondary);
--metrics-description-line-height: var(--leading-normal);
--metrics-chart-margin-block-start: var(--space-3);
--metrics-actions-gap: var(--space-3);
--metrics-actions-margin-block-start: var(--space-3);
--metrics-actions-padding-block-start: var(--space-3);
--metrics-actions-border-color: var(--color-border-subtle);
--metrics-flash-duration: var(--duration-slow);
--metrics-flash-ease: var(--ease-out);
--metrics-flash-radius: var(--radius-sm);
--metrics-flash-inset: var(--space-1);
--stepper-gap-sm: var(--space-3);
--stepper-gap-md: var(--space-4);
--stepper-gap-lg: var(--space-5);
@ -2768,7 +2812,6 @@
--stepper-nav-bg: var(--color-surface-raised);
--stepper-nav-color: var(--color-content-primary);
--stepper-nav-hover-brightness: 1.04;
--stepper-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--stepper-transition-duration: var(--duration-fast);
--stepper-transition-ease: var(--ease-default);
--stepper-disabled-opacity: var(--opacity-disabled);
@ -2869,7 +2912,6 @@
--slider-thumb-shadow: var(--shadow-subtle);
--slider-thumb-scale-active: 1.15;
--slider-thumb-shadow-active: var(--shadow-3);
--slider-thumb-focus-shadow: 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color), var(--slider-thumb-shadow);
--slider-tick-radius: var(--radius-full);
--slider-tick-bg: var(--color-neutral-border);
--slider-tick-bg-active: var(--color-primary-solid);
@ -2902,10 +2944,6 @@
--switch-thumb-radius: var(--radius-full);
--switch-thumb-border-width: var(--border-width);
--switch-thumb-shadow: 0 1px 2px rgb(17 14 10 / 0.12);
--switch-focus-ring-offset: var(--focus-ring-offset);
--switch-focus-ring-width: var(--focus-ring-width);
--switch-focus-ring-color: var(--focus-ring-color);
--switch-focus-ring-surface: var(--color-surface-default);
--switch-transition-duration: var(--duration-fast);
--switch-transition-ease: var(--ease-default);
--switch-track-bg-off: var(--color-surface-overlay);
@ -3361,6 +3399,7 @@
--pin-input-cell-filled-border: var(--color-primary-border);
--pin-input-cell-active-border: var(--color-primary-solid);
--pin-input-cell-invalid-border: var(--color-threat-solid);
--scroll-area-auto-max-block: none;
--scroll-area-bg: transparent;
--scroll-area-radius: var(--radius-md);
--scroll-area-scrollbar-padding: var(--space-0-5);

@ -458,8 +458,6 @@ export const THEME_BASE_RECIPE_TOKENS = {
'ellipsis-bg-hover': 'var(--color-surface-raised)',
'ellipsis-color': 'var(--color-content-muted)',
'ellipsis-color-hover': 'var(--color-content-primary)',
'focus-shadow':
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
'transition-duration': 'var(--duration-fast)',
'transition-ease': 'var(--ease-default)'
},
@ -655,12 +653,6 @@ export const THEME_BASE_RECIPE_TOKENS = {
'size-xl-indicator-stroke': '2px',
radius: 'var(--radius-sm)',
'border-width': 'var(--border-width)',
'focus-ring-offset': 'var(--focus-ring-offset)',
'focus-ring-width': 'var(--focus-ring-width)',
'focus-ring-color': 'var(--focus-ring-color)',
'focus-ring-surface': 'var(--color-surface-default)',
'focus-ring':
'0 0 0 var(--checkbox-focus-ring-offset) var(--checkbox-focus-ring-surface), 0 0 0 calc(var(--checkbox-focus-ring-offset) + var(--checkbox-focus-ring-width)) var(--checkbox-focus-ring-color)',
'transition-duration': 'var(--duration-fast)',
'transition-ease': 'var(--ease-default)',
// Checkmark stroke draw — synced with the box transition (--duration-fast).
@ -833,8 +825,6 @@ export const THEME_BASE_RECIPE_TOKENS = {
'control-bg-disabled': 'var(--color-surface-muted)',
'control-color': 'var(--color-content-primary)',
'control-placeholder-color': 'var(--color-content-muted)',
'control-focus-shadow':
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
'affix-color': 'var(--color-content-secondary)',
'message-line-height': 'var(--leading-ui)',
'helper-color': 'var(--color-content-secondary)',
@ -1066,8 +1056,6 @@ export const THEME_BASE_RECIPE_TOKENS = {
'border-invalid': 'var(--color-risk-border)',
bg: 'var(--color-surface-default)',
color: 'var(--color-content-primary)',
'focus-shadow':
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
// Steppers are square segments flush to the field edge (the field clips
// their outer corners), separated from the input by a divider on the
// inner side.
@ -1093,8 +1081,6 @@ export const THEME_BASE_RECIPE_TOKENS = {
'control-decrement-glyph': "'\\2212'",
'control-increment-glyph-stacked': "'\\25B2'",
'control-decrement-glyph-stacked': "'\\25BC'",
'control-focus-shadow':
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
'control-disabled-opacity': '0.48',
'transition-duration': 'var(--duration-fast)',
'transition-ease': 'var(--ease-default)',
@ -1231,12 +1217,12 @@ export const THEME_BASE_RECIPE_TOKENS = {
'border-invalid': 'var(--color-risk-border)',
bg: 'var(--color-surface-default)',
color: 'var(--color-content-primary)',
'label-color': 'var(--color-content-secondary)',
// Field label = max contrast on its surface (content-primary), homogeneous
// across the whole field family — not a de-emphasized secondary.
'label-color': 'var(--color-content-primary)',
'label-font-weight': 'var(--font-weight-regular)',
'placeholder-color': 'var(--color-content-muted)',
'literal-color': 'var(--color-content-muted)',
'focus-shadow':
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
'segment-height': 'calc(var(--_date-field-height) - var(--space-2))',
'segment-radius': 'var(--radius-sm)',
'segment-color': 'var(--color-content-primary)',
@ -1283,12 +1269,12 @@ export const THEME_BASE_RECIPE_TOKENS = {
'border-invalid': 'var(--color-risk-border)',
bg: 'var(--color-surface-default)',
color: 'var(--color-content-primary)',
'label-color': 'var(--color-content-secondary)',
// Field label = max contrast on its surface (content-primary), homogeneous
// across the whole field family — not a de-emphasized secondary.
'label-color': 'var(--color-content-primary)',
'label-font-weight': 'var(--font-weight-regular)',
'placeholder-color': 'var(--color-content-muted)',
'literal-color': 'var(--color-content-muted)',
'focus-shadow':
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
'segment-height': 'calc(var(--_time-field-height) - var(--space-2))',
'segment-radius': 'var(--radius-sm)',
'segment-color': 'var(--color-content-primary)',
@ -1335,12 +1321,12 @@ export const THEME_BASE_RECIPE_TOKENS = {
'border-invalid': 'var(--color-risk-border)',
bg: 'var(--color-surface-default)',
color: 'var(--color-content-primary)',
'label-color': 'var(--color-content-secondary)',
// Field label = max contrast on its surface (content-primary), homogeneous
// across the whole field family — not a de-emphasized secondary.
'label-color': 'var(--color-content-primary)',
'label-font-weight': 'var(--font-weight-regular)',
'placeholder-color': 'var(--color-content-muted)',
'literal-color': 'var(--color-content-muted)',
'focus-shadow':
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
'segment-height': 'calc(var(--_color-field-height) - var(--space-2))',
'segment-radius': 'var(--radius-sm)',
'segment-color': 'var(--color-content-primary)',
@ -1366,11 +1352,10 @@ export const THEME_BASE_RECIPE_TOKENS = {
'disabled-opacity': 'var(--opacity-disabled)'
},
'color-picker': {
// Popover content shell
'content-gap': 'var(--space-3)',
'content-padding-sm': 'var(--space-3)',
'content-padding-md': 'var(--space-3-5)',
'content-padding-lg': 'var(--space-4)',
// Popover content shell. The panel padding is the popover's canonical
// size-aware `--popover-padding-*` (shared by every floating panel) — the
// picker declares NO padding of its own. Inter-row gap is owned by
// [data-picker-body]; both kept the picker from being a padding outlier.
'content-width-sm': '17rem',
'content-width-md': '19.5rem',
'content-width-lg': '22rem',
@ -1395,8 +1380,6 @@ export const THEME_BASE_RECIPE_TOKENS = {
'trigger-swatch-size-lg': 'calc(var(--control-height-lg) - var(--space-2))',
'trigger-swatch-radius': 'var(--radius-full)',
'trigger-swatch-border': 'var(--color-border-subtle)',
'trigger-focus-shadow':
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
// 2D area
'area-height': '11rem',
@ -1719,8 +1702,6 @@ export const THEME_BASE_RECIPE_TOKENS = {
'day-weekend-color': 'var(--color-content-secondary)',
'day-holiday-shadow': 'inset 0 -2px 0 var(--_calendar-accent-border)',
'day-unavailable-decoration': 'var(--color-risk-border)',
'focus-shadow':
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
'event-shadow':
'0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--_calendar-accent-border)',
'transition-duration': 'var(--duration-fast)',
@ -1777,8 +1758,6 @@ export const THEME_BASE_RECIPE_TOKENS = {
'selected-bg-hover': 'var(--color-primary-solid-hover)',
'selected-color': 'var(--color-content-on-solid)',
'ellipsis-color': 'var(--color-content-muted)',
'focus-shadow':
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
'transition-duration': 'var(--duration-fast)',
'transition-ease': 'var(--ease-default)',
'disabled-opacity': 'var(--opacity-disabled)'
@ -1922,12 +1901,6 @@ export const THEME_BASE_RECIPE_TOKENS = {
'dot-color': 'var(--color-content-on-solid)',
'dot-color-disabled':
'color-mix( in srgb, var(--color-content-on-solid) 82%, var(--color-surface-default) )',
'focus-ring-offset': 'var(--focus-ring-offset)',
'focus-ring-width': 'var(--focus-ring-width)',
'focus-ring-color': 'var(--focus-ring-color)',
'focus-ring-surface': 'var(--color-surface-default)',
'focus-ring':
'0 0 0 var(--radio-group-focus-ring-offset) var(--radio-group-focus-ring-surface), 0 0 0 calc(var(--radio-group-focus-ring-offset) + var(--radio-group-focus-ring-width)) var(--radio-group-focus-ring-color)',
'transition-duration': 'var(--duration-fast)',
'transition-ease': 'var(--ease-default)',
'disabled-opacity': 'var(--opacity-disabled)',
@ -1970,6 +1943,29 @@ export const THEME_BASE_RECIPE_TOKENS = {
// not theme tokens: a QR must stay dark-on-light to scan — inverting it in
// dark mode would break scanners. Theming goes through the `color` /
// `background` props (consumer owns the contrast).
chart: {
// Mark geometry sourced from canonical primitives. The two off-scale
// values (area fill tint, point radius) are NAMED here — never inlined
// as magic numbers in the wrapper.
'line-width': 'var(--border-width-medium)',
'axis-width': 'var(--border-width-thin)',
'grid-width': 'var(--border-width-thin)',
'bar-radius': 'var(--radius-sm)',
'point-radius': '3.5px',
'area-opacity': '0.15',
'slice-stroke-width': 'var(--border-width-thin)',
'transition-duration': 'var(--duration-fast)',
'transition-ease': 'var(--ease-out)',
// Hover tooltip surface — matches the overlay / popover chrome.
'tooltip-bg': 'var(--color-surface-overlay)',
'tooltip-border': 'var(--color-border-default)',
'tooltip-radius': 'var(--radius-md)',
'tooltip-shadow': 'var(--shadow-3)',
'tooltip-padding': 'var(--space-2)',
'tooltip-gap': 'var(--space-1-5)',
'crosshair-color': 'var(--color-border-strong)',
'crosshair-width': 'var(--border-width-thin)'
},
'qr-code': {
fg: '#18181b',
bg: '#ffffff',
@ -2037,7 +2033,6 @@ export const THEME_BASE_RECIPE_TOKENS = {
// hosts) — a :root token referencing it is invalid-at-computed-value
// and poisons down the tree. It's inlined in radio-cards.css instead,
// applied on the card item where the palette IS in scope.
'focus-ring': 'var(--radio-group-focus-ring)',
'transition-duration': 'var(--duration-fast)',
'transition-ease': 'var(--ease-default)',
'disabled-opacity': 'var(--opacity-disabled)'
@ -2065,8 +2060,6 @@ export const THEME_BASE_RECIPE_TOKENS = {
'item-color-partial': 'var(--color-fulfill-solid)',
'item-hover-offset': '-1px',
'focus-radius': 'var(--radius-md)',
'focus-shadow':
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
'transition-duration': 'var(--duration-fast)',
'transition-ease': 'var(--ease-default)',
'disabled-opacity': 'var(--opacity-disabled)'
@ -2102,8 +2095,6 @@ export const THEME_BASE_RECIPE_TOKENS = {
bg: 'var(--color-surface-default)',
color: 'var(--color-content-primary)',
'placeholder-color': 'var(--color-content-muted)',
'focus-shadow':
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
'clear-hidden-opacity': '0',
'icon-size-xs': 'var(--icon-size-xs)',
'icon-size-sm': 'var(--icon-size-sm)',
@ -2156,13 +2147,9 @@ export const THEME_BASE_RECIPE_TOKENS = {
bg: 'var(--color-surface-default)',
color: 'var(--color-content-primary)',
'placeholder-color': 'var(--color-content-muted)',
'focus-shadow':
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
'trigger-radius': 'var(--radius-sm)',
'trigger-bg': 'transparent',
'trigger-color': 'var(--color-content-secondary)',
'trigger-focus-shadow':
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
'icon-size-xs': 'var(--control-height-xs)',
'icon-size-sm': 'var(--control-height-xs)',
'icon-size-md': 'var(--control-height-sm)',
@ -2235,8 +2222,6 @@ export const THEME_BASE_RECIPE_TOKENS = {
'trigger-indicator-color': 'var(--color-content-secondary)',
'trigger-indicator-size': 'var(--icon-size-sm)',
'placeholder-color': 'var(--color-content-muted)',
'focus-shadow':
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
'disabled-border': 'var(--color-border-subtle)',
'disabled-bg': 'var(--color-surface-raised)',
'disabled-color': 'var(--color-content-disabled)',
@ -2395,8 +2380,6 @@ export const THEME_BASE_RECIPE_TOKENS = {
'control-color': 'var(--color-content-secondary)',
'control-color-hover': 'var(--color-content-primary)',
'control-color-active': 'var(--color-primary-text)',
'focus-shadow':
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
'separator-bg': 'var(--color-border-subtle)',
'separator-thickness': 'var(--border-width)',
'separator-length': 'var(--control-height-md)',
@ -2433,8 +2416,6 @@ export const THEME_BASE_RECIPE_TOKENS = {
'dropzone-radius': 'var(--radius-lg)',
'dropzone-bg': 'var(--color-surface-raised)',
'dropzone-color': 'var(--color-content-secondary)',
'focus-shadow':
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
'button-gap': 'var(--space-1-5)',
'button-px': 'var(--space-3)',
'button-border-width': 'var(--border-width)',
@ -2605,8 +2586,6 @@ export const THEME_BASE_RECIPE_TOKENS = {
'item-shadow': 'var(--shadow-subtle)',
'item-color': 'var(--color-content-secondary)',
'item-color-hover': 'var(--color-content-primary)',
'focus-shadow':
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
'solid-fg': 'var(--color-content-on-solid)',
'remove-size': 'var(--control-height-xs)',
'remove-radius': 'var(--radius-full)',
@ -2795,8 +2774,6 @@ export const THEME_BASE_RECIPE_TOKENS = {
'control-border-hover': 'var(--color-border-default)',
'control-bg': 'var(--color-surface-default)',
'control-shadow': 'var(--shadow-subtle)',
'focus-shadow':
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
'placeholder-color': 'var(--color-content-muted)',
'trigger-gap': 'var(--space-1)',
'trigger-px': 'var(--space-2-5)',
@ -3016,8 +2993,6 @@ export const THEME_BASE_RECIPE_TOKENS = {
'control-border-hover': 'var(--color-border-strong)',
'control-bg': 'var(--color-surface-default)',
'control-shadow': 'var(--shadow-subtle)',
'focus-shadow':
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
'input-min-inline-size': '8ch',
'placeholder-color': 'var(--color-content-muted)',
'item-radius': 'var(--radius-full)',
@ -3105,6 +3080,83 @@ export const THEME_BASE_RECIPE_TOKENS = {
]
}
},
metrics: {
// Metrics is SURFACELESS — no per-colour palette here. The Delta's colour
// comes from the composed <Badge>; the featured icon and the live flash
// tint use the canonical `--color-*` tokens directly.
// ── Vertical rhythm between label / value / delta / description ──
'gap-xs': 'var(--space-1)',
'gap-sm': 'var(--space-1)',
'gap-md': 'var(--space-1-5)',
'gap-lg': 'var(--space-2)',
'gap-xl': 'var(--space-2)',
'gap-xxl': 'var(--space-3)',
// ── Featured icon (leading container; soft/solid/outline × colour from
// 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-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)',
'icon-border-width': 'var(--border-width)',
'icon-bg': 'var(--color-surface-muted)',
'icon-color': 'var(--color-content-secondary)',
// ── Label ──
'label-font-family': 'var(--font-ui)',
'label-font-size-xs': 'var(--font-size-xxs)',
'label-font-size-sm': 'var(--font-size-xs)',
'label-font-size-md': 'var(--font-size-sm)',
'label-font-size-lg': 'var(--font-size-md)',
'label-font-size-xl': 'var(--font-size-lg)',
'label-font-size-xxl': 'var(--font-size-xl)',
'label-font-weight': 'var(--font-weight-medium)',
'label-color': 'var(--color-content-secondary)',
'label-line-height': 'var(--leading-ui)',
'label-letter-spacing': 'var(--tracking-normal)',
// ── Value (headline figure, tabular numerals) ──
'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-weight': 'var(--font-weight-semibold)',
'value-color': 'var(--color-content-primary)',
'value-line-height': 'var(--leading-tight)',
'value-letter-spacing': 'var(--tracking-tight)',
'value-font-feature': 'var(--font-feature-tabular)',
// ── Description ──
'description-font-family': 'var(--font-ui)',
'description-font-size-xs': 'var(--font-size-xxs)',
'description-font-size-sm': 'var(--font-size-xs)',
'description-font-size-md': 'var(--font-size-sm)',
'description-font-size-lg': 'var(--font-size-md)',
'description-font-size-xl': 'var(--font-size-lg)',
'description-font-size-xxl': 'var(--font-size-xl)',
'description-color': 'var(--color-content-secondary)',
'description-line-height': 'var(--leading-normal)',
// ── Trend chart (sparkline) slot ──
'chart-margin-block-start': 'var(--space-3)',
// ── Actions row (View report link / dropdown) ──
'actions-gap': 'var(--space-3)',
'actions-margin-block-start': 'var(--space-3)',
'actions-padding-block-start': 'var(--space-3)',
'actions-border-color': 'var(--color-border-subtle)',
// ── Live-update flash on the value (data-event-family='signal') ──
'flash-duration': 'var(--duration-slow)',
'flash-ease': 'var(--ease-out)',
'flash-radius': 'var(--radius-sm)',
'flash-inset': 'var(--space-1)'
},
stepper: {
'gap-sm': 'var(--space-3)',
'gap-md': 'var(--space-4)',
@ -3153,8 +3205,6 @@ export const THEME_BASE_RECIPE_TOKENS = {
'nav-bg': 'var(--color-surface-raised)',
'nav-color': 'var(--color-content-primary)',
'nav-hover-brightness': '1.04',
'focus-shadow':
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
'transition-duration': 'var(--duration-fast)',
'transition-ease': 'var(--ease-default)',
'disabled-opacity': 'var(--opacity-disabled)',
@ -3318,8 +3368,6 @@ export const THEME_BASE_RECIPE_TOKENS = {
// because a ~20px thumb needs a larger multiplier to read as a lift.
'thumb-scale-active': '1.15',
'thumb-shadow-active': 'var(--shadow-3)',
'thumb-focus-shadow':
'0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color), var(--slider-thumb-shadow)',
'tick-radius': 'var(--radius-full)',
'tick-bg': 'var(--color-neutral-border)',
'tick-bg-active': 'var(--color-primary-solid)',
@ -3354,10 +3402,6 @@ export const THEME_BASE_RECIPE_TOKENS = {
'thumb-radius': 'var(--radius-full)',
'thumb-border-width': 'var(--border-width)',
'thumb-shadow': '0 1px 2px rgb(17 14 10 / 0.12)',
'focus-ring-offset': 'var(--focus-ring-offset)',
'focus-ring-width': 'var(--focus-ring-width)',
'focus-ring-color': 'var(--focus-ring-color)',
'focus-ring-surface': 'var(--color-surface-default)',
'transition-duration': 'var(--duration-fast)',
'transition-ease': 'var(--ease-default)',
'track-bg-off': 'var(--color-surface-overlay)',
@ -4176,6 +4220,10 @@ export const THEME_BASE_RECIPE_TOKENS = {
// ScrollArea — overlay scrollbars (macOS-style).
// ─────────────────────────────────────────────────────────────────────
'scroll-area': {
// Auto-fit max height — the CONSUMER sets this (e.g. the command list caps
// to min(60vh, 24rem)); the recipe declares the `none` default so the token
// is part of the public contract instead of an undeclared consumed var.
'auto-max-block': 'none',
bg: 'transparent',
radius: 'var(--radius-md)',
'scrollbar-padding': 'var(--space-0-5)',

@ -0,0 +1,17 @@
import type { LangNode } from '$libs/langs';
/**
* Default strings for the chart family. Merged under `components.chart.*` by
* `ActiveUix` (via the `componentLangs` barrel). Referenced by absolute
* idlangref: `'#?components.chart.{key}|fallback'`.
*/
export const chartLangs = {
label: {
es: 'Gráfico',
en: 'Chart'
},
empty: {
es: 'Sin datos',
en: 'No data'
}
} satisfies LangNode;

@ -0,0 +1,145 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* Chart — data-visualisation family rendered from the own zero-dependency
* plotting engine (`$libs/plots`). Eidos-native display primitive (like Avatar
* / QrCode): `data` → scales → SVG. No soma — there is no interactive state,
* just a derived render; no sema — a static chart emits no perceptual events.
*
* `scope: ['eidos']`, 0-event surface. One morfo declares the shared contract
* for the whole family: the line / area / bar / scatter presets and the
* standalone pie all compose these parts. The Provider is the root
* `<svg role="img">` whose `aria-label` carries the meaning; the marks
* (line / area / bar / point / slice) are decorative paths the label
* summarises; grid + axes are structural guides.
*
* Parts are optional because each chart type renders a subset — a LineChart
* has line (+ area) marks, a BarChart has bars, the pie has slices. Only the
* Provider is always present.
*/
export const chartMorfo = {
name: 'Chart',
kebab: 'chart',
scope: ['eidos'],
apg: 'https://www.w3.org/WAI/ARIA/apg/#img',
texts: {
label: '#?components.chart.label|Chart'
},
parts: [
{
// The plotting surface — the root `<svg>`. `role="img"` + `aria-label`
// make the chart a single labelled graphic; the marks inside are
// decorative (the label is the text alternative).
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'svg',
role: 'img',
optional: false,
data: [],
aria: [{ attr: 'aria-label', value: v.propRef('ariaLabel'), severity: 'recommended' }]
},
{
// Horizontal grid lines at the value-axis ticks. Decorative.
name: 'Grid',
kebab: 'grid',
archetype: 'separator',
kind: 'public',
defaultElement: 'g',
optional: true,
data: [],
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
},
{
// A value / category axis with ticks + labels. `data-chart-axis`
// carries which axis it is. Decorative (labels are visual guides).
name: 'Axis',
kebab: 'axis',
archetype: 'separator',
kind: 'public',
defaultElement: 'g',
optional: true,
data: [{ attr: 'data-chart-axis', values: ['x', 'y'] }],
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
},
{
// A line mark — one polyline per series.
name: 'Line',
kebab: 'line',
archetype: 'indicator',
kind: 'public',
defaultElement: 'path',
optional: true,
data: [],
aria: []
},
{
// A filled area mark between the baseline and a series.
name: 'Area',
kebab: 'area',
archetype: 'indicator',
kind: 'public',
defaultElement: 'path',
optional: true,
data: [],
aria: []
},
{
// A bar mark — one rect per datum (grouped across series).
name: 'Bar',
kebab: 'bar',
archetype: 'indicator',
kind: 'public',
defaultElement: 'rect',
optional: true,
data: [],
aria: []
},
{
// A scatter point — one circle per datum.
name: 'Point',
kebab: 'point',
archetype: 'indicator',
kind: 'public',
defaultElement: 'circle',
optional: true,
data: [],
aria: []
},
{
// A pie / donut slice.
name: 'Slice',
kebab: 'slice',
archetype: 'indicator',
kind: 'public',
defaultElement: 'path',
optional: true,
data: [],
aria: []
},
{
// Series legend (HTML chrome composed around the plot).
name: 'Legend',
kebab: 'legend',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
// Optional chart title.
name: 'Title',
kebab: 'title',
archetype: 'title',
kind: 'public',
defaultElement: 'figcaption',
optional: true,
data: [],
aria: []
}
]
} as const satisfies Morfo;

@ -78,6 +78,12 @@ const elementSchema = union(
literal('img'),
literal('svg'),
literal('path'),
literal('g'),
literal('rect'),
literal('circle'),
literal('line'),
literal('figure'),
literal('figcaption'),
literal('label'),
literal('form'),
literal('fieldset'),

@ -77,6 +77,12 @@ export type MorfoElement =
| 'img'
| 'svg'
| 'path'
| 'g'
| 'rect'
| 'circle'
| 'line'
| 'figure'
| 'figcaption'
| 'label'
| 'form'
| 'fieldset'

@ -0,0 +1,243 @@
<script lang="ts">
import { AreaChart } from '$uix/eidos/components/chart';
import { compileMorfo } from '$uix/morfo';
import { chartMorfo } from '@/uix/morfo/components/chart';
import type { CurveName } from '$libs/plots';
type Tab = 'live' | 'examples' | 'api' | 'morfo' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const data = months.map((month, i) => ({
month,
revenue: [42, 48, 55, 51, 67, 72, 69, 78, 74, 83, 88, 95][i],
expenses: [30, 33, 37, 35, 44, 46, 45, 50, 48, 53, 55, 58][i]
}));
let curve = $state<CurveName>('monotone');
let opacity = $state(0.18);
let multi = $state(false);
let legend = $state(true);
let grid = $state(true);
let niceY = $state(true);
let height = $state(260);
let showTooltip = $state(true);
const curves: CurveName[] = ['monotone', 'linear', 'step'];
const opacities = [0.1, 0.18, 0.3];
const heights = [200, 260, 320];
const seriesKeys = $derived(multi ? ['revenue', 'expenses'] : 'revenue');
const compiled = compileMorfo(chartMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const money = (v: number) => `${v}k`;
const snippet = $derived(
[
"<script lang='ts'>",
" import { AreaChart } from '$uix/eidos/components/chart';",
'',
' // rows keyed by x (a month), one column per series',
' const data = [',
" { month: 'Jan', revenue: 42, expenses: 30 },",
" { month: 'Feb', revenue: 48, expenses: 33 }",
' // …',
' ];',
'</' + 'script>',
'',
'<AreaChart',
' {data}',
' x="month"',
' xScale="point"',
multi ? " y={['revenue', 'expenses']}" : ' y="revenue"',
curve !== 'monotone' && ` curve="${curve}"`,
opacity !== 0.18 && ` opacity={${opacity}}`,
!legend && ' legend={false}',
!grid && ' noGrid',
!niceY && ' niceY={false}',
height !== 260 && ` height={${height}}`,
' formatY={(v) => `${v}k`}',
'/>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Charts · Area chart</div>
<h1 data-uix-page-title>Area chart</h1>
<p data-uix-page-lede>
Filled area with a line edge per series, on the zero-dependency <code>$libs/plots</code> engine.
Smooth / straight / stepped curves; tunable fill opacity; single or stacked-by-series.
</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>render</span>svg · 0-dep</span>
<span data-uix-meta-pill><span data-uix-meta-key>series</span>{multi ? 2 : 1}</span>
<span data-uix-meta-pill><span data-uix-meta-key>curve</span>{curve}</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area>
<AreaChart {data} x="month" xScale="point" y={seriesKeys} {curve} {opacity} {legend} {height} {niceY} tooltip={showTooltip} noGrid={!grid} formatY={money} ariaLabel="Monthly revenue area" />
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>renders</span>
<span><span data-uix-stage-trace-event>{multi ? 2 : 1} series</span> · {curve} · opacity {opacity}</span>
<span style="margin-inline-start: auto;"><span data-uix-stage-trace-key>height</span> {height}px</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 === 'examples'} onclick={() => (tab = 'examples')}>Examples</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p</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>Pure <span data-uix-layer-badge="eidos">eidos</span> display — every control changes the live chart above.</p>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>curve</span>
<span data-uix-chips role="radiogroup">
{#each curves as c}<button data-uix-chip data-active={curve === c} onclick={() => (curve = c)}>{c}</button>{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>fill opacity</span>
<span data-uix-chips role="radiogroup">
{#each opacities as o}<button data-uix-chip data-active={opacity === o} onclick={() => (opacity = o)}>{o}</button>{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>height</span>
<span data-uix-chips role="radiogroup">
{#each heights as h}<button data-uix-chip data-active={height === h} onclick={() => (height = h)}>{h}</button>{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>series</span>
<span data-uix-switch><input type="checkbox" bind:checked={multi} /><span data-uix-switch-label>{multi ? 'revenue + expenses' : 'revenue only'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>legend</span>
<span data-uix-switch><input type="checkbox" bind:checked={legend} /><span data-uix-switch-label>{legend ? 'on' : 'off'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>grid</span>
<span data-uix-switch><input type="checkbox" bind:checked={grid} /><span data-uix-switch-label>{grid ? 'on' : 'off'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>niceY</span>
<span data-uix-switch><input type="checkbox" bind:checked={niceY} /><span data-uix-switch-label>{niceY ? 'on' : 'off'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>tooltip</span>
<span data-uix-switch><input type="checkbox" bind:checked={showTooltip} /><span data-uix-switch-label>{showTooltip ? 'on' : 'off'}</span></span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>AreaChart preset</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{snippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'examples'}
<section data-uix-section>
<h2 data-uix-section-title>Examples</h2>
<div data-uix-stage-area style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem;">
<AreaChart {data} x="month" xScale="point" y="revenue" title="Default" formatY={money} height={200} />
<AreaChart {data} x="month" xScale="point" y="revenue" curve="step" opacity={0.3} title="Stepped · denser" formatY={money} height={200} />
<AreaChart {data} x="month" xScale="point" y={['revenue', 'expenses']} title="Two series" formatY={money} height={200} />
</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>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">data</td><td class="type">{`readonly Record<string, unknown>[]`}</td><td class="default empty">—</td><td>Rows of data.</td></tr>
<tr><td class="name">x</td><td class="type">string</td><td class="default empty">—</td><td>x position key.</td></tr>
<tr><td class="name">y</td><td class="type">{`string | string[]`}</td><td class="default empty">—</td><td>Series key(s).</td></tr>
<tr><td class="name">xScale</td><td class="type">'linear' | 'time' | 'band' | 'point'</td><td class="default">'linear'</td><td>x mapping.</td></tr>
<tr><td class="name">curve</td><td class="type">'monotone' | 'linear' | 'step'</td><td class="default">'monotone'</td><td>Point connection.</td></tr>
<tr><td class="name">opacity</td><td class="type">number</td><td class="default">0.18</td><td>Area fill opacity.</td></tr>
<tr><td class="name">legend / noGrid / niceY</td><td class="type">boolean</td><td class="default">true / false / true</td><td>Chrome toggles.</td></tr>
<tr><td class="name">height</td><td class="type">number</td><td class="default">260</td><td>Plot height.</td></tr>
<tr><td class="name">formatX / formatY</td><td class="type">{`(v: number) => string`}</td><td class="default empty">—</td><td>Axis label formatters.</td></tr>
<tr><td class="name">title / ariaLabel</td><td class="type">string</td><td class="default empty">—</td><td>Title / accessible name.</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/chart.ts</code>. The chart family shares one <code>scope:['eidos']</code> contract.</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>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="type">{part.role ?? '—'}</td><td class="default">{part.optional ? 'yes' : 'no'}</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>No <code>--chart-*</code> tokens — styles from global theme tokens.</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Surface</th><th>Token</th></tr></thead>
<tbody>
<tr><td class="name">series / area</td><td class="type">var(--color-{'{'}role{'}'}-solid) + fill-opacity</td></tr>
<tr><td class="name">axis / grid</td><td class="type">var(--color-border-default / -subtle)</td></tr>
<tr><td class="name">labels / title</td><td class="type">var(--color-content-secondary / -primary)</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">provider (svg)</td><td>role / aria-label</td><td class="type">"img" / ariaLabel</td></tr>
<tr><td class="name">grid / axis</td><td>aria-hidden</td><td class="type">"true"</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>

@ -0,0 +1,229 @@
<script lang="ts">
import { BarChart } from '$uix/eidos/components/chart';
import { compileMorfo } from '$uix/morfo';
import { chartMorfo } from '@/uix/morfo/components/chart';
type Tab = 'live' | 'examples' | 'api' | 'morfo' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
const data = [
{ quarter: 'Q1', productA: 45, productB: 30 },
{ quarter: 'Q2', productA: 52, productB: 41 },
{ quarter: 'Q3', productA: 48, productB: 55 },
{ quarter: 'Q4', productA: 67, productB: 49 }
];
let multi = $state(true);
let radius = $state(2);
let legend = $state(true);
let grid = $state(true);
let niceY = $state(true);
let height = $state(260);
let showTooltip = $state(true);
const radii = [0, 2, 6];
const heights = [200, 260, 320];
const seriesKeys = $derived(multi ? ['productA', 'productB'] : 'productA');
const compiled = compileMorfo(chartMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const snippet = $derived(
[
"<script lang='ts'>",
" import { BarChart } from '$uix/eidos/components/chart';",
'',
' // rows keyed by x (a quarter), one column per series',
' const data = [',
" { quarter: 'Q1', productA: 45, productB: 30 },",
" { quarter: 'Q2', productA: 52, productB: 41 }",
' // …',
' ];',
'</' + 'script>',
'',
'<BarChart',
' {data}',
' x="quarter"',
multi ? " y={['productA', 'productB']}" : ' y="productA"',
radius !== 2 && ` radius={${radius}}`,
!legend && ' legend={false}',
!grid && ' noGrid',
!niceY && ' niceY={false}',
height !== 260 && ` height={${height}}`,
'/>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Charts · Bar chart</div>
<h1 data-uix-page-title>Bar chart</h1>
<p data-uix-page-lede>
Categorical bars on a band scale, grouped side-by-side across series, on the zero-dependency
<code>$libs/plots</code> engine. Rounded corners; 0-anchored value axis; themed per series.
</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>render</span>svg · 0-dep</span>
<span data-uix-meta-pill><span data-uix-meta-key>series</span>{multi ? 2 : 1}</span>
<span data-uix-meta-pill><span data-uix-meta-key>scale</span>band</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area>
<BarChart {data} x="quarter" y={seriesKeys} {radius} {legend} {height} {niceY} tooltip={showTooltip} noGrid={!grid} ariaLabel="Quarterly sales by product" />
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>renders</span>
<span><span data-uix-stage-trace-event>{multi ? 2 : 1} series</span> · radius {radius}</span>
<span style="margin-inline-start: auto;"><span data-uix-stage-trace-key>height</span> {height}px</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 === 'examples'} onclick={() => (tab = 'examples')}>Examples</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p</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>Pure <span data-uix-layer-badge="eidos">eidos</span> display — every control changes the live chart above.</p>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>corner radius</span>
<span data-uix-chips role="radiogroup">
{#each radii as r}<button data-uix-chip data-active={radius === r} onclick={() => (radius = r)}>{r}</button>{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>height</span>
<span data-uix-chips role="radiogroup">
{#each heights as h}<button data-uix-chip data-active={height === h} onclick={() => (height = h)}>{h}</button>{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>series</span>
<span data-uix-switch><input type="checkbox" bind:checked={multi} /><span data-uix-switch-label>{multi ? 'grouped (A + B)' : 'productA only'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>legend</span>
<span data-uix-switch><input type="checkbox" bind:checked={legend} /><span data-uix-switch-label>{legend ? 'on' : 'off'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>grid</span>
<span data-uix-switch><input type="checkbox" bind:checked={grid} /><span data-uix-switch-label>{grid ? 'on' : 'off'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>niceY</span>
<span data-uix-switch><input type="checkbox" bind:checked={niceY} /><span data-uix-switch-label>{niceY ? 'on' : 'off'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>tooltip</span>
<span data-uix-switch><input type="checkbox" bind:checked={showTooltip} /><span data-uix-switch-label>{showTooltip ? 'on' : 'off'}</span></span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>BarChart preset</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{snippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'examples'}
<section data-uix-section>
<h2 data-uix-section-title>Examples</h2>
<div data-uix-stage-area style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem;">
<BarChart {data} x="quarter" y="productA" title="Single series" height={200} />
<BarChart {data} x="quarter" y="productA" radius={6} title="Rounded" height={200} />
<BarChart {data} x="quarter" y={['productA', 'productB']} title="Grouped" height={200} />
</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>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">data</td><td class="type">{`readonly Record<string, unknown>[]`}</td><td class="default empty">—</td><td>Rows of data.</td></tr>
<tr><td class="name">x</td><td class="type">string</td><td class="default empty">—</td><td>Category key (band scale).</td></tr>
<tr><td class="name">y</td><td class="type">{`string | string[]`}</td><td class="default empty">—</td><td>Series key(s); multiple → grouped.</td></tr>
<tr><td class="name">radius</td><td class="type">number</td><td class="default">2</td><td>Bar corner radius.</td></tr>
<tr><td class="name">legend / noGrid / niceY</td><td class="type">boolean</td><td class="default">true / false / true</td><td>Chrome toggles.</td></tr>
<tr><td class="name">height</td><td class="type">number</td><td class="default">260</td><td>Plot height.</td></tr>
<tr><td class="name">yDomain</td><td class="type">[number, number]</td><td class="default">0-anchored</td><td>Override the y domain.</td></tr>
<tr><td class="name">title / ariaLabel</td><td class="type">string</td><td class="default empty">—</td><td>Title / accessible name.</td></tr>
</tbody>
</table>
</div>
<p data-uix-section-desc>Bars require a band x scale; <code>BarChart</code> sets <code>xScale="band"</code> automatically.</p>
</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/chart.ts</code>. The chart family shares one <code>scope:['eidos']</code> contract.</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>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="type">{part.role ?? '—'}</td><td class="default">{part.optional ? 'yes' : 'no'}</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>No <code>--chart-*</code> tokens — styles from global theme tokens.</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Surface</th><th>Token</th></tr></thead>
<tbody>
<tr><td class="name">bar</td><td class="type">var(--color-{'{'}role{'}'}-solid) per series</td></tr>
<tr><td class="name">axis / grid</td><td class="type">var(--color-border-default / -subtle)</td></tr>
<tr><td class="name">labels / title</td><td class="type">var(--color-content-secondary / -primary)</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">provider (svg)</td><td>role / aria-label</td><td class="type">"img" / ariaLabel</td></tr>
<tr><td class="name">grid / axis</td><td>aria-hidden</td><td class="type">"true"</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>

@ -0,0 +1,219 @@
<script lang="ts">
import { BarList, type ChartCategory } from '$uix/eidos/components/chart';
import { compileMorfo } from '$uix/morfo';
import { chartMorfo } from '@/uix/morfo/components/chart';
import type { ColorRole } from '$uix/eidos/lib/types';
type Tab = 'live' | 'examples' | 'api' | 'morfo' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
// Deliberately NOT in value order, so the `sort` prop has a visible effect.
const data: ChartCategory[] = [
{ label: '/docs', value: 2400 },
{ label: '/home', value: 4200 },
{ label: '/about', value: 900 },
{ label: '/blog', value: 1800 },
{ label: '/pricing', value: 3100 }
];
const colored: ChartCategory[] = [
{ label: 'Passed', value: 86, color: 'affirm' },
{ label: 'Flaky', value: 9, color: 'risk' },
{ label: 'Failed', value: 5, color: 'threat' }
];
let barColor = $state<ColorRole>('primary');
let showTooltip = $state(true);
let sort = $state(true);
let limited = $state(false);
const limit = $derived(limited ? 3 : undefined);
const colorRoles: ColorRole[] = ['primary', 'secondary', 'affirm', 'fulfill', 'risk', 'threat'];
const compiled = compileMorfo(chartMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const snippet = $derived(
[
"<script lang='ts'>",
" import { BarList } from '$uix/eidos/components/chart';",
'',
' // each row is { label, value, color? } — `color` (a ColorRole) is optional, per row',
' const data = [',
" { label: '/docs', value: 2400 },",
" { label: '/home', value: 4200 },",
" { label: '/about', value: 900 },",
" { label: '/blog', value: 1800 },",
" { label: '/pricing', value: 3100 }",
' ];',
'</' + 'script>',
'',
'<BarList',
' {data}',
` color="${barColor}"`,
!sort && ' sort={false}',
limited && ' limit={3}',
!showTooltip && ' tooltip={false}',
'/>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Charts · Bar list</div>
<h1 data-uix-page-title>Bar list</h1>
<p data-uix-page-lede>
A ranked list of horizontal bars — label over a proportional bar, value at the end. Pure HTML/CSS
(no SVG, no scales): each bar's width is its value relative to the max. Themed per row from the
palette; values formatted through the locale-aware engine. Chakra bar-list style.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill><span data-uix-meta-key>render</span>html · 0-dep</span>
<span data-uix-meta-pill><span data-uix-meta-key>family</span>chart</span>
<span data-uix-meta-pill><span data-uix-meta-key>items</span>{limit ?? data.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>sorted</span>{sort ? 'yes' : 'no'}</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area style="max-inline-size: 460px;">
<BarList {data} color={barColor} {sort} {limit} tooltip={showTooltip} />
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>renders</span>
<span><span data-uix-stage-trace-event>{limit ?? data.length} rows</span> · {sort ? 'sorted desc' : 'source order'}</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 === 'examples'} onclick={() => (tab = 'examples')}>Examples</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p</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>Pass <code>data</code> as an array of <code>{`{ label, value, color? }`}</code> — one row each. Every control changes the live chart; the code below reflects the current props.</p>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>color</span>
<span data-uix-chips role="radiogroup">
{#each colorRoles as r}<button data-uix-chip data-active={barColor === r} onclick={() => (barColor = r)}>{r}</button>{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>sort (desc)</span>
<span data-uix-switch><input type="checkbox" bind:checked={sort} /><span data-uix-switch-label>{sort ? 'on' : 'off'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>limit to 3</span>
<span data-uix-switch><input type="checkbox" bind:checked={limited} /><span data-uix-switch-label>{limited ? 'on' : 'off'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>tooltip</span>
<span data-uix-switch><input type="checkbox" bind:checked={showTooltip} /><span data-uix-switch-label>{showTooltip ? 'on' : 'off'}</span></span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>BarList</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{snippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'examples'}
<section data-uix-section>
<h2 data-uix-section-title>Examples</h2>
<p data-uix-section-desc>Default (sorted), source order, top-N, and explicit per-row intent colours.</p>
<div data-uix-stage-area style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem;">
<div><div data-uix-control-label style="margin-bottom: 0.5rem;">Sorted</div><BarList {data} /></div>
<div><div data-uix-control-label style="margin-bottom: 0.5rem;">Source order</div><BarList {data} sort={false} /></div>
<div><div data-uix-control-label style="margin-bottom: 0.5rem;">Top 3</div><BarList {data} limit={3} /></div>
<div><div data-uix-control-label style="margin-bottom: 0.5rem;">Intent colours</div><BarList data={colored} sort={false} /></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>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">data</td><td class="type">{`{ label, value, color? }[]`}</td><td class="default empty">—</td><td>Rows; per-item <code>color</code> is a ColorRole.</td></tr>
<tr><td class="name">color</td><td class="type">ColorRole</td><td class="default">'primary'</td><td>Default bar colour for items without their own.</td></tr>
<tr><td class="name">sort</td><td class="type">boolean</td><td class="default">true</td><td>Sort descending by value.</td></tr>
<tr><td class="name">limit</td><td class="type">number</td><td class="default empty">—</td><td>Show only the top N rows.</td></tr>
<tr><td class="name">tooltip</td><td class="type">boolean</td><td class="default">true</td><td>Show the themed hover tooltip (label · value · % of total).</td></tr>
<tr><td class="name">format</td><td class="type">{`(v: number) => string`}</td><td class="default empty">—</td><td>Value formatter (defaults to the locale-aware number format).</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> · chart family contract</h2>
<p data-uix-section-desc>Bar list is a pure HTML/CSS sub-surface of the <code>chart</code> family (<code>scope:['eidos']</code>, 0 events) — it doesn't use the SVG plotting parts below (provider / axes / marks); it emits its own <code>data-chart-bar-list-*</code> structure. The shared family contract:</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>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="type">{part.role ?? '—'}</td><td class="default">{part.optional ? 'yes' : 'no'}</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>Global theme tokens (no <code>--chart-*</code> recipe), so rows re-tint with the theme.</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Surface</th><th>Token</th></tr></thead>
<tbody>
<tr><td class="name">bar</td><td class="type">var(--color-{'{'}role{'}'}-solid) — opaque, per row</td></tr>
<tr><td class="name">track</td><td class="type">var(--color-surface-muted) · height var(--control-height-sm)</td></tr>
<tr><td class="name">label / value</td><td class="type">var(--color-content-primary / -secondary)</td></tr>
<tr><td class="name">radius / gap</td><td class="type">var(--radius-sm) / var(--space-*)</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Element</th><th>Role / attribute</th><th>Notes</th></tr></thead>
<tbody>
<tr><td class="name">row</td><td class="type">label + value text</td><td>Both read by the screen reader; order follows the sorted ranking.</td></tr>
<tr><td class="name">bar</td><td class="type">decorative</td><td>The proportional fill is visual only; the value carries the meaning.</td></tr>
</tbody>
</table>
<p data-uix-section-desc style="margin-top: 0.75rem;">Put a self-describing string in each <code>label</code> (e.g. "/home — 4,200 views").</p>
</div>
</section>
{/if}
</div>

@ -0,0 +1,188 @@
<script lang="ts">
import { BarSegment, type ChartCategory } from '$uix/eidos/components/chart';
import { compileMorfo } from '$uix/morfo';
import { chartMorfo } from '@/uix/morfo/components/chart';
type Tab = 'live' | 'examples' | 'api' | 'morfo' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
const data: ChartCategory[] = [
{ label: 'Direct', value: 38 },
{ label: 'Organic', value: 27 },
{ label: 'Referral', value: 18 },
{ label: 'Social', value: 12 },
{ label: 'Email', value: 5 }
];
const status: ChartCategory[] = [
{ label: 'Healthy', value: 72, color: 'affirm' },
{ label: 'Degraded', value: 21, color: 'risk' },
{ label: 'Down', value: 7, color: 'threat' }
];
let legend = $state(true);
let showTooltip = $state(true);
const compiled = compileMorfo(chartMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const snippet = $derived(
[
"<script lang='ts'>",
" import { BarSegment } from '$uix/eidos/components/chart';",
'</' + 'script>',
'',
'<BarSegment',
' data={channels}',
!legend && ' legend={false}',
!showTooltip && ' tooltip={false}',
'/>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Charts · Bar segment</div>
<h1 data-uix-page-title>Bar segment</h1>
<p data-uix-page-lede>
A single horizontal bar split into proportional segments (parts of a whole), with an optional
legend and a themed hover tooltip. Pure HTML/CSS, themed per segment; values formatted through the
locale-aware engine. Chakra bar-segment style.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill><span data-uix-meta-key>render</span>html · 0-dep</span>
<span data-uix-meta-pill><span data-uix-meta-key>family</span>chart</span>
<span data-uix-meta-pill><span data-uix-meta-key>segments</span>{data.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>whole</span>100%</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area style="max-inline-size: 520px;">
<BarSegment {data} {legend} tooltip={showTooltip} />
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>renders</span>
<span><span data-uix-stage-trace-event>{data.length} segments</span> · hover for label · 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 === 'examples'} onclick={() => (tab = 'examples')}>Examples</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p</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>Pure <span data-uix-layer-badge="eidos">eidos</span> display — every control changes the live chart above. Hover a segment for the tooltip.</p>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>legend</span>
<span data-uix-switch><input type="checkbox" bind:checked={legend} /><span data-uix-switch-label>{legend ? 'on' : 'off'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>tooltip</span>
<span data-uix-switch><input type="checkbox" bind:checked={showTooltip} /><span data-uix-switch-label>{showTooltip ? 'on' : 'off'}</span></span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>BarSegment</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{snippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'examples'}
<section data-uix-section>
<h2 data-uix-section-title>Examples</h2>
<p data-uix-section-desc>Default, no legend, and explicit intent colours.</p>
<div data-uix-stage-area style="display: flex; flex-direction: column; gap: 1.5rem; max-inline-size: 520px;">
<div><div data-uix-control-label style="margin-bottom: 0.5rem;">Traffic channels</div><BarSegment {data} /></div>
<div><div data-uix-control-label style="margin-bottom: 0.5rem;">No legend</div><BarSegment {data} legend={false} /></div>
<div><div data-uix-control-label style="margin-bottom: 0.5rem;">Service health (intent colours)</div><BarSegment data={status} /></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>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">data</td><td class="type">{`{ label, value, color? }[]`}</td><td class="default empty">—</td><td>Segments; <code>color</code> is a ColorRole.</td></tr>
<tr><td class="name">legend</td><td class="type">boolean</td><td class="default">true</td><td>Show the legend below the bar.</td></tr>
<tr><td class="name">tooltip</td><td class="type">boolean</td><td class="default">true</td><td>Show the themed hover tooltip.</td></tr>
<tr><td class="name">format</td><td class="type">{`(v: number) => string`}</td><td class="default empty">—</td><td>Value formatter (defaults to the locale-aware number format).</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> · chart family contract</h2>
<p data-uix-section-desc>Bar segment is a pure HTML/CSS sub-surface of the <code>chart</code> family (<code>scope:['eidos']</code>, 0 events) — it emits its own <code>data-chart-bar-segment-*</code> structure, not the SVG plotting parts below. The shared family contract:</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>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="type">{part.role ?? '—'}</td><td class="default">{part.optional ? 'yes' : 'no'}</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>Global theme tokens; the tooltip reuses the chart's <code>--chart-tooltip-*</code> surface so it matches every other chart.</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Surface</th><th>Token</th></tr></thead>
<tbody>
<tr><td class="name">segment fill</td><td class="type">var(--color-{'{'}role{'}'}-solid) per segment</td></tr>
<tr><td class="name">dim (non-hovered)</td><td class="type">opacity var(--opacity-scrim)</td></tr>
<tr><td class="name">track height / radius</td><td class="type">var(--control-height-sm) / var(--radius-md)</td></tr>
<tr><td class="name">tooltip</td><td class="type">var(--chart-tooltip-bg / -border / -shadow)</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Element</th><th>Role / attribute</th><th>Notes</th></tr></thead>
<tbody>
<tr><td class="name">segment</td><td class="type">decorative</td><td>Width is visual; the legend carries label · value · %.</td></tr>
<tr><td class="name">legend</td><td class="type">text</td><td>Read in order; the value + percentage are the accessible content.</td></tr>
</tbody>
</table>
<p data-uix-section-desc style="margin-top: 0.75rem;">The hover tooltip is a pointer affordance; the legend is the keyboard / screen-reader equivalent.</p>
</div>
</section>
{/if}
</div>

@ -0,0 +1,272 @@
<script lang="ts">
import {
Chart,
LineChart,
AreaChart,
BarChart,
ScatterChart,
Sparkline,
PieChart,
type PieDatum
} from '$uix/eidos/components/chart';
import { compileMorfo } from '$uix/morfo';
import { chartMorfo } from '@/uix/morfo/components/chart';
import type { CurveName } from '$libs/plots';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const revenue = months.map((month, i) => ({
month,
revenue: [42, 48, 55, 51, 67, 72, 69, 78, 74, 83, 88, 95][i],
expenses: [30, 33, 37, 35, 44, 46, 45, 50, 48, 53, 55, 58][i]
}));
const sales = [
{ quarter: 'Q1', productA: 45, productB: 30 },
{ quarter: 'Q2', productA: 52, productB: 41 },
{ quarter: 'Q3', productA: 48, productB: 55 },
{ quarter: 'Q4', productA: 67, productB: 49 }
];
const scatter = [5, 12, 18, 25, 31, 38, 44, 52, 60, 68].map((x, i) => ({
x,
y: [12, 19, 15, 28, 24, 35, 31, 42, 38, 49][i]
}));
const traffic: PieDatum[] = [
{ label: 'Direct', value: 38 },
{ label: 'Organic', value: 27 },
{ label: 'Referral', value: 18 },
{ label: 'Social', value: 12 },
{ label: 'Email', value: 5 }
];
const spark = [4, 6, 5, 8, 7, 9, 8, 11, 10, 13, 12, 15, 14, 17].map((v, i) => ({ i, v }));
let curve = $state<CurveName>('monotone');
let area = $state(true);
let multi = $state(true);
const curves: CurveName[] = ['monotone', 'linear', 'step'];
const compiled = compileMorfo(chartMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const money = (v: number) => `${v}k`;
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { Chart } from '$uix/eidos/components/chart';",
'',
' // rows keyed by x (a month), one column per series',
' const data = [',
" { month: 'Jan', revenue: 42, expenses: 30 },",
" { month: 'Feb', revenue: 48, expenses: 33 }",
' // …',
' ];',
'</' + 'script>',
'',
'<Chart',
' {data}',
' x="month"',
' xScale="point"',
multi ? " y={['revenue', 'expenses']}" : ' y="revenue"',
'>',
' <Chart.Grid />',
' <Chart.YAxis format={(v) => `${v}k`} />',
' <Chart.XAxis />',
area && ' <Chart.Area series="revenue" />',
' <Chart.Line series="revenue" ' + `curve="${curve}"` + ' />',
multi && ` <Chart.Line series="expenses" curve="${curve}" />`,
' <Chart.Tooltip />',
' <Chart.Legend />',
'</Chart>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Charts · Chart</div>
<h1 data-uix-page-title>Chart</h1>
<p data-uix-page-lede>
The compositional chart frame and the chart family overview. <code>&lt;Chart&gt;</code> measures its
width, computes the scales from <code>data</code> + <code>x</code>/<code>y</code> via the
zero-dependency <code>$libs/plots</code> engine, and shares them with SVG children
(<code>Grid</code> / <code>XAxis</code> / <code>YAxis</code> / <code>Line</code> /
<code>Area</code> / <code>Bar</code> / <code>Scatter</code>). The ergonomic presets —
<code>LineChart</code>, <code>AreaChart</code>, <code>BarChart</code>, <code>ScatterChart</code>,
<code>Sparkline</code>, <code>PieChart</code> — compose these for you (one demo each in the
Charts group).
</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>0</span>
<span data-uix-meta-pill><span data-uix-meta-key>render</span>svg · 0-dep</span>
<span data-uix-meta-pill><span data-uix-meta-key>scope</span>eidos</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area>
<Chart data={revenue} x="month" xScale="point" y={multi ? ['revenue', 'expenses'] : 'revenue'} height={260} ariaLabel="Revenue overview">
<Chart.Grid />
<Chart.YAxis format={money} />
<Chart.XAxis />
{#if area}<Chart.Area series="revenue" {curve} />{/if}
<Chart.Line series="revenue" {curve} />
{#if multi}<Chart.Line series="expenses" {curve} />{/if}
<Chart.Tooltip />
<Chart.Legend />
</Chart>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>renders</span>
<span><span data-uix-stage-trace-event>compositional</span> · {multi ? 2 : 1} series · {curve}</span>
<span style="margin-inline-start: auto;"><span data-uix-stage-trace-key>scope</span> eidos · 0 events</span>
</div>
</div>
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</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</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>0</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>Pure <span data-uix-layer-badge="eidos">eidos</span> display — the controls drive the compositional chart above; the gallery shows the presets.</p>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>curve</span>
<span data-uix-chips role="radiogroup">
{#each curves as c}<button data-uix-chip data-active={curve === c} onclick={() => (curve = c)}>{c}</button>{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>area fill</span>
<span data-uix-switch><input type="checkbox" bind:checked={area} /><span data-uix-switch-label>{area ? 'on' : 'off'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>series</span>
<span data-uix-switch><input type="checkbox" bind:checked={multi} /><span data-uix-switch-label>{multi ? 'revenue + expenses' : 'revenue only'}</span></span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>compositional &lt;Chart&gt;</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
<h2 data-uix-section-title style="margin-top: 1.5rem;">The family</h2>
<p data-uix-section-desc>Each preset has its own demo page under Charts.</p>
<div data-uix-stage-area style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem;">
<LineChart data={revenue} x="month" xScale="point" y={['revenue', 'expenses']} title="LineChart" formatY={money} height={190} />
<AreaChart data={revenue} x="month" xScale="point" y="revenue" title="AreaChart" formatY={money} height={190} />
<BarChart data={sales} x="quarter" y={['productA', 'productB']} title="BarChart" height={190} />
<ScatterChart data={scatter} x="x" y="y" xScale="linear" title="ScatterChart" height={190} />
<PieChart data={traffic} size={170} donut={0.55} title="PieChart" ariaLabel="Traffic" />
<div><div style="font-size: 0.875rem; font-weight: 500; margin-bottom: 0.5rem; color: var(--color-content-primary);">Sparkline</div><Sparkline data={spark} x="i" y="v" /></div>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference — &lt;Chart&gt; frame</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">data</td><td class="type">{`readonly Record<string, unknown>[]`}</td><td class="default empty">—</td><td>Rows of data.</td></tr>
<tr><td class="name">x</td><td class="type">string</td><td class="default empty">—</td><td>x position key.</td></tr>
<tr><td class="name">y</td><td class="type">{`string | string[]`}</td><td class="default empty">—</td><td>Series key(s) for the y domain.</td></tr>
<tr><td class="name">xScale</td><td class="type">'linear' | 'time' | 'band' | 'point'</td><td class="default">'linear'</td><td>x mapping.</td></tr>
<tr><td class="name">height</td><td class="type">number</td><td class="default">260</td><td>Plot height (width fills container).</td></tr>
<tr><td class="name">margin / padding / niceY / yDomain</td><td class="type">various</td><td class="default empty">—</td><td>Layout + domain tuning.</td></tr>
<tr><td class="name">children</td><td class="type">Snippet</td><td class="default empty">—</td><td>SVG parts: Grid / XAxis / YAxis / Line / Area / Bar / Scatter.</td></tr>
</tbody>
</table>
</div>
<p data-uix-section-desc>Parts (<code>Chart.Line</code>, <code>Chart.Bar</code>, …) take <code>series</code>, <code>color</code> (ColorRole) and per-mark options (<code>curve</code>, <code>radius</code>, <code>opacity</code>). See each preset's demo for the full surface.</p>
</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/chart.ts</code>. <code>scope:['eidos']</code> — a display family, 0 events. One contract for the whole family.</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>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="type">{part.role ?? '—'}</td><td class="default">{part.optional ? 'yes' : 'no'}</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> · perceptual events</h2>
<p data-uix-section-desc>
<strong>No events.</strong> A static chart is a derived render (<code>data</code> → scales →
SVG); <code>scope: ['eidos']</code>, like <code>Avatar</code>, <code>Image</code> and
<code>QrCode</code>. It emits no perceptual signal, so there is nothing for sema to project.
Hover tooltips (a future, opt-in interaction) will introduce <code>contact</code> events at
that point — see the component README · Gaps.
</p>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>No <code>--chart-*</code> tokens — the chart styles from global theme tokens directly, so series re-tint with the theme. <code>chart.css</code> is code-split (imported from <code>chart.svelte</code>).</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Surface</th><th>Token</th></tr></thead>
<tbody>
<tr><td class="name">series</td><td class="type">var(--color-{'{'}role{'}'}-solid) · primary · affirm · risk · …</td></tr>
<tr><td class="name">axis / grid</td><td class="type">var(--color-border-default / -subtle)</td></tr>
<tr><td class="name">labels / title</td><td class="type">var(--color-content-secondary / -primary)</td></tr>
<tr><td class="name">pie separator</td><td class="type">var(--color-surface-default)</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">provider (svg)</td><td>role / aria-label</td><td class="type">"img" / ariaLabel</td></tr>
<tr><td class="name">grid / axis</td><td>aria-hidden</td><td class="type">"true" (decorative)</td></tr>
<tr><td class="name">marks</td><td>—</td><td class="type">summarised by the provider label</td></tr>
</tbody>
</table>
<p data-uix-section-desc style="margin-top: 0.75rem;">Always pass <code>ariaLabel</code> with a one-line summary of the chart. A keyboard-navigable data-table fallback is a planned P2 enhancement (README · Gaps).</p>
</div>
</section>
{/if}
</div>

@ -0,0 +1,289 @@
<script lang="ts">
import { LineChart, type LineChartProps } from '$uix/eidos/components/chart';
import { compileMorfo } from '$uix/morfo';
import { chartMorfo } from '@/uix/morfo/components/chart';
import type { CurveName } from '$libs/plots';
type Tab = 'live' | 'examples' | 'api' | 'morfo' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const data = months.map((month, i) => ({
month,
revenue: [42, 48, 55, 51, 67, 72, 69, 78, 74, 83, 88, 95][i],
expenses: [30, 33, 37, 35, 44, 46, 45, 50, 48, 53, 55, 58][i]
}));
// ── Controls — one per public prop ──────────────────────────────────────
let curve = $state<CurveName>('monotone');
let area = $state(false);
let dots = $state(false);
let multi = $state(true);
let legend = $state(true);
let grid = $state(true);
let niceY = $state(true);
let height = $state(260);
let showTooltip = $state(true);
const curves: CurveName[] = ['monotone', 'linear', 'step'];
const heights = [200, 260, 320];
const seriesKeys = $derived<LineChartProps['y']>(multi ? ['revenue', 'expenses'] : 'revenue');
const compiled = compileMorfo(chartMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const snippet = $derived(
[
"<script lang='ts'>",
" import { LineChart } from '$uix/eidos/components/chart';",
'',
' // rows keyed by x (a month), one column per series',
' const data = [',
" { month: 'Jan', revenue: 42, expenses: 30 },",
" { month: 'Feb', revenue: 48, expenses: 33 }",
' // …',
' ];',
'</' + 'script>',
'',
'<LineChart',
' {data}',
' x="month"',
' xScale="point"',
multi ? " y={['revenue', 'expenses']}" : ' y="revenue"',
curve !== 'monotone' && ` curve="${curve}"`,
area && ' area',
dots && ' dots',
!legend && ' legend={false}',
!grid && ' noGrid',
!niceY && ' niceY={false}',
height !== 260 && ` height={${height}}`,
' formatY={(v) => `${v}k`}',
'/>'
]
.filter(Boolean)
.join('\n')
);
const money = (v: number) => `${v}k`;
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Charts · Line chart</div>
<h1 data-uix-page-title>Line chart</h1>
<p data-uix-page-lede>
Multi-series line over a continuous or categorical x, built on the in-house
<strong>zero-dependency</strong> <code>$libs/plots</code> engine. Smooth (monotone), straight or
stepped curves; optional area fill and point dots; themed per series from the palette.
</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>render</span>svg · 0-dep</span>
<span data-uix-meta-pill><span data-uix-meta-key>series</span>{multi ? 2 : 1}</span>
<span data-uix-meta-pill><span data-uix-meta-key>curve</span>{curve}</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area>
<LineChart
{data}
x="month"
xScale="point"
y={seriesKeys}
{curve}
{area}
{dots}
{legend}
{height}
{niceY}
tooltip={showTooltip}
noGrid={!grid}
formatY={money}
ariaLabel="Monthly revenue versus expenses"
/>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>renders</span>
<span><span data-uix-stage-trace-event>{multi ? 2 : 1} series</span> · {curve}{area ? ' · area' : ''}{dots ? ' · dots' : ''}</span>
<span style="margin-inline-start: auto;"><span data-uix-stage-trace-key>height</span> {height}px</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 === 'examples'} onclick={() => (tab = 'examples')}>Examples</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p</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>
Pure <span data-uix-layer-badge="eidos">eidos</span> display — no soma, no sema. Every control
changes the live chart above.
</p>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>curve</span>
<span data-uix-chips role="radiogroup">
{#each curves as c}
<button data-uix-chip data-active={curve === c} onclick={() => (curve = c)}>{c}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>height</span>
<span data-uix-chips role="radiogroup">
{#each heights as h}
<button data-uix-chip data-active={height === h} onclick={() => (height = h)}>{h}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>series</span>
<span data-uix-switch><input type="checkbox" bind:checked={multi} /><span data-uix-switch-label>{multi ? 'revenue + expenses' : 'revenue only'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>area fill</span>
<span data-uix-switch><input type="checkbox" bind:checked={area} /><span data-uix-switch-label>{area ? 'on' : 'off'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>dots</span>
<span data-uix-switch><input type="checkbox" bind:checked={dots} /><span data-uix-switch-label>{dots ? 'on' : 'off'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>legend</span>
<span data-uix-switch><input type="checkbox" bind:checked={legend} /><span data-uix-switch-label>{legend ? 'on' : 'off'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>grid</span>
<span data-uix-switch><input type="checkbox" bind:checked={grid} /><span data-uix-switch-label>{grid ? 'on' : 'off'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>niceY</span>
<span data-uix-switch><input type="checkbox" bind:checked={niceY} /><span data-uix-switch-label>{niceY ? 'on' : 'off'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>tooltip</span>
<span data-uix-switch><input type="checkbox" bind:checked={showTooltip} /><span data-uix-switch-label>{showTooltip ? 'on' : 'off'}</span></span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>LineChart preset</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{snippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'examples'}
<section data-uix-section>
<h2 data-uix-section-title>Examples</h2>
<p data-uix-section-desc>Curve styles, area fill and single vs multi-series — all themed from the palette.</p>
<div data-uix-stage-area style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem;">
<LineChart {data} x="month" xScale="point" y="revenue" title="Monotone" formatY={money} height={200} />
<LineChart {data} x="month" xScale="point" y="revenue" curve="step" title="Stepped" formatY={money} height={200} />
<LineChart {data} x="month" xScale="point" y="revenue" area title="Area fill" formatY={money} height={200} />
<LineChart {data} x="month" xScale="point" y={['revenue', 'expenses']} dots title="Two series · dots" formatY={money} height={200} />
</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>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">data</td><td class="type">{`readonly Record<string, unknown>[]`}</td><td class="default empty">—</td><td>Rows of data.</td></tr>
<tr><td class="name">x</td><td class="type">string</td><td class="default empty">—</td><td>Key on each row for the x position.</td></tr>
<tr><td class="name">y</td><td class="type">{`string | string[]`}</td><td class="default empty">—</td><td>Series key(s).</td></tr>
<tr><td class="name">xScale</td><td class="type">'linear' | 'time' | 'band' | 'point'</td><td class="default">'linear'</td><td>How x maps to pixels.</td></tr>
<tr><td class="name">curve</td><td class="type">'monotone' | 'linear' | 'step'</td><td class="default">'monotone'</td><td>Point connection.</td></tr>
<tr><td class="name">area</td><td class="type">boolean</td><td class="default">false</td><td>Fill under each line.</td></tr>
<tr><td class="name">dots</td><td class="type">boolean</td><td class="default">false</td><td>Dot at each point.</td></tr>
<tr><td class="name">legend</td><td class="type">boolean</td><td class="default">true</td><td>Show legend (multi-series).</td></tr>
<tr><td class="name">noGrid</td><td class="type">boolean</td><td class="default">false</td><td>Hide the grid.</td></tr>
<tr><td class="name">niceY</td><td class="type">boolean</td><td class="default">true</td><td>Round the y domain to nice numbers.</td></tr>
<tr><td class="name">height</td><td class="type">number</td><td class="default">260</td><td>Plot height (width fills the container).</td></tr>
<tr><td class="name">yDomain</td><td class="type">[number, number]</td><td class="default">0-anchored extent</td><td>Override the y domain.</td></tr>
<tr><td class="name">formatX / formatY</td><td class="type">{`(v: number) => string`}</td><td class="default empty">—</td><td>Format axis labels.</td></tr>
<tr><td class="name">title</td><td class="type">string</td><td class="default empty">—</td><td>Title above the plot.</td></tr>
<tr><td class="name">ariaLabel</td><td class="type">string</td><td class="default empty">—</td><td>Accessible name of the graphic.</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/chart.ts</code>. <code>scope:['eidos']</code> — a display family, no soma/sema. The whole chart family shares this contract.</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>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="type">{part.role ?? '—'}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</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>No <code>--chart-*</code> tokens — the chart styles from global theme tokens directly, so series re-tint with the theme.</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Surface</th><th>Token</th></tr></thead>
<tbody>
<tr><td class="name">series</td><td class="type">var(--color-{'{'}role{'}'}-solid) · primary · affirm · risk · …</td></tr>
<tr><td class="name">axis line / tick</td><td class="type">var(--color-border-default)</td></tr>
<tr><td class="name">grid line</td><td class="type">var(--color-border-subtle)</td></tr>
<tr><td class="name">axis label</td><td class="type">var(--color-content-secondary)</td></tr>
<tr><td class="name">title</td><td class="type">var(--color-content-primary)</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">provider (svg)</td><td>role / aria-label</td><td class="type">"img" / ariaLabel</td></tr>
<tr><td class="name">grid / axis</td><td>aria-hidden</td><td class="type">"true" (decorative guides)</td></tr>
<tr><td class="name">marks</td><td>—</td><td class="type">summarised by the provider label</td></tr>
</tbody>
</table>
<p data-uix-section-desc style="margin-top: 0.75rem;">A keyboard-navigable data table fallback is a planned P2 enhancement (see component README · Gaps).</p>
</div>
</section>
{/if}
</div>

@ -0,0 +1,216 @@
<script lang="ts">
import { PieChart, type PieDatum } from '$uix/eidos/components/chart';
import { compileMorfo } from '$uix/morfo';
import { chartMorfo } from '@/uix/morfo/components/chart';
type Tab = 'live' | 'examples' | 'api' | 'morfo' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
const data: PieDatum[] = [
{ label: 'Direct', value: 38 },
{ label: 'Organic', value: 27 },
{ label: 'Referral', value: 18 },
{ label: 'Social', value: 12 },
{ label: 'Email', value: 5 }
];
let donut = $state(0);
let padAngle = $state(0.02);
let size = $state(220);
let legend = $state(true);
let showTooltip = $state(true);
const donuts = [0, 0.4, 0.6];
const pads = [0, 0.02, 0.05];
const sizes = [180, 220, 260];
const compiled = compileMorfo(chartMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const snippet = $derived(
[
"<script lang='ts'>",
" import { PieChart } from '$uix/eidos/components/chart';",
'',
' // slices: { label, value, color? }',
' const data = [',
" { label: 'Direct', value: 38 },",
" { label: 'Organic', value: 27 }",
' // …',
' ];',
'</' + 'script>',
'',
'<PieChart',
' {data}',
donut !== 0 && ` donut={${donut}}`,
padAngle !== 0.02 && ` padAngle={${padAngle}}`,
size !== 220 && ` size={${size}}`,
!legend && ' legend={false}',
' ariaLabel="Traffic sources"',
'/>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Charts · Pie chart</div>
<h1 data-uix-page-title>Pie chart</h1>
<p data-uix-page-lede>
Part-to-whole slices (pie or donut) from the zero-dependency <code>$libs/plots</code> arc engine.
Slices follow input order, themed from the palette; tune the hole size and the gap between slices.
</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>render</span>svg · 0-dep</span>
<span data-uix-meta-pill><span data-uix-meta-key>slices</span>{data.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>shape</span>{donut > 0 ? 'donut' : 'pie'}</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area style="justify-content: center;">
<PieChart {data} {donut} {padAngle} {size} {legend} tooltip={showTooltip} ariaLabel="Traffic sources" />
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>renders</span>
<span><span data-uix-stage-trace-event>{donut > 0 ? 'donut' : 'pie'}</span> · {data.length} slices · pad {padAngle}</span>
<span style="margin-inline-start: auto;"><span data-uix-stage-trace-key>size</span> {size}px</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 === 'examples'} onclick={() => (tab = 'examples')}>Examples</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p</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>Pure <span data-uix-layer-badge="eidos">eidos</span> display — every control changes the live chart above.</p>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>donut (inner radius)</span>
<span data-uix-chips role="radiogroup">
{#each donuts as d}<button data-uix-chip data-active={donut === d} onclick={() => (donut = d)}>{d === 0 ? 'pie' : d}</button>{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>pad angle</span>
<span data-uix-chips role="radiogroup">
{#each pads as p}<button data-uix-chip data-active={padAngle === p} onclick={() => (padAngle = p)}>{p}</button>{/each}
</span>
</label>
<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>legend</span>
<span data-uix-switch><input type="checkbox" bind:checked={legend} /><span data-uix-switch-label>{legend ? 'on' : 'off'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>tooltip</span>
<span data-uix-switch><input type="checkbox" bind:checked={showTooltip} /><span data-uix-switch-label>{showTooltip ? 'on' : 'off'}</span></span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>PieChart (standalone)</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{snippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'examples'}
<section data-uix-section>
<h2 data-uix-section-title>Examples</h2>
<div data-uix-stage-area style="display: flex; gap: 1.5rem; flex-wrap: wrap;">
<PieChart {data} size={180} title="Pie" ariaLabel="Traffic sources pie" />
<PieChart {data} size={180} donut={0.6} title="Donut" ariaLabel="Traffic sources donut" />
</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>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">data</td><td class="type">{`{ label, value, color? }[]`}</td><td class="default empty">—</td><td>Slices, in render order.</td></tr>
<tr><td class="name">size</td><td class="type">number</td><td class="default">220</td><td>Diameter in pixels.</td></tr>
<tr><td class="name">donut</td><td class="type">number</td><td class="default">0</td><td>Inner radius fraction (0 = pie, 0.6 = donut).</td></tr>
<tr><td class="name">padAngle</td><td class="type">number</td><td class="default">0.02</td><td>Gap between slices in radians.</td></tr>
<tr><td class="name">legend</td><td class="type">boolean</td><td class="default">true</td><td>Show the legend.</td></tr>
<tr><td class="name">title / ariaLabel</td><td class="type">string</td><td class="default empty">—</td><td>Title / accessible name.</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/chart.ts</code>. Pie uses the provider + slice + legend parts of the shared <code>scope:['eidos']</code> contract.</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>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="type">{part.role ?? '—'}</td><td class="default">{part.optional ? 'yes' : 'no'}</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>No <code>--chart-*</code> tokens — styles from global theme tokens.</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Surface</th><th>Token</th></tr></thead>
<tbody>
<tr><td class="name">slice</td><td class="type">var(--color-{'{'}role{'}'}-solid) per slice</td></tr>
<tr><td class="name">slice separator</td><td class="type">var(--color-surface-default)</td></tr>
<tr><td class="name">legend</td><td class="type">var(--color-content-secondary)</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">provider (svg)</td><td>role / aria-label</td><td class="type">"img" / ariaLabel ?? title</td></tr>
<tr><td class="name">legend</td><td>—</td><td class="type">visible label + swatch per slice</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>

@ -0,0 +1,217 @@
<script lang="ts">
import { RadarChart } from '$uix/eidos/components/chart';
import { compileMorfo } from '$uix/morfo';
import { chartMorfo } from '@/uix/morfo/components/chart';
type Tab = 'live' | 'examples' | 'api' | 'morfo' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
const data = [
{ axis: 'Speed', current: 80, target: 90 },
{ axis: 'Reliability', current: 70, target: 85 },
{ axis: 'Comfort', current: 60, target: 78 },
{ axis: 'Safety', current: 90, target: 95 },
{ axis: 'Economy', current: 65, target: 72 },
{ axis: 'Design', current: 75, target: 82 }
];
let multi = $state(true);
let levels = $state(4);
let legend = $state(true);
let showTooltip = $state(true);
let size = $state(300);
const levelChoices = [3, 4, 5];
const sizes = [260, 300, 340];
const seriesKeys = $derived(multi ? ['current', 'target'] : 'current');
const compiled = compileMorfo(chartMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const snippet = $derived(
[
"<script lang='ts'>",
" import { RadarChart } from '$uix/eidos/components/chart';",
'',
' // one row per axis (category) + one column per series',
' const data = [',
" { axis: 'Speed', current: 80, target: 90 },",
" { axis: 'Safety', current: 90, target: 95 }",
' // …',
' ];',
'</' + 'script>',
'',
'<RadarChart',
' {data}',
' axis="axis"',
multi ? " series={['current', 'target']}" : ' series="current"',
levels !== 4 && ` levels={${levels}}`,
!legend && ' legend={false}',
!showTooltip && ' tooltip={false}',
'/>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Charts · Radar</div>
<h1 data-uix-page-title>Radar chart</h1>
<p data-uix-page-lede>
A radar / spider chart — categories around a circle, each series a polygon whose vertices are the
value at each axis (placed by a radial <code>scaleLinear</code> from the zero-dependency
<code>$libs/plots</code> engine). N-gon grid rings + spokes; hover a sector for the values.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill><span data-uix-meta-key>render</span>svg · 0-dep</span>
<span data-uix-meta-pill><span data-uix-meta-key>axes</span>{data.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>series</span>{multi ? 2 : 1}</span>
<span data-uix-meta-pill><span data-uix-meta-key>rings</span>{levels}</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area style="justify-content: center;">
<RadarChart {data} axis="axis" series={seriesKeys} {levels} {legend} {size} tooltip={showTooltip} ariaLabel="Vehicle ratings" />
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>renders</span>
<span><span data-uix-stage-trace-event>{data.length} axes</span> · {multi ? 2 : 1} series · {levels} rings</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 === 'examples'} onclick={() => (tab = 'examples')}>Examples</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p</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>Pass <code>data</code> as rows of <code>{`{ axis, ...series }`}</code>, with <code>axis</code> + <code>series</code> naming the keys. Every control changes the live chart.</p>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>rings (levels)</span>
<span data-uix-chips role="radiogroup">
{#each levelChoices as l}<button data-uix-chip data-active={levels === l} onclick={() => (levels = l)}>{l}</button>{/each}
</span>
</label>
<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>series</span>
<span data-uix-switch><input type="checkbox" bind:checked={multi} /><span data-uix-switch-label>{multi ? 'current + target' : 'current only'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>legend</span>
<span data-uix-switch><input type="checkbox" bind:checked={legend} /><span data-uix-switch-label>{legend ? 'on' : 'off'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>tooltip</span>
<span data-uix-switch><input type="checkbox" bind:checked={showTooltip} /><span data-uix-switch-label>{showTooltip ? 'on' : 'off'}</span></span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>RadarChart</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{snippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'examples'}
<section data-uix-section>
<h2 data-uix-section-title>Examples</h2>
<div data-uix-stage-area style="display: flex; gap: 1.5rem; flex-wrap: wrap; justify-content: center;">
<RadarChart {data} axis="axis" series="current" size={240} levels={3} ariaLabel="Single series" />
<RadarChart {data} axis="axis" series={['current', 'target']} size={240} ariaLabel="Two series" />
</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>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">data</td><td class="type">{`Record<string, unknown>[]`}</td><td class="default empty">—</td><td>One row per axis/category.</td></tr>
<tr><td class="name">axis</td><td class="type">string</td><td class="default empty">—</td><td>Key of the axis (category) label.</td></tr>
<tr><td class="name">series</td><td class="type">{`string | string[]`}</td><td class="default empty">—</td><td>Series key(s) — one polygon each.</td></tr>
<tr><td class="name">max</td><td class="type">number</td><td class="default">niced</td><td>Radial domain max.</td></tr>
<tr><td class="name">levels</td><td class="type">number</td><td class="default">4</td><td>Concentric grid rings.</td></tr>
<tr><td class="name">size</td><td class="type">number</td><td class="default">300</td><td>Diameter in pixels.</td></tr>
<tr><td class="name">legend / tooltip</td><td class="type">boolean</td><td class="default">true</td><td>Chrome toggles.</td></tr>
<tr><td class="name">format</td><td class="type">{`(v: number) => string`}</td><td class="default empty">—</td><td>Tooltip value formatter (locale-aware default).</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> · chart family contract</h2>
<p data-uix-section-desc>Radar is part of the <code>chart</code> family (<code>scope:['eidos']</code>, 0 events): the root is the <code>provider</code> SVG (<code>role="img"</code>); it adds radar-specific <code>data-chart-radar-*</code> surfaces. The shared contract:</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>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="type">{part.role ?? '—'}</td><td class="default">{part.optional ? 'yes' : 'no'}</td></tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Surface</th><th>Token</th></tr></thead>
<tbody>
<tr><td class="name">series polygon</td><td class="type">var(--color-{'{'}role{'}'}-solid) + fill-opacity var(--chart-area-opacity)</td></tr>
<tr><td class="name">rings / spokes</td><td class="type">var(--color-border-subtle) · var(--chart-grid-width)</td></tr>
<tr><td class="name">labels</td><td class="type">var(--color-content-secondary)</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">provider (svg)</td><td>role / aria-label</td><td class="type">"img" / ariaLabel</td></tr>
<tr><td class="name">rings / spokes</td><td>decorative</td><td class="type">visual grid only</td></tr>
</tbody>
</table>
<p data-uix-section-desc style="margin-top: 0.75rem;">Pass <code>ariaLabel</code> summarising the comparison (e.g. "Vehicle ratings: current vs target").</p>
</div>
</section>
{/if}
</div>

@ -0,0 +1,229 @@
<script lang="ts">
import { ScatterChart } from '$uix/eidos/components/chart';
import { compileMorfo } from '$uix/morfo';
import { chartMorfo } from '@/uix/morfo/components/chart';
type Tab = 'live' | 'examples' | 'api' | 'morfo' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
const data = [
{ x: 5, alpha: 12, beta: 22 },
{ x: 12, alpha: 19, beta: 18 },
{ x: 18, alpha: 15, beta: 30 },
{ x: 25, alpha: 28, beta: 24 },
{ x: 31, alpha: 24, beta: 36 },
{ x: 38, alpha: 35, beta: 29 },
{ x: 44, alpha: 31, beta: 44 },
{ x: 52, alpha: 42, beta: 38 },
{ x: 60, alpha: 38, beta: 51 },
{ x: 68, alpha: 49, beta: 46 }
];
let radius = $state(3.5);
let multi = $state(false);
let grid = $state(true);
let niceY = $state(true);
let height = $state(260);
let showTooltip = $state(true);
const radii = [2.5, 3.5, 5];
const heights = [200, 260, 320];
const seriesKeys = $derived(multi ? ['alpha', 'beta'] : 'alpha');
const compiled = compileMorfo(chartMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const snippet = $derived(
[
"<script lang='ts'>",
" import { ScatterChart } from '$uix/eidos/components/chart';",
'',
' // rows: a numeric x + one or more y series',
' const data = [',
' { x: 5, alpha: 12, beta: 22 },',
' { x: 12, alpha: 19, beta: 18 }',
' // …',
' ];',
'</' + 'script>',
'',
'<ScatterChart',
' {data}',
' x="x"',
multi ? " y={['alpha', 'beta']}" : ' y="alpha"',
' xScale="linear"',
radius !== 3.5 && ` radius={${radius}}`,
!grid && ' noGrid',
!niceY && ' niceY={false}',
height !== 260 && ` height={${height}}`,
'/>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Charts · Scatter chart</div>
<h1 data-uix-page-title>Scatter chart</h1>
<p data-uix-page-lede>
Points over a continuous x/y, one circle per datum, on the zero-dependency <code>$libs/plots</code>
engine. Multiple series overlay with distinct palette colours; adjustable point radius.
</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>render</span>svg · 0-dep</span>
<span data-uix-meta-pill><span data-uix-meta-key>series</span>{multi ? 2 : 1}</span>
<span data-uix-meta-pill><span data-uix-meta-key>scale</span>linear</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area>
<ScatterChart {data} x="x" y={seriesKeys} xScale="linear" {radius} {height} {niceY} tooltip={showTooltip} noGrid={!grid} ariaLabel="Scatter plot" />
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>renders</span>
<span><span data-uix-stage-trace-event>{multi ? 2 : 1} series</span> · r {radius}</span>
<span style="margin-inline-start: auto;"><span data-uix-stage-trace-key>height</span> {height}px</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 === 'examples'} onclick={() => (tab = 'examples')}>Examples</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p</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>Pure <span data-uix-layer-badge="eidos">eidos</span> display — every control changes the live chart above.</p>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>point radius</span>
<span data-uix-chips role="radiogroup">
{#each radii as r}<button data-uix-chip data-active={radius === r} onclick={() => (radius = r)}>{r}</button>{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>height</span>
<span data-uix-chips role="radiogroup">
{#each heights as h}<button data-uix-chip data-active={height === h} onclick={() => (height = h)}>{h}</button>{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>series</span>
<span data-uix-switch><input type="checkbox" bind:checked={multi} /><span data-uix-switch-label>{multi ? 'alpha + beta' : 'alpha only'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>grid</span>
<span data-uix-switch><input type="checkbox" bind:checked={grid} /><span data-uix-switch-label>{grid ? 'on' : 'off'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>niceY</span>
<span data-uix-switch><input type="checkbox" bind:checked={niceY} /><span data-uix-switch-label>{niceY ? 'on' : 'off'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>tooltip</span>
<span data-uix-switch><input type="checkbox" bind:checked={showTooltip} /><span data-uix-switch-label>{showTooltip ? 'on' : 'off'}</span></span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>ScatterChart preset</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{snippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'examples'}
<section data-uix-section>
<h2 data-uix-section-title>Examples</h2>
<div data-uix-stage-area style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem;">
<ScatterChart {data} x="x" y="alpha" xScale="linear" title="Single series" height={200} />
<ScatterChart {data} x="x" y="alpha" xScale="linear" radius={5} title="Bigger points" height={200} />
<ScatterChart {data} x="x" y={['alpha', 'beta']} xScale="linear" title="Two series" height={200} />
</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>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">data</td><td class="type">{`readonly Record<string, unknown>[]`}</td><td class="default empty">—</td><td>Rows of data.</td></tr>
<tr><td class="name">x</td><td class="type">string</td><td class="default empty">—</td><td>x value key (numeric).</td></tr>
<tr><td class="name">y</td><td class="type">{`string | string[]`}</td><td class="default empty">—</td><td>Series key(s).</td></tr>
<tr><td class="name">xScale</td><td class="type">'linear' | 'time' | 'band' | 'point'</td><td class="default">'linear'</td><td>x mapping.</td></tr>
<tr><td class="name">radius</td><td class="type">number</td><td class="default">3.5</td><td>Point radius.</td></tr>
<tr><td class="name">noGrid / niceY</td><td class="type">boolean</td><td class="default">false / true</td><td>Chrome toggles.</td></tr>
<tr><td class="name">height</td><td class="type">number</td><td class="default">260</td><td>Plot height.</td></tr>
<tr><td class="name">title / ariaLabel</td><td class="type">string</td><td class="default empty">—</td><td>Title / accessible name.</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/chart.ts</code>. The chart family shares one <code>scope:['eidos']</code> contract.</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>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="type">{part.role ?? '—'}</td><td class="default">{part.optional ? 'yes' : 'no'}</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>No <code>--chart-*</code> tokens — styles from global theme tokens.</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Surface</th><th>Token</th></tr></thead>
<tbody>
<tr><td class="name">point</td><td class="type">var(--color-{'{'}role{'}'}-solid) per series</td></tr>
<tr><td class="name">axis / grid</td><td class="type">var(--color-border-default / -subtle)</td></tr>
<tr><td class="name">labels</td><td class="type">var(--color-content-secondary)</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">provider (svg)</td><td>role / aria-label</td><td class="type">"img" / ariaLabel</td></tr>
<tr><td class="name">grid / axis</td><td>aria-hidden</td><td class="type">"true"</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>

@ -0,0 +1,216 @@
<script lang="ts">
import { Sparkline } from '$uix/eidos/components/chart';
import { compileMorfo } from '$uix/morfo';
import { chartMorfo } from '@/uix/morfo/components/chart';
import type { CurveName } from '$libs/plots';
import type { ColorRole } from '$uix/eidos/lib/types';
type Tab = 'live' | 'examples' | 'api' | 'morfo' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
const data = [4, 6, 5, 8, 7, 9, 8, 11, 10, 13, 12, 15, 14, 17].map((v, i) => ({ i, v }));
let mode = $state<'line' | 'bars'>('line');
let curve = $state<CurveName>('monotone');
let area = $state(true);
let color = $state<ColorRole>('primary');
let height = $state(40);
const modes = ['line', 'bars'] as const;
const curves: CurveName[] = ['monotone', 'linear', 'step'];
const colorRoles: ColorRole[] = ['primary', 'affirm', 'risk', 'secondary', 'threat'];
const heights = [32, 40, 64];
const compiled = compileMorfo(chartMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const snippet = $derived(
[
"<script lang='ts'>",
" import { Sparkline } from '$uix/eidos/components/chart';",
'',
' // just { x, y } points',
' const data = [{ i: 0, v: 4 }, { i: 1, v: 6 }, { i: 2, v: 5 }];',
'</' + 'script>',
'',
'<Sparkline',
' {data}',
' x="i"',
' y="v"',
mode === 'bars' && ' as="bars"',
curve !== 'monotone' && ` curve="${curve}"`,
!area && ' area={false}',
color !== 'primary' && ` color="${color}"`,
height !== 40 && ` height={${height}}`,
'/>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Charts · Sparkline</div>
<h1 data-uix-page-title>Sparkline</h1>
<p data-uix-page-lede>
A compact, axis-less trend line for inline use in tables, cards and feeds — built on the same
zero-dependency <code>$libs/plots</code> engine. Optional area fill; themed by role.
</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>render</span>svg · 0-dep</span>
<span data-uix-meta-pill><span data-uix-meta-key>chrome</span>none</span>
<span data-uix-meta-pill><span data-uix-meta-key>curve</span>{curve}</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area style="max-inline-size: 240px;">
<Sparkline {data} x="i" y="v" as={mode} {curve} {area} {color} {height} />
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>renders</span>
<span><span data-uix-stage-trace-event>{curve}</span>{area ? ' · area' : ' · line'} · {color}</span>
<span style="margin-inline-start: auto;"><span data-uix-stage-trace-key>height</span> {height}px</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 === 'examples'} onclick={() => (tab = 'examples')}>Examples</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p</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>Pure <span data-uix-layer-badge="eidos">eidos</span> display — every control changes the live chart above.</p>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>as</span>
<span data-uix-chips role="radiogroup">
{#each modes as m}<button data-uix-chip data-active={mode === m} onclick={() => (mode = m)}>{m}</button>{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>curve</span>
<span data-uix-chips role="radiogroup">
{#each curves as c}<button data-uix-chip data-active={curve === c} onclick={() => (curve = c)}>{c}</button>{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>color</span>
<span data-uix-chips role="radiogroup">
{#each colorRoles as r}<button data-uix-chip data-active={color === r} onclick={() => (color = r)}>{r}</button>{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>height</span>
<span data-uix-chips role="radiogroup">
{#each heights as h}<button data-uix-chip data-active={height === h} onclick={() => (height = h)}>{h}</button>{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>area fill</span>
<span data-uix-switch><input type="checkbox" bind:checked={area} /><span data-uix-switch-label>{area ? 'on' : 'off'}</span></span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>Sparkline preset</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{snippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'examples'}
<section data-uix-section>
<h2 data-uix-section-title>Examples</h2>
<p data-uix-section-desc>Inline trends, one per metric — the canonical sparkline use.</p>
<div data-uix-stage-area style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; max-inline-size: 640px;">
<div><div style="font-size: 0.75rem; color: var(--color-content-secondary); margin-bottom: 0.25rem;">Sessions</div><Sparkline {data} x="i" y="v" /></div>
<div><div style="font-size: 0.75rem; color: var(--color-content-secondary); margin-bottom: 0.25rem;">Revenue</div><Sparkline {data} x="i" y="v" color="affirm" /></div>
<div><div style="font-size: 0.75rem; color: var(--color-content-secondary); margin-bottom: 0.25rem;">Errors · bars</div><Sparkline {data} x="i" y="v" as="bars" color="threat" /></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>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">data</td><td class="type">{`readonly Record<string, unknown>[]`}</td><td class="default empty">—</td><td>Rows of data.</td></tr>
<tr><td class="name">x</td><td class="type">string</td><td class="default empty">—</td><td>x position key.</td></tr>
<tr><td class="name">y</td><td class="type">string</td><td class="default empty">—</td><td>Single series key.</td></tr>
<tr><td class="name">as</td><td class="type">'line' | 'bars'</td><td class="default">'line'</td><td>Render as a trend line or vertical bars.</td></tr>
<tr><td class="name">curve</td><td class="type">'monotone' | 'linear' | 'step'</td><td class="default">'monotone'</td><td>Point connection.</td></tr>
<tr><td class="name">area</td><td class="type">boolean</td><td class="default">true</td><td>Fill under the line.</td></tr>
<tr><td class="name">color</td><td class="type">ColorRole</td><td class="default empty">primary</td><td>Series colour by role.</td></tr>
<tr><td class="name">height</td><td class="type">number</td><td class="default">40</td><td>Compact height (width fills container).</td></tr>
</tbody>
</table>
</div>
<p data-uix-section-desc>No axes, grid or legend — a sparkline is intentionally chrome-less.</p>
</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/chart.ts</code>. Sparkline uses only the provider + line (+ area) parts of the shared contract.</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>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="type">{part.role ?? '—'}</td><td class="default">{part.optional ? 'yes' : 'no'}</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>No <code>--chart-*</code> tokens — the line/area colour comes from the role token.</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Surface</th><th>Token</th></tr></thead>
<tbody>
<tr><td class="name">line / area</td><td class="type">var(--color-{'{'}role{'}'}-solid) + fill-opacity</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">provider (svg)</td><td>role / aria-label</td><td class="type">"img" / ariaLabel</td></tr>
</tbody>
</table>
<p data-uix-section-desc style="margin-top: 0.75rem;">Pass <code>ariaLabel</code> with the trend summary (e.g. "Sessions, up 12% this week"); decorative sparklines may omit it.</p>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.