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
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 hittingiframe.html?id=<storyId>&globals=colorMode:{light|dark}. Story IDs come fromhttp://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.
computeLayoutsets 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 themonotonecurve overshoots above the plot.
There is no clipPath (v1 hasastryx-chart-plot) so overshoot renders into
the margin. Bars look fine only because they forceincludeZero.
โlayout.ts:128-136, no clip inChart.tsx:295-346. - Empty data breaks scales โ streaming renders nothing. With
data=[],isNumericXis false (xValues.length > 0guard), so x falls into the band
branch with an empty domain; y-domain becomes[Infinity,-Infinity].streamGL
then maps pushed points throughxScale(n)/yScale(n)โundefined/NaNโ
blank. Verified blank after 5s. โlayout.ts:44-60,84-136,streamGL.tsx:146-152.
streamGL cannot work withoutxDomain/yDomainon 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 passesmaxTicks. 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 inpackages/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 theresolvedmap (layout.tsresolved.set(s.key,โฆ))
and in React keys (Chart.tsxkey={s.key}), silently dropping data. Live React
warning fires onAreaGradient(area('revenue')+line('revenue')).
AlsoareabuildsgradientId = 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/dotacceptColorAccessor(string | fn);line/area/band/etc. accept onlystring. Accessor-colored series also
vanish from the legend:deriveLegendItemsdrops any series withcolor == null,
and accessor series setcolor: undefined. โbar.tsx:12-14,83,dot.tsx:11,30,line.tsx:26-27,area.tsx:26-27,legend.ts:18-32. dot.dodgeis 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/getChartColorswithcategorical/sequential/diverging/semantic/structural/alpha). v2
ships none. โ compareChart/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
- 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). - heatmapGL scale โ real fix: teach the chart root a categorical y-scale so the
heatmap shares it. yBaseline/yDomain/xDomainโ full v1 parity (required for streaming).- 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 theresolvedmap, theChartrender key, tooltip lookups (resolved.get,resolvedKeys), and hover-dot
keys.keystays a semantic label. Replace everykey={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).
FixareagradientIdto include_uid. - Files:
types.ts,layout.ts,Chart.tsx,ChartTooltip.tsx,tooltip.ts,marks/{bar,line,dot,candlestick,errorBar,area}.tsx. - Verify:
AreaGradientReact 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?toChartProps; thread intocomputeLayout(mirror v1Chart/Chart.tsx:150-220:
explicit domains authoritative + no.nice();zerosymmetric;autoincludes
0). Precedence:yDomain> rootyBaseline> per-markincludeZero. Add aclipPath(like v1astryx-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: ifxDomain
given, honor it even whendata=[](so streaming has a real numeric scale);
likewiseyDomain. - Files:
Chart.tsx,layout.ts,types.ts(SeriesContext already carries scales). - Verify:
Simple Lineno longer clips; streaming story (givenxDomain/yDomain,
or a rolling window) draws.
W3 โ Streaming works end-to-end [ ]
- Depends on W2. Update
StreamingLinestory to pass a domain / rolling window (v1
"stable streaming window"). Confirm streamGL maps points and the axis slides. - Files:
ChartV2Advanced.stories.tsx, possiblystreamGL.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) viaChartV2/index.ts+ packageindex.ts. - Marks default
colortoundefined= "auto"; the Chart root resolves a
categorical palette (useChartColors().categorical(n)) and assigns a color to
each series lacking one, by index โ store as_resolvedColoron the def (like_uid); marks readself._resolvedColor ?? ownColor. Fixes the broken token
AND gives multi-series auto colors. - Widen
line/area(andbandwhere sensible) to acceptColorAccessorfor
parity withbar/dot. Share oneColorAccessortype (move totypes.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.
- Re-export v1 palette from ChartV2 (
- Files:
ChartV2/index.ts, packageindex.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
requiringmaxTicks. โ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
dotscatter (not WebGL). dotGLInteractivehover.referenceLinesolo: singley;y+y2band;xline.- 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,legendderivation (incl. accessor),tooltipderivation. Runpnpm 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
yBaselinedefault stay'auto'-equivalent given per-markincludeZero
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 |
Recommended first slice (ship to canary next week, with vega)
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:
- Verify tooltip hover (Playwright-drive a hover) โ the last unverified interaction.
- Confirm
percent/date formatters in a story. - Wire
/chartsinto the docsite (add as a dep, fill the empty.doc.mjsfiles) so it appears in docs. - Final light+dark screenshot pass of the slice.
Fast-follows (after the first slice)
heatmapGLcategorical-y shared-scale fix (the architectural exception).dotGLInteractivehover story + verification.- Mode-aware translucent fills so bands/error bars read in dark mode.
- Tooltip value semantics for candlestick (OHLC) / streamGL / GL marks.
dotGLpalette/token color integration.- Time scale + date-axis formatting.
referenceLinesolo 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,
sizevsradius,upper/lowervshigh/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:
- Uniform channels (Plot/Vega-Lite): color/size/opacity accept constant | field |
accessor everywhere. (We do this only forbar/dotcolor.) - 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. - Time scale (everyone). We have none โ financial/streaming fake it with band
strings or raw numbers. (ScaleTimeis imported in the axis types but the root
never builds one.) - 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. - CVD-safe default categorical palette (Vega/Plot ship tableau10-like sets). Ours
is close in spirit; needs verification. - (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)
| requiredstring(dotGL/dotGLInteractive/streamGL) |string[]ramp (heatmapGL)
|upColor/downColorpair (candlestick). Five shapes. - "size of a point":
radius(dot, a radius) vssize(dotGL, a diameter). - opacity:
opacity(most) vsbandOpacity(referenceLine) vs none (line/candlestick).
The proposal: one `Channel` concept
Borrow Observable Plot's channels. A visual property accepts one of three things:
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); // accessorApply 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
- 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 viacategorical(n)). Candlestick up/down and
errorBar/referenceLine default tosemantic/structuralfrom the palette. - GL marks can't use tokens.
hexToGLdoesparseInt(hex,16)โ a CSS var orrgb()becomesNaN(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 havetoken(name)) and/or a canvasgetComputedStylefallback. Then GL marks accept the same colors as SVG marks and
can auto-assign from the palette too. - Accessor colors vanish from the legend.
deriveLegendItemsdrops any series
whosecolorisn'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. - 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. - 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. - 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 โ
NaNpositions โ streaming renders nothing. - No time scale; no log scale; no per-axis control.
Proposal
- 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. - Degenerate-domain handling (correctness): single point / zero range / all-equal
โ synthesize a sensible span (e.g.,vยฑ1or[0, v*2]) so the mark is visible and
ticks are sane; empty + explicit domain โ honor the domain (fixes streaming). - Explicit scale config (new, optional): allow the caller to set x/y scale type
and options rather than pure inference:Inference stays the default. This is how MUI/ECharts/Plot all work and it removes atextxScale={{type: 'time'}} // or 'linear' | 'band' | 'log' yScale={{type: 'log', domain: [1, 1e6], nice: true}}
class of "it guessed wrong" bugs. - Time scale (fills a real gap):
scaleTimefor Date x-values; the axis already
referencesScaleTime. Enables honest financial/streaming time axes and good tick
formatting (via the portedshortDate/monthYear). - 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'syAxisId/ Plotly'sy2. 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;yBaselinecorrectness. - 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/
windowaccess. โ 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;
handlewebglcontextlost. - 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; honorprefers-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): recommendhigh/low(matches candlestick/finance)
orupper/lowerโ one, not both.referenceLinekeepsy/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 (orcolor:{up,down}). - Size: standardize on one meaning. Recommend
radius(SVG semantics) everywhere
points are round; if we keep GLsizeas diameter, document the 2ร relationship or
convert soradiusmeans radius for both. - Stroke:
strokeWidtheverywhere (retirelineWidthon streamGL); expose the
currently-hardcoded strokes (area top line, candlestick wick, line-dots radius). - Opacity:
opacityeverywhere (retirebandOpacity; referenceLine band usesfillOpacityor nestedband:{opacity}). - Curve: export one shared
CurveType; exposecurveon area (and band?) using it. - label: every non-utility mark accepts
labeland setsSeriesDef.labelso legend/
tooltip are correct; referenceLine'slabelis 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
xPixelhelper (every mark reinvents the band/linear px math);
shareColorAccessor/Channeltypes (currently duplicated in bar/dot, unexported). - Tooltip semantics: fix value derivation (candlestick shows only
open; streamGL
yieldsundefined; 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/tooltipas boolean-or-config props. - Composable chrome primitives (
ChartSwatch, standaloneChartLegend,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.
8. Design decisions โ DECIDED (recommended defaults; vetoable)
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.
- Channel model โ ADOPTED.
color/size/opacityacceptconstant | {field} | accessoruniformly across marks. It's the backbone that makes
the API coherent and unlocks color-by-field. (Bigger refactor of mark options, worth it.) - 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). - Scales โ parity + time + degenerate now; log if cheap. Do
yBaseline/yDomain/xDomainparity + clip + headroom + degenerate-domain handling + a time scale +
explicit scale config this stage. Add explicittype:'log'if inexpensive, else
follow-on. - 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. - Bounds vocabulary โ DECIDED:
high/low. Usehigh/lowfor bothbandanderrorBar(matches candlestick/finance).referenceLinekeepsy/y2(values, not
fields), documented. - 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. - 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),_uiduniqueness,
formatters, each mark factory'sSeriesDefshape. - 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.tshexToGL - Shared x helper (underused):
packages/lab/src/Chart/utils.tsxPixel - Domain/baseline parity reference:
packages/lab/src/Chart/Chart.tsx - Full API audit + inconsistency list: this session's catalog (mirrored into
CHARTV2_PHASE1_PLAN.mdfindings).
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/showTickstoggles 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 requiringmaxTicks.[?]tickCountrespected for linear axes (d3 picks "nice" counts near it).[?]maxTicksevenly 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 usesyScale.ticks(5);
axis usestickCount=5โ confirm they match whentickCountchanges).
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]NoyBaseline/yDomain/xDomaincontrol (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/customaround 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]HeatmapcolorRangeand 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 tosemantic.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 (hexToGLis 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 (areagradient) 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
(nolinevariant, 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 noSeriesDef.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 onlabel).
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 onlyopen;
streamGL yieldsundefined; 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).[?]dotGLInteractiveGPU-picking hover fires its ownrenderTooltip; 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]referenceLinelabel 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.[?]marginoverrides โ 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-motionhonored (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,monthYearon 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/YBaselinetypes).[?]No mark leaks internal-only fields;SeriesDefshape stable across all 12.[?]Channel type accepts constant | {field} | accessor uniformly (post-refactor).[?]_uiduniqueness 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(notchangeset publish, whosenpm whoamiprecheck 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 inpackages/cli/assets/codemods/transforms/next/, not in a guessed future version
folder. During the Version Packages PR, pnpm version-packages runsscripts/promote-codemod-next.mjs immediately after changeset version, whenpackages/core/package.json contains the actual version being released.
The promotion step copies every entry from next except README.md intopackages/cli/assets/codemods/transforms/v<released-version>/, removes the
promoted files from next, and registers the new version folder inpackages/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:
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 registryThe publish step uses pnpm-native publishing and carries no NODE_AUTH_TOKEN / secrets.NPM_TOKEN:
- name: Publish changed packages
run: pnpm -r publish --provenance --access public --no-git-checkspnpm -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_refclaim matters: npm matches its stored trust config against the OIDC token'sworkflow_refclaim, which GitHub sets to the calling (entry) workflow โdeploy.ymlhere โ not any reusable workflow. astryx's publish runs directly indeploy.yml(noworkflow_callindirection), so the trusted workflow filename to register isdeploy.yml. If publishing is ever refactored into a reusable workflow, keepdeploy.ymlin the trust config (the caller) and grantid-token: writeto 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 tonpm 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. Runnpm i npm@latestbefore 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:
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
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 trustAfter 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_TOKENsecret stays in place until the pnpm-native OIDC change indeploy.ymlhas merged AND the first OIDC publish has succeeded with provenance. RemoveNPM_TOKENonly AFTER that first green OIDC publish โ never before โ otherwise any push to main in the gap runs the still-token-basedpnpm changeset publish(and the canarynpm 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:
- API Conventions โ naming, prop patterns, composition rules (read before submitting an RFC)
- Design Conventions โ the design-side bar: tokens, spacing, radius, elevation, type, color, motion, and state representations
- Specification Protocol โ the 9-phase process for new components
- Component Lifecycle โ how components move from lab โ core and templates from hidden โ visible
- API Arbitration โ how we resolve API design questions
- Contributing Templates โ building templates/blocks and the template grading rubric
- Blog Review Rubric โ how docsite blog posts are reviewed
- Contributing with AI โ safe zones, spec protocol, and working with AI tools
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):
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.zshrc
nvm install # no argument โ reads .nvmrcfnm 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 ofpackage.json). You can install pnpm directly:
# 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/latestOr use Corepack to install the exact
pnpm version Astryx pins:
corepack enableCorepack 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:
npm install corepack
corepack enableVerify installation:
node --version # v22.x.x or v24.x.x
pnpm --version # 11.x.xGetting Started
# 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 devRunning Storybook
Storybook loads pre-built packages from dist/ folders, so you need to build packages before running Storybook.
First time setup:
# Build all packages
pnpm build
# Or build just core
pnpm -F @astryxdesign/core buildStart Storybook:
cd apps/storybook
pnpm devStorybook 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:
# Rebuild core package
pnpm -F @astryxdesign/core build
# Restart Storybook to see changes
cd apps/storybook
pnpm devRunning 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:
# First time only โ build the workspace packages it depends on
pnpm build
# Start the doc site (Next dev server, defaults to localhost:3000)
pnpm docsitepnpm 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 docscollides with thenpm docsbuiltin, which
tries to open the package's npm page in a browser. Usepnpm docsite
instead.
Project Structure
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 helpersDevelopment 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
mkdir -p packages/core/src/MyComponent2. Create the Component Files
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 exportsStories are not colocated โ they live in the Storybook app:
apps/storybook/stories/MyComponent.stories.tsx3. Component Template
// 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
// 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 underpackages/ is never picked up. Import the component through its published
entry point, and title it under Core/ (or Lab/ for @astryxdesign/lab).
// 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
// packages/core/src/index.ts
export * from './MyComponent';Note: Do not manually edit the
"exports"field inpackages/core/package.json.
It is auto-generated from thesrc/directory byscripts/sync-exports.jsand
committed automatically when changes land onmain. If you need to verify your
component will be included, runpnpm 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 (seepackages/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-a11yjob inci.ymlruns an axe audit on every PR that touches
components, a weekly workflow scans the full component surface, and theuseAnnouncelint rule rejects hand-wiredaria-liveregions. 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 matchingapi/function โ render (JSON viajsonOut, or text via the formatter kit inclients/cli/formatters/).api/โ the programmatic API (@astryxdesign/cli/api). Each command maps toapi/<name>/, whose functions return a typed{ type, data }envelope. This is the behavior source of truth, soastryx --jsonand 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 stableERROR_CODES, discovery (components, templates), integration contribution validators, and path-safety. It never importsapi/orclients/; 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.
- Add the behavior under
api/<name>/, with a colocated<name>.type.mjs(theOptions+{ type, data }response typedefs โ the shape source of truth) and a test. - Author the docs โ a
FunctionDocatapi/<name>/<fn>.doc.mjsand aCommandDocatclients/cli/commands/<name>.doc.mjs. Copy thesearchpair as a template. - Write the thin handler in
clients/cli/commands/<name>.mjs, registering it withdefineCommand(program, <name>Command, {fn: <name>Fn, action})so--helpand the manifest come from the doc. Call itsregister<Name>fromclients/cli/index.mjs. - Run the checks below. The drift harness catches a doc that disagrees with the live command, and
check:cli-structurecatches a missing typedef, doc, or test.
Testing the CLI
# 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 driftTesting
Run Tests
# 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:auditTest Structure
Tests are colocated with components:
src/Button/
โโโ Button.tsx
โโโ Button.test.tsx # Tests live hereAccessibility 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 keyedComponent::Story::rule-id, so unrelated markup churn does not invalidate
baseline entries.
To reproduce and fix a failure locally:
# One-time setup
pnpm storybook:build
npx playwright install chromium
# Audit specific components against the baseline (what CI does)
pnpm a11y:audit -- --components Button,DialogFix 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):
pnpm a11y:baseline -- --components Button,DialogWhen 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 greenpr-a11yjob 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). Seeapps/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:
pnpm changeset:newThis wrapper:
- Detects which packages you changed from your git diff and pre-selects them โ no hand-enumerating the frontmatter.
- Asks for a category (
breaking,component,feat,fix,perf,docs,chore) โ this drives changelog grouping, not the semver bump. - 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). - 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:
---
'@astryxdesign/core': patch
---
[fix] Spinner inherits the variant foreground on themed buttons (#2717)
@yourhandleYou can also pass everything as flags for non-interactive use:
pnpm changeset:new --category fix --summary "โฆ" --pr 2717 --contributor yourhandleThe bare
pnpm changesetCLI still works, but you must follow the body
convention by hand ([category]first line +@handleline). CI
(pnpm check:changesets) rejects changesets missing a category or
contributor, or whose bump doesn't match the category ([breaking]must beminor, everything elsepatch), or declaring amajorbump while 0.x.
Version Bumps
- 0.x (current): bump follows the category. We track standard semver for the
0.x.yrange, where a minor bump is the breaking tier (under a caret range like^0.1.8, npm resolves<0.2.0, so0.1.x โ 0.2.0is 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.majoris never used while 0.x โ it would jump to1.0.0.pnpm changeset:newwrites the right bump from the category you pick;pnpm check:changesetsis the CI backstop that enforces the coupling both ways. - All publishable packages are a
fixedgroup, 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
pnpm version-packages # changeset version + scripts/format-changelogs.mjsformat-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 ofdiscussion. 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 incoregets a full audit; a new component inlabis 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'scomponent-scores.jsonledger, 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.
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 buildpnpm 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
- Create a feature branch from
main - Make your changes with tests
- Clear Before you push:
pnpm lint:strict,pnpm test,pnpm build - Add a changeset if needed:
pnpm changeset:new - Open a PR with a clear description
- Leave "Allow edits by maintainers" enabled (it's checked by default when
you open the PR). This lets us rebase your branch onto the latestmainto
clear merge conflicts and keep CI passing against currentmain, so a PR
that's ready doesn't get stuck behind staleness while you're away.
Why this helps.
mainmoves quickly, and a branch that was green a few
days ago can go stale โ CI last ran against an oldermain, 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
refas a prop (React 19 โ noforwardRef;@eslint-react/no-forward-refrejects it, and@astryx/require-ref-proprequiresref?: 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 ofpnpm check:repo)- JSDoc comments for AI-assisted development, with
@examplefences left
untagged (plain```) or Storybook autodocs won't render them - Export types alongside components
Troubleshooting
Setup Issues
pnpm: command not found
Install pnpm directly:
npm install pnpm@11Or enable Corepack if you want to use the repository's pinned pnpm version:
corepack enablecorepack: command not found
Install Corepack manually, then enable it:
npm install corepack
corepack enableNode 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:
node --versionUse 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:
pnpm astryx -- component --listpnpm 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):
brew install pnpm # Homebrew (macOS)
curl -fsSL https://get.pnpm.io/install.sh | sh - # Standalone installer
npm install pnpm@11 # Via npmYou can also download the binary directly from
GitHub Releases.
Sandboxed IDE terminals: if your IDE blocks all network, runcorepack 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 buildthen 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/notsrc/. 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.