### 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=&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 | --- ## 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: 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. ``. 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. ``. 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 // 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: ``` 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` 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. --- ## 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. 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 ``/`<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 `pnpm@11.10.0`. 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 `pnpm@10.34.1`, 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: ``` 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 <pkg> --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 <url>`, `--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 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](https://github.com/facebook/astryx/wiki/Contributing)**. Key pages: - **[API Conventions](https://github.com/facebook/astryx/wiki/API-Conventions)** — naming, prop patterns, composition rules (read before submitting an RFC) - **[Design Conventions](https://github.com/facebook/astryx/wiki/Design-Conventions)** — the design-side bar: tokens, spacing, radius, elevation, type, color, motion, and state representations - **[Specification Protocol](https://github.com/facebook/astryx/wiki/Component-Specification-Protocol)** — the 9-phase process for new components - **[Component Lifecycle](https://github.com/facebook/astryx/wiki/Component-Lifecycle)** — how components move from lab → core and templates from hidden → visible - **[API Arbitration](https://github.com/facebook/astryx/wiki/API-Arbitration)** — how we resolve API design questions - **[Contributing Templates](https://github.com/facebook/astryx/wiki/Contributing-Templates)** — building templates/blocks and the template grading rubric - **[Blog Review Rubric](https://github.com/facebook/astryx/wiki/Blog-Review-Rubric)** — how docsite blog posts are reviewed - **[Contributing with AI](https://github.com/facebook/astryx/wiki/Contributing-with-AI-Assistants)** — 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):** ```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](https://pnpm.io/) 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](https://nodejs.org/api/corepack.html) 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 ``` 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 ``` 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: ``` 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 ( <div ref={ref} {...props}> {children} </div> ); } 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](https://github.com/facebook/astryx/wiki/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](https://github.com/facebook/astryx/wiki/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 `EnumDoc`s 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: ``` 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](https://github.com/changesets/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/<id>.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 `@handle`s). 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](https://github.com/facebook/astryx/wiki/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](https://github.com/facebook/astryx/wiki/Component-Audit-Rubric#the-checks)** — 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](https://github.com/facebook/astryx/wiki/Component-Audit-Rubric#reviewing-a-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](https://github.com/facebook/astryx/wiki/Component-Audit-Rubric#reviewing-a-change)** — 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](https://github.com/facebook/astryx/wiki/Component-Audit-Rubric#recording-an-audit)** — 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](#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](https://github.com/facebook/astryx/wiki/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](https://github.com/pnpm/pnpm/releases/latest). **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](https://bugbounty.meta.com/) 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. ---