registry:ui · Data Display
chart — terminal-native charts, zero dependencies
Four deterministic display primitives, not a chart library: horizontal bars drawn in Unicode block glyphs on the mono text grid, one-line sparklines in block or braille cells, an SVG polyline over hairline rules, and a stroke-dasharray donut ring in the theme's OKLCH chart palette. Every part renders static data — no Recharts, no animation runtime, no tooltips — and every degenerate input (empty, all-negative, constant, NaN, zero-total) has a frozen, unit-tested output. Each chart is role=img with a REQUIRED accessible name and an opt-in visually-hidden data table fallback.
$npx jixoai-ui add chart
https://ui.jixoai.com/r/chart.json
the family context, four parts + the barrel — one item
chart
The four primitives over one week of deploy data — flip the table fallback, the sparkline cell system, the area wash and the markers in the playground; the usage file in the drawer tracks every flag.
deploys per day — block bars
week at a glance — sparkline
⠤⠘⠁one line, no axes — the finite min and max own the glyph range's endpoints
deploy trend · 8 weeks — line
incident severities — donut
<ChartBar data={[3,5,2,8,7]} labels={["mon","tue","wed","thu","fri"]} label="deploys per day" />
<ChartSparkline data={[3,5,2,8,7]} label="deploys this week" cells="braille" />
<ChartLine data={[4,6,5,8,7,9,8,12]} label="deploy trend" />
<ChartDonut data={[4,3,2,1]} label="incident severities">
<span class="tabular-nums">10</span>
</ChartDonut>usage
Usage
Compose from parts: each chart owns its data payload and its REQUIRED label; the Chart root only shares a density tier across an ensemble.
<script lang="ts">
import { Chart, ChartBar, ChartSparkline, ChartLine, ChartDonut } from '@ui/chart/index';
</script>
<!-- each part is standalone; Chart only shares a density tier -->
<Chart density="sm">
<ChartBar data={[3, 5, 2, 8, 7]} labels={['mon', 'tue', 'wed', 'thu', 'fri']} label="deploys per day" />
<ChartSparkline data={[3, 5, 2, 8, 7]} label="deploys this week" />
<ChartLine data={[4, 6, 5, 8, 7, 9, 8, 12]} label="deploy trend" area />
<ChartDonut data={[4, 3, 2, 1]} label="incident severities">
<span class="tabular-nums">10</span>
</ChartDonut>
</Chart>examples
Examples
Ability-named demos — one phrase, one capability. Every demo is live; open the code drawer for the exact composition.
The live demo up top plays the presentation flags. These canvases pin the family's compositional abilities: the a11y mirror, the inline trend, authored axes, the center slot, and the stat-card ensemble.
with a data table fallback
table adds a REAL data table beside the img — clip-path hidden from the eye, fully present to assistive tech. Flip it to feel what the screen reader gains.
| row | value |
|---|---|
| mon | 3 |
| tue | 5 |
| wed | 2 |
| thu | 8 |
| fri | 7 |
<!--
jixoai chart bar (registry/files/ui/chart/chart-bar.svelte, OpenSpec
2026-08-30-add-chart-family).
Horizontal bars on the text grid: Unicode block fill, value-
proportional — floor(value/max × cells) full blocks plus one
left-eighth partial tail (chart.svelte's barRun). The whole chart
is ONE shared three-column grid (label lane · run · value lane) —
every run starts at the same x by construction, and the glyph
budget re-bins to the track's measured capacity (self-adaptive,
never scrollable; see the re-bin block below). The mono lock lives
in chart.css (.jx-chart-glyphs).
Data honesty (frozen semantics, unit-tested): non-finite and
non-positive values render an EMPTY run — the glyph lane refuses to
lie; the value lane keeps showing the number (non-finite as the em
dash). The scale is the max of the finite values; an all-non-positive
series renders empty runs (max must be > 0).
Variant grammar: fill | tonal | outline — the run's INK through the
four global hue slots (valued data-jx-chart-bar hook), so intensity
composes with hue injection like every other painted surface:
variant = how, jx-hue-* = which voice.
A11y: role="img" + REQUIRED `label` (aria-label; the type contract
has no default). `table` opts into the visually-hidden data table
fallback (a REAL sibling table — role="img" makes this subtree
presentational, so the mirror lives outside it).
-->
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils';
import {
densityRungOf,
provideQueryAnchor,
provideUniversalLanes,
stampCarriersForLanes,
type ColorLane,
type DensityLane,
type ElevationLane,
type MotionLane,
type QueryResult,
type RadiusLane,
type ShapeLane,
type ThemeLane,
} from '$lib/defaults.svelte';
import { chartStyles } from './chart.stylex';
import { ChartDefaults } from './chart-defaults.svelte';
import { barRun, seriesBounds, type ChartVariant } from './chart.svelte';
import './chart.css';
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'color'> {
/** the series — one row per datum (a value-domain payload, the
* chart family's whole contract) */
data: readonly number[];
/** REQUIRED accessible name (role="img"); no default by contract */
label: string;
/** parallel label lane (zipped by index; absent → empty lane) */
labels?: readonly (string | number)[];
/** the ink's prominence rung through the global hue slots */
variant?: ChartVariant;
/** the bar-length budget in character cells */
cells?: number;
/** the inline-end value lane (raw numbers — formatting is yours) */
values?: boolean;
/** opt-in visually-hidden data table fallback */
table?: boolean;
/** density policy: the universal §4 lane (named rungs + the
* documented small/medium/large aliases · auto · a coefficient
* number · query()) */
density?: DensityLane | QueryResult<DensityLane>;
/** universal shape axis (§2): corner geometry; auto = inherit */
shape?: ShapeLane | QueryResult<ShapeLane>;
/** universal radius axis (§3): corner size; auto = the concentric
* broadcast */
radius?: RadiusLane | QueryResult<RadiusLane>;
/** universal color axis (§5): the hue axis of the oklch system */
color?: ColorLane | QueryResult<ColorLane>;
/** universal theme axis (§6): light/dark/system; auto = tree
* inheritance (the .dark class bridge) */
theme?: ThemeLane | QueryResult<ThemeLane>;
/** universal elevation axis (§7): official M3 levels · dp ·
* query() */
elevation?: ElevationLane | QueryResult<ElevationLane>;
/** universal motion axis (§8): intensity — reduced…expressive ·
* a coefficient · query() */
motion?: MotionLane | QueryResult<MotionLane>;
class?: string;
}
let {
data,
label,
labels,
variant,
cells = 20,
values = true,
table = false,
density,
shape,
radius,
color,
theme,
elevation,
motion,
class: className = '',
style = '',
...rest
}: Props = $props();
// the family Defaults is the single read point (context-defaults-
// economy 3.4 + W3-D1): variant rides a literal slot (own 'fill'),
// the seven non-size universal axes resolve one record — the
// contract's `size` key is the donut diameter literal, and the bar
// carries no size opinion of its own (the size axis forwards
// ambient through the supply below)
const d = $derived(
ChartDefaults.resolve({ variant, density, shape, radius, color, theme, elevation, motion }),
);
// the icon law (donut's guard, the 96px-explosion fix): the defaults'
// open size slot (own 96 = the DONUT's outer diameter) backfills d.size
// for every part — stamping it here put --jx-size-effective: 96px /
// font-size 96px on bar/sparkline/line roots (7.38× magnified glyphs,
// scrolling runs; introduced 3e8c38ec, diagnosed 2026-09-24). Only an
// explicit number may stamp; these parts carry no size prop at all.
const carriers = $derived(stampCarriersForLanes({ ...d, size: undefined }));
provideUniversalLanes({ density, shape, radius, color, theme, elevation, motion });
let uniRoot = $state<HTMLDivElement>();
provideQueryAnchor(() => uniRoot ?? null);
const rootStyle = $derived([carriers, style].filter(Boolean).join('; ') || undefined);
const max = $derived(seriesBounds(data)?.max ?? 0);
// the adaptive re-bin (Owner ruling 2026-09-24: charts are
// self-adaptive, never scrollable — and 2026-09-25: adaptation is
// RE-BINNING, never font-scaling): the run's font stays on the
// density rung; the glyph budget re-bins from pure geometry — the
// width the CEILING art wants (label lane + value lane + gaps +
// ceiling × advance) against the space the CONTAINER offers (the
// first real ancestor box, clamped by the root's own max-width).
// When that space can hold the ceiling — a shrink-to-fit box
// FOLLOWS the art, so it always can; a wide grid cell can too —
// the full resolution stays; when it is tighter (max-width cap,
// narrow viewport) the budget drops to the real capacity. Reading
// only container-determined geometry — never the current run's
// rendered width, never the shrink-to-fit root's own clientWidth —
// is what makes this bidirectional and ratchet-proof: measured-
// content feedback loops were tried first and spiraled (the
// shrink-to-fit demos collapsed toward the 4-glyph floor one rebin
// at a time — a fallback-font advance at mount was enough to start
// it — and could never grow back). The advance comes from canvas
// measureText (grid tracks do NOT shrink under a tight box — the
// whole grid just overflows — so a rendered-run measure could
// never see the constraint). A CSS font-size fit predates all this
// and was rolled back too: hairline ink at wide budgets, ZERO px
// in the flex demos (inline-size-contained rows contributed no
// intrinsic width). The 4-glyph floor keeps a degenerate track
// from erasing the art entirely (clip catches the residue).
let effCells = $state(cells);
const run = $derived((v: number) => barRun(v, max, effCells));
$effect(() => {
const root = uniRoot;
const ceiling = cells;
if (!root || typeof ResizeObserver === 'undefined') return;
const rebin = () => {
// used track sizes: "label-lane run-track value-lane" (the
// label/value lanes are content-determined, not run-dependent)
const tracks = getComputedStyle(root).gridTemplateColumns.split(' ').map(parseFloat);
if (tracks.length < 3 || !Number.isFinite(tracks[0]) || !Number.isFinite(tracks[2])) return;
const gap = parseFloat(getComputedStyle(root).columnGap) || 0;
const flank = tracks[0] + tracks[2] + 2 * gap;
// the AVAILABLE box is the container's, never the root's own:
// a shrink-to-fit root (center-stage flex) FOLLOWS the current
// content, so sizing against root.clientWidth ratchets — one
// fallback-font measure at mount locked the a11y demo two
// glyphs under its ceiling. Walk to the first ancestor with a
// real box (display:contents rows have none), then clamp by
// the root's own max-width when present.
let host: HTMLElement | null = root.parentElement;
let avail = 0;
while (host && !(avail = host.clientWidth)) host = host.parentElement;
if (!avail) return;
// clamp by the root's own max-width ONLY in absolute px (the
// a11y demo's 28rem cap): a PERCENTAGE resolves against the
// walked parent already — parseFloat("100%") = 100px once
// collapsed the table-fallback chart to a 5-glyph stub
const maxWRaw = getComputedStyle(root).maxWidth;
if (maxWRaw.endsWith('px')) {
const maxW = parseFloat(maxWRaw);
if (maxW > 0) avail = Math.min(avail, maxW);
}
const ink = root.querySelector<HTMLElement>('[data-jx-chart-bar-run]');
if (!ink || !ink.textContent) return;
const cs = getComputedStyle(ink);
const ctx = document.createElement('canvas').getContext('2d');
if (!ctx) return;
ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
const advance = ctx.measureText('█').width;
if (!(advance > 0)) return;
const next =
flank + ceiling * advance <= avail
? ceiling
: Math.min(ceiling, Math.max(4, Math.floor((avail - flank) / advance)));
if (next !== effCells) effCells = next;
};
rebin();
const ro = new ResizeObserver(rebin);
ro.observe(root);
document.fonts?.ready.then(rebin).catch(() => {});
return () => ro.disconnect();
});
// the payload's own join (separator's serialize law): objects in
// dev, joined strings in payloads — never a raw interpolation
const cx = (
...styles: ({ readonly [key: string]: string | object } | undefined | string)[]
): string =>
styles
.filter(Boolean)
.map((style) =>
typeof style === 'string'
? style
: Object.entries(style ?? {}).flatMap(([key, value]) =>
key !== '$$css' && typeof value === 'string' ? [value] : [],
).join(' '),
)
.join(' ');
// the variant ladder's ink — atom groups at module scope (the
// dynamic-producer law: data-built STRINGS must resolve to
// registered identities; the map walks cx() over static members)
const INK_CLASS: Record<ChartVariant, string> = {
fill: cx(chartStyles.inkFill),
tonal: cx(chartStyles.inkTonal),
outline: cx(chartStyles.inkOutline),
};
</script>
<div
{...rest}
bind:this={uniRoot}
role="img"
aria-label={label}
data-jx-chart-bar={d.variant}
data-density={densityRungOf(d.density)}
class:dark={d.theme === 'dark'}
style={rootStyle}
class={cn(cx(chartStyles.barRoot), className)}
>
{#each data as v, i (i)}
<div data-jx-chart-bar-row="" class={cx(chartStyles.barRow)}>
<span data-jx-chart-bar-label="" class={cx(chartStyles.barLabel)}>{labels?.[i] ?? ''}</span>
<span data-jx-chart-bar-run="" class="jx-chart-glyphs {INK_CLASS[d.variant]}">{run(v)}</span>
{#if values}
<span data-jx-chart-bar-value="" class={cx(chartStyles.barValue)}>{Number.isFinite(v) ? v : '—'}</span>
{/if}
</div>
{/each}
</div>
{#if table}
<table class="jx-chart-table" data-jx-chart-bar-table="">
<caption>{label}</caption>
<thead>
<tr><th scope="col">row</th><th scope="col">value</th></tr>
</thead>
<tbody>
{#each data as v, i (i)}
<tr>
<th scope="row">{labels?.[i] ?? i}</th>
<td>{Number.isFinite(v) ? v : '—'}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
inline in a stat row
The sparkline is a single inline span: it composes inside a sentence, and inside Statistic's suffix snippet — the metric readout carries its own trend.
deploys / week
42 ▲ ⠤⠘⠁
latency p95 ⠡⢂⢄ down 12%
braille packs two points per cell ▂▅▁█▇ block rides the eighth-block ramp
<!--
jixoai chart sparkline (registry/files/ui/chart/chart-sparkline.svelte,
OpenSpec 2026-08-30-add-chart-family).
The one-line inline trend for stat rows: no axes, honest min/max
endpoints (the finite bounds map to the glyph range's extremes — no
padded lie). Two cell systems (the valued data-jx-chart-sparkline
hook):
block one eighth-block glyph per point (U+2581..2588), 8 levels
braille points packed two per braille cell (U+2800 matrix), each
finite point a single dot at its 4-level height
A single inline <span> — composes inside a sentence, a Statistic
suffix, a list row. Frozen degenerates: non-finite points keep
their x position with NO geometry (a blank block cell / a dotless
braille column); empty or all-non-finite data renders the empty
span; a constant series renders the midline (block ▅, braille the
level-1 dot pair).
A11y: role="img" + REQUIRED `label`; `table` opts into the
visually-hidden data table fallback (a REAL sibling table).
-->
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils';
import {
densityRungOf,
provideQueryAnchor,
provideUniversalLanes,
stampCarriersForLanes,
type ColorLane,
type DensityLane,
type ElevationLane,
type MotionLane,
type QueryResult,
type RadiusLane,
type ShapeLane,
type ThemeLane,
} from '$lib/defaults.svelte';
import { chartStyles } from './chart.stylex';
import { ChartDefaults } from './chart-defaults.svelte';
import { sparkBlocks, sparkBraille } from './chart.svelte';
import './chart.css';
interface Props extends Omit<HTMLAttributes<HTMLSpanElement>, 'color'> {
/** the series (a value-domain payload) */
data: readonly number[];
/** REQUIRED accessible name (role="img"); no default by contract */
label: string;
/** the cell system: block (8 levels/point) | braille (2 points/cell) */
cells?: 'block' | 'braille';
/** opt-in visually-hidden data table fallback */
table?: boolean;
/** density policy: the universal §4 lane (named rungs + the
* documented small/medium/large aliases · auto · a coefficient
* number · query()) */
density?: DensityLane | QueryResult<DensityLane>;
/** universal shape axis (§2): corner geometry; auto = inherit */
shape?: ShapeLane | QueryResult<ShapeLane>;
/** universal radius axis (§3): corner size; auto = the concentric
* broadcast */
radius?: RadiusLane | QueryResult<RadiusLane>;
/** universal color axis (§5): the hue axis of the oklch system */
color?: ColorLane | QueryResult<ColorLane>;
/** universal theme axis (§6): light/dark/system; auto = tree
* inheritance (the .dark class bridge) */
theme?: ThemeLane | QueryResult<ThemeLane>;
/** universal elevation axis (§7): official M3 levels · dp ·
* query() */
elevation?: ElevationLane | QueryResult<ElevationLane>;
/** universal motion axis (§8): intensity — reduced…expressive ·
* a coefficient · query() */
motion?: MotionLane | QueryResult<MotionLane>;
class?: string;
}
let {
data,
label,
cells = 'braille',
table = false,
density,
shape,
radius,
color,
theme,
elevation,
motion,
class: className = '',
style = '',
...rest
}: Props = $props();
// the family Defaults is the single read point (context-defaults-
// economy 3.4 + W3-D1): the seven non-size universal axes resolve
// one record (the contract's `size` key is the donut diameter
// literal; the sparkline carries no size opinion — the axis
// forwards ambient through the supply below)
const d = $derived(
ChartDefaults.resolve({ density, shape, radius, color, theme, elevation, motion }),
);
// the icon law (donut's guard, the 96px-explosion fix): the defaults'
// open size slot (own 96 = the DONUT's outer diameter) backfills d.size
// for every part — stamping it here put --jx-size-effective: 96px /
// font-size 96px on bar/sparkline/line roots (7.38× magnified glyphs,
// scrolling runs; introduced 3e8c38ec, diagnosed 2026-09-24). Only an
// explicit number may stamp; these parts carry no size prop at all.
const carriers = $derived(stampCarriersForLanes({ ...d, size: undefined }));
provideUniversalLanes({ density, shape, radius, color, theme, elevation, motion });
let uniRoot = $state<HTMLSpanElement>();
provideQueryAnchor(() => uniRoot ?? null);
const rootStyle = $derived([carriers, style].filter(Boolean).join('; ') || undefined);
const glyphs = $derived(cells === 'block' ? sparkBlocks(data) : sparkBraille(data));
// the payload's own join (separator's serialize law): objects in
// dev, joined strings in payloads — never a raw interpolation
const cx = (
...styles: ({ readonly [key: string]: string | object } | undefined | string)[]
): string =>
styles
.filter(Boolean)
.map((style) =>
typeof style === 'string'
? style
: Object.entries(style ?? {}).flatMap(([key, value]) =>
key !== '$$css' && typeof value === 'string' ? [value] : [],
).join(' '),
)
.join(' ');
</script>
<span
{...rest}
bind:this={uniRoot}
role="img"
aria-label={label}
data-jx-chart-sparkline={cells}
data-density={densityRungOf(d.density)}
class:dark={d.theme === 'dark'}
style={rootStyle}
class={cn('jx-chart-glyphs', cx(chartStyles.spark), className)}
>{glyphs}</span>
{#if table}
<table class="jx-chart-table" data-jx-chart-sparkline-table="">
<caption>{label}</caption>
<thead>
<tr><th scope="col">point</th><th scope="col">value</th></tr>
</thead>
<tbody>
{#each data as v, i (i)}
<tr>
<th scope="row">{i}</th>
<td>{Number.isFinite(v) ? v : '—'}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
with area fill and authored axes
area adds the 12% tonal wash under the curve; the axes are YOUR snippets — the component renders them inside the svg and never guesses a domain.
the hairline rules are grid GEOMETRY (top / mid / bottom) — ticks and labels are the consumer's authored snippets
<!--
jixoai chart line (registry/files/ui/chart/chart-line.svelte,
OpenSpec 2026-08-30-add-chart-family).
The SVG trend on the jx token surface: ONE polyline over three
hairline grid rules (top/mid/bottom, var(--border), non-scaling —
grid GEOMETRY, never guessed axes: no ticks, no labels, no
generated domains). The brand stroke rides --chart-1 (the theme's
own chart palette); the optional area fill is the 12% tonal under-
curve wash; markers are round linecap dots via the doubled-point
polyline trick (stroke-width CAN be non-scaling, <circle r> cannot
— dots never stretch under the viewBox scale).
Axes are AUTHORED SLOTS, never guessed: xAxis / yAxis snippets
render inside the svg (draw your own rules and labels — the
component supplies no domain it did not read from your data).
Sizing: a fixed 5:2 viewBox (100×40) + width 100% + height auto —
the aspect holds, dots stay round, and the consumer constrains
geometry with utilities (the layer law).
Frozen degenerates: non-finite points are SKIPPED (x positions of
the survivors stay honest); empty / all-non-finite data renders the
grid shell alone; a single point renders its marker centered; a
constant series is the honest horizontal midline; the area wash
needs at least two finite points (a one-point area is nothing).
A11y: role="img" + REQUIRED `label`; `table` opts into the
visually-hidden data table fallback (a REAL sibling table).
-->
<script lang="ts">
import type { Snippet } from 'svelte';
import type { SVGAttributes } from 'svelte/elements';
import {
densityRungOf,
provideQueryAnchor,
provideUniversalLanes,
stampCarriersForLanes,
type ColorLane,
type DensityLane,
type ElevationLane,
type MotionLane,
type QueryResult,
type RadiusLane,
type ShapeLane,
type ThemeLane,
} from '$lib/defaults.svelte';
import { ChartDefaults } from './chart-defaults.svelte';
import { cn } from '$lib/utils';
import { chartStyles } from './chart.stylex';
import { linePoints, markerPoints } from './chart.svelte';
import './chart.css';
// 'radius' and 'elevation' join the Omit: SVGAttributes carries
// its own svg-primitive attributes under those names — the §3/§7
// axes own the names (the W3-D1 collision rule, the rest lane
// yields)
interface Props extends Omit<SVGAttributes<SVGSVGElement>, 'color' | 'radius' | 'elevation'> {
/** the series (a value-domain payload) */
data: readonly number[];
/** REQUIRED accessible name (role="img"); no default by contract */
label: string;
/** the 12% tonal wash under the curve */
area?: boolean;
/** round dot markers at every finite point */
markers?: boolean;
/** authored axis slots — the consumer's own rules/labels, rendered
* inside the svg AFTER the data (axes never guessed) */
xAxis?: Snippet;
yAxis?: Snippet;
/** opt-in visually-hidden data table fallback */
table?: boolean;
/** density policy: the universal §4 lane (named rungs + the
* documented small/medium/large aliases · auto · a coefficient
* number · query()) */
density?: DensityLane | QueryResult<DensityLane>;
/** universal shape axis (§2): corner geometry; auto = inherit */
shape?: ShapeLane | QueryResult<ShapeLane>;
/** universal radius axis (§3): corner size; auto = the concentric
* broadcast */
radius?: RadiusLane | QueryResult<RadiusLane>;
/** universal color axis (§5): the hue axis of the oklch system */
color?: ColorLane | QueryResult<ColorLane>;
/** universal theme axis (§6): light/dark/system; auto = tree
* inheritance (the .dark class bridge) */
theme?: ThemeLane | QueryResult<ThemeLane>;
/** universal elevation axis (§7): official M3 levels · dp ·
* query() */
elevation?: ElevationLane | QueryResult<ElevationLane>;
/** universal motion axis (§8): intensity — reduced…expressive ·
* a coefficient · query() */
motion?: MotionLane | QueryResult<MotionLane>;
class?: string;
}
let {
data,
label,
area = false,
markers = true,
xAxis,
yAxis,
table = false,
density,
shape,
radius,
color,
theme,
elevation,
motion,
class: className = '',
style = '',
...rest
}: Props = $props();
// the family Defaults is the single read point (context-defaults-
// economy 3.4 + W3-D1): the seven non-size universal axes resolve
// one record (the contract's `size` key is the donut diameter
// literal; the line glyph carries no size opinion — the axis
// forwards ambient through the supply below)
const d = $derived(
ChartDefaults.resolve({ density, shape, radius, color, theme, elevation, motion }),
);
// the icon law (donut's guard, the 96px-explosion fix): the defaults'
// open size slot (own 96 = the DONUT's outer diameter) backfills d.size
// for every part — stamping it here put --jx-size-effective: 96px /
// font-size 96px on bar/sparkline/line roots (7.38× magnified glyphs,
// scrolling runs; introduced 3e8c38ec, diagnosed 2026-09-24). Only an
// explicit number may stamp; these parts carry no size prop at all.
const carriers = $derived(stampCarriersForLanes({ ...d, size: undefined }));
provideUniversalLanes({ density, shape, radius, color, theme, elevation, motion });
let uniRoot = $state<SVGSVGElement>();
provideQueryAnchor(() => uniRoot ?? null);
const rootStyle = $derived([carriers, style].filter(Boolean).join('; ') || undefined);
// the payload's own join (separator's serialize law): objects in
// dev, joined strings in payloads — never a raw interpolation
const cx = (
...styles: ({ readonly [key: string]: string | object } | undefined | string)[]
): string =>
styles
.filter(Boolean)
.map((style) =>
typeof style === 'string'
? style
: Object.entries(style ?? {}).flatMap(([key, value]) =>
key !== '$$css' && typeof value === 'string' ? [value] : [],
).join(' '),
)
.join(' ');
// viewBox constants — the 5:2 frame; y band [1, 39] (the documented
// 1-unit marker-radius inset; the DATA range maps the full band)
const W = 100;
const H = 40;
const points = $derived(linePoints(data, W, H, 1));
const dots = $derived(markerPoints(points));
// the area wash: the line's points closed along the inset baseline
// (first x = 0, last x = W for any multi-point series — linePoints's
// own index mapping)
const areaPoints = $derived.by(() => {
if (!area || !points) return '';
if (points.split(' ').length < 2) return '';
return `${points} ${W},${H - 1} 0,${H - 1}`;
});
</script>
<svg
{...rest}
bind:this={uniRoot}
role="img"
aria-label={label}
data-jx-chart-line=""
data-density={densityRungOf(d.density)}
class:dark={d.theme === 'dark'}
style={rootStyle}
viewBox="0 0 {W} {H}"
fill="none"
class={cn(cx(chartStyles.lineRoot), className)}
>
<!-- the hairline grid: geometry, never axes -->
<line class="jx-chart-grid" x1="0" y1="1" x2="{W}" y2="1" vector-effect="non-scaling-stroke" stroke-width="1" />
<line class="jx-chart-grid" x1="0" y1="{H / 2}" x2="{W}" y2="{H / 2}" vector-effect="non-scaling-stroke" stroke-width="1" />
<line class="jx-chart-grid" x1="0" y1="{H - 1}" x2="{W}" y2="{H - 1}" vector-effect="non-scaling-stroke" stroke-width="1" />
{#if areaPoints}
<polygon class="jx-chart-area" points={areaPoints} />
{/if}
{#if points}
<polyline
class="jx-chart-path"
points={points}
vector-effect="non-scaling-stroke"
stroke-width="1.5"
stroke-linejoin="round"
stroke-linecap="round"
/>
{/if}
{#if markers && dots}
<polyline
class="jx-chart-mark"
points={dots}
vector-effect="non-scaling-stroke"
stroke-width="3"
stroke-linecap="round"
/>
{/if}
{#if yAxis}
<g data-jx-chart-line-axis="y">{@render yAxis()}</g>
{/if}
{#if xAxis}
<g data-jx-chart-line-axis="x">{@render xAxis()}</g>
{/if}
</svg>
{#if table}
<table class="jx-chart-table" data-jx-chart-line-table="">
<caption>{label}</caption>
<thead>
<tr><th scope="col">point</th><th scope="col">value</th></tr>
</thead>
<tbody>
{#each data as v, i (i)}
<tr>
<th scope="row">{i}</th>
<td>{Number.isFinite(v) ? v : '—'}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
with a center total
The ring is stroke-dasharray segments over a muted track; the center is a snippet slot for YOUR composition — here the running total.
<!--
jixoai chart donut (registry/files/ui/chart/chart-donut.svelte,
OpenSpec 2026-08-30-add-chart-family).
The SVG ring: one circle per segment, painted by stroke-dasharray
(dash = share × circumference, one dash per circle; offset = the
accumulated lengths before it; the -90° group rotation pins segment
0 at 12 o'clock) over a full muted track. Segment colors ride the
theme's --chart-1..5 semantic palette, then a deterministic neutral
oklch ladder derived from var(--brand-hue) (hue injection stays the
consumer's job — restyle through the hooks/classes; the palette is
the component's frozen default).
The center is a SNIPPET SLOT (the total, a delta, a label — the
consumer's composition, absolutely positioned over the ring).
Frozen degenerates: negative and non-finite values are 0-share
segments (a donut is composition shares — it never invents
magnitude); a zero total (empty, all-bad, all-non-positive) renders
the BARE TRACK; shares always sum to the circumference exactly.
A11y: role="img" + REQUIRED `label`; `table` opts into the
visually-hidden data table fallback (a REAL sibling table with each
segment's value and share).
-->
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils';
import {
densityRungOf,
provideQueryAnchor,
provideUniversalLanes,
stampCarriersForLanes,
type ColorLane,
type DensityLane,
type ElevationLane,
type MotionLane,
type QueryResult,
type RadiusLane,
type ShapeLane,
type ThemeLane,
} from '$lib/defaults.svelte';
import { chartStyles } from './chart.stylex';
import { ChartDefaults } from './chart-defaults.svelte';
import { donutGeometry } from './chart.svelte';
import './chart.css';
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'color'> {
/** the segment values (composition shares of the total) */
data: readonly number[];
/** REQUIRED accessible name (role="img"); no default by contract */
label: string;
/** the ring's outer size in px — the open literal (own 96).
* §13/W3-D1 (the icon/spin ruling, same semantics): a NUMBER is
* ALSO the universal size axis' number lane verbatim — an
* explicit number stamps the §1 carrier (the glyph takes no
* font-size opinion it was not given); named/auto stay
* unadopted on the svg glyph */
size?: number;
/** the stroke thickness in px */
thickness?: number;
/** the center slot — the total, a delta, your composition */
children?: Snippet;
/** opt-in visually-hidden data table fallback */
table?: boolean;
/** density policy: the universal §4 lane (named rungs + the
* documented small/medium/large aliases · auto · a coefficient
* number · query()) */
density?: DensityLane | QueryResult<DensityLane>;
/** universal shape axis (§2): corner geometry; auto = inherit */
shape?: ShapeLane | QueryResult<ShapeLane>;
/** universal radius axis (§3): corner size; auto = the concentric
* broadcast */
radius?: RadiusLane | QueryResult<RadiusLane>;
/** universal color axis (§5): the hue axis of the oklch system */
color?: ColorLane | QueryResult<ColorLane>;
/** universal theme axis (§6): light/dark/system; auto = tree
* inheritance (the .dark class bridge) */
theme?: ThemeLane | QueryResult<ThemeLane>;
/** universal elevation axis (§7): official M3 levels · dp ·
* query() */
elevation?: ElevationLane | QueryResult<ElevationLane>;
/** universal motion axis (§8): intensity — reduced…expressive ·
* a coefficient · query() */
motion?: MotionLane | QueryResult<MotionLane>;
class?: string;
}
let {
data,
label,
size,
thickness = 12,
children,
table = false,
density,
shape,
radius,
color,
theme,
elevation,
motion,
class: className = '',
style = '',
...rest
}: Props = $props();
// the family Defaults is the single read point (context-defaults-
// economy 3.4 + W3-D1): size rides a literal slot (own 96px), the
// seven other universal axes resolve one record. The §1 number
// lane rides the size fragment VERBATIM (the icon law): only an
// explicit number stamps the carrier — the resolved default 96
// stays the glyph's own geometry, never a font-size opinion
const d = $derived(
ChartDefaults.resolve({ size, density, shape, radius, color, theme, elevation, motion }),
);
const geo = $derived(donutGeometry(data, d.size, thickness));
const carriers = $derived(
stampCarriersForLanes({ ...d, size: typeof size === 'number' ? size : undefined }),
);
provideUniversalLanes({
density,
size: typeof size === 'number' ? size : undefined,
shape,
radius,
color,
theme,
elevation,
motion,
});
let uniRoot = $state<HTMLDivElement>();
provideQueryAnchor(() => uniRoot ?? null);
const rootStyle = $derived([carriers, style].filter(Boolean).join('; ') || undefined);
// the payload's own join (separator's serialize law): objects in
// dev, joined strings in payloads — never a raw interpolation
const cx = (
...styles: ({ readonly [key: string]: string | object } | undefined | string)[]
): string =>
styles
.filter(Boolean)
.map((style) =>
typeof style === 'string'
? style
: Object.entries(style ?? {}).flatMap(([key, value]) =>
key !== '$$css' && typeof value === 'string' ? [value] : [],
).join(' '),
)
.join(' ');
</script>
<div
{...rest}
bind:this={uniRoot}
role="img"
aria-label={label}
data-jx-chart-donut=""
data-density={densityRungOf(d.density)}
class:dark={d.theme === 'dark'}
style={rootStyle}
class={cn(cx(chartStyles.grid), className)}
>
<svg viewBox="0 0 {d.size} {d.size}" width={d.size} height={d.size} fill="none" aria-hidden="true" class={cx(chartStyles.stacked)}>
<g transform="rotate(-90 {d.size / 2} {d.size / 2})">
<circle
class="jx-chart-track"
cx={d.size / 2}
cy={d.size / 2}
r={geo?.radius ?? (d.size - thickness) / 2}
stroke-width={thickness}
/>
{#if geo}
{#each geo.segments as seg (seg.index)}
<circle
class="jx-chart-seg"
cx={d.size / 2}
cy={d.size / 2}
r={geo.radius}
stroke-width={thickness}
stroke-dasharray={seg.dash}
stroke-dashoffset={seg.offset}
data-seg={seg.index}
style={`--jx-chart-seg-color: ${seg.color}`}
/>
{/each}
{/if}
</g>
</svg>
{#if children}
<!-- the center slot is a grid item of the SAME cell (CR-2, 2026-09-02): grid stacking, not absolute overlay -->
<div data-jx-chart-donut-center="" class={cx(chartStyles.stacked, chartStyles.center)}>
{@render children()}
</div>
{/if}
</div>
{#if table}
<table class="jx-chart-table" data-jx-chart-donut-table="">
<caption>{label}</caption>
<thead>
<tr><th scope="col">segment</th><th scope="col">value</th><th scope="col">share</th></tr>
</thead>
<tbody>
{#if geo}
{#each geo.segments as seg (seg.index)}
<tr>
<th scope="row">{seg.index}</th>
<td>{seg.value > 0 ? seg.value : '—'}</td>
<td>{Math.round(seg.share * 100)}%</td>
</tr>
{/each}
{/if}
</tbody>
</table>
{/if}
composed in stat cards
The family's ensemble: one Chart root shares the sm density tier while bars, sparklines and a donut compose a dashboard row of plain card surfaces.
<!--
jixoai chart — the ROOT half + the family math (registry/files/ui/
chart/chart.svelte, OpenSpec 2026-08-30-add-chart-family).
The chart family is a set of DETERMINISTIC DISPLAY PRIMITIVES, not a
chart library: every part renders static data with text glyphs and
inline SVG — zero runtime dependencies, no Recharts, no animation
system, no tooltips (a recorded future change owns interaction).
The root carries NO chart state: like the tabs root, it owns only
the family's shared policy — the density tier provided to the
subtree — so an ensemble (a stat-card grid of sparklines, a bar list
beside a donut) adopts ONE tier:
<Chart density="sm">
<ChartBar … /> <ChartDonut … />
</Chart>
Each part is standalone-complete (resolves its own density opinion)
— the root is a convenience, never a requirement.
THE GLYPH MATH lives in this file's module script and is exported
for unit tests (no DOM, pure functions of props data). Every
degenerate input is FROZEN, not invented (same input, same output):
non-finite points contribute NO geometry (no glyph, no dot, no
segment) and never perturb the min/max of the
finite points around them — x positions stay
honest
empty / all-bad each part's documented empty state (the part
still names itself; nothing renders)
constant series the horizontal midline (block ramp: ▅;
braille: the level-1 dot pair)
zero/negative totals donut renders the bare track; bars render
empty runs with the value lane intact (the
NUMBER stays visible — the glyph run is the
only thing that refuses to lie)
A11y contract (spec delta): every part is role="img" with a
REQUIRED accessible name — `label` has no default, the type contract
enforces it — plus an opt-in visually-hidden data table fallback
(`table` prop) that mirrors the series as a REAL sibling table (a
sibling, never a child: role="img" makes its own subtree
presentational, so the table must live outside it).
Motion: charts ship ZERO entrance motion — the strongest form of
the reduced-motion law (final state painted immediately,
structurally: no WAAPI, no animate-* utilities, nothing to kill).
Hue: paint consumes tokens only — the theme's --chart-1..5 semantic
palette (the D5 harmony audit's own chart ramp) plus a neutral
oklch ladder derived from var(--brand-hue) beyond the fifth segment.
Hue injection stays the consumer's job (the four global slots for
the bar's variant grammar).
-->
<script module lang="ts">
// ---------------------------------------------------------------------
// The family math — pure, DOM-free, exported for the unit suite
// (every rule here is FROZEN: same input, same output, every time).
// The barrel re-exports this surface (`export * from './chart.svelte'`).
// ---------------------------------------------------------------------
/** the vertical eighth-block ramp (U+2581..2588), bottom-filled —
* the sparkline BLOCK mode's per-point glyph */
export const BLOCK_RAMP = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] as const;
/** the LEFT eighth-block ramp (U+258F..2589 + U+2588), start-filled —
* the horizontal bar's partial tail cell (the lower-eighth glyphs
* fill from the BOTTOM and cannot express partial horizontal
* extent; the block family's left eighths can) */
export const BAR_RAMP = ['▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'] as const;
/** the full block — BAR_RAMP's last step and every full bar cell */
export const BLOCK = '█';
/** braille base code point (U+2800) — the blank cell */
export const BRAILLE_BASE = 0x2800;
/** dot bits per column, top→bottom rows: the left column is the
* FIRST point of a cell pair, the right the second */
export const BRAILLE_LEFT = [0x01, 0x02, 0x04, 0x08] as const;
export const BRAILLE_RIGHT = [0x10, 0x20, 0x40, 0x80] as const;
/** the theme's chart palette size (--chart-1..5) */
export const CHART_PALETTE_SIZE = 5;
/** the neutral ladder beyond the semantic palette — oklch lightness
* steps at a whisper of the brand hue (token-derived; the theme
* sheet's own primary is authored the same way) */
export const DONUT_NEUTRALS = [
'oklch(0.72 0.025 var(--brand-hue))',
'oklch(0.58 0.02 var(--brand-hue))',
'oklch(0.44 0.015 var(--brand-hue))',
'oklch(0.32 0.01 var(--brand-hue))',
] as const;
/** the semantic palette first (the theme's own chart ramp), then the
* neutral ladder, cycling deterministically */
export function donutColor(index: number): string {
if (!Number.isInteger(index) || index < 0) return 'var(--chart-1)';
if (index < CHART_PALETTE_SIZE) return `var(--chart-${index + 1})`;
return DONUT_NEUTRALS[(index - CHART_PALETTE_SIZE) % DONUT_NEUTRALS.length];
}
/** min/max over the FINITE points only — null when none exist (the
* every-part empty guard; non-finite points never perturb bounds) */
export function seriesBounds(data: readonly number[]): { min: number; max: number } | null {
let min = Infinity;
let max = -Infinity;
for (const v of data) {
if (Number.isFinite(v)) {
if (v < min) min = v;
if (v > max) max = v;
}
}
return min === Infinity ? null : { min, max };
}
/** value → 0..1 against [min,max], clamped. FROZEN edges: a constant
* series (min===max) maps to 0.5 (the honest midline — no invented
* range); a non-finite value maps to 0 (callers that must not draw
* it skip the point BEFORE normalizing) */
export function normalize(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return 0;
if (min === max) return 0.5;
return Math.min(1, Math.max(0, (value - min) / (max - min)));
}
/** value → ramp level 0..levels-1 (round-half-up on the normalized
* position; 8 block levels, 4 braille levels) */
export function rampIndex(value: number, min: number, max: number, levels: number): number {
return Math.round(normalize(value, min, max) * (levels - 1));
}
/** one BLOCK-mode sparkline glyph per point; non-finite points keep
* their x position as a blank cell (the run length always equals
* the data length) */
export function sparkBlocks(data: readonly number[]): string {
const b = seriesBounds(data);
if (!b) return '';
let out = '';
for (const v of data) {
out += Number.isFinite(v) ? BLOCK_RAMP[rampIndex(v, b.min, b.max, 8)] : ' ';
}
return out;
}
/** one braille cell from a pair of levels (0=top .. 3=bottom);
* `null` leaves that column dotless (a non-finite point) */
export function brailleCell(left: number | null, right: number | null): string {
let bits = 0;
if (left !== null) bits |= BRAILLE_LEFT[left];
if (right !== null) bits |= BRAILLE_RIGHT[right];
return String.fromCharCode(BRAILLE_BASE + bits);
}
/** the BRAILLE-mode sparkline: points packed two per cell (left
* column first), each finite point a single dot at its 4-level
* height, non-finite points dotless, the trailing odd point riding
* the LEFT column in reading order */
export function sparkBraille(data: readonly number[]): string {
const b = seriesBounds(data);
if (!b) return '';
const level = (v: number): number | null =>
Number.isFinite(v) ? 3 - rampIndex(v, b.min, b.max, 4) : null;
let out = '';
for (let i = 0; i < data.length; i += 2) {
out += brailleCell(level(data[i]), i + 1 < data.length ? level(data[i + 1]) : null);
}
return out;
}
/** a horizontal bar's glyph run: value-proportional length against
* `cells` (the character-cell budget), full blocks + one partial
* left-eighth tail. FROZEN guards: non-finite / non-positive value,
* non-positive max or cells → the EMPTY run (the value lane keeps
* telling the truth); the run never exceeds `cells` */
export function barRun(value: number, max: number, cells: number): string {
if (!Number.isFinite(value) || value <= 0) return '';
if (!Number.isFinite(max) || max <= 0 || !Number.isFinite(cells) || cells <= 0) return '';
const len = (value / max) * cells;
const full = Math.floor(len);
if (full >= cells) return BLOCK.repeat(cells);
const frac = len - full;
if (frac <= 0) return BLOCK.repeat(full);
const partial = BAR_RAMP[Math.max(0, Math.round(frac * BAR_RAMP.length) - 1)];
return BLOCK.repeat(full) + partial;
}
/** ChartLine's polyline points ("x,y x,y …") in the viewBox's user
* units. x is the point's INDEX position (non-finite points are
* skipped, never shifted); y maps the finite bounds to the honest
* [inset, h-inset] band (a 1-unit marker-radius inset, not a data
* lie); a single point centers (x = w/2 — i/(n-1) is undefined) */
export function linePoints(data: readonly number[], w = 100, h = 40, inset = 1): string {
const b = seriesBounds(data);
if (!b) return '';
const n = data.length;
const pts: string[] = [];
for (let i = 0; i < n; i++) {
const v = data[i];
if (!Number.isFinite(v)) continue;
const x = n === 1 ? w / 2 : (i / (n - 1)) * w;
const y = inset + (1 - normalize(v, b.min, b.max)) * (h - 2 * inset);
pts.push(`${Number(x.toFixed(2))},${Number(y.toFixed(2))}`);
}
return pts.join(' ');
}
/** each point DOUBLED — fed to a stroke-linecap="round" polyline it
* renders a round marker dot at every point, sized by stroke-width
* (which CAN be non-scaling, unlike <circle r>), so markers never
* stretch under preserveAspectRatio */
export function markerPoints(points: string): string {
if (!points) return '';
return points
.split(' ')
.flatMap((p) => [p, p])
.join(' ');
}
export interface DonutSegment {
index: number;
/** the sanitized value (non-finite / negative → 0) */
value: number;
/** value / total, 0..1 */
share: number;
/** `${dashLength} ${circumference}` — one dash per circle */
dash: string;
/** the accumulated offset of every PREVIOUS segment */
offset: number;
/** the token-derived stroke color */
color: string;
}
export interface DonutGeometry {
radius: number;
circumference: number;
segments: DonutSegment[];
}
/** the ring's dasharray geometry: each segment is its own circle
* with one dash of share×circumference, offset by the accumulated
* lengths before it (the -90° group rotation pins segment 0 at 12
* o'clock). FROZEN zero-total guard: no finite positive values →
* null (the part renders the bare track). Negative and non-finite
* values are 0-share segments — a donut is composition shares, it
* never invents magnitude */
export function donutGeometry(
data: readonly number[],
size: number,
thickness: number,
): DonutGeometry | null {
const radius = (size - thickness) / 2;
const c = 2 * Math.PI * radius;
const clean = data.map((v) => (Number.isFinite(v) && v > 0 ? v : 0));
const total = clean.reduce((a, b) => a + b, 0);
if (!(total > 0)) return null;
// offsets accumulate the SERIALIZED dash lengths, so the rendered
// ring tiles exactly what the browser consumes (no micro-overlap
// between adjacent segments from raw-vs-rounded drift)
const cStr = c.toFixed(3);
let acc = 0;
const segments = clean.map((v, i) => {
const share = v / total;
const lenStr = (share * c).toFixed(3);
const seg: DonutSegment = {
index: i,
value: v,
share,
dash: `${lenStr} ${cStr}`,
offset: -acc,
color: donutColor(i),
};
acc += Number(lenStr);
return seg;
});
return { radius, circumference: c, segments };
}
/** the opt-in table fallback's rows: value null marks a non-finite
* point (rendered as the em dash, never a JS literal) */
export function seriesRows(data: readonly number[]): { index: number; value: number | null }[] {
return data.map((v, i) => ({ index: i, value: Number.isFinite(v) ? v : null }));
}
/** ChartBar's variant grammar — the prominence ladder's three
* chart-applicable rungs, painted through the four global hue
* slots (never a semantic variant name) */
// The union lives in the family Defaults (r11 same-folder-literal
// convention) and is re-exported here so the public surface keeps
// its shape.
import type { ChartVariant } from './chart-defaults.svelte';
export type { ChartVariant };
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
import { getDensityContext, provideDensity, resolveDensity } from '$lib/density.svelte';
import { cn } from '$lib/utils';
import {
densityRungOf,
provideQueryAnchor,
provideUniversalLanes,
stampCarriersForLanes,
type ColorLane,
type DensityLane,
type ElevationLane,
type MotionLane,
type QueryResult,
type RadiusLane,
type ShapeLane,
type SizeLane,
type ThemeLane,
} from '$lib/defaults.svelte';
import { chartStyles } from './chart.stylex';
import { ChartDefaults } from './chart-defaults.svelte';
import './chart.css';
interface Props {
/** density policy: explicit, inherited — provided to the subtree
* so chart ensembles adopt one tier */
density?: DensityLane | QueryResult<DensityLane>;
/** universal size axis (§1) — the ROOT carries the axis WHOLE
* (named · auto · px number · query()): no legacy size prop
* lives at the ensemble root (the donut's diameter literal is
* the glyph's own prop) */
size?: SizeLane | QueryResult<SizeLane>;
/** universal shape axis (§2): corner geometry; auto = inherit */
shape?: ShapeLane | QueryResult<ShapeLane>;
/** universal radius axis (§3): corner size; auto = the concentric
* broadcast */
radius?: RadiusLane | QueryResult<RadiusLane>;
/** universal color axis (§5): the hue axis of the oklch system */
color?: ColorLane | QueryResult<ColorLane>;
/** universal theme axis (§6): light/dark/system; auto = tree
* inheritance (the .dark class bridge) */
theme?: ThemeLane | QueryResult<ThemeLane>;
/** universal elevation axis (§7): official M3 levels · dp ·
* query() */
elevation?: ElevationLane | QueryResult<ElevationLane>;
/** universal motion axis (§8): intensity — reduced…expressive ·
* a coefficient · query() */
motion?: MotionLane | QueryResult<MotionLane>;
class?: string;
children: Snippet;
}
let { density, size, shape, radius, color, theme, elevation, motion, class: className = '', children }: Props = $props();
// The CAPTURE is load-bearing and eager (r11 provider contract, the
// button-group form): getDensityContext() rides the $derived.by
// ARGUMENT subtree, which evaluates at this statement — BEFORE
// provideDensity writes the key — so it captures the PARENT's
// context (not the ensemble's own). Reading it lazily (in the
// $derived initializer body, or the getter itself) would resolve
// the very getter it feeds — derived_references_self. W3-D1: the
// lane narrows at the legacy edge (the input-group law — the
// legacy channel keeps its five-rung spelling; the universal
// density axis rides provideUniversalLanes below).
const legacyDensityLane = $derived(
typeof density === 'string' && density !== 'auto' ? density : undefined,
);
const resolvedDensity = $derived.by(
((inherited) => () => resolveDensity(legacyDensityLane, inherited))(getDensityContext()),
);
provideDensity(() => resolvedDensity);
// the family Defaults is the single read point for the STAMP
// (context-defaults-economy 3.4 + W3-D1): the slot's ambient read
// lands on this ensemble's own provided policy — exactly what the
// glyphs below see, one resolution for the whole subtree. The size
// axis rides the record EXPLICITLY (the contract's `size` key is
// the donut diameter literal — the root's axis lane feeds the
// carriers/supply directly, never the literal slot); a query()
// carrier cannot cross the resolved-lane record (the anchor unwrap
// rides the slot seam) — it stamps nothing here and keeps flowing
// through the supply's own unwrap
const d = $derived(
ChartDefaults.resolve({ density, shape, radius, color, theme, elevation, motion }),
);
const sizeLane = $derived(typeof size === 'object' ? undefined : size);
const carriers = $derived(stampCarriersForLanes({ ...d, size: sizeLane }));
// DENSITY rides the bridged provideDensity write above (it is
// reactive — the object literal here would SNAPSHOT the prop at
// init and freeze the explicit lane over the bridge); this supply
// carries the other seven axes
// (the supply takes the RAW prop — it unwraps query carriers
// itself through the anchor seam)
provideUniversalLanes({ size, shape, radius, color, theme, elevation, motion });
let uniRoot = $state<HTMLDivElement>();
provideQueryAnchor(() => uniRoot ?? null);
</script>
<div
bind:this={uniRoot}
data-jx-chart=""
data-density={densityRungOf(d.density)}
class:dark={d.theme === 'dark'}
style={carriers || undefined}
class={cn(chartStyles.contents, className)}
>
{@render children()}
</div>
a11y
Accessibility
Every chart is a named image; the glyphs are decoration with the semantics carried once, by the name — and the opt-in table mirror gives the actual numbers back to the screen reader.
ARIA
| Attribute | Value | Description |
|---|---|---|
| role | img | Every part, always — a chart is one image to the a11y tree, never a soup of glyphs. |
| label | string (REQUIRED) | The accessible name. No default exists by contract — a nameless chart fails to compile in your head first, then in the type. |
| table | opt-in mirror | A REAL sibling table (caption + headers + rows) clip-path hidden from the eye, present to assistive tech. A sibling, never a child: role=img makes its own subtree presentational. |
| glyphs | decorative | Block and braille runs are ink — the name and the table carry the data semantics. |
| motion | zero | Charts ship no entrance motion at all: the reduced-motion law holds structurally (final state painted immediately). |
chart-bar · the fallback, demoed
| row | value |
|---|---|
| mon | 3 |
| tue | 5 |
| wed | 2 |
| thu | 8 |
| fri | 7 |
<script lang="ts">
import { ChartBar } from '@ui/chart/index';
</script>
<!-- table adds the REAL sibling mirror beside the role=img root -->
<ChartBar
data={[3, 5, 2, 8, 7]}
labels={['mon', 'tue', 'wed', 'thu', 'fri']}
label="deploys per day, with the screen-reader mirror on"
table
class="max-w-md"
/>inspect the DOM: beside the role=img root sits a real table with a caption, scoped headers and one row per datum — visually hidden, machine-honest.
axes
Universal props
The eight-axis surface (explicit-props): size · shape · radius · density · color · theme · elevation · motion — each axis takes named steps, auto (inherit the ambient context; stamps nothing), an exact number (px · coefficient · dp · hue per axis), or query() for responsive/container-conditional values. The ensemble ROOT carries the size axis whole; the glyphs ride the icon §13 law (a NUMBER is the size number lane verbatim — the donut diameter literal keeps the name).
Chart · universal props
Deterministic display primitives: zero runtime, the axes ride the family root only.
<Chart size="medium">…</Chart>api
API
One family: a context root that shares a density tier, and four standalone parts. The glyph math (barRun, sparkBraille, linePoints, donutGeometry…) exports from the folder for your own tests.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
| data * | readonly number[] | — | The series — one row / point / segment per datum. A value-domain payload: the family renders it, never re-authors it. |
| label * | string | — | The REQUIRED accessible name (role=img + aria-label). No default by contract. |
| table | boolean | false | Opt-in visually-hidden data table fallback — a REAL sibling table mirroring the series. |
| class | string | '' | Merged into the part root (cn()) — constrain geometry with utilities; they win by the layer law. |
| ...rest | HTMLAttributes / SVGAttributes | — | aria-*, data-*, handlers — land on the part root verbatim. |
Universal props
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | the base scale — root font-size; parts size in em |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' · ambient scope | corner geometry (CSS corner-shape; §14 degrade table) |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | corner size; auto = the concentric broadcast (§3) |
| density | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | spacing/leading scale over the kernel channels (§4) |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' · ambient scope | the hue axis of the fixed oklch primary system (§5) |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' · ambient scope | light/dark profile; system = the JS-mutable global (§6) |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' · ambient scope | official M3 levels over the surface ladder (§7) |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' · ambient scope | intensity across the motion kernels (§8) |
Properties
| Property | Type | Default | Description |
|---|---|---|---|
| labels (ChartBar) | readonly (string | number)[] | — | Parallel label lane, zipped by index; absent entries render an empty lane. |
| variant (ChartBar) | 'fill' | 'tonal' | 'outline' | 'fill' | The glyph ink through the four global hue slots — compose with jx-hue-* utilities like every painted surface. Defaults: literal slot — own 'fill', ambient when a table freeze lands. |
| cells (ChartBar) | number | 20 | The bar-length budget in character cells. |
| values (ChartBar) | boolean | true | The inline-end value lane (raw numbers; formatting is yours). Non-finite renders the em dash. |
| cells (ChartSparkline) | 'block' | 'braille' | 'braille' | The cell system: eighth-block glyphs (8 levels/point) or braille packing (2 points/cell, 4 levels). |
| area (ChartLine) | boolean | false | The 12% tonal wash under the curve (needs two finite points). |
| markers (ChartLine) | boolean | true | Round dot markers at every finite point — non-scaling, never ellipses. |
| xAxis / yAxis (ChartLine) | Snippet | — | Authored axis slots, rendered inside the svg after the data — axes are never guessed. |
| size / thickness (ChartDonut) | number | 96 / 12 | The ring outer size and stroke thickness in px. size defaults through a literal slot — own 96, ambient when an axis opens (thickness stays a plain prop). |
| children (ChartDonut) | Snippet | — | The center slot — the total, a delta, your composition. |
theming
Density and tokens
Text-glyph parts size from the shared density ruler; every color is a token — the semantic chart palette, the variant slots, the mono face.
| Token | Default | Source |
|---|---|---|
| --chart-1 .. --chart-5 | semantic palette | color |
| --brand-hue | oklch hue degrees | color |
| --jx-fill / --jx-tonal / --jx-outline | variant slots | color |
| --font-mono | JetBrains Mono stack | component |
| --jx-text / --jx-line / --jx-gap | density scale | density |
| --border / --muted | theme | color |
see also
See also
The family around the numbers.