astryx

An open source design system that's fully customizable and agent ready

11,844 stars TypeScript
RAW Doc

CHARTV2 PHASE1 PLAN

ChartV2 โ€” Phase 1 Plan (make it good, in place)

Living plan for getting packages/lab/src/ChartV2/ production-ready in place โ€” no
renames, no deleting Chart v1, no new package, no docsite. Guiding principle
(from the owner): real, production-ready fixes; default to the proper fix
whenever it's obviously right.

Status legend: [ ] todo ยท [~] in progress ยท [x] done


0. How to run / review

bash
pnpm -F @astryxdesign/build build      # prereq
pnpm -F @astryxdesign/core build       # prereq (lab typecheck needs core's dist)
pnpm storybook                         # http://localhost:6006  (Lab / ChartV2*)
pnpm -F @astryxdesign/lab typecheck    # ChartV2 currently typechecks clean
pnpm test                              # vitest (no ChartV2 tests exist yet)

Visual review harness (how we "look at every chart"): a Playwright script
screenshots every ChartV2 story in light + dark by hitting
iframe.html?id=<storyId>&globals=colorMode:{light|dark}. Story IDs come from
http://localhost:6006/index.json. Kept in /tmp/chartv2-shots/. This is the
review loop โ€” re-run after each change and eyeball the diffs.


1. Confirmed findings (evidence)

Scale / domain

  • Continuous marks (line/area/dot) glue to plot edges + overshoot escapes.
    computeLayout sets y-domain to exactly [min,max] (.nice() adds no padding
    when bounds are already round). Simple Line: Jan(38)=min sits on the axis,
    Jun(52)=max at top, and the monotone curve overshoots above the plot.
    There is no clipPath (v1 has astryx-chart-plot) so overshoot renders into
    the margin. Bars look fine only because they force includeZero.
    โ†’ layout.ts:128-136, no clip in Chart.tsx:295-346.
  • Empty data breaks scales โ†’ streaming renders nothing. With data=[],
    isNumericX is false (xValues.length > 0 guard), so x falls into the band
    branch with an empty domain; y-domain becomes [Infinity,-Infinity]. streamGL
    then maps pushed points through xScale(n)/yScale(n) โ†’ undefined/NaN โ†’
    blank. Verified blank after 5s. โ†’ layout.ts:44-60,84-136, streamGL.tsx:146-152.
    streamGL cannot work without xDomain/yDomain on the root.
  • Financial composite is misleading: volume bars (500โ€“1500) share the price
    y-axis (80โ€“130) and crush the candlesticks/MA to a sliver. Inherent to one
    shared scale. Story needs rethinking (drop volume, or a documented secondary-axis
    pattern). โ†’ ChartV2Advanced.stories.tsx:121-171.

Chrome / layout

  • Band axis draws every category โ†’ candlestick x-labels overlap into a smear
    (30 "Day N" labels). Auto-skip only runs if caller passes maxTicks. Needs
    width-aware default skipping. โ†’ ChartAxis.tsx:89-113.
  • Reference-line label badges clip at the right edge ("Acceptable"); text width
    is estimated (label.length*5.5+8) and can overrun. โ†’ referenceLine.tsx:70-71,177.
  • Mixed Marks with legend="end" clips the last bar/label (chart area shrinks
    but plot width/label centering overflow). โ†’ Chart.tsx:277-282.
  • Heatmap top row clips slightly at the plot top. โ†’ heatmapGL.tsx:102-105.

Broken defaults / correctness

  • Every mark's default color token is undefined. --color-chart-1
    (bar/line/dot/area/band) and --color-positive/--color-negative
    (candlestick) do not exist in packages/core/src/theme. Default-colored
    marks render black fills / invisible strokes. All current stories dodge this by
    passing explicit hex. โ†’ bar.tsx:72, line.tsx:35, dot.tsx:22, area.tsx:37,
    band.tsx, candlestick.tsx:21-22.
  • Duplicate-key collision. Series identity = dataKey (key: dataKey). Two
    series on one dataKey collide in the resolved map (layout.ts resolved.set(s.key,โ€ฆ))
    and in React keys (Chart.tsx key={s.key}), silently dropping data. Live React
    warning fires on AreaGradient (area('revenue') + line('revenue')).
    Also area builds gradientId = area-grad-${dataKey} โ†’ duplicate DOM ids.
    โ†’ types.ts:52-54, all mark factories, layout.ts:219, Chart.tsx:310-316.
  • streamGL hardcodes key:'stream' โ†’ two streams collide. โ†’ streamGL.tsx:228.
  • Inconsistent color API. bar/dot accept ColorAccessor (string | fn);
    line/area/band/etc. accept only string. Accessor-colored series also
    vanish from the legend: deriveLegendItems drops any series with color == null,
    and accessor series set color: undefined. โ†’ bar.tsx:12-14,83, dot.tsx:11,30,
    line.tsx:26-27, area.tsx:26-27, legend.ts:18-32.
  • dot.dodge is a stubbed TODO (declared, never used). โ†’ dot.tsx:17,48.
  • key={i} array-index React keys in mark render loops โ†’ reconciliation bugs
    on reorder/stream. โ†’ bar.tsx:151, line.tsx:88, dot.tsx:58,
    candlestick.tsx:83, errorBar.tsx:67.
  • No color palette API (v1 has useChartColors/getChartColors with
    categorical/sequential/diverging/semantic/structural/alpha). v2
    ships none. โ†’ compare Chart/useChartColors.ts, Chart/getChartColors.ts.
  • heatmapGL builds its own y band scale (breaks one-shared-scale). โ†’ heatmapGL.tsx:102-105.

Works well (leave alone)

All bar variants, negative values, grouped/stacked, mixed, area + stacked area,
confidence bands, error bars + reference band, WebGL scatter, heatmap coloring,
legend, swatch, and dark mode across the board.


2. Locked decisions

  1. Palette API โ†’ build the real thing (reuse v1's getChartColors/useChartColors
    from the same package; wire marks to consume it + fix the broken default token).
  2. heatmapGL scale โ†’ real fix: teach the chart root a categorical y-scale so the
    heatmap shares it.
  3. yBaseline/yDomain/xDomain โ†’ full v1 parity (required for streaming).
  4. Mark names (bar/line/dot) โ†’ deferred to packaging (not a bug; on the
    "not now" list). No renames now.

3. Work items (ordered)

Ordered to build foundation first and keep each step independently verifiable via
the screenshot harness.

W1 โ€” Series identity (`_uid`) [ ]

  • Problem: duplicate-key collision (#3) + key={i} (#7) + streamGL key (#5).
  • Approach: add layout-assigned _uid (mutable field, mirrors _isTopOfStack)
    = ${index}:${key}; use it as the true identity for the resolved map, the
    Chart render key, tooltip lookups (resolved.get, resolvedKeys), and hover-dot
    keys. key stays a semantic label. Replace every key={i} in mark render loops
    with a stable per-datum key (e.g. ${dataIndex} is fine within a series, but
    prefer the x-value; index-in-series is acceptable since series are _uid-scoped).
    Fix area gradientId to include _uid.
  • Files: types.ts, layout.ts, Chart.tsx, ChartTooltip.tsx, tooltip.ts,
    marks/{bar,line,dot,candlestick,errorBar,area}.tsx.
  • Verify: AreaGradient React warning gone; a new "two series, same dataKey"
    story renders both.

W2 โ€” Domain / baseline parity + clip + continuous headroom [ ]

  • Problem: edge-gluing + overshoot + empty-data breakage (#2, streaming).
  • Approach: add yBaseline?: 'auto'|'zero'|'data', yDomain?, xDomain? to
    ChartProps; thread into computeLayout (mirror v1 Chart/Chart.tsx:150-220:
    explicit domains authoritative + no .nice(); zero symmetric; auto includes
    0). Precedence: yDomain > root yBaseline > per-mark includeZero. Add a
    clipPath (like v1 astryx-chart-plot) around the marks layer. Add small domain
    headroom for continuous-only charts (no bar/includeZero, no explicit domain)
    so line/area/dot endpoints aren't glued to edges. Handle empty-data: if xDomain
    given, honor it even when data=[] (so streaming has a real numeric scale);
    likewise yDomain.
  • Files: Chart.tsx, layout.ts, types.ts (SeriesContext already carries scales).
  • Verify: Simple Line no longer clips; streaming story (given xDomain/yDomain,
    or a rolling window) draws.

W3 โ€” Streaming works end-to-end [ ]

  • Depends on W2. Update StreamingLine story to pass a domain / rolling window (v1
    "stable streaming window"). Confirm streamGL maps points and the axis slides.
  • Files: ChartV2Advanced.stories.tsx, possibly streamGL.tsx (guard when scale
    isn't linear).

W4 โ€” Color: unified accessor + palette API + legend fix [ ]

  • Problem: inconsistent color (#4), broken default token, accessor legend drop,
    no palette (#8).
  • Approach:
    • Re-export v1 palette from ChartV2 (useChartColors, getChartColors, types) via
      ChartV2/index.ts + package index.ts.
    • Marks default color to undefined = "auto"; the Chart root resolves a
      categorical palette (useChartColors().categorical(n)) and assigns a color to
      each series lacking one, by index โ€” store as _resolvedColor on the def (like
      _uid); marks read self._resolvedColor ?? ownColor. Fixes the broken token
      AND gives multi-series auto colors.
    • Widen line/area (and band where sensible) to accept ColorAccessor for
      parity with bar/dot. Share one ColorAccessor type (move to types.ts).
    • Legend: show accessor-colored series using a representative color (resolve the
      accessor at index 0, or the assigned palette color) instead of dropping them.
  • Files: ChartV2/index.ts, package index.ts, types.ts, Chart.tsx,
    legend.ts, marks/{bar,dot,line,area,band,candlestick}.tsx.
  • Verify: default-colored multi-series chart shows distinct colors; new
    accessor-colored story appears in legend; dark mode palette adapts.

W5 โ€” dot.dodge [ ]

  • Implement real dodge (offset overlapping points at the same x by radius), or
    remove the option. Prefer implement (production-ready). โ†’ dot.tsx.

W6 โ€” heatmapGL shares the chart scale [ ]

  • Add categorical y-scale support to the chart root so the heatmap reads the shared
    y-scale instead of building its own. Bigger core change; do after W1โ€“W4 land.
  • Files: layout.ts, types.ts (ChartScale/SeriesContext), Chart.tsx,
    ChartAxis.tsx/ChartGrid.tsx (categorical y), marks/heatmapGL.tsx.
  • Verify: heatmap rows align to a shared left axis; no own scaleBand.

W7 โ€” Axis label auto-density [ ]

  • Width-aware default label skipping for band axes (fix candlestick smear) without
    requiring maxTicks. โ†’ ChartAxis.tsx:89-113.

W8 โ€” Story-level fixes [ ]

  • Reference-line label clipping (measure/clamp badge x). Mixed-marks legend="end"
    clipping. Financial composite rework (secondary-axis story or drop volume).
    Heatmap top-row clip.

W9 โ€” New stories (review targets from the brief) [ ]

  • Plain SVG dot scatter (not WebGL).
  • dotGLInteractive hover.
  • referenceLine solo: single y; y+y2 band; x line.
  • Edge cases: empty data, single point, all zero, all negative, one huge outlier.
  • Accessor-colored series (exercises the legend fix).
  • Duplicate-dataKey series (exercises W1).
  • Dark-mode coverage confirmed for each via the harness.

W10 โ€” Tests + final verification [ ]

  • Add colocated vitest for: layout (domain/baseline/empty/stacking), _uid
    uniqueness, legend derivation (incl. accessor), tooltip derivation. Run
    pnpm test, pnpm -F @astryxdesign/lab typecheck, lint, and re-screenshot all
    forms light+dark.

4. To verify / open questions

  • Confirm exact set of missing tokens and the right categorical token names to
    default to (palette resolves --color-data-categorical-*).
  • Headroom amount for continuous charts (e.g. ~5%) โ€” pick + document.
  • heatmap categorical-y: does introducing a band y-scale disturb any existing
    numeric-y consumer? (Guard: only when a mark requests categorical y.)
  • Should yBaseline default stay 'auto'-equivalent given per-mark includeZero
    already exists? (Lean: root override wins; keep per-mark default.)

5. Out of scope (deferred)

Rename ChartV2 โ†’ Chart ยท delete Chart v1 ยท move to packages/charts ยท docsite
second-library support + README ยท publish + CI.


CHARTV2 READINESS

@astryxdesign/charts โ€” Launch Readiness Audit

Full inventory of what the package contains today and how ready each piece is to
ship (canary), based on the API audit, the verification checklist, and the Stage 1
fixes already landed on feat/charts-extraction. Goal: pick a solid first slice to
ship soon (coordinated with the vega launch), and track the rest as fast-follows.

Readiness legend:

  • Ship-ready โ€” verified working, fixes landed, low risk for canary.
  • Minor polish โ€” works; needs 1โ€“2 small fixes and/or a story before it's presentable.
  • Needs work โ€” architectural gap or unverified interaction; not for the first slice.

Bar for "ready" = canary quality: works, looks right in light + dark, reasonable API,
has a story. (Not full stable/a11y bar โ€” that's later.)


Marks (12)

Mark What it is Readiness Notes / blockers
bar Bars: simple/stacked/grouped/grouped-stacked/negative Ship-ready All variants verified L+D; _uid + color fixes landed
line Line series (curves, optional dots) Ship-ready Edge-clip + headroom fixed
area Area fill (+gradient, stacked) Ship-ready Gradient-id collision fixed
dot SVG scatter Ship-ready dodge implemented; story added
band Confidence-interval band Minor polish Utility (no legend); translucent fill washes out in dark
candlestick OHLC financial Minor polish Semantic default colors now; not in legend by default; tooltip shows only the open value
errorBar Whisker/error bars Minor polish Utility; dark custom color (#1e3a5f) washes out on dark
referenceLine Annotation line/band at fixed x or y Minor polish Label clamp fixed; needs solo stories; x only on linear scale
dotGL WebGL scatter (large N) Minor polish Works; hex-only color (not palette/token integrated)
streamGL WebGL streaming line (imperative push) Minor polish Renders now with a domain window; niche; needs a documented usage pattern
dotGLInteractive WebGL scatter + GPU-picking hover Needs work Hover interaction never exercised by a story / unverified
heatmapGL WebGL 2D heatmap Needs work Builds its own categorical y-scale (breaks the shared-scale rule); top-row clip; hex ramp isn't token-aware

Chrome (6)

Component What it is Readiness Notes / blockers
Chart Root: layout, scales, domains, clip, palette, events Ship-ready Strong; domains/baseline/streaming/palette all landed
ChartAxis Axes (ticks, formatters, density) Ship-ready Label auto-density fixed; time axis + top/right/showTicks combos less tested (minor)
ChartGrid Grid lines (h/v) Ship-ready โ€”
ChartLegend Legend (positions, alignment) Ship-ready Accessor/auto-color series now appear
ChartSwatch Color swatch primitive Ship-ready Component fine; story under-demonstrates (cosmetic)
ChartTooltip Grouped hover tooltip + crosshair Minor polish Core works but hover interaction isn't screenshot-verified; value semantics wrong for candlestick/streamGL/GL

Utilities

Util Readiness Notes
useChartColors / getChartColors (palette) Ship-ready Solid; resolves the --color-data-* tokens (which are JS-only, not CSS vars)
currency formatter Ship-ready Verified on an axis
percent / compactNumber / shortDate / monthYear Minor polish Not yet verified in a story

Everything in this set is Ship-ready and covers the "few basic charts" people
have been asking for:

  • Marks: bar, line, area, dot
  • Chrome: Chart, ChartAxis, ChartGrid, ChartLegend, ChartSwatch, ChartTooltip*
  • Utilities: the color palette + currency

* Tooltip's static rendering is fine; before shipping, do one pass of hover
verification (it's the one interaction we haven't screenshot-tested).

Stretch (nice for a compelling demo, small effort): candlestick โ€” a strong
financial showcase; just needs the legend/tooltip-value polish. And confirm the
remaining formatters render.

To actually ship this slice, the remaining work is small:

  1. Verify tooltip hover (Playwright-drive a hover) โ€” the last unverified interaction.
  2. Confirm percent/date formatters in a story.
  3. Wire /charts into the docsite (add as a dep, fill the empty .doc.mjs files) so it appears in docs.
  4. Final light+dark screenshot pass of the slice.

Fast-follows (after the first slice)

  • heatmapGL categorical-y shared-scale fix (the architectural exception).
  • dotGLInteractive hover story + verification.
  • Mode-aware translucent fills so bands/error bars read in dark mode.
  • Tooltip value semantics for candlestick (OHLC) / streamGL / GL marks.
  • dotGL palette/token color integration.
  • Time scale + date-axis formatting.
  • referenceLine solo stories; edge-case stories (empty/single/all-zero/outlier).
  • Tests (unit + export-surface + SSR) and a11y pass.
  • Cross-chart interactivity broker โ€” owned by indigo; keep Chart's container
    context + pointer stream clean as the integration seam.

CHARTV2 STAGE1 DESIGN

ChartV2 Stage 1 โ€” Design & Correctness Research

Deep design work for the "make it correct + a genuinely good API" stage. This is the
thinking behind Stage 1 of the plan (the package-extraction is a separate, mechanical
stage). Goal: decide what a great, hard-to-regret chart API looks like before we lock
it by publishing. Companion to CHARTV2_PHASE1_PLAN.md (findings/evidence) and the
plan panel (sequencing).

Status: research + proposals. Owner decisions needed in section 8.


0. TL;DR

  • The core architecture โ€” marks are factory functions returning config; the chart
    owns one x/y scale; marks resolve() then render()
    โ€” is the modern, correct choice.
    It matches Observable Plot (the best-in-class "marks + shared scale" design) and the
    series-array model of MUI X / Plotly / Highcharts. We should keep it.
  • The API surface is inconsistent in ~25 concrete ways (color has 5 shapes,
    size vs radius, upper/lower vs high/low, positional vs options-only, label
    only on 4 marks, GL marks can't take theme colors, etc.). This is where most of the
    design effort goes.
  • The single highest-leverage idea is to adopt a channel/encoding model (a
    visual property can be a constant, a field name, or an accessor) uniformly across
    color/size/opacity. It collapses most inconsistencies into one coherent concept and
    fixes the accessor-color + legend problems as a side effect.
  • The color tokens are good (10 categorical + 9 sequential ramps + neutral) and v1
    already has a strong palette API to adopt. Real gaps: auto-assignment, GL can't use
    tokens, categorical tokens are identical in light/dark, CVD-safety unverified.
  • The biggest correctness gaps are scale-related: no time scale, no log scale,
    degenerate domains (single point / zero range / empty), and no honest baseline/clip
    (already in the plan). Plus a long failure-mode matrix (section 5).

1. Where ChartV2 sits in the charting-library landscape

There are four broad API families. Understanding them tells us which lessons to borrow.

  • Compositional / JSX children โ€” Recharts, Victory, visx.
    <LineChart data><Line dataKey="x"/><XAxis/></LineChart>. Discoverable and
    React-idiomatic, but scales are hard to coordinate, perf suffers with many nodes,
    and cross-mark consistency is manual. This is exactly Chart v1's model โ€” and the
    reason v2 exists.
  • Per-chart-type config monolith โ€” Nivo, Chart.js, (partly) Tremor.
    <ResponsiveLine data={...} big-prop-bag/>. Fast for the common case; poor at mixing
    mark types (bar + line + band on one chart) and at composition.
  • Grammar of graphics / marks โ€” Observable Plot, Vega-Lite, AntV G2.
    Plot.plot({marks:[Plot.barY(data,{x,y,fill}), Plot.lineY(...)]}). Marks are
    functions/objects; the plot owns scales; every visual property is a channel.
    Best-in-class for correctness and composition; slightly higher concept count.
  • Series array config โ€” MUI X Charts, Plotly, Highcharts, ECharts.
    series={[{type:'bar', data...}, {type:'line', ...}]}, axes configured separately,
    multiple named axes for dual-scale. Pragmatic, very common in dashboards.

ChartV2 is a hybrid of the best two: Observable-Plot-style marks (bar(...),
line(...) returning config) fed through a MUI-style series={[...]} array, with a
single shared scale. That is a strong, defensible foundation. Verdict: keep the
architecture; fix the API surface and add the missing scale capabilities.

What the best-in-class do that we currently don't:

  1. Uniform channels (Plot/Vega-Lite): color/size/opacity accept constant | field |
    accessor everywhere. (We do this only for bar/dot color.)
  2. First-class, configurable scales (all of MUI/ECharts/Plot): x/y (and color/size)
    scale type and options are explicit and overridable. We infer linear-or-band from
    data and expose nothing.
  3. Time scale (everyone). We have none โ€” financial/streaming fake it with band
    strings or raw numbers. (ScaleTime is imported in the axis types but the root
    never builds one.)
  4. Multiple / secondary axes (MUI/ECharts/Highcharts/Plotly/Chart.js) for dual-unit
    charts (price vs volume). We force one shared y โ€” which is why the financial
    composite looks broken.
  5. CVD-safe default categorical palette (Vega/Plot ship tableau10-like sets). Ours
    is close in spirit; needs verification.
  6. (Out of scope, noting for completeness) faceting / small multiples.

2. The encoding / channel model (the biggest single API decision)

The problem, concretely

From the API audit, "how do I set a visual property" is spelled many ways:

  • color: ColorAccessor (bar/dot) | string (line/area/band/errorBar/referenceLine)
    | required string (dotGL/dotGLInteractive/streamGL) | string[] ramp (heatmapGL)
    | upColor/downColor pair (candlestick). Five shapes.
  • "size of a point": radius (dot, a radius) vs size (dotGL, a diameter).
  • opacity: opacity (most) vs bandOpacity (referenceLine) vs none (line/candlestick).

The proposal: one `Channel` concept

Borrow Observable Plot's channels. A visual property accepts one of three things:

ts
type Channel<T> =
  | T // constant: color: '#0171E3' or a token
  | {field: string} // read a data column (maps via a scale)
  | ((datum: Row, index: number) => T); // accessor

Apply it uniformly to the properties that vary per-datum or per-series: color
(fill/stroke), size/r, opacity. Then:

  • bar('rev', {color: '#...'}) still works (constant).
  • bar('rev', {color: d => d.up ? green : red}) works (accessor) โ€” and shows in the
    legend (section 3).
  • dot('y', {color: {field: 'category'}}) maps a column through the color scale
    (categorical) โ€” the thing you actually want for scatter-by-category, which is
    currently impossible.

This single concept replaces C3/C8/C9/C14 from the audit and unlocks
color-by-field (a common, currently-missing capability). It is additive: constants
(today's usage) keep working.

Scope note: we do NOT need the full Vega-Lite type system (quantitative/ordinal/
temporal). Channels + a small set of explicit scales (section 4) is the right amount
of power for this library.


3. Color system (deep dive)

What we have (good foundation)

Tokens (packages/core/src/theme/domainTokens/dataTokens.ts):

  • 10 categorical: blue, orange, purple, green, pink, cyan, red, teal, brown,
    indigo. (Tableau10-like โ€” distinct hues for distinct series.)
  • 9 sequential ramps ร— 5 steps (5=darkest โ†’ 1=lightest): blue, shamrock, orange,
    pink, purple, red, teal, yellow, gray. For ordered/quantitative (heatmaps, choropleth).
  • 1 neutral (labels, reference lines, empty states).

v1's palette API (getChartColors/useChartColors) already exposes: categorical(n),
sequential[hue](n), diverging.positiveNegative/coldHot/custom, semantic
(positive/negative/warning/neutral), structural (axis/grid/tick/label), alpha().
This is a genuinely good API โ€” adopt it wholesale into the charts package.

The problems to fix

  1. Broken defaults. Marks default to --color-chart-1 / --color-positive /
    --color-negative, none of which exist โ†’ black/invisible. Fix by auto-assigning
    the categorical palette by series index at the chart root
    (mark default color =
    "auto"; the root fills unset colors via categorical(n)). Candlestick up/down and
    errorBar/referenceLine default to semantic/structural from the palette.
  2. GL marks can't use tokens. hexToGL does parseInt(hex,16) โ€” a CSS var or
    rgb() becomes NaN (silent mis-render), which is why GL color is required-hex.
    Fix: resolve any CSS color (token/var/named/rgb) to concrete RGB before GL upload โ€”
    via the theme resolver (we already have token(name)) and/or a canvas
    getComputedStyle fallback. Then GL marks accept the same colors as SVG marks and
    can auto-assign from the palette too.
  3. Accessor colors vanish from the legend. deriveLegendItems drops any series
    whose color isn't a static string. With channels, a series has a resolved
    representative color (the assigned categorical slot, or the accessor sampled at a
    stable point, or โ€” for color-by-field โ€” a small scale the legend can enumerate).
    Legend should show it.
  4. Dark mode. The categorical tokens are identical in light and dark
    (light-dark(#0171E3, #0171E3)). They're mid-tones chosen to read on both, and our
    screenshot pass shows them working โ€” but this is a deliberate design point to
    confirm, not an accident. The sequential ramps include very dark steps
    (#02165E) that will lose contrast on the dark body; heatmaps/areas using low ramp
    steps need checking in dark mode.
  5. CVD (color-blind) safety. The 10 categorical are tableau10-like but not verified
    for deuteranopia/protanopia/tritanopia. Add a verification task; document a
    recommended max distinct series (~8) before hues get hard to distinguish.
  6. Too many series. Decide behavior past 10 categorical: recycle with a pattern/
    opacity shift, or warn. (Plot warns; ECharts recycles.) Recommend: recycle + dev
    warning.

Color scale (new, small)

Introduce an explicit color scale at the root for color: {field} usage:

  • categorical field โ†’ categorical(n) by distinct value (ordinal color scale).
  • quantitative field โ†’ a sequential ramp (choose hue).
  • diverging โ†’ diverging.* around a midpoint.
    This is what makes color-by-field coherent and legendable.

4. Scales โ€” the correctness foundation

Current state

computeLayout builds either scaleLinear (numeric x) or scaleBand (else)
for x, and always scaleLinear for y with domain [min,max].nice() (+ zero-inclusion
only if a bar/area is present). No configuration is exposed. Consequences:

  • Endpoints glue to edges; monotone curves overshoot outside the plot (no clip).
  • Empty data โ†’ band branch โ†’ NaN positions โ†’ streaming renders nothing.
  • No time scale; no log scale; no per-axis control.

Proposal

  1. Domain/baseline parity (already planned): yBaseline: 'auto'|'zero'|'data',
    yDomain, xDomain โ€” authoritative when provided (no .nice()), mirroring v1.
    Add a plot clipPath and small headroom for continuous-only charts.
  2. Degenerate-domain handling (correctness): single point / zero range / all-equal
    โ†’ synthesize a sensible span (e.g., vยฑ1 or [0, v*2]) so the mark is visible and
    ticks are sane; empty + explicit domain โ†’ honor the domain (fixes streaming).
  3. Explicit scale config (new, optional): allow the caller to set x/y scale type
    and options rather than pure inference:
    text
    xScale={{type: 'time'}}   // or 'linear' | 'band' | 'log'
    yScale={{type: 'log', domain: [1, 1e6], nice: true}}
    Inference stays the default. This is how MUI/ECharts/Plot all work and it removes a
    class of "it guessed wrong" bugs.
  4. Time scale (fills a real gap): scaleTime for Date x-values; the axis already
    references ScaleTime. Enables honest financial/streaming time axes and good tick
    formatting (via the ported shortDate/monthYear).
  5. Secondary y-axis (resolves the financial-composite problem): allow a mark to
    target a secondary y-scale (e.g., bar('volume', {axis: 'y2'})) with its own
    domain, like MUI's yAxisId / Plotly's y2. This is the principled fix for
    price-vs-volume rather than crushing one series. Decision needed (section 8) โ€”
    it's powerful but adds surface; could be deferred to a follow-on if we instead
    document "don't mix incompatible units on one axis."

Design tension to be explicit about: one shared scale is the whole value prop
(marks can't disagree). Secondary axes are the sanctioned exception, opt-in and
explicit, not automatic โ€” preserving the guarantee for the default case.


5. Everything that can go wrong (failure-mode matrix)

For each: what happens today โ†’ what should happen. Drives Stage 1 fixes + Stage 2 tests.

Data pathologies

  • Empty data=[] โ†’ band scale, NaN, blank (breaks streaming). โ†’ honor explicit
    domain; otherwise render an empty-state (axes + "no data").
  • Single datum โ†’ zero-width/near-degenerate domain. โ†’ synthesize span; center the point.
  • All values equal / zero range โ†’ flat domain, nice() may collapse ticks. โ†’ pad domain.
  • All zero / all negative โ†’ baseline handling (bars grow wrong way?). โ†’ verified by
    new edge-case stories; yBaseline correctness.
  • One huge outlier โ†’ everything else squashed. โ†’ document log scale option; outlier
    is legitimate but the story should show the log remedy.
  • NaN / null / undefined / non-numeric in a numeric column โ†’ currently coerced to 0
    silently (misleading). โ†’ skip (gap in line/area path) vs 0; decide + document. Prefer
    gap for line/area, skip point for dot.
  • Missing dataKey (typo) โ†’ silent 0/undefined everywhere. โ†’ dev warning.
  • Duplicate x values (band) / unsorted x (line) โ†’ band de-dupes silently; line
    draws zig-zag. โ†’ dev warning for unsorted line x.
  • Very large N (10k+ SVG nodes) โ†’ jank. โ†’ GL marks exist; document thresholds;
    (m4 downsampling is a tracked follow-on).

Scale / rendering

  • Overshoot outside plot (monotone/natural curves) โ†’ escapes into margins. โ†’ clipPath.
  • Axis label overflow / overlap (many band categories; long labels; big numbers) โ†’
    candlestick smear today. โ†’ width-aware auto-skip + rotation/truncation option.
  • Tooltip at viewport edges โ†’ can clip. โ†’ flip/clamp (partly handled; verify).
  • Legend overflow (many series) โ†’ wrap/scroll; legend="end" currently clips the plot.
  • Reference-line label off-canvas โ†’ clamp badge within plot.

Runtime / platform

  • SSR / no DOM (Next.js) โ†’ ResizeObserver/canvas/window access. โ†’ guard; render
    nothing or a placeholder until mounted. (Docsite is Next.js โ€” this matters.)
  • WebGL unavailable / context loss โ†’ GL marks blank. โ†’ fallback message or SVG path;
    handle webglcontextlost.
  • Canvas / listener cleanup (streaming rAF, ring buffer, ResizeObserver) โ†’ leaks. โ†’
    audit teardown.
  • High-DPI โ†’ blurriness. โ†’ DPR handling exists in webgl utils; verify across marks.
  • Theme switch at runtime โ†’ colors must re-resolve (esp. GL, which caches hex). โ†’
    re-upload on theme change.
  • Reduced motion โ†’ axis has animated; honor prefers-reduced-motion.

Accessibility

  • Screen readers โ†’ SVG has title/desc on root only; series/points aren't described.
    โ†’ at least role/aria-label; consider a data-table fallback (Highcharts/visx pattern).
  • Keyboard โ†’ no keyboard access to tooltip/points. โ†’ follow-on, but note it.
  • Contrast โ†’ structural (axis/grid/label) and series colors vs both backgrounds.

i18n / formatting

  • Number/date/locale โ†’ formatters exist (currency/percent/compactNumber/
    shortDate/monthYear); ensure locale-aware and ported into the package.
  • RTL โ†’ axis sides, legend order, tooltip placement.

6. Per-mark API consistency pass (proposed normalization)

Target: a predictable surface where the same concept has the same name/shape everywhere.
(Full current-state evidence in the audit; this is the proposed end-state.)

  • Data input: every data-bound mark takes a positional primary key where it has an
    obvious "primary" (bar/line/dot/area/dotGL/dotGLInteractive); multi-field marks stay
    options-only (band/candlestick/errorBar/heatmapGL) but use consistent field
    names
    .
  • Bounds naming: unify the interval concept. Pick one vocabulary for
    band/errorBar (they're twins): recommend high/low (matches candlestick/finance)
    or upper/lower โ€” one, not both. referenceLine keeps y/y2 (they're values,
    not fields) but document the distinction.
  • Color: one Channel<Color> everywhere (section 2); default = auto palette;
    candlestick uses {up, down} as a documented special case (or color:{up,down}).
  • Size: standardize on one meaning. Recommend radius (SVG semantics) everywhere
    points are round; if we keep GL size as diameter, document the 2ร— relationship or
    convert so radius means radius for both.
  • Stroke: strokeWidth everywhere (retire lineWidth on streamGL); expose the
    currently-hardcoded strokes (area top line, candlestick wick, line-dots radius).
  • Opacity: opacity everywhere (retire bandOpacity; referenceLine band uses
    fillOpacity or nested band:{opacity}).
  • Curve: export one shared CurveType; expose curve on area (and band?) using it.
  • label: every non-utility mark accepts label and sets SeriesDef.label so legend/
    tooltip are correct; referenceLine's label is the badge (document the difference).
  • key/identity: semantic key + layout-assigned _uid (planned) so duplicate
    dataKeys and two streams/refs don't collide.
  • x-scale requirements in types: encode band-vs-linear-vs-time requirements so the
    compiler catches "heatmap on a linear x" instead of a silent null (via typed mark
    variants or a runtime dev warning at minimum).
  • De-dupe: share the xPixel helper (every mark reinvents the band/linear px math);
    share ColorAccessor/Channel types (currently duplicated in bar/dot, unexported).
  • Tooltip semantics: fix value derivation (candlestick shows only open; streamGL
    yields undefined; GL scatter creates chrome-tooltip rows). Decide per-mark what a
    tooltip row means, or let marks declare their tooltip contribution.

7. What is genuinely good (keep, don't churn)

  • The marks + one-shared-scale architecture; resolve()/render() split; "root has
    zero knowledge of mark types."
  • The series={[...]} config ergonomics; legend/tooltip as boolean-or-config props.
  • Composable chrome primitives (ChartSwatch, standalone ChartLegend, ChartAxis
    toggles for line/ticks/grid).
  • The pointer-event model (single capture rect โ†’ subscribers; tooltip re-renders only
    on index change).
  • v1's palette API design and the token set.
  • WebGL marks for scale (scatter/heatmap/stream) โ€” a real differentiator vs Recharts/Nivo.
  • Dark mode already works across the SVG marks.

These meaningfully change scope/shape. Owner is new to charting and delegated these to
the recommended, industry-standard calls; each is reversible and open to veto by the
owner or by Ruby Cheung in the Stage-4 design review. Ranked by leverage.

  1. Channel model โ€” ADOPTED. color/size/opacity accept
    constant | {field} | accessor uniformly across marks. It's the backbone that makes
    the API coherent and unlocks color-by-field. (Bigger refactor of mark options, worth it.)
  2. Secondary y-axis โ€” DESIGN NOW, implement if time allows. Provide an opt-in
    axis:'y2' for dual-unit charts (price/volume); it also informs heatmap categorical-y.
    If it slips, document "one scale โ€” don't mix units" and defer implementation to a
    follow-on. The default stays one shared scale (the core guarantee).
  3. Scales โ€” parity + time + degenerate now; log if cheap. Do yBaseline/yDomain/
    xDomain parity + clip + headroom + degenerate-domain handling + a time scale +
    explicit scale config this stage. Add explicit type:'log' if inexpensive, else
    follow-on.
  4. Null/NaN policy โ€” DECIDED: gap + skip + dev warning. Line/area break the path at
    null/NaN (gap); point marks skip the point; emit a dev-only warning. No silent
    coerce-to-zero.
  5. Bounds vocabulary โ€” DECIDED: high/low. Use high/low for both band and
    errorBar (matches candlestick/finance). referenceLine keeps y/y2 (values, not
    fields), documented.
  6. Beyond-10 series โ€” DECIDED: recycle + dev warning. Reuse the categorical palette
    past 10 series and warn in dev; recommend a practical max of ~8 distinct hues.
  7. A11y target โ€” DECIDED: minimum now. Root aria/title/desc + per-series labels this
    stage; keyboard nav and a data-table fallback are a tracked follow-on.

Additional call recorded here: 8. CVD-safety โ€” verify, don't redesign. Keep the existing categorical tokens; add a
verification pass (deuter/protan/tritan) and document findings rather than inventing a
new palette this stage.


9. Testing implications (feeds Stage 2)

  • Unit: scale/domain (auto, baseline modes, explicit, empty, single, zero-range,
    time), channel resolution (constant/field/accessor), palette resolver + tokenโ†’hex,
    legend/tooltip derivation (incl. accessor + color-by-field), _uid uniqueness,
    formatters, each mark factory's SeriesDef shape.
  • Failure-mode fixtures: one dataset per row in section 5, asserted to not throw and
    to produce sane output.
  • Export-surface test: the barrel + package aliases export the full contract.
  • Visual: Playwright screenshots of every story in light + dark (existing harness),
    including the new edge-case and channel/color-by-field stories.
  • SSR smoke: render to string without a DOM (guards Next.js/docsite usage).

Appendix โ€” source references

  • Tokens: packages/core/src/theme/domainTokens/dataTokens.ts
  • Palette API (to adopt): packages/lab/src/Chart/getChartColors.ts, useChartColors.ts
  • GL color (hex-only): packages/lab/src/Chart/webgl.ts hexToGL
  • Shared x helper (underused): packages/lab/src/Chart/utils.ts xPixel
  • Domain/baseline parity reference: packages/lab/src/Chart/Chart.tsx
  • Full API audit + inconsistency list: this session's catalog (mirrored into
    CHARTV2_PHASE1_PLAN.md findings).

CHARTV2 VERIFICATION CHECKLIST

ChartV2 โ€” Verification Checklist

Exhaustive, specific things to verify before we call the chart library "correct." This
is the QA backbone for Stage 1 (fixes) and Stage 2 (tests). It combines (a) what the
screenshot pass already checked and (b) everything still untested.

Status legend:

  • [ok] verified acceptable in the light+dark screenshot review of all 21 stories
  • [BUG] confirmed problem (tracked in Stage 1)
  • [?] not yet verified โ€” needs a check and/or a dedicated story
  • [new] needs a new story/fixture to even exercise it

How we verify:

  • Visual: Playwright screenshot harness over every story, light + dark (existing).
  • Unit: vitest on layout/scales/color/legend/tooltip/formatters.
  • Manual: hover/keyboard/resize interactions Playwright can drive.
  • SSR: render-to-string smoke test.

1. Axes ("are all axes good?")

Positions & structure

  • [ok] Bottom axis renders a baseline line; labels centered under band categories.
  • [ok] Left axis renders as floating labels (no line, no ticks) by default.
  • [?] Confirm the floating y-axis (no line/no ticks by default) is the INTENDED default
    look vs. drawing a subtle axis line/ticks. (Design call โ€” looks Google-Sheets clean.)
  • [?] Right axis (position="right") โ€” placement, label anchor (start), used for
    secondary-axis story. [new]
  • [?] Top axis (position="top") โ€” label placement above plot. [new]
  • [?] showAxisLine / showTicks toggles in every combination (line-only,
    line+ticks, ticks force line on). [new] dedicated story.

Ticks & density

  • [BUG] Band axis with many categories overlaps into a smear (candlestick, 30 days).
    Needs width-aware auto-skip without requiring maxTicks.
  • [?] tickCount respected for linear axes (d3 picks "nice" counts near it).
  • [?] maxTicks evenly skips labels when exceeded (verify the every-Nth logic).
  • [?] Long category labels โ€” truncation (truncate) appends "โ€ฆ"; consider rotation.
  • [new] Rotated labels option for dense/long band labels (currently none).
  • [?] First/last linear tick not clipped at plot edges (edge visibility guard exists โ€”
    offset within [-10, width+10]).
  • [?] Gridlines align exactly with axis tick positions (grid uses yScale.ticks(5);
    axis uses tickCount=5 โ€” confirm they match when tickCount changes).

Domains reflected in axis

  • [ok] Negative y domain: bottom axis line drawn at y=0, not chart edge (NegativeValues).
  • [ok] Currency formatter on left axis ($ values) renders (SimpleBar).
  • [?] Percent / compactNumber / shortDate / monthYear formatters on axes. [new]
  • [BUG] Continuous y (line/area/dot) glues min/max to top/bottom edges (no headroom).
  • [new] Time axis: Date x-values with sensible date ticks + formatting (no time scale today).
  • [new] Log axis: decade ticks (1,10,100โ€ฆ) (no log scale today).
  • [?] Zero-range / single-value domain: axis still shows sane ticks (not all same).

Axis appearance

  • [ok] Axis label text color adapts in dark mode (secondary text token).
  • [?] Axis line / tick contrast on both light and dark bodies.
  • [?] RTL: left/right axis sides + label anchors mirror. [new]
  • [?] Streaming: axis tick slide animation (animated) is smooth and honors
    reduced-motion. [new]

2. Scales & domains

  • [BUG] Empty data โ†’ band fallback โ†’ NaN โ†’ blank (breaks streaming).
  • [BUG] No yBaseline/yDomain/xDomain control (v1 parity).
  • [new] Single datum โ†’ domain must synthesize a span; point centered/visible.
  • [new] All-equal / zero-range values โ†’ padded domain, sane ticks.
  • [new] All zero โ†’ baseline correct, bars flat at 0.
  • [new] All negative โ†’ bars grow downward from 0; axis line at 0 (top).
  • [new] One huge outlier โ†’ document/log remedy; linear still not throwing.
  • [?] nice() behavior: applied for auto domains, NOT for explicit domains (avoid
    ratcheting during zoom/stream).
  • [?] x numeric vs band inference correctness (all-number โ†’ linear; else band).
  • [new] Explicit scale config (type: 'linear'|'band'|'time'|'log') overrides inference.
  • [new] Secondary y-axis (axis:'y2') maps a mark to its own domain (financial).
  • [?] Stacked domain = sum of stack (verified visually on StackedBars/Areas); unit-test it.
  • [?] Mixed positive/negative stacks compute correct extents.

3. Colors & palette ("colors?")

Palette correctness

  • [BUG] Default color tokens (--color-chart-1, --color-positive/negative) don't
    exist โ†’ black/invisible when no color passed. Fix: auto-assign categorical by index.
  • [?] categorical(n) returns distinct hues in series order; matches legend swatches.
  • [?] sequential[hue](n) ramp ordering (darkโ†’light) for heatmaps/choropleth.
  • [?] diverging.positiveNegative/coldHot/custom around a midpoint.
  • [?] semantic (positive/negative/warning/neutral) drive candlestick up/down,
    reference lines, error bars (not raw tokens).
  • [?] structural (axis/grid/tick/label) used by chrome, theme-aware.
  • [?] alpha(hex, o) produces correct rgba.

Dark mode & contrast

  • [ok] Solid SVG marks (bar/line/area-line/dot) and GL scatter dots read with good
    contrast on the dark body (reviewed: bars, line, scatter, candlestick, heatmap).
  • [BUG] Translucent / dark-hued fills wash out on dark โ€” broad. Reviewed in dark:
    confidence bands (opacity 0.1โ€“0.2) nearly invisible; kitchen-sink reference bands
    (amber 0.15 / green 0.08) go muddy; area-gradient fill very dark; error bars
    (#1e3a5f) nearly invisible on dark bars
    ; financial volume bars (gray 0.3) muddy.
    Root cause: colors/opacities are chosen for a light background (often hardcoded hex),
    not mode-aware. Fix at the palette/semantic level (mode-aware fill base + opacity;
    error-bar/structural defaults from theme tokens, not hex).
  • [ok] Categorical mid-tones read acceptably on dark for the ones exercised (blue/red/
    orange/green). Still [?] for the full 10 (cyan/teal/brown/indigo/purple/pink) on dark.
  • [ok] Heatmap sequential ramp low-end (near-white) reads fine on dark (high contrast);
    BUT note it's a story-supplied hex range, not a theme token (see below).
  • [note] Heatmap colorRange and candlestick up/down colors are story-supplied hex,
    not theme tokens
    โ†’ they don't adapt to light/dark. Consider defaulting heatmap to a
    sequential-ramp token set and candlestick to semantic.positive/negative.
  • [new] CVD (color-blind) safety pass on the 10 categorical (deuter/protan/tritan);
    document a recommended max (~8) distinct series.

Color application

  • [BUG] Accessor-colored series vanish from the legend.
  • [new] Color-by-field (color:{field}) โ†’ ordinal color scale + legend entries.
  • [BUG] GL marks can't take tokens (hexToGL is hex-only) โ€” need tokenโ†’hex resolver.
  • [?] Theme switch at runtime re-resolves colors, including cached GL hex.
  • [new] >10 series โ†’ palette recycles + dev warning.
  • [?] Opacity: stacked/overlapping fills (area 0.3, band 0.15) composite correctly.
  • [?] Gradient fill (area gradient) stops from colorโ†’transparent; unique per series
    (gradient id collision when two areas share a dataKey โ†’ dedupe via _uid).
  • [?] Swatch color exactly matches the rendered series color (square for bar, line else).
  • [BUG-story] The Swatch story renders only a single square โ€” it under-demonstrates
    (no line variant, no multiple colors, no dark comparison). Expand the story.

4. Legend

  • [ok] Standalone legend renders items with swatches + labels (Legend story).
  • [ok] Positions top/bottom (horizontal wrap) and start/end (vertical stack).
  • [BUG] legend="end" shrinks the plot but the last bar/label clips (MixedMarks).
  • [?] Alignment start/center/end within position.
  • [?] Utility marks (band/errorBar/referenceLine) correctly EXCLUDED from legend.
  • [BUG] Marks with no SeriesDef.color (candlestick/GL) excluded โ€” after color fix,
    decide which should appear (candlestick up/down? GL series?).
  • [new] Many-series legend overflow (wrap/scroll), long labels.
  • [?] Legend swatch variant matches mark type (bar=square, others=line).
  • [?] Duplicate labels don't collide as React keys (legend keys on label).

5. Tooltip & hover (largely UNTESTED โ€” screenshots are static)

  • [?] Hover shows crosshair for line/area/dot; band-highlight rect for bars.
  • [?] Tooltip card content: correct series rows, labels, colors, values at hovered x.
  • [BUG-latent] Tooltip value = datum[dataKeys[0]]: candlestick shows only open;
    streamGL yields undefined; GL scatter creates rows. Fix per-mark tooltip semantics.
  • [?] Hover dots render for line/area/dot at the focused index; skipped for bars/utility.
  • [?] Card flips/clamps near viewport edges (placement auto/left/right/top).
  • [?] Re-render only on data-index change (perf) โ€” verify no per-move React renders.
  • [?] Tooltip in dark mode (popover bg, text, swatch).
  • [?] dotGLInteractive GPU-picking hover fires its own renderTooltip; ensure it
    doesn't double up with the chrome tooltip.
  • [new] Empty/pointer-leave clears the card and indicator.
  • [?] Touch/pointer on mobile (touch-action) โ€” no scroll interference.

6. Grid

  • [ok] Horizontal grid default; aligns with y ticks; skips the y=0 line.
  • [?] Vertical grid (vertical) for band (per category) and linear (ticks).
  • [?] Grid contrast in dark mode (border-emphasized token).
  • [?] Grid behind marks (render order) โ€” never over data.

7. Per-mark rendering (all 12)

  • [ok] bar โ€” simple/stacked/grouped/grouped-stacked/negative; rounded top corners;
    corner radius only on stack top.
  • [ok] line โ€” curves (monotone default); optional dots (hardcoded r=3 โ€” expose?).
  • [ok] area โ€” fill + gradient + stacked; top stroke.
  • [?] dot (SVG scatter) โ€” no dedicated story yet. [new]
  • [BUG] dot.dodge โ€” declared, does nothing.
  • [ok] band โ€” confidence interval between upper/lower.
  • [ok] candlestick โ€” up/down bodies + wicks; min body height.
  • [ok] errorBar โ€” stem + caps.
  • [ok] referenceLine โ€” single y, y+y2 band, x line (need solo stories). [new]
  • [BUG] referenceLine label badge clips at right edge (Acceptable/Target).
  • [ok] dotGL โ€” WebGL scatter (200 pts).
  • [new] dotGLInteractive โ€” hover/picking not exercised by a story.
  • [BUG] heatmapGL โ€” builds its own y scale; top row clips; should share chart scale.
  • [BUG] streamGL โ€” blank (empty-data scale); hardcoded key collides for two streams.
  • [?] Marks with x-scale requirements fail gracefully on the wrong scale
    (heatmap needs band; referenceLine.x needs linear; streamGL needs numeric).
  • [BUG] Two series on the same dataKey collide (resolved map + React key) โ€” dup-key.

8. Layout & responsiveness

  • [ok] Title/subtitle render above chart; aria wired to <title>/<desc>.
  • [?] Responsive width via ResizeObserver โ€” resize from wideโ†’narrow re-lays-out.
  • [?] Container width 0 (initial mount) renders a placeholder, no crash.
  • [?] Very small container (e.g. 150px) โ€” axes/labels degrade gracefully.
  • [?] margin overrides โ€” insets applied; enough room for long axis labels.
  • [BUG] legend="end" clips plot (see ยง4).
  • [?] Multiple charts on one page don't share/leak ids (gradient/clip ids unique).

9. Data edge cases (need fixtures โ€” ยง5 of design doc)

  • [new] Empty [], single row, two rows.
  • [new] null/undefined/NaN/non-numeric in a numeric column โ†’ gap (line/area) /
    skip (points) + dev warning (per decision).
  • [new] Missing dataKey (typo) โ†’ dev warning, not silent zeros.
  • [new] Duplicate x values (band); unsorted x (line) โ†’ dev warning.
  • [new] Very large N (5kโ€“50k) โ†’ GL path; document SVG thresholds.
  • [new] Extreme values (1e9, 1e-9) โ†’ axis formatting (compactNumber) not overflowing.

10. Platform / runtime

  • [new] SSR: render-to-string without DOM (Next.js/docsite) โ€” guarded, no crash.
  • [?] WebGL unavailable / webglcontextlost โ†’ GL marks fallback/message, no hard fail.
  • [?] High-DPI (retina) โ€” GL circles/lines crisp; SVG sharp.
  • [?] Memory: streaming ring buffer bounded; canvas/ResizeObserver/rAF cleaned up on
    unmount (no leaks over long sessions).
  • [?] prefers-reduced-motion honored (axis slide, any transitions).
  • [?] Rapid re-render / prop churn โ€” layout memoization stable (no thrash).

11. Accessibility (minimum target this stage)

  • [?] Root SVG has role/title/desc; aria-label from title.
  • [new] Per-series accessible labels (name + type).
  • [new] Color is not the ONLY channel where it matters (consider patterns/labels).
  • [new] (Follow-on) keyboard navigation of points/tooltip; data-table fallback.
  • [?] Focus-visible styles for any interactive elements.

12. Theming / dark mode

  • [ok] All 21 stories reviewed in dark mode; chrome + SVG marks adapt.
  • [?] Non-neutral themes (stone, y2k) โ€” chart colors + structural tokens resolve.
  • [?] theme="none" (base tokens only) โ€” no broken/missing token fallbacks.
  • [?] Live theme/mode switch mid-session re-resolves everything (incl. GL).

13. Formatting / i18n

  • [ok] currency() on axis.
  • [?] percent, compactNumber, shortDate, monthYear on axes + tooltip.
  • [?] Locale-awareness (decimal/grouping separators, date locale).
  • [?] RTL layout (axes, legend, tooltip placement).

14. API / type-level verifications

  • [new] Export-surface test: barrel + package aliases export the full contract
    (option types, ColorAccessor/Channel, legend/margin/scale/YBaseline types).
  • [?] No mark leaks internal-only fields; SeriesDef shape stable across all 12.
  • [?] Channel type accepts constant | {field} | accessor uniformly (post-refactor).
  • [?] _uid uniqueness across series (incl. dup dataKey, two streams, two ref lines).
  • [?] Types encode x-scale requirements (or dev-warn) so wrong-scale pairings caught.
  • [?] Tree-shaking: importing one mark doesn't pull all GL/d3 code.

Current-state summary (from the screenshot pass โ€” light + dark, all 21 stories)

Confirmed working: all bar variants, negative values, area/stacked area, WebGL scatter
(light+dark), heatmap coloring (light+dark), candlestick colors (light+dark), legend,
and dark-mode chrome/solid marks generally.
Confirmed bugs: line/area/dot edge clipping + overshoot; streaming blank; candlestick
axis-label smear (light+dark); financial-composite shared-scale; reference-label clip;
legend="end" clip; duplicate-key warning; broken default color tokens; heatmap
top-row clip + own scale; translucent fills (band/area) wash out on dark; Swatch
story under-demonstrates.
Design notes: heatmap ramp + candlestick colors are story-supplied hex, not theme
tokens (don't adapt to mode); y-axis is intentionally floating labels (confirm).
Largely untested: all hover/tooltip INTERACTION (screenshots are static), time/log
scales, SSR, WebGL context loss, runtime theme switching, non-neutral themes (stone/y2k),
RTL, a11y, formatters beyond currency, and all data edge cases.


Release

Releasing to npm via Trusted Publishing (OIDC)

astryx publishes its 12 public @astryxdesign/* packages to the public npm registry from GitHub Actions using npm trusted publishing (OIDC) โ€” there is no long-lived NPM_TOKEN anywhere in CI. The publish job exchanges a short-lived GitHub OIDC token for a registry credential at publish time, and stamps provenance onto every package. A misconfigured trust relationship fails the publish loudly; there is no token to fall back to.

How it fits together

  • Versioning is local and unchanged. pnpm run version-packages (= changeset version && promote-codemod-next && sync-internal-deps && format-changelogs) only edits files, changelogs, and release-staged codemods on disk. It needs no npm auth and is untouched by trusted publishing.
  • Publishing is pnpm-native and tokenless. CI runs pnpm publish ... --provenance --access public --no-git-checks (not changeset publish, whose npm whoami precheck breaks under tokenless OIDC). pnpm natively fetches the OIDC token and attaches provenance.
  • Trust is per-package. npm allows exactly one trust configuration per package, registered against the calling workflow. Each of the 12 packages must be configured individually.

Version-package codemod promotion

Core codemods for unreleased breaking changes are staged in
packages/cli/assets/codemods/transforms/next/, not in a guessed future version
folder. During the Version Packages PR, pnpm version-packages runs
scripts/promote-codemod-next.mjs immediately after changeset version, when
packages/core/package.json contains the actual version being released.

The promotion step copies every entry from next except README.md into
packages/cli/assets/codemods/transforms/v<released-version>/, removes the
promoted files from next, and registers the new version folder in
packages/cli/assets/codemods/registry.mjs. Review those generated files in the
Version Packages PR the same way you review changelog output.

The publish workflow (`.github/workflows/deploy.yml`)

The publish job runs on push to main after test passes. The two requirements for OIDC are already present:

yaml
permissions:
  contents: read
  id-token: write # required for npm trusted publishing (OIDC)
steps:
  - uses: actions/setup-node@v6
    with:
      node-version: 22
      registry-url: 'https://registry.npmjs.org' # writes .npmrc pointing at the registry

The publish step uses pnpm-native publishing and carries no NODE_AUTH_TOKEN / secrets.NPM_TOKEN:

yaml
- name: Publish changed packages
  run: pnpm -r publish --provenance --access public --no-git-checks

pnpm -r publish resolves workspace:*/catalog: deps to real versions, skips already-published versions (so re-running is a no-op), respects "private": true, and publishes in dependency order.

Why the workflow_ref claim matters: npm matches its stored trust config against the OIDC token's workflow_ref claim, which GitHub sets to the calling (entry) workflow โ€” deploy.yml here โ€” not any reusable workflow. astryx's publish runs directly in deploy.yml (no workflow_call indirection), so the trusted workflow filename to register is deploy.yml. If publishing is ever refactored into a reusable workflow, keep deploy.yml in the trust config (the caller) and grant id-token: write to both workflows.

Version requirements

  • pnpm: the repo pins [email protected]. The 11.x line must be โ‰ฅ 11.1.3, which is where the pnpm 11.0โ€“11.1.2 OIDC 404 regression was fixed (11.10.0 satisfies this). pnpm 10.16+ / 11.1.3+ both support OIDC + provenance. (Previously pinned to [email protected], the Lexical-proven known-good 10.x floor whose line shells out to npm publish; see the upgrade PR for the move to 11.x.)
  • npm (in the runner): Node 22.14+/24.x runners bundle npm โ‰ฅ 11.5.1, which is what the 10.x publish path needs. No action required in CI.
  • npm (on the maintainer's machine, for setup only): the setup script requires npm โ‰ฅ 11.10 for npm trust github. Run npm i npm@latest before setup.

The trusted-publishing maintainer script

scripts/npm/setup-trusted-publishing.mjs is a one-time/occasional script a maintainer runs locally (with an interactive npm session) to prepare every public package for trusted publishing. It is decoupled from CI โ€” its only job is to make the CI publish succeed. It discovers the same 12 publishable packages CI does (pnpm-workspace.yaml globs, minus private packages and the .changeset/config.json ignore list).

There is no package.json script alias for it; run the file directly with Node:

text
node scripts/npm/setup-trusted-publishing.mjs [flags]

It has three modes, gated by flags:

Mode Flag What it does
Audit (default) (none) For each public package: probe the registry (HEAD), report which already exist and which already have a matching trust config, and print a manual-setup guide. No writes.
Bootstrap --bootstrap npm trusted publishing cannot be configured on a name that does not exist (no PyPI-style pending publisher). For each package not yet on npm, publish a deprecated placeholder 0.0.0-bootstrap.0 stub under the bootstrap dist-tag (never latest) to claim the name.
Setup trust --setup-trust Run npm trust github --file deploy.yml --repo facebook/astryx --allow-publish -y for each package, skipping ones already correctly configured. --replace revokes a conflicting config first.

Other flags: --dry-run, --registry , --stub-version <v>, --workflow <file> (default deploy.yml; single filename, no commas), --repo <owner/name> (default facebook/astryx). Because the script is invoked directly via node, pass flags straight through (no -- separator).

Preflights: when it will write, it runs npm whoami (fails with a npm login hint if unauthenticated); for --setup-trust it also enforces npm โ‰ฅ 11.10 and verifies npm trust exists.

2FA / the "skip for 5 minutes" prompt: reading and writing trust config requires account-level 2FA. npm only runs its interactive web-auth/OTP flow when both stdin and stdout are TTYs, but per-package trust reads must capture stdout (a pipe). So the script does one fully-interactive warm-up read first and instructs you to choose "Skip two-factor authentication for the next 5 minutes" at that prompt, so all subsequent reads/writes in the run complete within that window without re-prompting.

Idempotency & conflicts: already-correctly-configured packages are skipped (no needless OTP). A non-matching existing config is reported as a CONFLICT (npm returns E409 if you POST a second config); re-run with --replace to revoke-then-add. E429 rate limits are retried with exponential backoff.

Typical first-time run

text
npm i npm@latest
npm login --registry https://registry.npmjs.org
node scripts/npm/setup-trusted-publishing.mjs                              # audit only
node scripts/npm/setup-trusted-publishing.mjs --bootstrap --setup-trust    # claim names + register trust

After the first OIDC publish succeeds, the bootstrap stubs are superseded by the real versions (they remain only under the deprecated bootstrap dist-tag).

Sequencing note: the legacy NPM_TOKEN secret stays in place until the pnpm-native OIDC change in deploy.yml has merged AND the first OIDC publish has succeeded with provenance. Remove NPM_TOKEN only AFTER that first green OIDC publish โ€” never before โ€” otherwise any push to main in the gap runs the still-token-based pnpm changeset publish (and the canary npm publish) with an empty token and breaks main.


CONTRIBUTING

Contributing to Astryx

For the full contribution process โ€” what we accept, how to propose new components, and how API decisions are made โ€” read the Contributing wiki.

Key pages:

This file covers local development setup.


Prerequisites

Node.js

The Node version lives in .nvmrc (currently the 24.x line). CI reads the same
file via node-version-file, so local and CI never drift apart. Don't declare
the version anywhere else.

Via nvm (recommended):

bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.zshrc
nvm install   # no argument โ€” reads .nvmrc

fnm and mise read .nvmrc too. asdf does not; its .tool-versions is
git-ignored precisely so it cannot become a competing source of truth.

Via nodejs.org:
Download and install from https://nodejs.org

pnpm

Astryx uses pnpm as its package manager (declared in
the packageManager and devEngines.packageManager fields of
package.json). You can install pnpm directly:

bash
# Via npm
npm install pnpm@11

# Via Homebrew (macOS)
brew install pnpm

# Via standalone installer (no npm or Node.js required)
curl -fsSL https://get.pnpm.io/install.sh | env PNPM_VERSION=11.10.0 sh -

# Via GitHub releases (single binary, no dependencies)
# https://github.com/pnpm/pnpm/releases/latest

Or use Corepack to install the exact
pnpm version Astryx pins:

bash
corepack enable

Corepack ships with Node.js 22 and 24, but current Node.js 25+ releases no
longer bundle it. If corepack is missing and you want the auto-pinning path,
install Corepack manually first:

bash
npm install corepack
corepack enable

Verify installation:

bash
node --version   # v22.x.x or v24.x.x
pnpm --version   # 11.x.x

Getting Started

bash
# Clone the repo
git clone https://github.com/facebook/astryx.git
cd astryx

# Install dependencies
pnpm install

# Build core package first (required for Storybook)
pnpm -F @astryxdesign/core build

# Start Storybook for component development
cd apps/storybook
pnpm dev

Running Storybook

Storybook loads pre-built packages from dist/ folders, so you need to build packages before running Storybook.

First time setup:

bash
# Build all packages
pnpm build

# Or build just core
pnpm -F @astryxdesign/core build

Start Storybook:

bash
cd apps/storybook
pnpm dev

Storybook will open at http://localhost:6006 with:

  • Theme switcher - Toggle between the base tokens and the Neutral, Stone, and Y2K themes
  • Mode switcher - Toggle between Light and Dark modes
  • Component stories - Interactive component examples

If you make changes to @astryxdesign/core:

bash
# Rebuild core package
pnpm -F @astryxdesign/core build

# Restart Storybook to see changes
cd apps/storybook
pnpm dev

Running the Doc Site

The doc site (apps/docsite/) is a Next.js app that renders the component
documentation at https://astryx.dev. To run it locally:

bash
# First time only โ€” build the workspace packages it depends on
pnpm build

# Start the doc site (Next dev server, defaults to localhost:3000)
pnpm docsite

pnpm docsite is a thin alias for pnpm -F @astryxdesign/docsite dev,
which runs the doc site's generate step (theme CSS, registries,
playground scope) before booting Next.

Note: pnpm docs collides with the npm docs builtin, which
tries to open the package's npm page in a browser. Use pnpm docsite
instead.

Project Structure

text
astryx/
โ”œโ”€โ”€ apps/
โ”‚   โ”œโ”€โ”€ storybook/      # Component playground (localhost:6006)
โ”‚   โ”œโ”€โ”€ docsite/        # Doc site (localhost:3000)
โ”‚   โ””โ”€โ”€ sandbox/        # Development testing
โ”‚
โ”œโ”€โ”€ packages/
โ”‚   โ”œโ”€โ”€ core/           # Core components (Button, Input, etc.)
โ”‚   โ”œโ”€โ”€ cli/            # CLI tooling (astryx)
โ”‚   โ”œโ”€โ”€ lab/            # Experimental components (not yet stable)
โ”‚   โ””โ”€โ”€ themes/         # Theme presets (neutral, stone, y2k, and more)
โ”‚
โ””โ”€โ”€ internal/           # Internal tooling (not published)
    โ””โ”€โ”€ test-utils/     # Shared test helpers

Development Workflow

Common Commands

Command Description
pnpm install Install all dependencies
pnpm dev Start Storybook (alias for pnpm storybook)
pnpm build Build all packages
pnpm test Run all tests
pnpm test:watch Run tests in watch mode
pnpm storybook Start Storybook at localhost:6006
pnpm lint Lint all packages

Adding a New Component

Components use colocated tests โ€” test files live alongside the component.

1. Create the Component Directory

bash
mkdir -p packages/core/src/MyComponent

2. Create the Component Files

text
packages/core/src/MyComponent/
โ”œโ”€โ”€ MyComponent.tsx        # Component implementation
โ”œโ”€โ”€ MyComponent.test.tsx   # Unit tests (colocated)
โ”œโ”€โ”€ MyComponent.doc.mjs    # Component doc (props, features, examples)
โ””โ”€โ”€ index.ts               # Public exports

Stories are not colocated โ€” they live in the Storybook app:

text
apps/storybook/stories/MyComponent.stories.tsx

3. Component Template

tsx
// MyComponent.tsx
import type {HTMLAttributes, ReactNode, Ref} from 'react';

export interface MyComponentProps extends HTMLAttributes<HTMLDivElement> {
  /** Ref forwarded to the root element */
  ref?: Ref<HTMLDivElement>;
  /** Description for AI-assisted development */
  children: ReactNode;
}

/**
 * Brief description of the component.
 *
 * @example
 * ```
 * <MyComponent>Hello</MyComponent>
 * ```
 */
export function MyComponent({children, ref, ...props}: MyComponentProps) {
  return (
    
      {children}
    
  );
}

MyComponent.displayName = 'MyComponent';

4. Test Template

tsx
// MyComponent.test.tsx
import {describe, it, expect} from 'vitest';
import {render, screen} from '@testing-library/react';
import {MyComponent} from './MyComponent';

describe('MyComponent', () => {
  it('renders children', () => {
    render(<MyComponent>Hello</MyComponent>);
    expect(screen.getByText('Hello')).toBeInTheDocument();
  });
});

5. Story Template

Stories live in the Storybook app, not next to the component โ€”
apps/storybook/.storybook/main.ts discovers them with
'../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)', so a story anywhere under
packages/ is never picked up. Import the component through its published
entry point, and title it under Core/ (or Lab/ for @astryxdesign/lab).

tsx
// apps/storybook/stories/MyComponent.stories.tsx
import type {Meta, StoryObj} from '@storybook/react';
import {MyComponent} from '@astryxdesign/core/MyComponent';

const meta = {
  title: 'Core/MyComponent',
  component: MyComponent,
  tags: ['autodocs'],
} satisfies Meta<typeof MyComponent>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
  args: {
    children: 'Hello World',
  },
};

6. Export from Package

ts
// packages/core/src/index.ts
export * from './MyComponent';

Note: Do not manually edit the "exports" field in packages/core/package.json.
It is auto-generated from the src/ directory by scripts/sync-exports.js and
committed automatically when changes land on main. If you need to verify your
component will be included, run pnpm sync:exports:check.

Accessibility Checklist

Every new component โ€” and any change to an interactive one โ€” must clear the
Accessibility Checklist
(wiki) before review. The checklist lives on the wiki so accessibility
experts can refine it without a code PR; reviewers block on it (it is A1โ€“A16
on the Component Audit Rubric),
and it is a hard requirement for a lab โ†’ core promotion (see
packages/lab/README.md).

Two repo-side rules worth restating here:

  • Compose the shared primitives โ€” VisuallyHidden, useAnnounce,
    useFocusTrap, and the focus hooks (useListFocus, useGridFocus,
    useTreeFocus) โ€” rather than hand-rolling equivalents. They implement the
    WAI-ARIA APG patterns and are tested once; a bespoke reimplementation of
    one is a review reject.
  • CI is the enforcement layer, not a replacement for the checklist: the
    pr-a11y job in ci.yml runs an axe audit on every PR that touches
    components, a weekly workflow scans the full component surface, and the
    useAnnounce lint rule rejects hand-wired aria-live regions. axe only
    catches static, DOM-level issues โ€” keyboard behavior, focus management, and
    announcement timing are exactly what the checklist and the component's unit
    tests cover.

Working on the `astryx` CLI

The CLI (packages/cli/) is layered so behavior, presentation, and contracts stay separable:

  • clients/cli/ โ€” the Commander program and per-command handlers. A handler is a thin wrapper: parse flags โ†’ call the matching api/ function โ†’ render (JSON via jsonOut, or text via the formatter kit in clients/cli/formatters/).
  • api/ โ€” the programmatic API (@astryxdesign/cli/api). Each command maps to api/<name>/, whose functions return a typed { type, data } envelope. This is the behavior source of truth, so astryx --json and the imported function return identical data.
  • authoring/ โ€” the pure data contracts (@astryxdesign/cli/authoring): the TypeScript types you author objects against (config, integration, codemod, and the doc-types) plus the sealed zod parsers the CLI runs at the load boundary.
  • foundation/ โ€” the bottom layer: cross-cutting infra that everything above builds on โ€” the { type, data } JSON contract, the stable ERROR_CODES, discovery (components, templates), integration contribution validators, and path-safety. It never imports api/ or clients/; if foundation needs something, that something belongs in foundation.

The CLI documents itself

Every CLI surface has a colocated, typed .doc.mjs next to what it describes, annotated with a @type from @astryxdesign/cli/authoring:

Surface Doc-type Lives next to
An API function (a hook or an api/ export) FunctionDoc api/<name>/<fn>.doc.mjs
A CLI command CommandDoc (references its FunctionDoc via fn) clients/cli/commands/<name>.doc.mjs
An authored object (config, integration, codemod, the doc-types, the response envelope) SchemaDoc beside the schema (authoring/**, foundation/response/)
A closed vocabulary (error codes, response types) EnumDoc foundation/response/
A long-form topic (tokens, principles, theming, โ€ฆ) ReferenceDoc assets/docs/<topic>.doc.mjs

These are not free-form. parseDoc validates each at load, and a drift harness (packages/cli/test/drift/) enforces that they mirror their source of truth: every CommandDoc's fn/args/options match the live CLI, and the EnumDocs equal ERROR_CODES / the manifest's response-type set exactly. A doc that drifts fails CI.

What's enforced for you

Most of the conventions above are mechanical, so they're checked rather than reviewed:

Rule Enforced by
the layer directions hold: authoring/ imports no other layer, foundation/ never imports api/ or clients/, api/ never imports clients/ ESLint (no-restricted-imports)
zod stays sealed behind the authoring/ parsers ESLint (no-restricted-imports)
commands register via defineCommand, never straight onto Commander ESLint (no-restricted-syntax)
each doc-type ships type.ts + parse.mjs + <kind>.doc.mjs, re-exports its parser, and appears in parseDoc's @returns pnpm check:cli-structure
each api/<name>/ ships its typedefs, a FunctionDoc, and a test pnpm check:cli-structure
every CommandDoc/EnumDoc matches the live CLI the drift harness

You never hand-write the .d.mts declarations. packages/cli/scripts/sync-api-types.mjs emits them for both api/ and authoring/ from the .mjs JSDoc โ€” gitignored, regenerated at prepack, and stamped @generated. Edit the JSDoc and run pnpm -F @astryxdesign/cli sync:api-types.

That matters because a hand-written declaration shadows the JSDoc in its .mjs, and both ways it can lie shipped once: a missing declaration made a strict consumer resolve the parser as any (surfacing only at pack time as TS7016, since local typechecks run with checkJs and never exercise the packed surface), and a stale parseDoc union silently dropped three doc kinds from the published type while still compiling. Generation removes both. The one declaration still written by hand is authoring/index.d.ts, the curated public barrel.

Adding a command

Author the docs before the handler: defineCommand builds the Commander command from the CommandDoc, so the handler needs it to exist.

  1. Add the behavior under api/<name>/, with a colocated <name>.type.mjs (the Options + { type, data } response typedefs โ€” the shape source of truth) and a test.
  2. Author the docs โ€” a FunctionDoc at api/<name>/<fn>.doc.mjs and a CommandDoc at clients/cli/commands/<name>.doc.mjs. Copy the search pair as a template.
  3. Write the thin handler in clients/cli/commands/<name>.mjs, registering it with defineCommand(program, <name>Command, {fn: <name>Fn, action}) so --help and the manifest come from the doc. Call its register<Name> from clients/cli/index.mjs.
  4. Run the checks below. The drift harness catches a doc that disagrees with the live command, and check:cli-structure catches a missing typedef, doc, or test.

Testing the CLI

bash
# Run the CLI locally (no build needed)
node packages/cli/clients/cli/bin/astryx.mjs --help

# Validate every colocated doc parses + mirrors its source of truth
pnpm -F @astryxdesign/cli test              # includes the drift suite
pnpm -F @astryxdesign/cli typecheck:authoring

# Structural conventions (doc-type quartets, api/ leaf contents). Also runs as
# part of `pnpm lint` via check:repo, and in the pre-commit hook.
pnpm check:cli-structure

# Keep the generated CLI README tables (commands, error codes, response types)
# in sync with the manifest + EnumDocs. After an intended change, refresh + review:
pnpm -F @astryxdesign/cli readme            # regenerate the tables
pnpm -F @astryxdesign/cli readme:check      # CI gate: fails on any un-refreshed drift

Testing

Run Tests

bash
# All tests
pnpm test

# Watch mode
pnpm test:watch

# Specific package
pnpm -F @astryxdesign/core test

# With coverage
pnpm test:coverage

# Accessibility and RTL audits over the built Storybook (see below)
pnpm a11y:audit
pnpm rtl:audit

Test Structure

Tests are colocated with components:

text
src/Button/
โ”œโ”€โ”€ Button.tsx
โ””โ”€โ”€ Button.test.tsx   # Tests live here

Accessibility audits

PRs that touch components run an axe-core audit (the pr-a11y CI job) over
the Storybook stories of the changed components. The job fails when it
finds a violation that is not listed in the checked-in baseline,
.github/a11y-baseline.json. Violations are keyed
Component::Story::rule-id, so unrelated markup churn does not invalidate
baseline entries.

To reproduce and fix a failure locally:

bash
# One-time setup
pnpm storybook:build
npx playwright install chromium

# Audit specific components against the baseline (what CI does)
pnpm a11y:audit -- --components Button,Dialog

Fix the violation whenever possible. If it is a known, intentional exception,
add it to the baseline (scoped to the affected components, and expect
reviewers to ask why):

bash
pnpm a11y:baseline -- --components Button,Dialog

When the audit reports baseline entries as "resolved", delete them from
.github/a11y-baseline.json โ€” the baseline should only shrink over time.

Scope caveat: axe-core automates only a subset of WCAG (roughly a
third of the success criteria). A green pr-a11y job does not mean a
component is accessible โ€” keyboard flows, focus order, screen-reader
semantics, and contrast in context still need manual checks.

RTL audits

PRs that touch components also run an RTL audit (pr-rtl), scoped to the
changed components like pr-a11y. It is soft-gated โ€” findings show in the job
summary but don't block. Repro locally with pnpm rtl:audit -- --filter Avatar
(the -- matters: pnpm -F is itself --filter). See
apps/storybook/rtl-audit/README.md.

Versioning & Releases

We use Changesets for versioning, with a thin Astryx layer on top so changelogs stay categorized, contributor-attributed, and aligned with our pre-1.0 conventions.

Adding a Changeset

When you make a change that should be released:

bash
pnpm changeset:new

This wrapper:

  1. Detects which packages you changed from your git diff and pre-selects them โ€” no hand-enumerating the frontmatter.
  2. Asks for a category (breaking, component, feat, fix, perf, docs, chore) โ€” this drives changelog grouping, not the semver bump.
  3. Captures the contributor(s) โ€” defaults to your gh/git identity, so credit is recorded at authoring time (not reconstructed from the release bot's commit).
  4. Derives the semver bump from the category โ€” a [breaking] change bumps the minor; everything else bumps the patch (see below).

It writes a normal .changeset/.md โ€” commit it with your PR. The body looks like:

md
---
'@astryxdesign/core': patch
---

[fix] Spinner inherits the variant foreground on themed buttons (#2717)
@yourhandle

You can also pass everything as flags for non-interactive use:

bash
pnpm changeset:new --category fix --summary "โ€ฆ" --pr 2717 --contributor yourhandle

The bare pnpm changeset CLI still works, but you must follow the body
convention by hand ([category] first line + @handle line). CI
(pnpm check:changesets) rejects changesets missing a category or
contributor, or whose bump doesn't match the category ([breaking] must be
minor, everything else patch), or declaring a major bump while 0.x.

Version Bumps

  • 0.x (current): bump follows the category. We track standard semver for the 0.x.y range, where a minor bump is the breaking tier (under a caret range like ^0.1.8, npm resolves <0.2.0, so 0.1.x โ†’ 0.2.0 is what signals "may break you"). A [breaking] change bumps the minor (0.x.y โ†’ 0.(x+1).0); every other category (feat, fix, component, perf, docs, chore) bumps the patch. major is never used while 0.x โ€” it would jump to 1.0.0. pnpm changeset:new writes the right bump from the category you pick; pnpm check:changesets is the CI backstop that enforces the coupling both ways.
  • All publishable packages are a fixed group, so a single change co-bumps them to the same version. Only genuinely-affected packages get a changelog entry โ€” the rest get a clean version-only bump.

How a release is cut

bash
pnpm version-packages   # changeset version + scripts/format-changelogs.mjs

format-changelogs.mjs rewrites each just-bumped package CHANGELOG into the doc-site format (h1 version, #### <Category> sections in canonical order, and a #### Contributors section aggregated from the changeset @handles). It's idempotent and has a --check mode for CI drift detection.

Finding Something to Work On

Labels signal what's open for contribution:

  • good first issue / help wanted โ€” ready to be picked up; start here.
  • discussion โ€” still being shaped and not ready for contribution. The problem is
    recorded but the solution isn't decided. Please don't start work on a fix until it's triaged
    out of discussion. Comments and ideas are welcome.

For pull requests, use GitHub's native Draft state to signal "not ready to review/merge
yet" โ€” open the PR as a draft and mark it ready for review when it's done.

What's expected of a change

The bar โ€” what a change has to carry, and what blocks โ€” lives on the
Component Audit Rubric.
It is stated there once, so it cannot drift between this file, the reviewer
instructions, and the wiki. Read it before you open a PR: it is the same page
the reviewer applies to your change, and every check carries an id
(A8, T1, P2โ€ฆ) so a finding always points back to the rule behind it.

What you will find there:

  • The bright lines that block โ€”
    hardcoded colors (T1, T3) ยท removing a themeable surface (T2) ยท raw CSS
    where StyleX suffices (T8) or raw HTML where a primitive exists (T29) ยท a
    broken accessible path (A8) and the accessibility bright lines (A1, A3,
    A14) ยท hardcoded user-facing strings (I1, I2, A16) ยท public
    API-convention violations (P1โ€“P10) ยท dropped passthroughs and breaking
    changes (P2, P11, P12) ยท a public-repo leak (L15) ยท a missing
    changeset (X20). Severity is set by what breaks if it ships, not by how
    likely the trigger is โ€” the rubric states each rule, its exceptions, and how
    it is judged.
  • The bar for your kind of change โ€”
    a bug fix owes evidence it was broken before and is fixed now; a new feature
    runs the automatable checks plus whatever the diff touches; a new component in
    core gets a full audit; a new component in lab is deliberately lax, with
    the audit as the promotion gate rather than an entry fee.
  • Which checks your diff earns โ€”
    a trigger table from what you touched to the checks that fire, so a two-line
    fix is not reviewed like a new component.
  • Recorded component grades โ€”
    audited components have a score and an open-blocker count in the wiki's
    component-scores.json ledger, which is useful context on what shape a
    component is in before you change it. Most components are unaudited, which
    means "no evidence", not "fine". No PR is gated on a score, and you are
    never asked to fix problems you inherited by touching a file.

Before you push

These are this repo's mechanical gates. A reviewer stops at a red one rather
than spending judgment on a PR that doesn't build.

bash
pnpm lint:strict   # CI severity, not the local warn tier โ€” a warn-tier-green PR is not lint-clean
pnpm test          # the full suite, locally; CI is not your test runner
pnpm build

pnpm lint:strict runs pnpm check:repo first, which covers check:sync,
check:package-boundaries, check:changesets, check:demo-media,
check:executable-bits, check:cli-structure, and check:use-client โ€” so a
green lint:strict also clears the changeset and 'use client' gates.

Also attach before/after screenshots for any visual change, and update the
Storybook story for anything you added or altered.

Pull Request Guidelines

  1. Create a feature branch from main
  2. Make your changes with tests
  3. Clear Before you push: pnpm lint:strict, pnpm test,
    pnpm build
  4. Add a changeset if needed: pnpm changeset:new
  5. Open a PR with a clear description
  6. Leave "Allow edits by maintainers" enabled (it's checked by default when
    you open the PR). This lets us rebase your branch onto the latest main to
    clear merge conflicts and keep CI passing against current main, so a PR
    that's ready doesn't get stuck behind staleness while you're away.

Why this helps. main moves quickly, and a branch that was green a few
days ago can go stale โ€” CI last ran against an older main, or a merge
conflict appears. With maintainer edits enabled we can rebase and re-run CI
for you instead of round-tripping. (One exception: PRs that modify
.github/workflows/** can't be pushed on your behalf โ€” GitHub requires the
author to update those; we'll ping you if so.)

Code Style

The design-system rules โ€” StyleX usage, semantic tokens, theming, API
conventions, accessibility โ€” are on the wiki and indexed from the
Component Audit Rubric.
What this repo enforces mechanically:

  • TypeScript strict mode
  • Functional components that declare ref as a prop (React 19 โ€” no
    forwardRef; @eslint-react/no-forward-ref rejects it, and
    @astryx/require-ref-prop requires ref?: React.Ref<T> on a publicly
    exported props interface)
  • 'use client'; as the first statement of any file importing a React client
    API โ€” only comments and blank lines may precede it (pnpm check:use-client,
    part of pnpm check:repo)
  • JSDoc comments for AI-assisted development, with @example fences left
    untagged (plain ```) or Storybook autodocs won't render them
  • Export types alongside components

Troubleshooting

Setup Issues

pnpm: command not found

Install pnpm directly:

bash
npm install pnpm@11

Or enable Corepack if you want to use the repository's pinned pnpm version:

bash
corepack enable

corepack: command not found

Install Corepack manually, then enable it:

bash
npm install corepack
corepack enable

Node 25+ does not include Corepack. You can either install Corepack manually or
install pnpm directly.

Unexpected Node.js version

Check the active version before installing dependencies:

bash
node --version

Use an active LTS line such as 22 or 24 if your shell selected a different
version, such as a non-LTS stable release.

CLI path issues

If astryx is not found in a consuming app, add the package script shown in the
root README.md and run it through your package manager:

bash
pnpm astryx -- component --list

pnpm Installation Issues

If corepack enable succeeds but pnpm fails to download its binary
(e.g. ECONNRESET, fetch failed, or 503 from registry.npmjs.org),
your environment likely blocks outbound network access.

Alternative install methods (no registry.npmjs.org needed):

bash
brew install pnpm                        # Homebrew (macOS)
curl -fsSL https://get.pnpm.io/install.sh | sh -  # Standalone installer
npm install pnpm@11                   # Via npm

You can also download the binary directly from
GitHub Releases.

Sandboxed IDE terminals: if your IDE blocks all network, run
corepack enable && pnpm install from a regular terminal first, then
open the project in your IDE โ€” node_modules is on the local filesystem
and doesn't need network to use.

Storybook Issues

"Failed to fetch dynamically imported module"

  • Cause: Core package not built or out of date
  • Fix: pnpm -F @astryxdesign/core build then restart Storybook

"React is not defined"

  • Cause: Missing React import in preview.tsx
  • Fix: Ensure import * as React from 'react'; at top of preview.tsx

"Unexpected 'stylex.defineVars' call at runtime"

  • Cause: StyleX code trying to run without compilation
  • Fix: Storybook should load from dist/ not src/. Check vite.config.ts aliases.

Changes not appearing in Storybook

  • Rebuild the package: pnpm -F @astryxdesign/core build
  • Hard refresh browser: Cmd+Shift+R (Mac) or Ctrl+Shift+R (Windows)
  • Clear Storybook cache: Remove apps/storybook/node_modules/.cache

Translations

Astryx accepts community translations via Crowdin. To help translate astryx
into your language, visit https://crowdin.com/project/astryx. New locales are
picked up automatically after a maintainer reviews the auto-generated
translations PR.

Contributor License Agreement ("CLA")

In order to accept your pull request, we need you to submit a CLA. You only need
to do this once to work on any of Meta's open source projects.

Complete your CLA here: https://code.facebook.com/cla

Issues

We use GitHub issues to track public bugs. Please ensure your description is
clear and has sufficient instructions to be able to reproduce the issue.

Meta has a bounty program for the safe disclosure
of security bugs. In those cases, please go through the process outlined on that
page and do not file a public issue.