jixoai-ui
the terminal design language
loading the style sheet…
Skip to content
docs · print medium: screen

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.

install — chart

$npx jixoai-ui add chart

https://ui.jixoai.com/r/chart.json

the family context, four parts + the barrel — one item

zero runtime deps block · braille glyphs frozen degenerates role=img + table fallback tokens only (--chart-1..5)

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

chart-usage.svelte svelte
<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.

Chart usage
<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.

deploys per day
rowvalue
mon3
tue5
wed2
thu8
fri7
chart-bar.svelte svelte
<!--
  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

chart-sparkline.svelte svelte
<!--
  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.

peak 12w1w8

the hairline rules are grid GEOMETRY (top / mid / bottom) — ticks and labels are the consumer's authored snippets

chart-line.svelte svelte
<!--
  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.

chart-donut.svelte svelte
<!--
  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.

deploys / week 42 ⠤⠘⠁
p95 latency 184ms █▅▆▂▃▁
throughput by lane
incidents by severity sev1 4 · sev2 3 · sev3 2 · sev4 1
error budget burn
chart.svelte svelte
<!--
  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

AttributeValueDescription
roleimgEvery part, always — a chart is one image to the a11y tree, never a soup of glyphs.
labelstring (REQUIRED)The accessible name. No default exists by contract — a nameless chart fails to compile in your head first, then in the type.
tableopt-in mirrorA 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.
glyphsdecorativeBlock and braille runs are ink — the name and the table carry the data semantics.
motionzeroCharts ship no entrance motion at all: the reduced-motion law holds structurally (final state painted immediately).

chart-bar · the fallback, demoed

deploys per day, with the screen-reader mirror on
rowvalue
mon3
tue5
wed2
thu8
fri7
chart-a11y-fallback-demo.svelte svelte
<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-universal.svelte svelte
<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

PropertyTypeDefaultDescription
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 booleanfalseOpt-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

PropertyTypeDefaultDescription
size'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopethe base scale — root font-size; parts size in em
shape'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto''auto' · ambient scopecorner geometry (CSS corner-shape; §14 degrade table)
radius'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopecorner size; auto = the concentric broadcast (§3)
density'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopespacing/leading scale over the kernel channels (§4)
color'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string'auto' · ambient scopethe hue axis of the fixed oklch primary system (§5)
theme'light' | 'dark' | 'system' | 'auto''auto' · ambient scopelight/dark profile; system = the JS-mutable global (§6)
elevation'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number'auto' · ambient scopeofficial M3 levels over the surface ladder (§7)
motion'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number'auto' · ambient scopeintensity across the motion kernels (§8)

Properties

PropertyTypeDefaultDescription
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) number20The bar-length budget in character cells.
values (ChartBar) booleantrueThe 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) booleanfalseThe 12% tonal wash under the curve (needs two finite points).
markers (ChartLine) booleantrueRound 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) number96 / 12The 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.

xs
⠤⠘⠁
default
⠤⠘⠁
lg
⠤⠘⠁
TokenDefaultSource
--chart-1 .. --chart-5semantic palettecolor
--brand-hueoklch hue degreescolor
--jx-fill / --jx-tonal / --jx-outlinevariant slotscolor
--font-monoJetBrains Mono stackcomponent
--jx-text / --jx-line / --jx-gapdensity scaledensity
--border / --mutedthemecolor
jixoai-ui ui.jixoai.com the jixoai design language

Theme

Brand hue

0°

Auto cycle · 24h