# Semiotic — AI Assistant Guide

## Quick Start
- Install: `npm install semiotic`
<!-- semiotic-bundle-sizes:start -->
<!-- Auto-generated by scripts/sync-bundle-sizes.mjs — do not edit by hand. -->
- **Use sub-path imports** — `semiotic/xy` (90KB gz), `semiotic/ordinal` (74KB gz), `semiotic/network` (68KB gz), `semiotic/geo` (55KB gz), `semiotic/realtime` (95KB gz), `semiotic/server` (128KB gz), `semiotic/utils` (38KB gz), `semiotic/recipes` (9KB gz), `semiotic/themes` (4KB gz), `semiotic/data` (3KB gz), `semiotic/value` (6KB gz), `semiotic/ai` (250KB gz). Full `semiotic` is 203KB gz.
<!-- semiotic-bundle-sizes:end -->
- CLI: `npx semiotic-ai [--schema|--compact|--examples|--doctor|--audit-a11y]` · MCP: `npx semiotic-mcp`

## Architecture
HOC Charts (simple, default) → Stream Frames (full control). Use HOCs unless you need control they don't expose. Stream Frames pass `RealtimeNode`/`RealtimeEdge` wrappers in callbacks, not your data. Every HOC accepts `frameProps`, has an error boundary + dev-mode validation. TypeScript `strict: true`.

## Common Props (all HOCs)
`title`, `description` (aria-label), `summary` (sr-only), `width` (600), `height` (400), `responsiveWidth`, `responsiveHeight`, `margin`, `className`, `color` (uniform fill), `stroke`, `strokeWidth`, `opacity`, `enableHover` (true), `tooltip` (boolean | "multi" | function | config), `showLegend`, `showGrid` (false), `frameProps`, `onObservation`, `onClick`, `chartId`, `loading`, `loadingContent` (ReactNode; `false` suppresses), `emptyContent`, `legendInteraction` ("none"|"highlight"|"isolate"), `legendPosition` ("right"|"left"|"top"|"bottom"), `emphasis` ("primary"|"secondary"), `annotations`, `accessibleTable` (true), `hoverHighlight` (requires `colorBy`), `hoverRadius` (30), `animate` (boolean | {duration?, easing?, intro?}), `axisExtent` ("nice"|"exact" — pins first/last tick to data min/max; XY x/y + ordinal value axis only).

**Primitive styling** (`color`/`stroke`/`strokeWidth`/`opacity`): apply to any shape the chart draws. Precedence: top-level prop > `frameProps.*Style` fn return > HOC base > theme. Use CSS vars (`stroke="var(--semiotic-border)"`) for cascade-overridable theming. Per-datum: use `frameProps.pieceStyle`/`pointStyle`/`lineStyle` fn form.

`onClick` receives `(datum, { x, y })`. `onObservation` receives `{ type, datum?, x?, y?, timestamp, chartType, chartId }`.

## XY Charts (`semiotic/xy`)

**LineChart** — `data`, `xAccessor` ("x"), `yAccessor` ("y"), `lineBy`, `lineDataAccessor`, `colorBy`, `colorScheme`, `curve`, `lineWidth` (2), `showPoints`, `pointRadius` (3), `fillArea` (boolean|string[]), `areaOpacity` (0.3), `lineGradient`, `anomaly`, `forecast`, `band` ({y0Accessor, y1Accessor, style?, perSeries?, interactive?} or array for fan charts; participates in yExtent; non-interactive by default), `directLabel`, `gapStrategy`, `xScaleType`/`yScaleType` ("linear"|"log"|"time"), `tooltip="multi"` for hover-anywhere
**AreaChart** — LineChart props + `areaBy`, `y0Accessor`, `gradientFill`, `areaOpacity` (0.7), `showLine` (true), `band`, `tooltip="multi"`
**DifferenceChart** — Two-series A/B. Fills between with `seriesAColor` where A>B, `seriesBColor` where B>A; crossovers interpolated. `data`, `xAccessor`, `seriesAAccessor` ("a"), `seriesBAccessor` ("b"), `seriesALabel`/`seriesBLabel`, `seriesAColor` (var(--semiotic-danger))/`seriesBColor` (var(--semiotic-info)), `showLines` (true), `lineWidth` (1.5), `showPoints` (false), `pointRadius` (3), `curve` ("linear"), `areaOpacity` (0.6), `gradientFill`, `xExtent`/`yExtent`, `pointIdAccessor`, `windowSize`. Push via `ref.push({x,a,b})`. Accessor outputs coerce through `toNumber`.
**StackedAreaChart** — flat array + `areaBy` (required), `colorBy`, `normalize`, `baseline` ("zero"|"wiggle" streamgraph|"silhouette" centered), `stackOrder` ("key"|"insideOut"|"asc"|"desc"). Streamgraph: `baseline="wiggle"` + `stackOrder="insideOut"`. `baseline` ⊥ `normalize`. No `lineBy`. `tooltip="multi"` interpolates between samples.
**Scatterplot** — `xAccessor`, `yAccessor`, `colorBy`, `sizeBy`, `sizeRange`, `pointRadius` (5), `pointOpacity` (0.8), `marginalGraphics`, `regression` (boolean | "linear"|"polynomial"|"loess" | RegressionConfig — sugar for trend overlay)
**BubbleChart** — Scatterplot + `sizeBy` (required), `sizeRange` ([5,40]), `regression`
**ConnectedScatterplot** — + `orderAccessor`, `regression`
**QuadrantChart** — Scatterplot + `quadrants`, `xCenter`, `yCenter`
**MultiAxisLineChart** — Dual Y-axis. `series` (`[{yAccessor, label?, color?, format?, extent?}]`). Falls back to multi-line if ≠ 2 series.
**Heatmap** — `xAccessor`, `yAccessor`, `valueAccessor`, `colorScheme`, `showValues`, `cellBorderColor`
**ScatterplotMatrix** — `fields` (numeric field names)
**MinimapChart** — Overview + detail with linked zoom. Wraps an XY chart.
**CandlestickChart** — `xAccessor`, `highAccessor` (req), `lowAccessor` (req), `openAccessor`+`closeAccessor` (optional → OHLC; high/low only → range). `candlestickStyle` ({upColor, downColor, wickColor, rangeColor, bodyWidth, wickWidth}). Honors `mode`.

## Ordinal Charts (`semiotic/ordinal`)

**BarChart** — `categoryAccessor`, `valueAccessor`, `orientation`, `colorBy`, `sort`, `barPadding` (40), `roundedTop`, `gradientFill` (true | {topOpacity, bottomOpacity} | {colorStops}; tip→base), `regression`
**StackedBarChart** — + `stackBy` (required), `normalize`, `sort` (false default — insertion order)
**GroupedBarChart** — + `groupBy` (required), `barPadding` (60), `sort` (false default)
**SwarmPlot** — `colorBy`, `sizeBy`, `pointRadius`, `pointOpacity`
**BoxPlot** — + `showOutliers`, `outlierRadius`
**Histogram** — + `bins` (25), `relative`. Always horizontal.
**ViolinPlot** — + `bins`, `curve`, `showIQR`
**RidgelinePlot** — + `bins`, `amplitude` (1.5)
**DotPlot** — + `sort` ("auto"), `dotRadius`, `showGrid` (true default), `regression`
**PieChart** — `categoryAccessor`, `valueAccessor`, `colorBy`, `startAngle`
**DonutChart** — PieChart + `innerRadius` (60), `centerContent`
**FunnelChart** — `stepAccessor`, `valueAccessor`, `categoryAccessor?`, `connectorOpacity`, `orientation`
**SwimlaneChart** — `categoryAccessor`, `subcategoryAccessor` (req), `valueAccessor`, `colorBy` (defaults to subcategoryAccessor), `orientation`, `roundedTop` (pixel radius on outer ends of each lane; middles stay square; single-segment lanes round all four)
**LikertChart** — `categoryAccessor`, `valueAccessor`|`levelAccessor`+`countAccessor`, `levels?`, `orientation`, `colorScheme`
**GaugeChart** — `value` (req), `min`, `max`, `thresholds`, `arcWidth`, `cornerRadius` (rounded segment ends), `sweep`, `fillZones`, `showNeedle`, `centerContent`

All ordinal: `colorBy`, `colorScheme`, `categoryFormat` (string|ReactNode), `showCategoryTicks` (true).

## Network Charts (`semiotic/network`)

**ForceDirectedGraph** — `nodes`, `edges`, `nodeIDAccessor`, `sourceAccessor`, `targetAccessor`, `colorBy`, `nodeSize`, `nodeSizeRange`, `edgeWidth`, `iterations` (300), `forceStrength` (0.1), `showLabels`, `nodeLabel`
**SankeyDiagram** — `edges`, `nodes`, `valueAccessor`, `nodeIdAccessor`, `colorBy`, `edgeColorBy`, `orientation`, `nodeAlign`, `nodeWidth`, `nodePaddingRatio`, `showLabels`
**ProcessSankey** — temporal sankey with real time x-axis. `nodes`, `edges` (each with `startTime`/`endTime`), `domain` (req `[t0, t1]`), `axisTicks?`, `xExtentAccessor` (optional `[start, end]` lifetime per node), `colorBy`/`colorScheme`/`showLegend`/`legendPosition`, `pairing` ("value"|"temporal"), `packing` ("off"|"reuse"), `laneOrder` ("crossing-min"|"inside-out"|"crossing-min+inside-out"|"insertion"), `lifetimeMode` ("full"|"half"), `ribbonLane` ("source"|"target"|"both"), `showLaneRails`, `showLabels` (true), `showQualityReadout`, `showParticles` + `particleStyle`, `timeFormat`/`valueFormat`, push API via ref. Static-graph cycles OK as long as edges move forward in time. Use ProcessSankey for time-stamped events; SankeyDiagram for static snapshots.
**ChordDiagram** — `edges`, `nodes`, `valueAccessor`, `edgeColorBy`, `padAngle`, `showLabels`
**TreeDiagram** — `data` (root), `layout`, `orientation`, `childrenAccessor`, `colorBy`, `colorByDepth`
**Treemap** — `data` (root), `childrenAccessor`, `valueAccessor`, `colorBy`, `colorByDepth`, `showLabels`
**CirclePack** — `data` (root), `childrenAccessor`, `valueAccessor`, `colorBy`, `colorByDepth`
**OrbitDiagram** — `data` (root), `childrenAccessor`, `orbitMode`, `speed`, `animated` (true), `colorBy`

## Geo Charts (`semiotic/geo`)

Import from `semiotic/geo` only — avoids d3-geo in non-geo bundles.

**ChoroplethMap** — `areas` (GeoJSON Feature[] or "world-110m"), `valueAccessor`, `colorScheme`, `projection` ("equalEarth"), `graticule`, `tooltip`, `showLegend`
**ProportionalSymbolMap** — `points`, `xAccessor` ("lon"), `yAccessor` ("lat"), `sizeBy`, `sizeRange`, `colorBy`, `areas?`
**FlowMap** — `flows`, `nodes`, `valueAccessor`, `edgeColorBy`, `lineType`, `showParticles`
**DistanceCartogram** — `points`, `center`, `costAccessor`, `strength`, `showRings`

All geo: `fitPadding`, `zoomable`, `zoomExtent`, `onZoom`, `dragRotate`, `graticule`, `tileURL`, `tileAttribution`. Helpers: `resolveReferenceGeography("world-110m"|"world-50m")`, `mergeData(features, data, {featureKey, dataKey})`.

## Value Charts (`semiotic/value`)

Single-focal-value displays — when one number is the answer, a chart is the wrong abstraction. Plain React (no Stream Frame); SSR-clean; ~7KB gz. **Ships no chart-family dependency** — embed your own Semiotic chart via two slots picked by aspect ratio.

**BigNumber** — `value` (required), `label`, `caption`, `format` ("number"|"currency"|"percent"|"compact"|"duration"|fn), `locale`, `currency`, `precision`, `prefix`/`suffix`/`unit`, `comparison` ({value, label?, format?, direction?}), `target` ({value, label?, format?, direction?}), `delta` (explicit override), `deltaFormat`, `showDeltaPercent` (true), `direction` ("higher-is-better" default | "lower-is-better" | "neutral"), `sentiment` ("auto" default | "positive" | "negative" | "neutral"), `thresholds` ([{at, level: "success"|"warning"|"danger"|"info"|"neutral", color?, label?}] — resolved by highest `at` ≤ value, painted via `--semiotic-{level}`), `windowSize` (60 — caps push buffer surfaced via `getData()` / `slotCtx.pushBuffer`), `mode` ("tile" default | "presentation" | "inline" | "thumbnail"), `align`, `padding`, `emphasis`, `color`/`background`/`borderColor`/`borderRadius`, `animate` (boolean | {duration?, easing?, intro?} — tweens between value changes), `stalenessThreshold` (ms; dims after no-push interval), `staleLabel`, slot overrides (`headerSlot`/`valueSlot`/`deltaSlot`/`footerSlot`/`trendSlot`/`chartSlot` — ReactNode or `(ctx) => ReactNode`), `chartSize` (px reserved for `chartSlot`; defaults to inner card height).

**Two chart slot positions, picked by chart aspect:**
- **`trendSlot`** — wide / rectangular charts beneath the value (LineChart, AreaChart, DifferenceChart in `mode="sparkline"`). Renders at full card width.
- **`chartSlot`** — square charts beside the value (DonutChart, PieChart, Scatterplot, Treemap, CirclePack). Splits the card horizontally: text-on-left, chart-on-right.
- Pair both: square chart anchors top-right, wide trend stretches across the bottom.
- Slot context `(ctx) => ReactNode` exposes `{ value, formattedValue, level, color, delta, deltaFormatted, deltaPercent, sentiment, isStale, pushBuffer }` — embedded charts read `ctx.color` to theme-link to the resolved threshold.

Push API via `forwardRef`: `ref.current.push(value | {value, time?, comparison?})`, `pushMany`, `clear`, `getValue()`, `getData()`. Stable across renders (refs back the imperative handle).

ARIA: auto sentence-form label combining `{label}: {formatted} {unit}, {up|down} {delta} ({percent}) from {comparison.label}, {target%} of {target.label}[, stale]`. Override via `description`; supplement via `summary` (sr-only).

Semantic classes: `semiotic-bignumber` root + `--mode-{...}` / `--level-{...}` / `--sentiment-{...}` / `--stale` modifiers; `__text-region`, `__value`, `__delta`, `__delta-row--{up|down|flat}`, `__arrow--{up|down|flat}`, `__trend` (wide slot wrapper), `__chart` (square slot wrapper), etc.

Helpers exported: `buildFormatter`, `formatSignedDelta`, `formatDeltaPercent`, `formatDuration`, `resolveThreshold`, `colorForLevel`, `buildSparklinePath` (for custom-slot rendering).

## Realtime Charts (`semiotic/realtime`)

Push API: `ref.current.push({time, value})`. All pushed data must include a time field.

**RealtimeLineChart**, **RealtimeHistogram** (+ `brush`, `onBrush`, `linkedBrush`, `direction`), **TemporalHistogram** (static sibling — same props minus `windowSize`/`windowMode`), **RealtimeSwarmChart**, **RealtimeWaterfallChart**, **RealtimeHeatmap**, **Streaming Sankey** (StreamNetworkFrame + `showParticles`).

Encoding: `decay`, `pulse`, `transition`, `staleness` — compose freely.

### Push API on HOC charts
Most HOCs support push via `forwardRef`. **Omit** `data` — do NOT pass `data={[]}`.
```jsx
const ref = useRef()
ref.current.push({ id: "p1", x: 1, y: 2 })
ref.current.pushMany([...points])
ref.current.replace([...points])                   // ordinal only — bounded-ingest, preserves category order + transitions
ref.current.remove("p1" | ["p1","p2"])             // requires ID accessor
ref.current.update("p1", d => ({ ...d, y: 99 }))   // requires ID accessor
ref.current.clear()
ref.current.getData()
ref.current.getScales()                            // {o, r, projection} (ordinal) | {x, y} (XY) — null if unmounted
<Scatterplot ref={ref} xAccessor="x" yAccessor="y" pointIdAccessor="id" />
```
ID accessor: `pointIdAccessor` (XY/realtime), `dataIdAccessor` (ordinal), `nodeIDAccessor`/`edgeIdAccessor` (network). `replace()` is ordinal-only — used by aggregator HOCs like LikertChart. Network HOC refs operate on nodes; for edges use `StreamNetworkFrameHandle` directly: `removeNode(id)`, `removeEdge(sourceId, targetId)` or `removeEdge(edgeId)`, `updateNode(id, updater)`, `updateEdge(sourceId, targetId, updater)`.
Not supported: Tree, Treemap, CirclePack, Orbit, ChoroplethMap, FlowMap, ScatterplotMatrix.

## Custom Charts (escape hatch)

When the catalog doesn't fit, three HOCs take a layout function emitting scene primitives. Frame still owns hit testing, transitions, decay, theme, SSR.

- **`XYCustomChart`** (`semiotic/xy`) — waffle, calendar heatmap, custom point/line/area
- **`OrdinalCustomChart`** (`semiotic/ordinal`) — marimekko, parallel coords, bullet, fan, slope
- **`NetworkCustomChart`** (`semiotic/network`) — flextree, dagre, custom force/radial

Layout signature differs by family:
- **XY/Ordinal**: `layout: (ctx) => { nodes, overlays? }`. `ctx`: `data`, `scales` ({x,y} XY | {o,r,projection} ordinal), `dimensions` (plot rect — center-anchored for radial ordinal, top-left otherwise), `theme`, `resolveColor(key)`, `config`.
- **Network**: `layout: (ctx) => { sceneNodes?, sceneEdges?, labels?, overlays? }`. `ctx`: `nodes`, `edges`, `dimensions`, `theme`, `resolveColor(key)`, `config`. Run external positioners (`d3-flextree`, `dagre`) then emit network scene primitives (circle/rect/arc nodes; line/bezier/curved edges).

`semiotic/recipes` ships pure layout functions (`waffleLayout`, `calendarLayout`, `marimekkoLayout`, `bulletLayout`, `parallelCoordinatesLayout`, `flextreeLayout`, `dagreLayout`). BYO heavy deps (`d3-flextree`, `dagre`) in user code.

**Chrome (labels/axes/legends): the recipe owns it.** Recipes emit own chrome via `overlays` return field (ReactNode painted on top). Built-in axes via `showAxes` on the HOC work for layouts respecting the standard scale. Recipe convention: `showXxx` boolean toggles, `xxxFormat` callbacks. Shipped recipes' toggles: marimekko `showCategoryLabels`, bullet `showLabels`+`showTicks`, parallelCoordinates `showAxes`, flextree/dagre `showLabels`, waffle/calendar none.

**Interaction (hover/brush/selection): the parent owns it.** Recipes are pure — they take predicate props (e.g. `parallelCoordinatesLayout`'s `highlightFn?`) and the parent manages state via `onObservation` (`{type: "hover" | "hover-end" | ...}`), feeding a derived predicate back into `layoutConfig`. Matching rows render at full opacity; non-matching dim; highlighted z-order on top.

**Notes:**
- Coords are plot-relative (frame translates by `margin`). Read `ctx.dimensions.plot`. Radial ordinal: `plot.x = -width/2`, `plot.y = -height/2` (center-translated).
- Layouts needing axis domains: pass `xExtent`/`yExtent` (XY) or `oExtent`/`rExtent` (ordinal) — those flow through scale construction *before* the layout runs.
- Streaming layouts: ingest via ref (`push`/`pushMany`); layout re-runs on each ingest. Overlays update on data-change paths, NOT per-frame.
- Custom layouts own their colors — always prefer `ctx.resolveColor(key)` over hardcoded literals. `CategoryColorProvider` integration is XY-only; for cross-chart sync on network/ordinal customLayouts, pass matching `colorScheme` to each.
- Tooltips: emit datum keys matching user-visible accessor names. Avoid underscored synthetic keys (default tooltip filters those out).

## Coordinated Views

**LinkedCharts** — `selections`. **CategoryColorProvider** — `colors`|`categories` + `colorScheme`.
Chart props: `selection`, `linkedHover`, `linkedBrush`. Hooks: `useSelection`, `useLinkedHover`, `useBrushSelection`.
**Shared categories inside LinkedCharts → wrap in `CategoryColorProvider`.** Gives identical per-category colors AND makes LinkedCharts render one unified legend (suppressing individual chart legends). Without it, mismatched colors and duplicate legends.
**Linked crosshair**: `linkedHover={{ name: "sync", mode: "x-position", xField: "time" }}`. Click locks crosshair (dashed white); click/Escape unlocks.
**Linked series highlight** (series↔bar cross-highlight): `linkedHover={{ name: "sync", mode: "series" }}` auto-resolves the chart's series-identity field (colorBy/lineBy/areaBy/stackBy/groupBy) and keys the linked selection off it — no hand-wired `fields`. Add `seriesField: "region"` to override (align charts whose series live under different prop names). Modes are exclusive: `mode` is `"field"` (default) | `"x-position"` (crosshair) | `"series"`.
Also: **ScatterplotMatrix**, **ChartContainer** (`title`, `subtitle`, `actions`), **ChartGrid** (`columns`, `gap`), **ContextLayout**.

## Server-Side Rendering (`semiotic/server`)

HOC charts render SVG automatically in server environments. For standalone generation:

```ts
import { renderChart, renderChartWithEvidence, renderToImage, renderToAnimatedGif, renderDashboard } from "semiotic/server"

const svg = renderChart("BarChart", { data, categoryAccessor, valueAccessor, theme: "tufte", showLegend, showGrid, annotations })
const { svg: svg2, evidence } = renderChartWithEvidence("BarChart", { data, categoryAccessor, valueAccessor })  // evidence: { markCount, markCountByType, empty, xDomain?, yDomain?, categories?, nodeCount?, edgeCount?, annotationCount, ariaLabel, warnings } — ground truth from the rendered scene; check `evidence.empty`/`markCount` instead of parsing SVG. MCP renderChart returns the same block.
const png = await renderToImage("LineChart", { ... }, { format: "png", scale: 2 })  // requires sharp
const gif = await renderToAnimatedGif("line", data, { xAccessor, yAccessor, theme: "dark" }, { fps: 12, transitionFrames: 4, decay: { type: "linear" } })  // requires sharp + gifenc
const dashboard = renderDashboard([{ component: "BarChart", props }, { component: "PieChart", colSpan: 2, props }], { title, theme, layout: { columns: 2 } })
```

All accept `theme` (preset name or object); theme categorical colors flow to data marks. `generateFrameSVGs()` returns frame SVGs without sharp/gifenc.
AnimatedGifOptions: `fps`, `stepSize`, `windowSize`, `frameCount`, `xExtent`/`yExtent` (lock axes), `transitionFrames`, `easing`, `decay`, `loop`, `scale`.
Server SVGs include `role="img"`, `<title>`, `<desc>`, grid, legend, annotations. SVG groups have stable `id` attrs for Figma layer naming: `data-area`, `axes`, `grid`, `annotations`, `legend`, `chart-title`.

`renderChart` props match the same accessors documented per-chart above. Sparkline has no axes/grid/legend/title by default (margin 2px). ForceDirectedGraph: materialize `nodes` before passing (don't infer from edge endpoints).

All components also accept: `width`, `height`, `theme`, `title`, `description`, `showLegend`, `showGrid`, `background`, `annotations`, `margin`, `colorScheme`, `colorBy`, `legendPosition`. Pass frame-level props via `frameProps`.

## Annotations

All HOCs accept `annotations`. Coordinates use data field names.

**Positioning**: `widget`, `label`, `callout`, `callout-circle`, `callout-rect`, `text`, `bracket`
**Reference lines**: `y-threshold` (`value`, `label`, `color`, `labelPosition`), `x-threshold`, `band` (`y0`, `y1`)
**Ordinal**: `category-highlight`
**Enclosures**: `enclose`, `rect-enclose`, `highlight`
**Statistical**: `trend`, `envelope`, `anomaly-band`, `forecast`
**Streaming anchors**: `"fixed" | "latest" | "sticky" | "semantic"` — also exposed as `lifecycle.anchor` on the `semiotic/ai` annotation lifecycle. `"semantic"` is typed but currently falls back to fixed positioning; stableId-based re-resolution remains open.
**Hierarchy**: any annotation accepts `emphasis: "primary" | "secondary"` across XY, ordinal, network, geo, and static SVG rendering. `secondary` dims (opacity 0.6) and yields z-order; `primary` paints at full weight and on top. Type-agnostic, no-op when unset; class hooks `annotation-emphasis--{primary|secondary}` for further styling.
**Connectors**: `connector: { end?: "arrow"; type?: "line" | "curve"; curve? }`. `type: "curve"` draws a swoopy quadratic-bezier connector (bend = `curve` × connector length, default 0.25; negate to bow the other way); the arrowhead aligns to the curve's tangent. Default is a straight line.
**Auto-placement** (opt-in): `autoPlaceAnnotations` (boolean | config) on any HOC runs the `annotationLayout` recipe — collision-avoiding offsets for notes without manual `dx`/`dy`, curved connector routing when placement must go far. Config: `defaultOffset`, `notePadding`, `markPadding`, `edgePadding`, `preserveManualOffsets` (true), `routeLongConnectors` (true), `connectorThreshold`.
**Density** (opt-in, within `autoPlaceAnnotations`): `density` (true | `{ maxAnnotations?, areaPerAnnotation? (20000), minVisible? (1) }`) sheds lowest-priority note annotations when the plot is over-crowded — priority = `emphasis` (`primary` never shed) → `provenance.confidence` → `lifecycle.freshness` (`expired` first); reference lines/bands/overlays never count. `progressiveDisclosure: true` keeps shed notes tagged `_annotationDeferred` (`.annotation-deferred`, hidden until chart `:hover`/`:focus-within`) instead of dropping them; the persistent set is always shown. Pure forms in `semiotic/recipes`: `annotationDensity({ annotations, width, height, ... }) → { visible, deferred, budget }` and `annotationBudget(w, h)`. `diagnoseConfig` flags `ANNOTATION_DENSITY` when notes exceed the budget.
**Association/redundant cues** (opt-in, within `autoPlaceAnnotations`): `redundantCues: true` gives a colored `text` note offset from its anchor (the one note type that draws no connector) a faint leader line back to the anchor — a spatial, CVD-safe cue instead of color-alone matching. `auditAccessibility` flags color-only association as `perceivable.annotation-association` (warn) and treats `redundantCues` as satisfying it; `diagnoseConfig` flags `ANNOTATION_FAR_NO_CONNECTOR` / `ANNOTATION_LONG_CONNECTOR` for connector-necessity.
**Responsive/cohesion** (opt-in, within `autoPlaceAnnotations`): `responsive: true` (or `{ minWidth }`, default 480) sheds `secondary`-emphasis notes once the plot narrows past the breakpoint (keeps `primary`/unmarked); composes with `density`, and with `progressiveDisclosure` defers instead of drops. `cohesion: "blended" | "layer"` (also a per-annotation field; per-annotation wins) — `blended` adopts mark colors/typography (default look), `layer` renders a distinct editorial layer (`--semiotic-annotation-color`, italic) via the `annotation-cohesion--*` class.
**Audience/defensive** (M6): per-annotation `defensive: true` is never shed by density/responsive (joins the floor) so it survives into every export; with `provenance`, the layout pass bakes `source`+`confidence` visibly into the label (`"… (AI · 70%)"`). `autoPlaceAnnotations: { density: true, audience }` accepts an `AudienceProfile` (anything with a `familiarity` map) and scales the density budget by aggregate familiarity — low-familiarity keeps more notes (×1.5), expert fewer (×0.6).
**Editorial visibility**: `filterAnnotationsByStatus(annotations, { showRetractedAnnotations?, showSupersededAnnotations? })` returns the current note set without applying styles. `applyAnnotationStatus` uses the same visibility rule; `describeChart` and `buildNavigationTree` skip retracted and superseded notes by default.

## Theming

CSS custom properties: `--semiotic-{bg, text, text-secondary, border, grid, primary, secondary, surface, success, danger, warning, error, info, focus, font-family, annotation-color, legend-font-size, title-font-size, tick-font-family, tick-font-size (12px), axis-label-font-size (12px), tooltip-{bg, text, radius, font-size, shadow}}`.

```jsx
<ThemeProvider theme="tufte">                                            {/* named preset */}
<ThemeProvider theme={{ mode: "dark", colors: { categorical: [...] } }}> {/* merge onto dark base */}
```

**Color priority** (with `colorBy`): CategoryColorProvider/LinkedCharts map > `colorScheme` > ThemeProvider `colors.categorical` > `"category10"`.
Presets: `light`, `dark`, `high-contrast`, `pastels`(-dark), `bi-tool`(-dark), `italian`(-dark), `tufte`(-dark), `journalist`(-dark), `playful`(-dark), `carbon`(-dark).
Serialization: `themeToCSS(theme, selector)`, `themeToTokens(theme)`, `resolveThemePreset(name)`.

**Semantic status roles** (every preset): `colors.success/danger/warning/error/info` + `secondary`/`surface`. Each emits as `--semiotic-{role}`. Use for status-driven charts: `<Waterfall positiveColor="var(--semiotic-success)" negativeColor="var(--semiotic-danger)" />`, `<Swimlane color="var(--semiotic-warning)" />`, status annotations.

**Scoped CSS cascade override** (per-subtree, no ThemeProvider needed): wrap a subtree in `<div style={{ "--semiotic-danger": "#4b0082" }}>` — canvas scene builders read CSS vars via `getComputedStyle` on the canvas DOM ancestor, so cascade rules apply even though rendering is canvas. CSS vars for single-role overrides; nested `ThemeProvider` for array/scale overrides (categorical palette, sequential/diverging scheme).

## AI Features

Surface APIs: `onObservation`/`useChartObserver`, `toConfig`/`fromConfig`/`toURL`/`fromURL`/`copyConfig`/`configToJSX`, `validateProps`, `diagnoseConfig`, `auditAccessibility`/`accessibilityCaveats` + `describeChart` + `buildNavigationTree`/`AccessibleNavTree`/`useNavigationSync` + `buildReaderGrounding` (a11y audit + descriptions + structured navigation + bidirectional sync + agent-reader grounding — see Accessibility), `exportChart(div, { format })`, `npx semiotic-ai --doctor`/`--audit-a11y`.

### Conversational Interrogation (`semiotic/ai`)
Headless "chat with the chart" hook. Library ships no UI — BYO chat surface.
- **`useChartInterrogation({ data, onQuery, componentName?, props?, initialAnnotations? })`** → `{ ask(query), history, summary, annotations, loading, error, reset }`
- **`onQuery: (query, context) => Promise<{ answer, annotations? }>`** — call your LLM. `context`: `{ data, summary, componentName?, props? }`.
- **`summary`**: stat summary (`rowCount`, per-field `{min, max, mean, median}` for numerics, top-k for categoricals, ISO range for dates). Available before any `ask()`.
- **`annotations`**: merged `initialAnnotations` + latest AI response. Wire to chart's `annotations` prop.
- **`summarizeData(data, options?)`**: standalone for server prompting or batch.
- **MCP tool**: `interrogateChart(component, props, query)` returns same summary + AI-facing instructions.

### Chart Capability Layer (`semiotic/ai`)
Heuristic chart-suggestion engine — no LLM required. Charts ship capability descriptors next to TSX files; engine ranks against a profiled dataset by intent.
- **`profileData(data, { rawInput?, seriesField? })`** → `ChartDataProfile`: per-role candidate fields (x/y/series/category/size/time), distinct counts, monotonicity, structure detection.
- **`suggestCharts(data, { intent?, allow?, deny?, maxResults?, includeVariants?, minScore?, audience? })`** → ranked `Suggestion[]` with `{ component, family, importPath, variant?, score, intentScores, rubric, reasons, caveats, props }`. `props` is spreadable directly. **Receivability**: set `audience.receptionModality` (`visual` default | `screen-reader` | `sonified` | `agent`); a non-visual channel audits each candidate and down-ranks charts the audience can't receive there (8-slice pie for a screen reader), adding the audit's findings to `caveats[]` (familiarity and receivability are separate axes). `accessibilityCaveats(auditResult)` distils any audit into the same caveat strings.
- **`scoreChart(component, data, { intent?, variantKey? })`** → evaluate a specific chart for a dataset.
- **`useChartSuggestions(data, options)`** → memoized React hook returning `{ suggestions, profile }`.
- **`registerChartCapability(capability)`** / **`unregisterChartCapability(name)`** — runtime registration for custom charts.
- **Intent taxonomy** (13 built-in): `trend`, `compare-series`, `compare-categories`, `rank`, `part-to-whole`, `distribution`, `correlation`, `flow`, `hierarchy`, `geo`, `outlier-detection`, `composition-over-time`, `change-detection`. Extend via `registerIntent`.
- **Capability authoring**: `Foo.capability.ts` next to `Foo.tsx`, append to registry in `src/components/ai/chartCapabilities.ts`. Declares `family`, `rubric` (familiarity/accuracy/precision 1-5), `fits(profile)` gate, `intentScores`, optional `variants` with `intentDeltas`, `buildProps(profile, variant)`.
- **Variants** encode that settings change what a chart is good for (e.g. StackedAreaChart's `streamgraph` variant boosts trend, penalizes part-to-whole).
- **Interrogation tie-in**: pass `includeSuggestions: true` to `useChartInterrogation` and the ranked list lands in `context.suggestions` for the LLM.
- **MCP tool**: `suggestCharts(data, intent?)`.

### Conversation-arc telemetry (`semiotic/ai`)
Opt-in event store recording the AI session arc: `suggestion-shown → suggestion-chosen → audience-set → chart-rendered → chart-edited → chart-replaced → chart-exported | chart-abandoned`, plus `interrogation-asked`/`interrogation-answered`, reader-navigation events, and `annotation-status-changed`. Module-scoped, no provider needed. Default surface is no-op — call `enableConversationArc()` to start.
- **`enableConversationArc({ capacity?, sessionId? })`** / **`disableConversationArc()`**. Bounded ring buffer (default 1000 events).
- **`getConversationArcStore()`** → `{ enabled, sessionId, capacity, record, flush, getEvents, subscribe, clear, reset }`. `getEvents()` returns referentially stable snapshot.
- **`useConversationArc({ enableOnMount?, disableOnUnmount?, capacity?, sessionId? })`** → `{ history, summary, enabled, sessionId, record, clear }`. Uses `useSyncExternalStore`.
- **`summarizeArc(events)`** → pure reducer. Server/replay safe.
- **Persistence / replay**: `registerConversationArcSink(sink)` attaches an opt-in durable sink. Built-ins: `createLocalStorageConversationArcSink({ key?, storage?, maxEvents? })`, `createIndexedDBConversationArcSink({ dbName?, storeName?, indexedDB?, maxEvents? })`, and `createWebhookConversationArcSink({ url, method?, headers?, fetch?, mapEvent? })`. Sinks receive accepted events only; disabled telemetry is still zero-overhead. `loadConversationArc(events, { enabled?, capacity?, sessionId?, append? })` / `replayConversationArc(...)` hydrate the visible store snapshot without re-emitting events to listeners or sinks; default `enabled: false` makes replay safe for analytics.
- **`recordAudienceChange(audience, previous?, { arcId?, meta? }?)`** — sugar for `audience-set`. Call from audience-picker `onChange`.
- **Events**: `ConversationArcEvent` discriminated union. Each variant carries its own payload.
- **Auto-instrumented**: `useChartSuggestions` emits `suggestion-shown` (dedup by component-list + intent); `useChartInterrogation` emits `interrogation-asked` + `interrogation-answered` (with `latencyMs`); `AccessibleNavTree` emits the reception pair `nav-node-focused`/`nav-branch-expanded` on reader traversal (keyboard/click), correlated by its `chartId` prop. Zero-overhead when disabled.

### Annotation provenance + lifecycle (`semiotic/ai`, types re-exported from `semiotic`)
Two optional blocks attach to any annotation — existing arrays keep working unchanged.
- **`provenance`**: `{ author?, authorKind?, source?, basis?, confidence?, createdAt?, dataVersion?, stableId? }` (union of the shipped fields + IDID §8 `ChartAnnotationProvenance`). `authorKind` = actor (`"human"|"agent"|"watcher"|"system"|(string & {})`); `basis` = evidence type (`"human-note"|"statistical-test"|"rule"|"llm-inference"|"external-source"|"computed"|(string & {})`), distinct from the actor; `source` open union (`"user"|"ai"|"agent"|"import"|"computed"|"system"|(string & {})`); `dataVersion` = data snapshot the note was made against.
- **`lifecycle`**: `{ freshness?, status?, supersedes?, ttlHint?, anchor? }`. Two orthogonal axes — **temporal** `freshness` (`"fresh"|"aging"|"stale"|"expired"`, derived from `createdAt`+`ttlHint`) and **editorial** `status` (`"proposed"|"accepted"|"disputed"|"retracted"`); a note can be fresh-but-disputed. `supersedes` = `stableId` of the note this replaces. `anchor`: `"fixed"|"latest"|"sticky"|"semantic"`. `ttlHint`: ISO 8601 duration (`"P30D"`) or ms.
- **`withProvenance(annotation, { provenance?, lifecycle? })`** → pure, SSR-safe.
- **`Annotated<T>`** type: `T & { provenance?, lifecycle? }`.
- **`computeAnnotationFreshness(annotations, { now?, dataExtent?, thresholds? })`** → populates `lifecycle.freshness`. `now` defaults to `dataExtent` max, then `Date.now()`. Default thresholds 1×/1.5×/3× TTL.
- **`annotationFreshnessFor(annotation, nowMs, thresholds?)`** → classifies a single annotation. Explicit `lifecycle.freshness` wins.
- **`applyAnnotationLifecycle(annotations, { now?, dataExtent?, opacity?, strokeDasharray?, labelSuffix?, showExpiredAnnotations?, thresholds? })`** → freshness + default visuals (aging dims opacity 0.55, stale dims + dashes `"4 4"`, expired filtered; set `showExpiredAnnotations: true` to keep). Per-band overrides; pass `null` to disable a band default. Annotation-level `opacity`/`strokeDasharray` win.
- **`applyAnnotationStatus(annotations, { opacity?, strokeDasharray?, labelSuffix?, showRetractedAnnotations?, showSupersededAnnotations? })`** (M7) → editorial-status treatment, orthogonal to freshness: `disputed`→`(?)` + dim, `proposed`→provisional dim+dash, `retracted`→filtered (like expired), `accepted`→full. Opacity **multiplies** into existing, so it composes with `applyAnnotationLifecycle` (run freshness first). Also resolves `supersedes` — a note superseded by a present, non-retracted note is hidden. Use **`filterAnnotationsByStatus`** when a non-visual or custom surface needs the same visibility contract without styling. Emit transitions via **`recordAnnotationStatusChange(toStatus, { annotationId?, fromStatus?, chartId? })`** → conversation-arc `annotation-status-changed` event.
- **Semantic anchors**: `anchor: "semantic"` / `lifecycle.anchor: "semantic"` re-resolves through `provenance.stableId` after data refresh, using point scene nodes or matching data rows and falling back to the recorded coordinate when the target is gone.

### Temporal lifecycle (shared `semiotic/realtime` + `semiotic/ai`)
Three systems answer "how does this look as it ages?" on three different time axes — not interchangeable.

| Policy | Lives in | Time axis | Output | Scope |
|---|---|---|---|---|
| `DecayConfig` | `semiotic/realtime` | buffer position | continuous opacity ramp | per-datum |
| `StalenessConfig` | `semiotic/realtime` | wall-clock idle | binary live/stale (+ optional badge) | chart-wide |
| Annotation freshness | `semiotic/ai` | `createdAt` + `ttlHint` | 4 named bands (opacity + dashing + expired filter) | per-annotation |

Shared primitive: **`bandFromAge(ageMs, ttlMs, thresholds?)`** → `"fresh"|"aging"|"stale"|"expired"`. Exported from both. `DEFAULT_LIFECYCLE_THRESHOLDS = { fresh: 1.0, aging: 1.5, stale: 3.0 }`. Shared **anchor mode** (`AnnotationAnchor` from `semiotic/realtime`, re-exported from `semiotic/ai` as `lifecycle.anchor`).

**Streaming chart-time aging**: pass chart's `dataExtent` to `applyAnnotationLifecycle` — latest data point becomes "now". Pair with `withCurrentProvenance(annotation, { author?, source? })` / `currentTimestamp()` to auto-stamp `createdAt`. Full survey: `/intelligence/temporal-lifecycle`.

### Variant discovery (`semiotic/ai`)
Interface for proposing/scoring variants beyond `capability.variants`. The built-in proposer emits registered variants, conservative heuristic transforms, and same-intent cross-family alternatives; external recommenders can register model/agent proposers.
- **`VariantProposal`**: `{ id, baseComponent, label?, intentDeltas?, rubricDeltas?, buildProps?, rationale?, source: "manual"|"heuristic"|"model", variantKey?, tags? }`.
- **`VariantScore`**: `{ proposalId, fit (0–5), novelty (0–1), risk (0–1), reasons }`. Mixes with `suggestCharts` composite scores.
- **`proposeVariant(component, capability, context)`** → `VariantProposal[]`. Context accepts `{ profile, audience?, intent?, existingVariants? }`.
- **`evaluateVariantProposal(proposal, profile, audience?, { intent?, baselineComponent? }?)`** → `VariantScore`.
- **MCP tool**: `proposeChartVariants(component, props?, data?, intent?, audience?)` ranks proposals and returns ready-to-use props.
- **`registerVariantDiscovery(fn)`** → registers proposer. `proposeVariant` dispatches through all and dedupes by `proposal.id`. Returns unregister. Inspect: `getRegisteredVariantDiscovery()` / `clearVariantDiscovery()`.

## AI Behavior Contracts

<!-- semiotic-behavior-contracts:start -->

These rules are generated from `ai/behaviorContracts.cjs` and are consumed by `semiotic-ai --doctor`, MCP resources, and docs checks.

- **Data required by usage mode** (`props.data-required-by-usage-mode`): Static usage (`renderChart`, MCP previews, SSR snapshots, and copy/paste examples with immediate data) requires data in props. React push mode selects live ingestion by omitting data and mutating through a ref.
  Agent action: Pass usageMode="push" to `semiotic-ai --doctor` when validating ref-based JSX with no data prop. Keep usageMode="static" or omit it for renderChart/MCP/static configs where data must be present.
- **Categorical color precedence** (`color.category-precedence`): When colorBy is set, CategoryColorProvider/LinkedCharts category maps win for mapped categories. Unmapped categories fall back to explicit colorScheme, then ThemeProvider colors.categorical, then the built-in categorical fallback.
  Agent action: Use colorBy for categorical encodings. Use CategoryColorProvider or LinkedCharts for cross-chart consistency, colorScheme for per-chart fallback palettes, and avoid frameProps style functions unless intentionally bypassing HOC color resolution.
- **Required prop combinations** (`props.required-combinations`): Some chart families need semantic props beyond data. These combinations are enforced by validation/schema for static configs and remain required in push mode unless explicitly noted.
  Agent action: Before returning code, check the selected component against the required combinations list. For push mode, omit data but keep semantic props such as areaBy, sizeBy, stackBy, and groupBy.
  Required combinations: StackedAreaChart: static data + areaBy; push areaBy. Stacked areas need a flat data array plus areaBy to identify the stacked series. BubbleChart: static data + sizeBy; push sizeBy. Bubbles need sizeBy in addition to x/y accessors so radius encodes data rather than a constant point size. StackedBarChart: static data + stackBy; push stackBy. Stacked bars need stackBy to split each category into stack segments. GroupedBarChart: static data + groupBy; push groupBy. Grouped bars need groupBy to split each category into side-by-side bars. SwimlaneChart: static data + subcategoryAccessor; push subcategoryAccessor. Swimlanes need subcategoryAccessor; colorBy defaults to the same field when not provided. GaugeChart: static value; push not supported. GaugeChart is value-only. thresholds, min, max, sweep, and arcWidth are optional. ForceDirectedGraph: static nodes + edges; push nodes + edges. ForceDirectedGraph schema/rendering requires nodes and edges. If an agent infers nodes from edge endpoints, it must materialize a nodes array before returning code.
- **Push mode omits data** (`streaming.push-mode-data`): HOC push mode is selected by omitting the data prop entirely. Passing data={[]} is static empty data and can clear/reinitialize the frame on render.
  Agent action: For live charts, create a ref, omit data, then call ref.current.push() or pushMany(). For static renderChart/MCP snapshots, provide data because renderChart cannot push later.
- **Ref mutations need stable IDs** (`streaming.ref-mutations-require-id-accessors`): push() and pushMany() can append without IDs, but remove(id) and update(id, updater) require a stable ID accessor: pointIdAccessor for XY/realtime charts, dataIdAccessor for ordinal charts, and nodeIDAccessor/edgeIdAccessor for network operations.
  Agent action: When generating code that calls remove() or update(), include the matching ID accessor and make sure pushed rows carry that ID field.
- **renderChart uses static props only** (`rendering.renderchart-static-props`): MCP renderChart and semiotic/server renderChart render a single static SVG/PNG snapshot. Browser-only realtime components and future ref pushes are not renderable through that path.
  Agent action: Use renderChart only with renderable HOC components and complete static data. For live behavior, return React code with a ref and do not promise MCP-rendered output.

<!-- semiotic-behavior-contracts:end -->

## Accessibility
`role="group"` (outer) + `role="img"` (inner canvas). Keyboard: arrows navigate points, Enter cycles neighbors, Home/End/PageUp/PageDown. Shape-adaptive focus ring (`--semiotic-focus`). `accessibleTable` (default true) for sr-only data summary (live aria-live region sits OUTSIDE `role="img"` so AT announces hovered/focused data). Auto-detects `prefers-reduced-motion`, `forced-colors`. Hooks: `useReducedMotion()`, `useHighContrast()`.

**Chartability audit**: `auditAccessibility(component, props, { inChartContainer?, describe?, navigable? })` (from `semiotic/ai` or `semiotic/utils`) grades a config against Chartability (POUR-CAF) — credits built-ins, flags author-actionable gaps, marks un-checkable items `manual` (not a false pass). `formatAccessibilityAudit(result)` renders the report. Surfaced as `npx semiotic-ai --audit-a11y` (non-zero exit on critical fail → CI gate) + the `auditAccessibility` MCP tool. Returns `{ ok, summary, findings[] }`; each finding has `{ id, principle, heuristic, critical, status: "pass"|"fail"|"warn"|"manual"|"not-applicable", message, fix }`. NOT a pass/fail cert — pair with manual NVDA/JAWS/VoiceOver testing.

**Chart descriptions**: `describeChart(component, props, { levels?, locale?, capability?, audience? })` (from `semiotic/ai` or `semiotic/utils`) generates a layered natural-language description (Lundgard L1 encoding / L2 statistics / L3 trend) → `{ text, levels: {l1?,l2?,l3?,l4?}, annotations? }`. Richest for XY/bar/part-to-whole/distribution; degrades to L1 for network/hierarchy/geo/value. **Annotations**: when `props.annotations` is present, the result carries an `annotations` sentence ("The author has marked 2 features…") and it *leads* `text` ahead of L1–L3 — an author-placed note is intent in its purest form. Provenance-aware: an `authorKind`/`source` of `agent`/`ai`/`watcher` qualifies it ("an AI-suggested callout"). Absent when no annotations, so un-annotated callers are unchanged. **L4 (intent)**: pass a `capability` (a chart's descriptor or a resolved `{ family, intentScores }`) and `describeChart` emits the illocutionary *communicative-act* sentence ("This is an alerting chart; the peak at March is the point to investigate") — opt-in, default output stays L1–L3. Helpers: `resolveCommunicativeAct(component, capability)`, `communicativeActForIntent(intent)`, type `CommunicativeAct`. **Full-accessibility chrome (title, caption, description, navigation, data download) is the opt-in `ChartContainer` layer — not baked into the bare chart.** `<ChartContainer describe chartConfig={{component, props}}>` renders an sr-only L1–L3 description (`describe={{ visible:true }}` to show it, `{ levels }` for verbosity). The audit's `assistive.features-described` passes when `describe` is on.

**Agent-reader grounding**: `buildReaderGrounding(component, props, { capability?, audience?, includeStructure? })` (from `semiotic/ai` or `semiotic/utils`) → `{ description (L1–L3), intent (act + L4 sentence), structure (nav tree), text }` — the single payload an LLM reads to interpret a chart faithfully (the reader-side complement to a capability descriptor). MCP: `groundChart` tool.

**Structured navigation** (Olli/Data-Navigator model): `buildNavigationTree(component, props, { maxLeaves?, locale? })` (from `semiotic/ai` or `semiotic/utils`) → a `NavTreeNode` tree (chart → axis/series → datum), labels composed via describeChart. When `props.annotations` is present it appends an **Annotations** branch (`role: "annotation"`) so a reader encounters author notes during traversal — provenance + M7 editorial `status` surfaced inline, retracted and superseded notes skipped, added on every family (incl. root-only network/geo/hierarchy). `describeChart` also leads its text with an annotation sentence when annotations are present. `AccessibleNavTree` (from `semiotic` or `semiotic/ai`) renders it as a WAI-ARIA `tree` widget (arrows/Enter/Home/End, roving tabindex, `onActiveChange`, controlled `activeId` auto-expands to the node, `chartId` for telemetry), sr-only by default. With the conversation-arc store enabled it emits `nav-node-focused`/`nav-branch-expanded` reception events on genuine traversal (not on canvas-driven `activeId` changes). `<ChartContainer navigable chartConfig={...}>` mounts it (`navigable={{ visible?, maxLeaves? }}`). Audit's `compromising.navigable-structure` passes when `navigable` is on (incl. hierarchy charts). Audit options: `auditAccessibility(component, props, { inChartContainer?, describe?, navigable? })`.

**Bidirectional sync**: `useNavigationSync({ tree, chartId?, matchFields?, selectionName?, annotations? })` (from `semiotic` or `semiotic/ai`) → `{ activeId, onActiveChange, selection, annotatedIds, focusAnnotation }`. Tree→canvas highlights the matching mark (field-value selection); canvas→tree maps the hovered/clicked datum back to its leaf. Rides the module-global selection + observation stores — **no provider needed**: give the chart `chartId` + `selection={sync.selection}`, give `AccessibleNavTree` `activeId`/`onActiveChange`. **Annotation anchors**: pass the chart's `annotations` and an anchored annotation (carrying the datum's `matchFields`) resolves to a nav leaf — `annotatedIds` are the leaf ids with a note; `focusAnnotation(annotation | index)` jumps the tree + canvas to the anchored point so a non-visual reader can reach an AI's anchored note.

## Usage Notes

- **Push API**: Omit `data`. `data={[]}` clears on every render.
- **Tooltip datum shape**: HOC tooltips get raw data. Frame `tooltipContent` gets wrapped — use `d.data`.
- **Tooltip format cascade**: `valueFormat`/`xFormat`/`yFormat` flow to default tooltip (axis + tooltip read identically). Custom `tooltip` fully overrides — re-pass via `Tooltip({format})` / `MultiLineTooltip({fields:[{format}]})`. Bespoke-tooltip charts (Histogram, FunnelChart, LikertChart, GaugeChart) don't participate; customize via `tooltip`.
- **`tooltip="multi"`**: shows all series at hovered X for LineChart, AreaChart, StackedAreaChart. Custom fn receives `datum.allSeries`.
- **Legend**: "bottom" expands margin ~80px. MultiAxisLineChart: use `legendPosition="bottom"`.
- **Horizontal bars**: need wider left margin (`margin={{ left: 120 }}`).
- **Log scale**: domain min clamped to 1e-6. **`xScaleType: "time"`**: creates `scaleTime`; required for landmark ticks with timestamps.
- **`barPadding`**: pixel value (40/60 default). Reduce for small charts.
- **`sort`** (BarChart/StackedBarChart/GroupedBarChart/DotPlot): `false` preserves insertion order; `"auto"` = insertion while streaming, value-desc on static (DotPlot default). StackedBar/GroupedBar default to `false`; the underlying frame value-sorts when `oSort` is undefined, so always pass `sort` explicitly if order matters.
- **`fillArea`**: `fillArea={["seriesA"]}` fills named series only — names must match `lineBy`/`colorBy` keys.
- **`hoverHighlight`**: requires `colorBy` as a string field.
- **`frameProps` style functions**: bypass HOC color resolution — use `colorBy` instead.
- **Geo imports**: always `semiotic/geo`, never `semiotic`.
- **Axis config**: `frameProps.axes: [{ orient, includeMax, autoRotate, gridStyle, landmarkTicks, tickAnchor }]`. `tickAnchor: "edges"` flips first tick's `text-anchor` to `start`, last to `end` (and `dominant-baseline` on vertical axes) so edge labels don't overflow. Pairs with `axisExtent: "exact"`.
- **Per-axis CSS**: every axis renders as `<g class="semiotic-axis semiotic-axis-{bottom|left|right|top}" data-orient="…">`. Style via `[data-orient="left"] text { font-size: 14px }` — CSS-var defaults are set inline via `var(--semiotic-tick-font-size, …)`, so cascade overrides win cleanly. Tick text: `class="semiotic-axis-tick"`; labels: `class="semiotic-axis-label"`; titles: `class="semiotic-chart-title"`.
- **`scalePadding`**: pixel inset on scale ranges (via `frameProps={{ scalePadding: 12 }}`).
- **`categoryFormat`/`xFormat`/`yFormat`**: can return ReactNode (renders in `<foreignObject>`). Tick deduplication: adjacent identical labels auto-removed.
- **Composing overlays**: XY/Ordinal paint `--semiotic-bg` across the canvas; stack with `frameProps={{ background: "transparent" }}` on the overlay. Network/Geo don't paint bg by default.

## Performance
Prefer string accessors (`xAccessor="value"`) — always referentially stable. Memoize function accessors with `useCallback`.
