llms-full.txt@docs/public · git:20261008.482600f · 2026-10-08 · sha256 270ba67f08e02acf

llms-full.txt@docs/public git:20261008.482600fA

Immutable. This exact content never changes and is served at /api/v1/blob/270ba67f08e02acf.

# Semiotic — Complete AI Reference

This is an on-demand encyclopedia, not a system prompt. Start with
`ai/system-prompt.md` and the exact component schema, search this file for the
relevant chart or behavior, and load only that section. Validate generated
configurations with `prepareChart`, `npx -p semiotic semiotic-ai --doctor`, or the MCP
diagnostic tools; when possible, confirm a non-empty render with evidence.

## 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/atlas` (303KB gz), `semiotic/atlas/core` (14KB gz), `semiotic/access` (29KB gz), `semiotic/evidence` (45KB gz), `semiotic/artifact` (123KB gz), `semiotic/artifact/react` (4KB gz), `semiotic/line` (145KB gz), `semiotic/xy` (184KB gz), `semiotic/ordinal` (136KB gz), `semiotic/network` (178KB gz), `semiotic/network/zoom` (184KB gz), `semiotic/network/perspective` (2KB gz), `semiotic/network/perspective/core` (3KB gz), `semiotic/vite` (1KB gz), `semiotic/geo` (112KB gz), `semiotic/realtime` (211KB gz), `semiotic/realtime/core` (210KB gz), `semiotic/realtime/react` (2KB gz), `semiotic/server` (265KB gz), `semiotic/server/node` (265KB gz), `semiotic/server/edge` (274KB gz), `semiotic/utils` (107KB gz), `semiotic/utils/core` (98KB gz), `semiotic/utils/react` (8KB gz), `semiotic/recipes` (114KB gz), `semiotic/recipes/core` (113KB gz), `semiotic/recipes/react` (2KB gz), `semiotic/themes` (11KB gz), `semiotic/themes/core` (11KB gz), `semiotic/themes/react` (8KB gz), `semiotic/data` (5KB gz), `semiotic/value` (6KB gz), `semiotic/physics` (172KB gz), `semiotic/physics/matter` (1KB gz), `semiotic/physics/rapier` (1KB gz), `semiotic/ai` (643KB gz), `semiotic/ai/core` (140KB gz), `semiotic/controls` (18KB gz), `semiotic/rough` (3KB gz), `semiotic/text` (2KB gz). Full `semiotic` is 422KB gz.
<!-- semiotic-bundle-sizes:end -->
- CLI: `npx -p semiotic semiotic-ai [--schema|--compact|--examples|--doctor|--audit-a11y|--evaluate]` · MCP: `npx -y -p semiotic 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 (chart HOCs only)
`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; use `{ portalTarget: "element-id" }` to portal its interactive UI outside a consumer-owned `role="img"`), `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; override per XY axis with `frameProps.axes[i].extent`), `maxDevicePixelRatio` (canvas backing-store cap; defaults 3 desktop / 2 coarse-pointer or narrow viewports; very large canvases are further capped to an 8-megapixel, 16K-side backing-store budget; canvases repaint on browser zoom / display-density changes).

**Resolved chart size**: `resolveChartSize(chartName, props)` from `semiotic/utils` or `semiotic/utils/core` returns the same viewport dimensions the chart uses: mode defaults, chart-specific primary sizes, explicit dimensions, supported `size` aliases, and responsive rules. Supply `containerSize: { width, height }` for measured responsive axes; without it, responsive axes return their initial fallback. Minimap includes its overview, ScatterplotMatrix reports its field/cell grid (excluding outside title/legend), and BigNumber retains CSS sizes including undefined for auto-sized inline content. `emptyContent`/`loadingContent` reserve the resolved chart slot, follow responsive sizing, and stretch custom content across the available width while centering its text.


This list is not global. Plain value components do not inherit chart-HOC props and must be
validated against their own schema. In particular, `BigNumber` uses `label`, `description`, and
`summary`; it does not accept `title` or `accessibleTable`.

**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`/`nodeStyle`/`edgeStyle` fn form. These retained mark styles also accept `cursor` (for example `nodeStyle={() => ({ cursor: "pointer" })}`), with canvas/SVG/static parity. Cursor is presentation only; pair it with the chart/frame click or observation API so pointer and keyboard behavior remain explicit.

`onClick` receives `(datum, { x, y })`. `onObservation` receives `{ type, datum?, x?, y?, timestamp, chartType, chartId }`.
`ObservationReadout` renders an inline live-region description from those events. Pass `chartId` inside `LinkedCharts` to subscribe to the observation store, or pass the latest event through `observation`; its render function receives the unwrapped user datum. `observedDatum(event)` exposes the same normalization without rendering UI.

## XY Charts (`semiotic/xy`)

All XY charts below (plus BumpChart and MinimapChart's detail chart) accept top-level `showAxes`: it follows the chart mode (off in context/sparkline), an explicit value wins, matching `responsiveRules` win over it, `frameProps.showAxes` wins over all, and hiding axes keeps margins.

**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` ("linear"|"log"|"time"), `yScaleType` ("linear"|"log"|"symlog"), `styleRules` (per-series), `brush` (x-axis overlay; also enabled by `linkedBrush`), `tooltip="multi"` for hover-anywhere
**AreaChart** — LineChart props + `areaBy`, `y0Accessor`, `gradientFill`, `semanticGradient` (`{stops: [{offset: 0–1, color?, opacity?}]}`; offset follows the resolved y-domain), `semanticLine` (true with semanticGradient; false keeps the normal stroke/lineGradient), `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`.
**BumpChart** — Ranking-over-x. Each x-column ranks series by `yAccessor`; rank is vertical position. `data`, `xAccessor` ("x"), `yAccessor` ("y"), `lineBy` ("series"), `rankDirection` ("descending"|"ascending"), `ribbon` (false — encode magnitude as perpendicular-offset ribbon width instead of fixed-width lines; widths are preserved at ranking columns, with folded offsets repaired locally between columns; stable vertices support line↔ribbon animation), `curve` ("smooth"|"linear"), `ribbonSizeRange` ([4,28]), `samplesPerSegment` (12), `lineWidth` (3), `highlightTop` (color only the N best series by mean rank, from `colorScheme` when given; the rest share `neutralColor` at 0.58 opacity), `neutralColor`, `styleRules` (per-series; resolve against each series' first observation), `showPoints`, `showLabels` (true|"start"|"end"|"both"|"auto"; endpoint collisions are shed deterministically; labeled sides reserve room from the label text, capped at 38% of the width, with over-long labels truncated and the full name in `<title>`; partial `margin` merges over those defaults), `labelPriorityAccessor` / `maxLabels` (prioritize and cap labels under `showLabels="auto"`), `hoverHighlight`, `annotations` (x may use original x values incl. `Date`). Wraps XYCustomChart.
**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`, `symbolBy` (categorical field → glyph **shape**: each mark becomes a d3-shape glyph; size still tracks `sizeBy`/`pointRadius`), `symbolMap` ({category → shape}; unmapped auto-assign — pass it for legend-matchable shapes), `pointRadius` (5), `pointOpacity` (0.8), `marginalGraphics`, `styleRules` (per-point; `axis:"x"`/`"y"` thresholds), `regression` (boolean | "linear"|"polynomial"|"loess" | RegressionConfig — sugar for trend overlay), `brush` (xy overlay; also enabled by `linkedBrush`)
**BubbleChart** — Scatterplot + `sizeBy` (required), `sizeRange` ([5,40]), `regression`, `brush` (xy overlay; also enabled by `linkedBrush`)
**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`, `customColorScale`, `showValues`, `cellBorderColor`, `styleRules` (displayed cells; aggregate cells expose `count`, `sum`, `agg`, bin centers, and the displayed aggregate as `ctx.value`)
**ScatterplotMatrix** — `fields` (numeric field names). `renderChart()` emits one standalone composite SVG containing every off-diagonal scatter scene, diagonal distribution, field label, and categorical legend; interactive brushing/hover remains a browser affordance.
**MinimapChart** — Overview + detail with linked zoom. Wraps an XY chart. Top-level `showAxes` governs the detail chart; `minimap.showAxes` (false) the overview. The brush (a lazily loaded LinearBrush) sets the detail's x domain, or its y domain with `minimap.brushDirection: "y"`; `onBrush`/`brushExtent` use ascending data-unit extents, and `onBrushEnd(extent, { source, atDomainStart, atDomainEnd })` fires once per changed release, key, or reset. `minimap` also takes `brushStyle` (`fill`, `fillOpacity` 0.2, `stroke`, `strokeWidth`, `activeStroke`, `mask`), `handles` (`true` or `{ size, radius, fill, activeFill, stroke, move }`; an empty brush shows them at the ends), `renderHandle(ctx)`, `showExtentLabels` + `extentLabelFormat` (defaults to `xFormat`), `resetOnDoubleClick`, `minSpan` (at least 1px), and `brushLabel`; the brush is keyboard-accessible and emits `brush`/`brush-end` plus `control-start`/`control-end` observations. renderChart draws the mask, handles, and labels (built-in handles in place of `renderHandle`). `renderChart()` preserves both scenes and paints a supplied `brushExtent` into the overview, so the standalone snapshot retains the selected-detail/context relationship.
**CandlestickChart** — `xAccessor`, `highAccessor` (req), `lowAccessor` (req), `openAccessor`+`closeAccessor` (optional → OHLC; high/low only → range). `candlestickStyle` ({upColor, downColor, wickColor, rangeColor, bodyWidth, wickWidth}). Honors `mode`.
**WaterfallChart** — cumulative signed steps as floating bars. `xAccessor` ("x"), `yAccessor` ("y" — each row is a **delta**, not a running total), `positiveColor`, `negativeColor`, `connectorStroke`, `connectorWidth`, `gap`. String/categorical x values are plotted by index. Live window: `RealtimeWaterfallChart`.

## Ordinal Charts (`semiotic/ordinal`)

**BarChart** — `categoryAccessor`, `valueAccessor`, `orientation`, `colorBy`, `sort`, `barPadding` (40), `roundedTop`, `gradientFill` (`{stops: [{offset: 0–1, color?, opacity?}]}`; tip→base), `styleRules`, `regression`, `brush` (value-axis overlay; `onBrush` / `linkedBrush`)
**StackedBarChart** — + `stackBy` (required), `normalize`, `sort` (false default — insertion order), `styleRules`, `brush` (value-axis overlay; `onBrush` / `linkedBrush`)
**GroupedBarChart** — + `groupBy` (required), `barPadding` (60), `sort` (false default), `styleRules`, `brush` (value-axis overlay; `onBrush` / `linkedBrush`)
**SwarmPlot** — `colorBy`, `sizeBy`, `symbolBy` (categorical field → glyph shape, like Scatterplot), `symbolMap`, `pointRadius`, `pointOpacity`
**BoxPlot** — + `showOutliers`, `outlierRadius`
**Histogram** — + `bins` (25), `relative`, `brush` (value-axis overlay; `onBrush` / `linkedBrush`). Always horizontal.
**ViolinPlot** — + `bins`, `curve`, `showIQR`
**RidgelinePlot** — + `bins`, `amplitude` (1.5)
**DotPlot** — + `sort` ("auto"), `dotRadius`, `showGrid` (true default), `regression`
**PieChart** — `categoryAccessor`, `valueAccessor`, `colorBy`, `startAngle`, `valueFormat` (default tooltip)
**DonutChart** — PieChart + `innerRadius` (60), `centerContent`
**FunnelChart** — `stepAccessor`, `valueAccessor`, `categoryAccessor?`, `connectorOpacity`, `orientation`, `valueFormat` (tooltip + vertical value-axis ticks)
**RadarChart** — multivariate comparison on a shared radial axis. Long-form rows: one observation per series × attribute. `categoryAccessor` ("attribute"), `valueAccessor` ("value"), `seriesAccessor` (defaults to `colorBy`), `pointRadius` (4), `valueExtent` (`[0, data-max]` default), `valueFormat` (radial ticks + default tooltip). Axes must be comparable magnitudes.
**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` (strings/numbers/SVG render as native SVG text; long strings shrink to fit inside the arc), `centerLabel` (SVG caption beneath the value)

To compare changing data on a consistent scale, set `valueExtent: [min, max]` on BarChart, StackedBarChart, GroupedBarChart, DotPlot, SwarmPlot, BoxPlot, ViolinPlot, Histogram, RidgelinePlot, RadarChart, SwimlaneChart or LikertChart. These bounds work in React and static `renderChart`/MCP configurations. Numeric `[min]` leaves the upper bound data-derived; React also accepts `undefined` for an automatic bound. For example, `valueExtent: [0, 40]` keeps a value of 12 the same size when another value changes from 18 to 36. A fully specified extent may exclude values outside its bounds, so choose a domain that covers the comparison.

All ordinal: `colorBy`, `colorScheme`, `categoryFormat` (string|ReactNode), `showCategoryTicks` (true), `styleRules`. Raw-mark charts use `valueAccessor`; box/violin/ridgeline use the rendered category median and expose `n`/`min`/`q1`/`median`/`q3`/`max`/`mean`; Histogram uses displayed bin count and exposes `bin`/`count`/`range`/`category`; Pie/Donut use absolute wedge magnitude; Likert uses displayed signed percentage; Gauge rules see synthetic zone segments (`category`, proportional `value`, `_zone`, `_isFill`).

**`styleRules`** (declarative threshold-aware styling — **every chart family**) — an ordered `StyleRule[]` where each `{ when, style }` matching a mark contributes its style, merged in list order so **the last applicable rule wins per property** (CSS-cascade model). Wired on: **all 16 ordinal HOCs**; **XY** LineChart/AreaChart/StackedAreaChart/BumpChart/MultiAxisLineChart/MinimapChart (per-series), Scatterplot/BubbleChart/QuadrantChart/ConnectedScatterplot/ScatterplotMatrix (per-point), Heatmap (displayed cells), WaterfallChart (signed bars), CandlestickChart (bodies; y=close or high), and DifferenceChart (fills/lines); **network** ForceDirectedGraph/SankeyDiagram/ProcessSankey/ChordDiagram plus Tree/Treemap/CirclePack/Orbit (authored nodes); **realtime** Line/Histogram/TemporalHistogram/Swarm/Waterfall/Heatmap; **geo** ChoroplethMap (features), ProportionalSymbolMap/DistanceCartogram (symbols), and FlowMap (edges); **physics** GaltonBoard/UnitPile/CollisionSwarm/EventDrop/PacketFlow/ProcessFlow (particles or packets). `when` = predicate `(datum, ctx) => boolean`, a declarative threshold (`{ axis?: "x"|"y"|"value", field?, gt, gte, lt, lte, eq, ne, within:[min,max], outside, in:[...] }`), or `true`/omitted (always). `ctx` channels are host-resolved: ordinal `{ value, category }`; XY `{ value(=y), x, y }`; node/feature/particle families `{ value, category }`; aggregate Heatmap/Histogram forms use the displayed aggregate and retain fields such as `count`, `sum`, `agg`, and `range`. A rule's style may include `cursor` plus normal fill/stroke/opacity fields; `style.fill` may be a color string or a **`HatchFill`** descriptor (`{ type:"hatch", background?, stroke?, spacing?, angle?, lineWidth?, lineOpacity? }`) resolving to a `CanvasPattern` on canvas and an SVG `<pattern>` in SSR. Precedence: top-level primitives > per-mark style fn (`pieceStyle`/`pointStyle`/`nodeStyle`) > `styleRules` > base `colorBy`/`color`/theme. Works through `renderChart`/MCP for every SSR-capable wired form (use declarative thresholds, not predicates, across JSON boundaries). Caveats: line/area rules resolve per-series, not per-vertex; MultiAxisLineChart thresholds read the original series y, not the unitized display scale; ScatterplotMatrix rules apply to scatter cells, not diagonal histograms. Exported from `semiotic` + each family entry + `semiotic/utils`: `StyleRule`, `resolveStyleRules`, `matchesThreshold`, `makeRuleValueResolver`, `makeXYRuleContext`, `makeNodeRuleContext`, `composeStyleRules`, `HatchFill`, `isHatchFill`. See `/features/style-rules`.

## Network Charts (`semiotic/network`)

**ForceDirectedGraph** — `nodes`, `edges`, `nodeIDAccessor`, `sourceAccessor`, `targetAccessor`, `colorBy`, `nodeSize`, `nodeSizeRange`, `nodeStroke`/`nodeStrokeWidth` (node-only outline), `edgeWidth`, `edgeColor`/`edgeOpacity` (edge-only stroke), `iterations` (300), `forceStrength` (0.1 — link-attraction multiplier), `layoutExecution` ("auto" default | "worker" | "sync" — auto runs big layouts in a Web Worker by estimated cost, sync fallback everywhere), `layoutLoadingContent` (ReactNode while worker layout pends; `false` suppresses), `onLayoutStateChange` (`"pending"|"ready"|"error"`), `showLabels`, `nodeLabel`, `styleRules` (style groups of nodes — rules see the raw node; `ctx.category` = colorBy group). **Node vs edge stroking**: the generic `stroke`/`strokeWidth`/`opacity` style *all* marks uniformly; to stroke nodes and edges separately use node-only `nodeStroke`/`nodeStrokeWidth` (e.g. `nodeStroke="none"` drops the node ring) and edge-only `edgeColor`/`edgeWidth`/`edgeOpacity`. Precedence per property: specific > generic > built-in default.
**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`; zero-duration OK), `domain` (req `[t0, t1]`), `axisTicks?`, `xExtentAccessor` (optional `[start, end]` lifetime per node), `nodeLabel` (visible lane-label accessor), `colorBy`/`colorScheme`/`showLegend`/`legendPosition`, `styleRules` (band fills; `fill` may be a color or **HatchFill** — canvas + SSR), `selection`/`linkedHover`, `selectionDatum` ("raw" default | "scene" — what selection predicates see), `pairing` ("value"|"temporal", default temporal), `packing` ("off"|"reuse"), `laneOrder` ("crossing-min"|"inside-out"|"crossing-min+inside-out"|"insertion"), `maxValueScale` (pixels-per-unit cap), `lanePlacement` ("stack"|"hug" — hug/binding maxValueScale also post-scale geometry-refines order), `lifetimeMode` ("full"|"half", default half), `ribbonLane` ("source"|"target"|"both"), `ribbonMinRun` (0 exact | pixel minimum | "auto" — **source-only feeder** runway + source-band handoff; not a general min-edge length), `showLaneRails`, `showLabels` (true|false|"auto" density budget; auto sheds keep deferred text for selection reveal), `labelPriorityAccessor` / `maxLabels` (auto priority + hard cap), `showQualityReadout` (crossings/pixel + non-fatal validation warnings), `layoutExecution` ("auto"|"worker"|"sync" — auto offloads packing/ordering to a module worker by cost; SSR always sync), `layoutWorkerThreshold`, `layoutLoadingContent`, `onLayoutStateChange` (`"pending"|"ready"|"error"`), `showParticles` + `particleStyle`, `timeFormat`/`valueFormat`, push API via ref (`getScales` → time + centerlines; `getCustomLayout` → `{ layout, bands, ribbons, warnings, … }` with `layout.layoutQuality`). Pure quality helpers from `semiotic/network`: `diagnoseProcessSankeyLayout` / `diagnoseProcessSankeyProps` / `explainProcessSankeyLayout` (also fed into `diagnoseConfig("ProcessSankey", props)`). **Validation policy**: static/MCP hard-fail duplicate ids; React push warns on duplicates and strips non-finite systemIn/Out times. Static-graph cycles OK as long as edges move forward in time. Use ProcessSankey for time-stamped events; SankeyDiagram for static snapshots.

ProcessSankey time values (`domain`, edge times, node lifetimes, and axis tick dates) accept numbers, JavaScript `Date` objects, numeric strings, or ISO date strings. The required domain is `[start, end]` with `start <= end`; JSON/MCP configs use numbers or strings. Numeric strings remain numeric. Date-only strings and ISO datetimes without an explicit timezone use UTC. `timeFormat` receives numbers for numeric domains and Dates for Date/ISO domains; explicit `axisTicks[].label` takes precedence. Omit `axisTicks` for automatic ticks or pass `[]` to hide them.

**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`
**Hierarchy data:** TreeDiagram, Treemap, CirclePack, and OrbitDiagram retain repeated names/IDs using occurrence suffixes assigned in source order, reserving authored IDs before generating suffixes. Resize and value sorting preserve those identities; supply unique `nodeIdAccessor` values when structurally reordering repeated records. TreeDiagram/Treemap/CirclePack sum each node's own finite nonnegative `valueAccessor` value plus descendants; missing, zero, negative, and non-finite contributions are zero. Leave parent values unset when leaves provide the measure. Zero-valued leaves have no area in treemap, circlepack, and partition layouts; all-zero circle packs have no painted circles. Hierarchy layouts take bounded root objects and do not support edge push mode.

**OrbitDiagram** — `data` (root), `childrenAccessor`, `orbitMode`, `speed`, `animated` (true), `colorBy`

All network (every HOC above, plus `NetworkCustomChart` and `StreamNetworkFrame`): `perspective` ("flat" default; see below).

Projection integration: import `NetworkPerspectiveGround`, `NetworkPerspectiveBillboard`, `resolveNetworkPerspective`, `createNetworkPerspectiveFrame`, `getNetworkPerspectiveSize`, and `useNetworkPerspective` from `semiotic/network/perspective/core` for a narrow entry point. `getNetworkPerspectiveSize(perspective, [groundWidth, groundHeight], bounds?)` returns the screen plot size at scale 1, including thickness and fit padding; add margins and keep the layout's ground dimensions fixed. Supply bounds for token radii, elevations and overhanging chrome that the helper cannot infer. `fit: "contain"` only shrinks and is required by this helper for projected views (it is the default); the helper throws `RangeError` for `fit: "none"` because origin-based placement cannot guarantee contained bounds. Flat sizing remains unchanged. `lineageDagLayout` accepts `showArrowheads: false` and `chromePlacement: "upright" | "ground"` (default upright); `transitDiagramLayout` accepts the same chrome placement and `stationBounds(info) => [left, right, top, bottom]` for custom glyph extents. Upright lineage cards participate in fitting automatically. `perspective.tokenRim: "flat"` merges token rim facets for lighter SVG; the default is `"faceted"`. SVG postprocessors can select `data-perspective-part` roles (`edge`, `edge-shadow`, `node-shadow`, `elevation-guide`, `slab-top`, `slab-face`, `token-top`, `token-rim`, `ground-grid`, `plate-top/face/outline`, `region-top/face/outline`); parse standard SVG path syntax rather than depending on spacing. Affine matrix coefficients retain at least eight decimals even with lower static coordinate precision.

All three `semiotic/atlas` readers accept `perspective`: MotifBraidChart, DependencyForestChart and FlowCircuitChart. Flow Circuit projects the same fixed apparatus and tape readings with a network renderer; `networkFrameProps` configures that projected frame, while `frameProps` configures its flat physics frame. Top-level `bodyId` annotations anchor to the same module in both views, including static SVG; an explicit `pointId` takes precedence. Use `semioticVite()` from `semiotic/vite` in Vite 7 builds to delete unreferenced Semiotic worker assets while preserving referenced external URLs. It recognizes standard worker filenames (optionally content-hashed); Vite 8 already avoids the unused assets.


**Network perspective** (`perspective`) — parallel (axonometric/oblique) projection applied **after** layout. Layout coordinates and data semantics never change; switching perspectives rebuilds the scene, not the layout. The prop is `perspective`, not `projection` (geo charts use `projection` for d3 map projections). Presets: `"flat"` (default, identity), `"isometric"` (true 30°, depth ratio 0.577), `"pixel"` (2:1), `"dimetric"`, `"military"` (plan-oblique; ground shapes preserved), `"cabinet"` (oblique front view). Config object: `type` ("isometric"), `rotation`, `tilt` (1–90), `verticalScale`, `origin`, `fit` ("contain" default — scales down, never up, and centers; or "none"), `fitPadding` (12), `elevation`, `elevationScale` ("auto" maps the largest value to 48px), `elevationGuides`, `depthSort` (true), `thickness` (6 layout px; 0 draws flat pieces), `edgeShadow` (true | `{ color, opacity }`; needs thickness > 0), `marks` ("token"|"billboard"|"ground"|"extrude"|fn), `anchor` ("center"|"feet"), `glyph`, `glyphSize`, `extrude` (24), `extrudeScale`, `edges: { route: "layout"|"orthogonal"|"orthogonal-rounded", elevation: "surface" (default: ends attach to the plate/ground their node stands on)|"nodes"|"ground"|number }`, `labels: { mode: "upright"|"ground", axis: "x"|"y" }`, `ground: { grid, plate }`, `regions: [{ id, label, nodes, bounds, padding, fill, stroke, elevation, depth, labelColor }]`, `transition` (boolean | `{ duration }`, 600ms; reduced motion is instant). Every piece has thickness: point marks (circles, symbols) default to `"token"` — discs or symbols lying on the ground at their pixel size with a shaded rim; `marks: "billboard"` keeps them upright; pictogram glyphs always stand as billboards. Area marks (sankey/treemap/partition rects, chord arcs, CirclePack circles) lie on the ground as slabs with shaded side walls, and Treemap/CirclePack levels stack on their parent's top surface. Edges and bands are projected one thickness above the surface their nodes stand on and cast a soft shadow onto it (`edgeShadow: false` removes it). Plates and regions default to the same thickness. Node-anchored labels keep their screen offset; `labels.mode: "ground"` rotates text along a ground axis. `elevation` (field name, constant px, or React callback) is the one added data encoding, drawn with dashed drop lines and ground shadows. `marks: "extrude"` turns leaf rects (e.g. Treemap leaves) into shaded prisms; containers stay on the ground. `regions` draw plates (optionally raised/thick) under member node ids and seat those members on the plate top; regions are chrome (no hit test or tooltip). Hit testing, tooltips, keyboard focus, `pointId` annotations, the accessible table, the zoom camera (`viewTransform`), and SSR follow the projected marks. Custom layouts: `ctx.perspective` is the active config or null; returned `sceneNodes`/`sceneEdges`/`labels`/`htmlMarks` are projected automatically; an HTML mark whose `id` matches a scene node or its datum follows that node's elevation (the top surface for solid pieces), preserving its screen offset and dimensions; otherwise its center can anchor to a coincident node, with unmatched marks placed at piece height; `backgrounds`/`overlays` stay in plot space unless the layout result sets `perspective: "ground"` (all on the ground) or `"manual"` with per-element placement: `<NetworkPerspectiveGround z>` lays flat geometry on the ground (`z="top"` = piece height, where edges ride — use it for arrowheads), `<NetworkPerspectiveBillboard x y z onGround?>` moves upright content (node cards, badges, edge labels) with its anchor, or lays it flat at pixel size with `onGround`; unwrapped content (legends) stays flat; decorations that reach past the scene marks are declared in `perspectiveBounds` (ground boxes `{ x, y, width, height, z? }` or upright `{ x, y, extent: [left, right, top, bottom], z? }`) so the fit keeps them in the plot; both are identity on flat charts, and a dev warning fires once when a projected layout returns decorations with neither mode; overlay components call `useNetworkPerspective()` for the fitted frame (`project(x, y, z)`, `unproject`, `groundTransform`, `billboardTransform`, `thickness`). Built-in network recipes (lineageDag, dagre, mermaidDag — solid pieces under a perspective — netEnsemble, packedClusterMatrix, transitDiagram, adjacencyFlow, dependencyForest, GoFish) already place their decorations. Ground grid/plate, regions, orthogonal routing, and extrusion ("perspective extras") load on demand in browsers and repaint when ready; `renderToStaticSVG`/`renderChart`/MCP render them synchronously; call `preloadNetworkPerspectiveExtras()` before hydrating server-rendered charts that use them. `semiotic/network` also exports `resolveNetworkPerspective`, `createNetworkPerspectiveFrame`, `projectNetworkScene`, and the `NetworkPerspective*` types. Opt-in `semiotic/network/perspective`: `isometricGlyphs` (`box`, `server`, `database`, `cylinder`, `tile`, `cloud`, `pin`) for `perspective.glyph`, builders `isoBox`/`isoStack`/`isoCylinder`/`isoTile`/`isoCloud`/`isoPin` (glyphs follow the node color), and `PerspectiveToggle` (accessible radiogroup: `value`, `onChange`, `options`). JSON/MCP configs use the string or object form; function `elevation`/`extrude`/`marks` and `glyph`/`glyphSize` are React-only. Use it for categorical or topological positions (architecture diagrams, lineage/DAGs, org trees, matrices); avoid it when position encodes a measured quantity. It is a parallel projection on the existing canvas/SVG renderers, not 3D or WebGL. See `/features/perspective`.

## 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`, `styleRules` (flag features — rules see the feature with `properties` flattened; `fill` may be a HatchFill)
**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})`.

## Physics Charts (`semiotic/physics`)

Import from `semiotic/physics` only — not re-exported from the root `semiotic` entry (keeps the physics kernel out of default dashboards).

Process/arrival/distribution charts backed by `StreamPhysicsFrame`. The settled projection is the chart; motion is explanatory context. Use when the movement has data semantics, not as decoration.

**GaltonBoardChart** — `data`, `valueAccessor` ("value"), `bins` (21), `simulationMode` ("sample"|"mechanical"; legacy `mode` aliases accepted), `pegRows`, `mechanicalCount`, `branchProbability`, `ballRadius`, `colorBy`, `styleRules` (per-particle; `ctx.category` = colorBy group), `seed`, `size`/`width`/`height`, `paused`, `frameProps`. Values enter assigned bins before physical replay. Mechanical mode generates seeded Bernoulli samples; the apparatus does not simulate peg collisions.
**EventDropChart** — `data`, `timeAccessor` ("time"), `arrivalAccessor` ("arrivalTime"), `windows` ({size}), `watermark` ({delay}|{value}|fn), `watermarkAtArrivalAccessor`, `timeScale`, `ballRadius`, `colorBy`, `seed`, `size`, `paused`, `frameProps`. Delay/function policies classify events in arrival order against a monotonic watermark; ties retain source order. A window is closed when its end is at or below the watermark. Accepted historical events stay accepted as later events advance it. An explicit `watermark.value` tests one fixed policy; supply `watermarkAtArrivalAccessor` to preserve recorded admission thresholds independently of current closure. `timeScale` changes pacing, not decisions. Push/update/remove and replacement data rebuild the source projection and scene together; `getData()` returns source records. Current lids are solid for every body: accepted history starts beneath them, while late arrivals roll into the far-left bin. For admission → closure → late-arrival teaching, supply successive data/watermark states and pause or coordinate source time while bodies travel; changing `timeScale` alone does not animate historical lid changes. Projection labels are source snapshot totals (accepted per window, late in the far-left bin). `readEventDropOccupancy(metadata, bodies)` reads body centers from the matching builder geometry and returns `{accepted: number[], late, inFlight, total}`; these sum to materialized body count, excluding queued arrivals. Metadata also exposes `windowWalls` rectangles derived from collision geometry for custom drawing.
**UnitPileChart** (was `PhysicsPileChart`; alias kept) — `data`, `categoryAccessor` ("category"), `valueAccessor` (omit to count rows), `simulationMode` ("sample"|"mechanical"; legacy `mode` aliases accepted), `mechanicalCount`, `mechanicalCategories`, `unitValue` (1), `ballRadius`, `colorBy`, `seed`, `showProjection`, `size`, `paused`, `frameProps`. A full circle represents `unitValue`; each source record's remainder uses proportional circle area. Labels retain exact source totals; pile height is approximate. Mechanical mode generates a seeded unit pile and defaults `valueAccessor` to "value".
**CollisionSwarmChart** — `data`, `xAccessor` ("x"), optional `groupAccessor`, `radiusAccessor`, `pointRadius`, `xExtent`, `collisionIterations`, `settle`, `showProjection`, `colorBy`, `seed`, `size`, `paused`, `frameProps`. X stays exact throughout motion; springs and collisions arrange vertical spacing within group lanes. If packing cannot fit at the chosen radius and height, points retain their values and radii and the projection discloses overlap. Reduce radius or increase height to make room. `settle` starts bodies at their packed targets; it does not run the settle loop.
**PacketFlowChart** (was `PhysicalFlowChart`; alias kept) — `nodes`, `links`/`edges`/`data`, `nodeIdAccessor` ("id"), `nodeXAccessor` ("x"), `nodeYAccessor` ("y"), `sourceAccessor` ("source"), `targetAccessor` ("target"), `throughputAccessor` ("value"), `pathAccessor` ("path"), `coordinateMode` ("auto"|"normalized"|"pixels"), `particleRate`, `maxParticles`, `particleRadius`, `flowSpeed`, `pathConstraint` ("path"|"none"), `reducedMotion`, `showStaticFlow`, `showNodeLabels`, `showSensors`, `paused`, `seed`, `size`. Experimental physics-backed flow chart where packets move along authored node coordinates or link paths while a static throughput layer keeps route quantities readable.
**ProcessFlowChart** — multi-body workflow lane. `data`, `stages` (required: `[{id, label?, force?, damping?, capacity?, pressure?, portal?, absorb?, share?}]`), `stageAccessor` ("stage"), `idAccessor`, `groupBy` (optional feature key; completion when all members hit an absorb stage), `groupLabelAccessor`, `workAccessor`, `radiusAccessor`, `ballRadius` (6), `colorBy`, `groupCompletion` ("allAbsorbed"|"none"), `groupAnchorAlong` (0.55), `showProjection` (true), `showChrome` (true — processChrome kit), `liveCapacity` (true — FIFO queues at `unitsPerSecond`), `onCapacityChange` (queue depth / processed), `bodyLimit` (soft stream budget + oldest eviction), `bodyMark` ("circle"|"halo"|"faceted"|"pill"|"diamond"|"square" or per-row `datum.__physicsMark`), `selection`, `settle`, `seed`, `size`, `paused`, `frameProps`. Settled projection is stage occupancy + capacity badges; use for review queues / triage / merge pipelines. Prefer **GauntletChart** for one compound plan with timed gate effects.
**Stage geography kit** (`semiotic/physics` + `semiotic/recipes`) — `physicsStageGeography({size, flow ("down"|"right"), destinations (count | [{id,label}]), padding, chargeExtent, destinationExtent, projectionExtent, channelRatio})` → `{charge, apparatus, destinations: [{id,label,order,centerX,centerY,…}], projection}`. The shared **charge → apparatus → destinations** vocabulary every physics chart re-invented locally (Gauntlet `startX`/`socketX`/`graveyardX`, Crucible `chamber`/`mouth`/`outlets`, Galton bins and Pile tubes writing the *same* lane formula). Companions: `physicsStageColliders` (floor + one divider per interior boundary, so bodies land in the bin the data says), `physicsChargePoint` (spread a burst across the entry zone instead of co-locating it), `physicsDestination(geo, id)`, `describePhysicsStageGeography` (one-sentence reading protocol for a11y/agent grounding). Authoring vocabulary for a **new** chart or `PhysicsCustomChart` layout — shipped charts keep their own layouts; a test pins the builder to their lane math.
**Terminal state (event-tape charts)** — a tape-driven physics chart's end state must be computable from authored inputs with no simulation, because that pure result is what reduced motion, SSR, snapshot export, and `describeChart` receive. `CrucibleChart`: `compileCruciblePlan(...)` → `plan.initialState`/`plan.terminalState`/`plan.terminalSpawns`. `ChainReactionChart`: `initialRuntime(machine, mode, currentTime, true)`. `GauntletChart`: **`resolveGauntletTerminalStates({projects, events, layout, positiveProperties, negativeProperties, viability?, outcome?})`** (also `resolveGauntletTerminalState` for one project) — folds the authored tape, mirroring the live tick's effect order. Caveat: with Gauntlet's `crashDetection` armed (default), physics can still override the outcome to `bad_design_crash`, which no pure fold can predict; the pure result is "what the plan earns on paper". Pass `crashDetection={false}` for a fully authored reading. **Design test for a new physics metaphor:** can you state its ledger in one sentence (else it's an animation), does its terminal state exist without simulating (else it's a movie), would a naive reader guess the reading protocol from the apparatus (else the name is wrong)?
**Physics family contracts** (every physics HOC) — `seed` (deterministic; an explicit `frameProps.config.kernel.seed` still wins), `rerunMS` (settle → delay → deterministic replay; `null`/omitted = single run), `onSimulationStateChange(state, previousState)` (public running/settled readiness without reaching into `frameProps.config`), `showProjection`, `paused`, and `selection` + `linkedHover`. `selection` accepts **either** a `{ name }` `SelectionConfig` — joining the shared selection store so physics bodies cross-highlight inside `LinkedCharts` like every other family — **or** a resolved `{ isActive, predicate }` `PhysicsBodySelection` body predicate as the escape hatch. The store's datum predicate is lifted over `body.datum`, so chrome bodies (walls, pegs, tubes) never match a data selection. Under `prefers-reduced-motion`, the frame preserves the current queue's arrival schedule and allows `config.settleStepLimit` steps after its last arrival. Default store settles and static rendering use the same policy; `settle(maxSteps)` and `settleWithObservations(maxSteps)` retain an explicit total step cap. Sleeping intervals without controllers may be skipped without changing admission times. Forces, controllers, and `onTick` execute at each fixed step (with `steps=0` at admission, `steps=1` thereafter), including imperative frame settles. Callback-driven execution uses sync mode. Budget exhaustion and sleeping bodies do not prove semantic completion: inspect the queue and process ledger, or declare an explicit horizon. Plain `PhysicsPipelineStore.settle()` and raw static rendering run the kernel; `tick`/`settleWithObservations` accept an optional `{onStep, continueWhile}` execution hook for headless authored behavior.
**Physics controllers** (`createCapacityQueueController`, `createPortalController`, `composePhysicsControllers`) — process plugins via `controllers`. Capacity `getSnapshot()` → queueDepth/processedCount; emit `physics-capacity-processed`. **processChrome** (`semiotic/physics` / `semiotic/recipes`) — stage bays, capacity badges, feature sockets (theme: `--semiotic-process-*`). `PhysicsCustomChart`: `layout()` may return `regionEffects`, `controllers`, `bodyForces`; `layoutConfig` hot path without re-enqueue. Guide: `/features/physics-process-guide`. Contracts: `PhysicsContracts.test.tsx`.
**GauntletChart** — compound project core + tethered positive/negative property bodies + timed gate events. `positiveProperties`/`negativeProperties` (req), `gates`, `events`, `showChrome` (true), `showProjection` (true — viability/outcome strip), `showTethers` (true), `onStateChange`, `frameProps`. Bodies clamp inside walls (`clampGauntletPoint`). Not for multi-item factory floors (use ProcessFlowChart).
**CrucibleChart** — bounded peer components undergo authored `phases` and `events`, form declared `products`, and settle into reason-labelled `outlets` with source lineage. The ledger/projection is authoritative; motion never infers classification, timing, membership, loss, or routing. `buildCrucibleProductEvents({productId, form, contributions?, complete})` is pure authoring sugar for `combine → contribute* → complete-product`; every source/relation id, event position, reason, and outlet remains caller-supplied. The ref handle's `replay()` atomically restarts the deterministic tape even mid-run (`reset()` restores-and-pauses; `rerunMS` repeats after settlement). `playbackRate` changes presentation only. No push/live-event API.
**ChainReactionChart** — dependency chain reaction. `data`, `taskIDAccessor`, `labelAccessor`, `laneAccessor`, `dependencyAccessor` (all required — edges are never inferred), plus optional `startAccessor`/`endAccessor`/`progressAccessor`/`statusAccessor`/`completionTimeAccessor`/`blockerAccessor`/`milestoneAccessor`. Tasks place by workstream lane × dependency depth; a completed task releases one delivery ball per outgoing edge and a downstream task arms only once every prerequisite ball arrives, so a blocked task's *reach* is the reading. `mode` ("snapshot" derives the settled state at `currentTime` with no simulation | "replay" animates deliveries over that same derived state | "mechanical"), `insight` ("blocker-amplification" reports unfinished downstream tasks + affected lanes), `controls` (play/pause/step/reset/settle), `selectedTaskIDs`/`onSelectionChange`, `reducedMotion: "settle"`, `seed`. Ref handle: `play`/`pause`/`step`/`reset`/`settle`, `previewResolve(taskID)`/`clearPreview` (ask "what would resolving this blocker unlock?" without editing data), `completeTask`/`blockTask`/`unblockTask`, `getAmplification`, `getMachineState`. Task completion is always an explicit data event — the simulation delivers prerequisites, it never decides done. `renderChart()` emits the authored task/dependency projection at `currentTime`, including blocker reach and selection; it deliberately omits arbitrary in-flight delivery-ball positions because those are replay state, not the chart's ledger.
**Pop (body-removal burst)** — every physics HOC ref is a `PhysicsFrameHandle` (extends the shared push handle) exposing **`popBodies(ids, options?)`** (`StreamPhysicsPopOptions` = `{ color?, durationMs?, radius? }`): removes the bodies and plays a burst — expanding ring + inner glow + radial sparks fading over `durationMs` (`drawPopAnimations`) — returning the removed ids. It reads as a *departure*, the physics/exit-emphasis counterpart to realtime **`pulse`**'s data-*arrival* glow (the same transient-emphasis metaphor on opposite ends of a datum's life). GauntletChart also fires it internally on gate-driven property removal; `/examples/nimby` (civic-value balloons) and `/examples/merge-pressure` (merge-risk traits) drive it that way.

## 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`; **stacked** via `categoryAccessor` + `colors` — bars sum by category within each bin; **mirrored/diverging** via `direction="down"` flipping the value domain — pair two halves with a shared `timeExtent`/`valueExtent` for an up/down detail view, and overlay extra instances on the same extent for layered envelopes), **TemporalHistogram** (static sibling — same props minus `windowSize`/`windowMode`), **RealtimeSwarmChart**, **RealtimeWaterfallChart**, **RealtimeHeatmap** (+ sequential `colorScheme`; use `colorScheme="custom"` with `customColorScale(value)` for a custom ramp), **Streaming Sankey** (StreamNetworkFrame + `showParticles`). All six named realtime chart forms expose `styleRules`; aggregate histogram/heatmap rules resolve against displayed bins/cells rather than arbitrary source rows.

Temporal histograms accept `binAlign="start"` (default) or `binAlign="center"`. Center uses a zero-anchored grid whose bins span `[timestamp - binSize/2, timestamp + binSize/2)`, so samples on grid timestamps sit at bar centers. Automatic centered extents include full first/last bins; explicit `timeExtent` remains authoritative and clips partial bins. Aggregation, stacking, bin hover data, selection coverage, and brush snapping use the same shifted boundaries.

Temporal histograms support native `responsiveWidth` / `responsiveHeight` (definite parent height required), `showTimeAxis` / `showValueAxis`, and shared XY `axes` configs with `visible: false`. Hidden axes reserve no default margin; explicit margins win. Other XY HOCs use `frameProps.axes`. For mirrored histograms, use shared ascending time/value extents and side margins, hide the upper time axis, set the lower top margin to zero, and use `direction="down"` below. Match plot heights, allowing extra outer height for the lower time axis. Realtime downward orientation also follows pushed data.

Histogram `linkedHover={{ name: "detail", mode: "field", fields: ["time", "category"] }}` emits bin fields or source-row values for absent bin fields. A LineChart with `selection={{ name: "detail" }}` dims nonmatching series, and `showPoints` dims nonmatching observations. Line field hover publishes the hovered row's fields; x-position crosshairs require explicit `mode: "x-position"`. Bin `onHover` receives `{ data: { binStart, binEnd, total, category? }, ... }` or null; `onObservation` emits standard hover/hover-end events. See the realtime histogram docs' linked/mirrored example.


All five realtime wrappers accept a top-level `cursor` as a presentation-only default for retained marks; `RealtimeSwarmChart.pointStyle` can override it per datum. Pair actionable cursors with explicit click or observation behavior.

With `transition` enabled on a sliding `windowSize`, set `pointIdAccessor` so retained path vertices join by identity and slide; without it, equal-length paths interpolate by array index and the whole line writhes as the ring buffer advances.

`RealtimeLineChart` supports `eventTime={{ lateness, latePolicy? }}` for bounded out-of-order input. Type its ref as `RealtimeLineChartHandle` and call `ref.current.flush()` when the source ends so events still inside the grace window are released in event-time order. A flush commits an ordering boundary: later newer events buffer normally, while events older than the flushed frontier follow `latePolicy`. Changing the event-time config or `timeAccessor` live drains the old tail before the new interpretation begins.

Realtime chart `timeAccessor` callbacks may return a number, `Date`, or numeric/date string, such as `timeAccessor={(d) => d.isoDate}`. The same types apply to `TemporalHistogram` and bounded server rendering. Value accessors remain numeric.

`RealtimeHistogram` and `TemporalHistogram` support `showGrid` (false by default); set `axes={[{ orient: "bottom", grid: false }, { orient: "left" }]}` for horizontal lines only. Their boolean `hoverHighlight` (false by default) matches `binStart`, keeping the whole stacked column highlighted and dimming other bins. No category accessor is required, and local bin highlighting is independent of linked-hover fields. `valueBands` splits each unstacked bar's fill at value edges: `valueBands={[{ upTo: 5, fill: "#2563eb" }, { upTo: 10, fill: { type: "hatch", background: "#fde68a", stroke: "#b45309" } }, { fill: warningHatch }]}` paints the bar solid to 5, hatched to 10, and in the warning hatch above (a last band without `upTo` covers every higher value; values no band covers keep the bar fill). Each bar stays one mark with its bin datum, so hover, selection, `hoverHighlight`, tooltips, and `getSourceRows` are unchanged; stacked (`categoryAccessor`) bins ignore it. Works in the browser and `renderChart`. A stacked segment's datum (`HistogramBinDatum`) carries `categories`, every non-zero `{ category, value }` in its bin from bottom to top; `getSourceRows(datumOrHover)` (`semiotic/realtime`, `semiotic/utils`) returns the authored rows behind a bin, segment, or `categories` entry. With `selection`, a bin matches a selected time anywhere in `[binStart, binEnd)` on its time field (string `timeAccessor`, else `time`), numbers, Dates, or date strings; `histogramBinSelectionDatum(timeField)(bin)` (`semiotic/realtime`, `semiotic/utils`) returns a bin that `useSelection().predicate` matches the same way, for finding matched bins outside the chart; mobile `tapToSelect` publishes the tapped bin's source-row values.

With `aggregate`, changing only `stat`/`band`/`sigma` reuses accumulated windows. Changing `window`/`size`/`hop`/`gap`/`retain`, `timeAccessor`, or `valueAccessor` starts a structurally new accumulator: controlled `data` is replayed under the new definition, while push-only history resets because aggregation intentionally retains window statistics rather than every raw event.

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

### Push API on HOC charts
Most HOCs support push via `forwardRef`. **Omit** `data` — do NOT pass `data={[]}`.
TypeScript: `RealtimeFrameHandle<TDatum, TReadDatum = TDatum>` preserves the authored row through `push`, `pushMany`, `remove`, `update`, and `getData`; omitting parameters retains the loose 3.x `Datum` contract. RealtimeLineChart aggregate mode accepts source `TDatum` but materializes `AggregatedRealtimeDatum`, exposed through `RealtimeLineChartHandle<TDatum, AggregatedRealtimeDatum>`.
```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
ref.current.getCustomLayout()                      // custom charts: the most recent layout(ctx) result (readback — don't re-run the layout host-side); null before first layout / on built-ins
<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)`. Network HOC `remove([ids])` / `update([ids], updater)` commit one layout; frame refs also expose `removeNodes(ids)` and `updateNodes(ids, updater)`, returning previous records with IDs. Worker execution applies the latest topology asynchronously and supersedes stale requests.
**Controlled→push bridge**: `useSyncedPushData(ref, rows, { id, resetKey })` (from `semiotic` / `semiotic/realtime`) reconciles a controlled React array into the push buffer — diffs by id, issues the minimal push/update/remove, and clears + rebuilds on `resetKey` change. Match `id` to the chart’s ID accessor. Missing or duplicate IDs preserve every row through snapshot replacement; positional appends remain incremental. Reorders, removals, or edits in that mode require a `clear()` handle. Reach for it instead of hand-rolling the mirror when rows live in React state; pass rows to the hook, not `data`. Pairs with `useStreamStatus` (live/stale badge). Pure core `syncPushBuffer` is exported for testing.
Not supported: Tree, Treemap, CirclePack, Orbit, ChoroplethMap, FlowMap, ScatterplotMatrix.

## Custom Charts (escape hatch)

When the catalog doesn't fit, four 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, packed-cluster beeswarm matrix
- **`GeoCustomChart`** (`semiotic/geo`) — isometric landmark boards, custom geographic tessellations

### Built-in portable recipes

`ParallelCoordinatesRecipe` and `CalendarHeatmapRecipe` are the first custom
forms promoted into the serialized catalog. They are discoverable through
`suggestCharts`, `getSchema`, the CLI/MCP schema index, and the server renderer.
Their component names are serialization and rendering identifiers; React uses
the generic host rather than one-off HOCs:

```jsx
import { ChartRecipe } from "semiotic/ai"

<ChartRecipe
  recipeId="ParallelCoordinatesRecipe"
  data={rows}
  layoutConfig={{ fields: ["mpg", "power", "weight"], colorBy: "origin" }}
  title="Vehicle profiles"
  description="Vehicle profiles across independently scaled measures."
  summary="No vehicle dominates every measure."
  accessibleTable
/>
```

The serialized equivalent is
`{ component: "ParallelCoordinatesRecipe", props: { data, layoutConfig,
title, description, summary, accessibleTable: true } }`; substitute
`CalendarHeatmapRecipe` with `layoutConfig: { dateAccessor, valueAccessor,
year?, timeZone?: "local" | "utc", weekStart?: 0 | 1, missingColor? }` for the calendar form. Calendar days default to local time; date-only strings retain their named day. Missing days emit `value: null, missing: true` and use the theme grid color unless `missingColor` is set. Color extents use only the rendered year. Both names work with `renderChart` and
`renderChartWithEvidence`. Import the raw `parallelCoordinatesLayout` or
`calendarLayout` from `semiotic/recipes` only for React-only callbacks or
bespoke frame control. `semiotic/ai/core` exposes their manifests and discovery
metadata without importing the chart renderer or layouts.

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) => { backgrounds?, sceneNodes?, sceneEdges?, labels?, overlays?, htmlMarks? }`. `ctx`: `nodes`, `edges`, `dimensions`, `theme`, `resolveColor(key)`, `config`, `selection` (shared-selection predicate `{ isActive, predicate(datum) }` from `LinkedCharts`, `null` when unwired — dim/highlight by it). `backgrounds` is plot-space SVG painted below the canvas/static scene (for fitted regions and enclosures); `overlays` paints above it. Both are included in SSR/static SVG and do not add hit targets. Run external positioners (`d3-flextree`, `dagre`) then emit network scene primitives (circle/rect/arc/**symbol**/**glyph** nodes; line/bezier/curved edges). The `symbol` node is the per-datum **shape** channel — a `d3-shape` glyph (`circle`/`square`/`triangle`/`diamond`/`star`/`cross`/`wye`/`chevron`, or a custom `path`) sized by `size` (area), rendered on canvas + SVG/SSR and hit-tested + keyboard-navigated as a unit. **The `symbol` mark is cross-pipeline**: XY and ordinal custom layouts emit it too (`{type:"symbol", x, y, size, symbolType}` — note `x`/`y`, vs the network variant's `cx`/`cy`), and Scatterplot/SwarmPlot expose it as the `symbolBy` encoding. One shared `symbolPath` implementation backs canvas/SVG/hit-test across all three families.
- **The `glyph` node — composite pictograms (ALL FOUR families incl. geo)**: where `symbol` is one path, `glyph` stamps a multi-part vector pictogram — a `GlyphDef` (`{viewBox?, anchor?, parts: [{d, fill?, stroke?, strokeWidth?, opacity?}]}`) whose parts declare **role paints** (`"color"`/`"accent"`/literal) resolved per node (`color`/`accent` props), so one definition recolors per category like a pictogram plate reused in many inks. `{type:"glyph", x, y, size, glyph, color?, accent?, fraction?, fractionStart?, fractionDirection?, ghostColor?, rotation?, style, datum, pointId?}` (network variant uses `cx`/`cy` + `id`/`label`). `size` = rendered **height** px (width follows viewBox aspect); `anchor: [0.5, 1]` stands a sign's feet on a baseline/terrain. **Partial fills**: `fraction`/`fractionStart` clip a `[start, end]` window (horizontal or bottom-up vertical) with an optional full-extent `ghostColor` silhouette — the ISOTYPE partial-symbol convention, fed directly by `unitize`. Full pipeline citizen: canvas + SVG/SSR, hit-test + keyboard nav over the drawn bounds, `pointId`/`id` annotation anchoring, and enter/move/exit transition identity (which `symbol` lacks). Datum-less glyph nodes (`datum: null`) paint but don't hit-test/navigate — use one `hitTarget` per logical mark under a multi-sign tally. `<Glyph def size color …/>` (from `semiotic/recipes`) renders the same definition as React SVG for overlays/legends/decoration; `glyphPlacement`/`glyphExtent` expose its geometry for layout math.
- **Geo**: `layout: (ctx) => { nodes?, overlays? }`. `ctx`: `areas`, `points`, `lines`, fitted `GeoScales`, `dimensions`, `theme`, `resolveColor(key)`, `config`, `selection`. Emit `geoarea`/`point`/`line`/`glyph` nodes; use overlays for labels and sprites.
  - **`htmlMarks`** (network only): `NetworkHtmlMark[]` = `{ id, x, y, width, height, content: ReactNode }`, positioned in the **same plot space as `sceneNodes`** in a real DOM layer above canvas/SVG (stack: `backgrounds` → canvas → `overlays` → `htmlMarks`). The frame owns margin and optional `viewTransform: {x,y,k}` placement across canvas/SVG/HTML and inverse hit testing. External CSS scaling is unsupported. Marks use `pointer-events: none`; emit a transparent `networkHitTarget` per card, and opt interactive card content into `pointer-events: auto`. Content can read `useCustomLayoutSelection()` to restyle without layout. Stable IDs and descendant types/keys preserve mounted content across position updates and replacement node arrays, though a replacement array can rerun layout. Virtualization unmounts off-screen wrappers and discards local state; keep durable edits outside culled cards.
  - **Network viewport / HTML culling** (React-only `NetworkCustomChart.frameProps`, or raw frame props): defaults to the nearest overflow auto/scroll/overlay ancestor with **400 CSS pixels of overscan**, measured on scroll/resize at most once per animation frame without relayout. `viewport: { scrollContainerRef }` selects an explicit target; `viewport: { scrollContainer }` accepts an element held by a callback ref/state for root replacement. Refs resolve after React commits; `.current` mutation alone does not notify React. Without a camera, no target, an explicit null target, SSR, or a zero-size target mounts all marks. A camera uses the clipped plot as its viewport (including SSR), inverse-projects culling into layout coordinates, and converts overscan from screen pixels. `htmlMarkCulling: { enabled?, overscan?, pinnedIds? }` controls mounting; zero overscan is valid, invalid/negative values use 400, and `enabled: false` mounts all. Focused card content is automatically retained until blur; deleting focused data returns lost focus to the chart. `onViewportChange(snapshot)` shares `visibleRect`, `renderRect` (overscanned), `visibleMarkIds`, and `mountedMarkIds`; rectangles are plot-relative and null when unmeasured. Off-screen pins appear only in mounted IDs. Reports update even when IDs stay unchanged and work without marks or with culling disabled. Use them in subscribed overlays/edge culling and minimaps, not `layoutConfig`. Test full edge bounds, not just endpoints. Refs, callbacks, and React content are not serialized JSON fields. Class hooks: `.semiotic-network-html-marks` / `.semiotic-network-html-mark`.

  - **Opt-in camera and semantic zoom**: import `ZoomableNetworkCustomChart`, `useNetworkZoom`, `useNetworkProjectedSize`, and `useNetworkLOD` from **`semiotic/network/zoom`**. The component takes ordinary `NetworkCustomChart` props plus controlled `zoom` / `onZoomChange`, `defaultZoom`, `zoomOptions`, and `zoomControls`. Options include `minZoom`/`maxZoom`, world-coordinate `panBounds`, `locked`, `zoomEnabled`, `pan` (true/false/"x"/"y"), `dragPan`, `wheelZoom` (default "modifier" for Ctrl/Meta-wheel), `pinchZoom`, `doubleClickZoom`, `keyboard`, `settleDelay`, `duration`, and `fitPadding`. Wheel, drag and pinch apply accumulated input each animation frame; `duration` eases button, keyboard, Fit/Reset and programmatic zoom only. `settleDelay` delays detail promotion, not camera movement. The ref adds `getZoom`, `zoomTo`, `zoomIn`, `zoomOut`, `panBy` (screen pixels), `fitToContent(bounds?)`, and `resetZoom`. Bounds apply to controls and imperative navigation; controlled values are constrained, and smaller content is centered. Layout and spatial indexes remain unchanged during camera movement. No native scrollbars. `useNetworkLOD(width, height, {breakpoints, hysteresis?, keepDetail?})` returns displayed size and integer `level`: immediate demotion, promotion only after motion settles. Choose skeleton/detail markup yourself, preserve card footprint/ports, and use `keepDetail` for focused editors. Gate/cancel consumer data requests yourself; the library does not fetch. Zoomed-out dense scenes may need aggregation or cheap canvas marks. `useNetworkZoom().visibleRect` and `onViewportChange` use layout coordinates; `snapshot.plotRect` gives resolved frame-pixel dimensions/margins. `getNetworkContentBounds` fits retained nodes/HTML; pass bounds for arbitrary SVG/edge excursions. Gesture and LOD code are absent from ordinary entry graphs. These are React APIs, not serialized configs.

**Custom-chart authoring kit** (`semiotic/recipes`; `hitTarget*` also from `semiotic/xy`/`ordinal`/`network`). The shape every hand-built custom chart converges on — draw real marks in `overlays`, emit a **transparent scene node per mark** for interaction — is first-class:
- **`createLineageDagFit(nodes, plot, config?)`** (`semiotic/recipes`): the same pure fit used by `lineageDagLayout`, accepting raw or ingested nodes. Returns resolved `lod`, `nodeWidth`, `nodeHeight`, `layerCount`, `maxLayerSize`, `nodeBounds(node)`, `project(layer, row)` and `invert(x, y)`. Fit each main/minimap plot and LOD separately; convert view rectangles through logical coordinates instead of copying recipe constants or scaling chart widths. Coordinates exclude frame margins/scroll. A collapsed single-layer axis inverts to `layer: null`; use the full minimap width. Automatic domains use actual layer/row extents, including negative layers and uncentered or sparse rows. Explicit `layerCount` reserves `[0, count - 1]` and `maxLayerSize` reserves a centered span of `size - 1`; both expand to include the data. The row mapping keeps a minimum span of one around its midpoint. Missing `isBackEdge` flags are inferred when source layer >= target layer; explicit booleans override inference. Directed edges have arrowheads and backedges use dashed loops routed inside the plot. Empty/tiny plots preserve minimum glyph sizes and can overflow; this helper does not describe unrelated custom layouts. `ref.getCustomLayout()` already reads back emitted scene positions.
- **`createDagreFit(nodes, edges, plot, config?)`** / **`createFlextreeFit(nodes, plot, config?)`** (`semiotic/recipes`): the exact fit `dagreLayout` / `flextreeLayout` apply to authored positions (dagre includes edge waypoints; defaults 100×36 and 80×30). Returns `fit`, `scale`, `dx`, `dy`, `bounds` (authored space; `null` when nothing is drawn), `project(point)`, `invert(point)`, and `nodeBounds(node)` (equals the drawn rect). Plot coordinates exclude frame margins; apply a zoom camera with `projectNetworkPoint` / `invertNetworkRect` from `semiotic/network/zoom`.
- **Network hit precedence and overlay edges:** among containing rectangles, the smallest area wins; equal areas keep the first `sceneNodes` entry. Nodes win over edges. `networkEdgeHitTarget` (from `semiotic/network` or `semiotic/recipes`) supplies transparent `sceneEdges` for visible SVG overlays in identical plot coordinates. The default curved path hits only the stroke; `type: "bezier" | "ribbon"` also hits the filled interior. `interactive: false` is table-only. Keep overlays pointer-transparent and enable normal hover/observation handling; keyboard navigation remains node-based.
- **`hitTargetPoint`/`hitTargetRect`** (XY/ordinal) + **`networkHitTarget`** (circle or rect) + **`geoHitTarget`** (geo — same transparent `PointSceneNode`; project lon/lat via `ctx.scales.projectedPoint` first): a zero-opacity, fully-transparent, hit-tested node from `{x, y, (r|width,height), datum, id, cursor?}`. The optional CSS `cursor` reaches the retained canvas and SVG/static output; it is presentation only, so pair `cursor:"pointer"` with the frame's click/observation API when the mark activates something. The `id` becomes the node's `pointId`/`id` (annotation anchor + nav-tree leaf) **and** its transition key. This is how a custom chart inherits **accessibility** (keyboard nav, focus ring, data table), **annotation** anchoring, **AI**/`onObservation` + shared selection, and **chart-mode** transitions for free — replaces the `rgba(0,0,0,0)`+`opacity:0`+`pointId`+`_transitionKey` boilerplate. The visible glyph lives in `overlays`; the focus ring still draws on the invisible target. A keyboard-focused **geoarea** outlines its polygon (a shape focus ring) rather than a centroid dot.
- **Radial coordinate kit**: `polarToXY`/`xyToAngle` (0 = up, clockwise), `angleScale`/`radiusScale`, `ringArcPath` (annular-sector / wedge / full-ring path), `TAU` — angle ⟂ radius for two-continuous-channel radial charts (the radial analogue of the decoration kit).
- **Edge-router kit** (custom network edges): `curvedEdgePath` (S-curve + near-level side-bow), `orthogonalEdgePath`, `boxEdgeAnchors` (box exit/entry by direction), `fanOutBend` (fan parallel edges apart). Plus cubic-Bézier evaluation — `cubicPoint`/`cubicTangent` (sample a point/tangent along a `CubicCurve` to *seat a mark on the curve* — a node mid-edge, an arrowhead at the end) and `cubicPath` (serialize to SVG).
- **2D vector kit**: `addPoints`/`subtractPoints`/`scalePoint`/`pointMagnitude`/`normalizePoint` — the point math any hand-built radial or network layout re-derives (an edge offset normal to its tangent, a spoke, a leader line). Operates on the shared `Point`, composes with the radial + edge kits.
- **Interval/timeline**: `packIntervals` (greedy Gantt sub-track packer), `activeCountOverDomain` (concurrency step series).
- **`runs`/`runLengthEncode`**: collapse a per-step categorical/boolean series into drawable runs (condition strips, status timelines, calendar ribbons).
- **Cyclical math** (day-of-year, hour, compass bearing): `wrapValue`, `shortestArcDelta`, `cyclicRangeContains`, `selectCyclicRange`.
- **`axisFixedForcePositions`** (+ shared **`rectCollide`** positioner): nodes with missing, blank, nonnumeric, or non-finite fixed-axis values and their incident edges are omitted; invalid domains/plots yield an empty layout. Pin one axis from a data field, relax the other with edge attraction + an anchor spring + **rectangular** (label-box) collision — the "time is structural, the graph settles the cross-axis" family that hierarchical recipes don't cover. `axisFixedForceLayout` wraps it as a ready `NetworkCustomLayout`.
- **Decoration**: `linearAxis` (tick axis + gridlines from *any* scale — the bespoke-scale escape hatch `showAxes` can't cover), `legendSwatches` (portable SVG legend for `overlays` — fill/line/shape/hatch swatches; sibling to `legendGroupsFrom` which feeds `frameProps.legend`), `hatchFill` (`{def, fill}` SVG `<pattern>` for percentile/uncertainty bands — the SVG analogue of `createHatchPattern`).
- **`unwrapDatum`** (`semiotic/recipes` + `semiotic/utils`): collapse the wrapped-vs-raw datum split — always the raw user object (handles both `.data` wrappers and `.datum` nesting). **The** unwrap path for `onObservation` handlers AND `frameProps.tooltipContent` renderers: call it once on the incoming value; never pre-unwrap the argument (`unwrapDatum(x?.data ?? x)` double-unwraps).

**Word Trails** (`wordTrailsLayout`) is the quantitatively anchored word-cloud recipe: column = category, segment = ordered vertical position, weight = word area (`fontSize = maxFontSize·√(weight/maxWeight)`; `minFontSize` is a legibility floor), with overlap-free stable placement. Its result adds `unplaced` (words left out because no free spot fit them) and `sizeFloored` (words raised to the floor); wrap the layout to read them. `wordColor`/`wordOpacity` receive `WordTrailsWordInfo`: canonical `word`/`column`/`weight`/`segment` plus the exact source `datum`, `dataIndex`, `columnIndex`, and `resolvedColumnColor`, so derived encodings do not need brittle compound-key lookup maps. Spread `wordTrailsProgressiveReveal({currentSegment, segmentDomain, oldestOpacity?, currentOpacity?, futureOpacity?, combineWeightOpacity?})` into `layoutConfig` for future hiding + linearly faded history without reflow; zero-opacity rows reserve geometry but emit no glyph or hit target. Analysis-derived color/distinctiveness remains a source-data field, not something the layout infers.

`semiotic/recipes` ships pure layout functions (`waffleLayout`, `calendarLayout`, `marimekkoLayout`, `bulletLayout`, `parallelCoordinatesLayout`, `intervalLanesLayout`, `flextreeLayout`, `dagreLayout`, `lineageDagLayout`, `netEnsembleLayout`, `axisFixedForceLayout`, `packedClusterMatrix`, `isometricLandmarkLayout`, `forceLayout`, `arcLayout`, `adjacencyMatrix`, `circularLayout`). **Network-analysis kit** (`semiotic/recipes`, pure graph algorithms): `buildAdjacency`, `bfsDistances`, `shortestPath`, `egoNetwork`, `degree`/`betweenness`(Brandes)/`closeness`/`clustering` (+ `normalizeScores`), `analyzeNetEnsemble` (the headless net census — see `netEnsembleLayout` below), and `proximityProblem` — the "spatial problem" layout diagnostic that flags nodes drawn closer than their graph distance warrants. Pair with network charts to size by centrality, highlight an ego network on hover, trace a shortest path, or diagnose a misleading layout. `forceLayout(nodes, edges, {seed})` is a **seeded, deterministic** positioner returning normalized `{id:{x,y}}` for `NetworkCustomChart` (same seed ⇒ same layout; re-seed for a "re-run the layout" interaction); `forceLayoutAsync(nodes, edges, {execution?, workerThreshold?, signal?})` is its Promise sibling that runs large layouts in a short-lived module Web Worker (identical deterministic output; graceful sync fallback), and `useForceLayout(nodes, edges, options)` → `{positions, status, error}` is the React wrapper — SSR and first hydration stay synchronous for markup parity, client graph changes go async while previous positions stay visible, and settled positions are memoized by node/edge array identity + options so remounting the same module-constant graph is "ready" immediately (no loading flash). `arcLayout`/`adjacencyMatrix`/`circularLayout` (+ `orderByGroupDegree`, `arcPath`) are the classic physics-free network forms. `allocateCells` is the largest-remainder grid allocator behind `waffleLayout` (turn `{key, weight}[]` + a cell count into integer cells with no rounding drift; `minPerCategory` keeps small categories visible) — reusable for any feature-mix / proportional waffle. **`unitize(value, {unit, maxUnits?, minFraction?})` / `unitizeRange(value, rangeValue, opts)`** is the counting sibling: the pictogram/tally allocator (value → repeated unit signs with a fractional final sign — ISOTYPE: symbols repeat, they never grow). Returns `{units: [{index, fraction, start, end, value}], total, shown, overflow}` — `maxUnits` caps with an `overflow` flag, `minFraction` drops trailing slivers while `total` vs `shown` keeps the ledger honest; `unitizeRange` extends the tally to a projected/scenario endpoint (`rangeUnits` drawn hatched), sharing a mid-sign boundary exactly via `startFraction`. Feeds `glyph`-node `fraction`s directly (unit charts, sign stacks, arrow bundles; `allocateCells` divides fixed cells, `unitize` counts). **Tokenized reasoning helpers**: `generateTokens(input, tokenEncoding)` wraps `unitize` plus `actual`, `fixed-denominator`, `quantile`, `posterior-sample`/`sample`, and seeded `random-sample` strategies with explicit `tokenType` (`dot`/`icon`/`glyph`), `tokenSemantics` (`observed-unit`, `unitized-measure`, `risk-case`, `possible-outcome`, etc.), and `countStrategy`; `{ value, rangeValue }` yields `rangeTokens` for scenario/projection tallies. `layoutTokenGrid` places the resulting tokens for icon/glyph arrays; `normalizeTokenEncoding` keeps legacy `token`/`unit` aliases working while canonical configs use `icon`/`unitValue`; `diagnoseTokenEncoding`, `suggestTokenEncoding`, and `tokenTaskIntentToCapabilityIntents` expose IDID-style warnings, task-aware defaults, and a bridge to `suggestCharts` intents. Built-in token glyph names include `person`, `server`, `chip`, `bolt`, and `bus`. `intervalLanesLayout` (ordinal) packs concurrent `{start,end,lane}` records into stacked Gantt sub-tracks per lane with period bands + lane labels + a time axis — packing runs in rendered-pixel space and honors `minBarWidth` (2) so zero/short-duration events stay visible without overlapping same-track neighbors; `axisFixedForceLayout` (network) pins one axis from a field and settles the other (rect-aware collision). BYO heavy deps (`d3-flextree`, `dagre`) in user code. `packedClusterMatrix` (network) bins records into a column×row matrix of **densely-packed beeswarm clusters** (deterministic self-contained packing, geometry cached) and emits **multi-channel glyphs** — hue (`colorAccessor`/`colorMap`), size (`sizeAccessor`, area), shade (`shadeAccessor`, CIELAB lightness), plus EITHER shape-encoding (`symbolAccessor`/`symbolMap` — the base mark becomes that shape) OR the **composite-glyph** model (`iconAccessor`/`iconMap` — base is a filled circle, only mapped values get a stroked inner icon). `rowMode:"banded"` (default) gives aligned global orbit-bands (row labels align, one enclosure spans the columns per band, columns vary in height) vs `"stacked"` (per-column cell heights ∝ count). `callouts:[{field,value,label}]` draws leader lines to named marks. `cellSizing:"proportional"` makes area ∝ count. **Recipe decoration kit** (exported from `semiotic/recipes`, for any custom-layout's `overlays`): `roundedEnclosure`/`boundsOf` (group/band borders), `bandLabel` (overflow-aware axis/band labels), `markCallout` (leader-line callout to a mark), `readField` (`node.data`-wrapper reader), `groupBy`, `dimFor` (the highlight/dim opacity rule — `{predicate?, highlight?, baseOpacity?, dimOpacity?, brighten?}`; `matchesHighlight` is its `{field,value}[]` matcher), `signatureKey`/`LayoutCache` (content-signature geometry cache so re-styling never re-runs an expensive layout — key by *content*, never by `ctx.nodes` identity), `legendGroupsFrom` (`{colorMap|keys, symbolMap?, sizeStops?}` → `LegendGroup[]` for `frameProps.legend`), `shade`/`makeShade`, `symbolPathString`/`symbolRadius`/`symbolExtent`/`SYMBOL_SEQUENCE`, and the small numeric/color one-liners every layout re-declares — `clamp`, `mean`, `withAlpha`, `nonNegativeFinite` (hex→`rgba()` so a hover-dim can ride a recipe's `resolveColor` callback). (`bandLabel`/`dimFor` are adopted by marimekko/bullet/parallelCoordinates/packedClusterMatrix.) `lineageDagLayout` renders a pre-positioned **layered lineage/DAG** (reads logical layer/row coords, no re-layout) with composite node glyphs (one hit-rect per node + icon/label/store-chip decoration in `overlays`), level-of-detail collapse (full→compact→icon→dot), distinct dashed back-edges, and host-driven reach-dimming (`layoutConfig.reachableIds`) + selection (`layoutConfig.selectedId` / shared `ctx.selection`). `netEnsembleLayout` (network) is the complement — for **ensembles of disconnected/trivially-connected DAGs** (a "bag of little graphs" force layout scatters and dagre/flextree can't place). It splits into weakly-connected components, tests each for **directedness** (borrowing the mathematical *net*/directed-set idea: for a weakly-connected DAG, a single sink ⟹ everything converges to one limit; ≥2 sinks ⟹ it branches), fingerprints components with Weisfeiler–Leman refinement so **order-isomorphic motifs** group together, lays each out converging toward its sink(s) at the bottom, and arranges the ensemble as small multiples in motif bands (collapsing to one census glyph per component when cells get tiny). Config: `colorMode` (`"directedness"|"motif"|"category"`), `groupByMotif`, `sort`, `fingerprintRounds`, `minCellForFull`. `analyzeNetEnsemble(nodes, edges)` is the pure headless census (per-component `directed`/sink/source counts + motif classes) with no rendering. Cycles are condensed iteratively; sink/source counts then describe terminal/initial strongly connected groups. Topology analysis is cached separately from current data and styling. When minimum-size cells cannot fit, a compact census grid keeps every component inside the plot; collapsed glyph IDs survive resize and selection matches member records. Demo + lay explanation at `/recipes/net-ensemble`.

For fitted lineage regions, set `lineageDagLayout`'s `hullGroupAccessor` (for example, `"subtopologyId"`). It emits one sorted, deterministic convex hull per present group through `NetworkLayoutResult.backgrounds`, behind edges/nodes in live and static SVG. Optional controls: `hullColors`, `hullPadding` (16), `hullRadius` (12), `hullFillOpacity` (0.08), `hullStrokeOpacity` (0.4), and `hullLabel`. Omitting `hullGroupAccessor` emits no background layer.

`tokenLayer({input, encoding, options})` is the high-level tokenized-rendering helper for custom layouts: it runs `generateTokens`, applies row/column/grid/waffle/dotplot/bar-segment/quantile-strip placement (or `positionToken` for scale/map-driven placement), and returns ordinary dot/symbol/glyph scene nodes with `pointId`/transition identity. Use it for ISOTYPE, icon arrays, risk grids, quantile dotplots, strips, and hybrid token overlays; pass `includeRange` to render `rangeTokens`; drop down to `generateTokens` when a bespoke layout only needs records.

### Network Atlas readers (semiotic/atlas)

**MotifBraidChart**, **DependencyForestChart** and **FlowCircuitChart** are public React readers and serialized chart names. Import them from `semiotic/atlas`; use `semiotic/atlas/core` for synchronous, React-free preparation and queries. All three render with `renderChartWithEvidence(name, props)` from `semiotic/server` (including Node and edge server entries).

- `MotifBraidChart`: required `atlas: PreparedNetworkAtlas`. `prepareNetworkAtlas(spec, source)` returns an admission result; inspect `ok` and issues before rendering. Rounded square steps share recognizable depths; divergent prefixes split strands. `stepEntityCounts` must align with `nodePath`; omitted counts retain constant `entityCount`. Overlapping motif counts do not partition the population.
- `DependencyForestChart`: required `forest: prepareDependencyForest(atlas)`. `reading` is `organize` or `required-paths`. `collapsedNodeIds`, `highlightedEdgeIds` and `{ nodeId, analysisRevision, relationScopeId: "directed-admitted" }` selection affect the reading without rewriting evidence. `DependencyMatrix`, `dependencyMatrix`, `getRequiredPaths`, `getBypassWitness` and `getBranchResidualConnections` expose local evidence. A structural bypass does not imply usable capacity.
- `FlowCircuitChart`: required `circuit: prepareFlowCircuit(atlas, semantics)`, `edition: admitCircuitEdition(circuit, tape)` and `reading: readCircuitEdition(edition, mode, time)`. Modes are `observed-snapshot`, `observed-replay`, `modeled-scenario`. The circuit, edition and reading must share their identities and revisions. Observed tapes and modeled scenarios remain separate; measurements use explicit units and null for unmeasured values. `particleBudget`, `placementSeed` and `reducedMotion` affect visual cues only. `exportCircuitEvidence` preserves provenance and assumptions.

These readers accept `title`, `description`, `summary`, `accessibleTable` (default true), `chartId`, `onObservation`, `linkedHover` and annotations. Dependency and Circuit expose `onSelectNode` for both pointer and keyboard activation, `linkedSelection: { name }`, and semantic `colors` overrides. Publish canonical vertex hover with `linkedHover: { name, fields: ["nodeId"] }`; consume that named selection with `selection: { name }` on Braid or `linkedSelection: { name }` on Dependency/Circuit. Use `ThemeProvider` from `semiotic/themes/react`; selections and observations share `LinkedCharts` from `semiotic/ai`. Preserve task tables and inspectors in phone layouts instead of shrinking a dense graph to unreadability.

Prepare analysis upstream or in an application-owned worker, then serialize the complete prepared artifact into chart props. `prepareNetworkAtlasAsync` loads preparation lazily but does not spawn a worker. Machine schemas describe prepared artifacts; flat-data chart suggestions cannot invent roots, route evidence, module semantics or editions. Interactive references: `/charts/motif-braid-chart`, `/charts/dependency-forest-chart`, `/charts/flow-circuit-chart`. The five synthetic stories are tested by an independent packed-package consumer.

**Motif Braid preparation** (`semiotic/recipes/core` or `semiotic/recipes`): the compatible lower-level recipe API remains available.

`await prepareNetworkAtlasAsync(spec: NetworkAtlasSpec, source: NetworkAtlasSource)` returns
`{ ok: true, atlas, issues }` or `{ ok: false, issues }`. Both preparation functions load their analysis code on demand. After checking `ok`, call
`await prepareMotifBraid(atlas)` to obtain a `MotifBraidProjection`. Use
`NetworkCustomChart` with `nodes={braid.sceneSeeds.nodes}`,
`edges={braid.sceneSeeds.edges}`, `layout={motifBraidLayout}`, and
`layoutConfig={{ braid }}`; the same props support `renderChart` from
`semiotic/server`. The layout labels every prefix step with a rounded square
at its depth, shares reference prefix ordering across selected comparison
partitions, and keeps separate parallel strands until their prefixes diverge.
Supply optional `stepEntityCounts` on each source occurrence: one finite,
nonnegative traffic count per `nodePath` entry. Width tapers between adjacent
counts using one scale across visible partitions (maximum 10 px, minimum 1 px
for positive traffic; zero traffic has zero width). Omit the array to use
`entityCount` at every step. Cohort weights and profile counts still use
`entityCount`; step traffic does not change motif prevalence. Repeated vertices
remain separate visits and short journeys end at their actual depth.
Episodes end at the second visit to a repeated state; profile
cells count each entity once and use the declared assigned denominator. Capsule
expansion is not supported. `MotifBraidChart` is public from `semiotic/atlas` and accepts `{ atlas }` directly. The existing asynchronous recipe APIs remain compatible.

Atlas layouts retain canonical `nodeId`, source and analysis revisions on
interaction data. For public Motif Braid consumers, give `NetworkCustomChart`
a `chartId` and read its events with `useChartObserver`, `ObservationReadout`
or `DetailsPanel` inside `LinkedCharts`. Link vertices with
`linkedHover={{ name: "vertices", fields: ["nodeId"] }}` and
`selection={{ name: "vertices" }}`. Selection dims marks without changing
the prepared analysis. Use `ThemeProvider` from `semiotic/themes/react`;
`colorScheme` maps can assign colors by complete journey signature.
Network keyboard navigation uses Space to activate a vertex; Enter follows
a connected edge. Accessible tables retain readable step and traffic fields.

For Dependency X-Ray preparation, optionally set
`forest.requiredPaths: { roots: ["world"], relationScopeId: "directed-admitted" }`.
The existing public `prepareNetworkAtlasAsync` facades then return
`atlas.requiredPaths`: original-graph immediate dominators, reachable and
unreachable node IDs, and an exact/incomplete status. Null parents refer to the
analytical synthetic root, not source edges. The scope currently admits only
the full directed graph. `rooted-traversal:id-asc` and
`rooted-traversal:id-desc` select an acyclic display backbone while retaining
every other original edge as residual. Neither dominance nor a structural
bypass proves usable capacity or AND-prerequisite completion. The X-Ray chart is public as `DependencyForestChart` from `semiotic/atlas`; projection and query helpers ship from `semiotic/atlas/core`. `/examples/dependency-xray` demonstrates the synthetic supplier study.

The public Flow Circuit reader exposes exact current module and original-pipe
readings to keyboard/table users and observers, including unmeasured fields.
Replay updates semantic data while retaining the physical bodies. Both public
readers accept standard chart text, `chartId`, `onObservation`, annotations,
`frameProps`, and `linkedHover`; use `linkedSelection={{ name: "vertices" }}`
alongside their local graph selection. Their `colors` layout option overrides
semantic theme roles. `/examples/flow-circuit` and `/examples/dependency-xray`
demonstrate shared observation readouts, documentation theme changes and
matching SVG exports through public package imports.

`diagnoseConfig` accepts JavaScript configurations of the five custom-chart
HOCs and checks their layout callback, input array shapes, dimensions and
interactive annotation IDs. It does not execute or prove a custom layout;
also use `renderChartWithEvidence` and `auditAccessibility`. Executable custom
layouts are still outside the serialized component schema. To export a linked
selection, supply its predicate through `frameProps.layoutSelection` on network
or geo custom charts; a server renderer cannot read a browser's selection store.

**Decoration (labels/axes/legends): the recipe owns it.** Recipes emit own decoration 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.
- **On `NetworkCustomChart`, a `layoutConfig` change re-runs the layout (`buildScene`) WITHOUT re-ingesting the node/edge topology** — so drive interaction state, styling, or animation progress through `layoutConfig` for cheap per-frame updates; swapping `nodes`/`edges` (or the chart `width`/`height`) is the heavier path that re-ingests. Custom overlays are read straight from the store at render time (the frame's repaint re-reads them), so a recipe returning fresh JSX every layout call needs no per-frame `setState`.
- 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.
- All four custom HOCs accept `selection` / `linkedHover` / `chartId` like the built-in HOCs: hover/click emit into the shared selection store, and the resolved predicate arrives as `ctx.selection` so the layout can dim/highlight by a cross-chart selection. Host-owned per-render dimming (e.g. a graph-reachability set) is orthogonal — pass it through `layoutConfig` and read `ctx.config`.
- **Selection restyle without a relayout** (the cheap hover path): by default a `ctx.selection` change re-runs the layout (rebuilds sceneNodes + repaints + rebuilds the quadtree). To restyle on hover/selection *without* re-positioning, opt in two ways: (1) return a **`restyle(node, selection)`** (network also `restyleEdge`) from the layout result — its presence makes a selection change re-apply styles to the existing scene **off each mark's base style** and just repaint (no relayout, no quadtree rebuild); compute geometry once in the layout body, express dimming in `restyle`. (2) For the React **`overlays`**, call **`useCustomLayoutSelection()`** (from `semiotic`/`semiotic/recipes` or the family entry) → `{ isActive, predicate }`; the frame swaps only the context value on selection change, so subscribing overlay components re-render while the canvas/quadtree stay untouched. Express selection/highlight through the selection store (not `layoutConfig`) to ride this path.
- **Annotations**: all four custom HOCs accept `annotations`. XY/ordinal/network custom marks can anchor by `pointId`; Geo annotations use geographic coordinates, while sprite- or tile-specific callouts can be emitted directly in the layout overlay.
- Tooltips: emit datum keys matching user-visible accessor names. Avoid underscored synthetic keys (default tooltip filters those out).

## Data transforms (`semiotic/data`)

- `rollup(data, { groupBy, value, agg?, outputField? })` groups by one field or an array of fields (`[]` aggregates all rows), preserving dimension types and first-seen order. Dates group by timestamp; other object keys by identity. `agg` is `"sum"` (default), `"mean"`, `"count"`, `"min"`, or `"max"`. Numeric operations accept finite numbers and decimal numeric strings, ignore missing/invalid measures, and return `null` when none remain; count includes every row. The output measure defaults to `"value"`; set `outputField` when that would collide with a grouping field. Collisions and malformed rows throw.
- `bin(data, { field, bins?, domain? })` returns `{ category, value, x0, x1 }` count rows with numeric interval bounds. Bin counts must be integers from 1 to 100,000 and domains finite and ordered, with representable boundaries. Finite numbers, nonblank numeric strings, and valid Dates (as epoch milliseconds) are accepted; missing, nonnumeric, nonfinite, and out-of-domain observations are excluded. Intervals include the lower edge and exclude the upper edge except for the final interval, which includes the domain maximum.
- Vega-Lite and Flint imports share aggregation semantics. Vega-Lite histogram imports retain raw observations for the chart to bin; do not aggregate those rows a second time. Strict imports reject unsupported aggregates; inspect warnings/losses when accepting a lossy conversion.

## Coordinated Views

**LinkedCharts** — `selections`. **CategoryColorProvider** — `colors`|`categories` + `colorScheme`.
Chart props: `selection`, `linkedHover`, `linkedBrush`. Hooks: `useSelection`, `useLinkedHover`, `useBrushSelection`. Works for `NetworkCustomChart` too — the resolved selection predicate is threaded into the custom layout as `ctx.selection`.
**`useSelectionActions(name)`** — write-only access (`selectPoints`/`clear`) that does NOT subscribe to selection state, so a *container* can push a selection (e.g. from a hover handler) without re-rendering; only the leaf consumers reading the selection re-render. The provider-at-top / consumers-at-leaves pattern for interaction-heavy coordinated views.
**Shared categories inside LinkedCharts:** generated colors keep charts without an explicit palette consistent. A child's `colorScheme` overrides those generated defaults; wrap in `CategoryColorProvider` to explicitly assign shared category colors above each child's palette. LinkedCharts builds its unified legend from resolved chart colors. If charts use different colors for the same category, it retains individual legends instead.
**Linked crosshair**: `linkedHover={{ name: "sync", mode: "x-position", xField: "time" }}`. Click locks the crosshair; click/Escape unlocks. Read or update hover/lock state from tables via `useLinkedCrosshair("sync")` (exported from `semiotic/ai`, `semiotic/xy`, and `semiotic/realtime`): `{ position, setPosition }`, where position is `{ xValue, sourceId, locked? } | null`. `setPosition({ xValue, locked: true })` sets a lock; `setPosition(null)` clears it. Omitted sourceId means external ownership and displays on every linked chart. For controlled React state, pass `<LinkedCharts crosshair={{ name: "sync", position, onPositionChange: setPosition }}>`; null controls an inactive position, undefined retains internal state. Changes from charts and hooks request state through the callback; supplied props remain authoritative and do not emit echo callbacks. Internal hover is ignored while locked. LinkedCharts scopes names to its own dashboard; controlled positions remain owned by the parent when a chart unmounts.
**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"`.
**CircularBrush** — accessible range brush over a **cyclical** domain (day-of-year, hour, compass): `value` ({start,end}) / `onChange` (value | updater) / `period` (365) / `radius` / `step`+`largeStep` / `formatValue` / `arcFill`/`stroke` / `onObservation`+`controlId`+`chartId`. Wrap-around ranges, pointer-capture drag, and full keyboard control (each handle + the range is a `role="slider"` with a visible focus ring, ←/→ nudges, Shift or PageUp/PageDown = `largeStep`, Home/End = first/last step). Pointer and keys snap to `step` (default 1 for an integer period ≥ 2, else `period / 100`), so fractional cycles such as a phase (`period: 1`) or half-hours (`period: 24, step: 0.5`) work; `aria-valuemax` is the last step before the wrap. `onObservation` receives `control-start`/`control-change`/`control-end` with `[start, end]` values (`controlType: "range-boundary"`). The radial counterpart to the linear `RealtimeHistogram` brush. **Control-surface contract**: it takes value/domain/geometry/`onChange` and never reaches into a chart — layer it over a chart sharing its coordinate space and feed `onChange` into your state (or the selection store). Built on the cyclical + radial kit.
**LinearBrush** (`semiotic/controls` only) — accessible one-dimensional range brush for any track: controlled `value` or uncontrolled `defaultValue` (`[start, end] | null`), `onChange(value, meta)` on every change and `onChangeEnd(value, meta)` once per release or keyboard step (`meta`: `source`, `mode`, `changed`, `atDomainStart`/`atDomainEnd`). Map with `domain` or the chart's own `scale`; `orientation` "x"/"y" (minimum at the bottom); overlay a chart's plot with `inset` (its margins). Drag an end to resize, the selection to move, the background to create (`allowCreate`), click to clear (`clearOnBackgroundClick`); `minSpan`, `snap` to `step`, `resetOnDoubleClick`/`resetValue`, `emptySelection: "full-extent"` (handles at the domain ends when empty). Styling: `selectionStyle`/`activeSelectionStyle`, `maskStyle` (dim outside), `handleStyle`/`activeHandleStyle`, `renderHandle(ctx)` (HTML at the edge; `ctx.active`/`dragging`/`focused`), `showMoveHandle`. Labels: `showExtentLabels`, `showDomainLabels`, `formatValue` (a `\n` makes two lines; also `aria-valuetext`), flipping inward at `labelBounds`. A `role="group"` with three `role="slider"`s (start, range, end): arrows step, Shift/PageUp/PageDown `largeStep`, Home/End limits, Escape clears; emits `control-start`/`control-change`/`control-end` observations (`range-boundary`). Themed through the ThemeProvider CSS variables (`colors.selection`, else `primary`), no store; positions are fractions, so it is responsive and SSR-stable.
Also: **ScatterplotMatrix**, **ChartContainer** (`title`, `subtitle`, `actions`, `notifications` — `ChartNotification[]` chart-level notices with no mark to anchor to, e.g. audit/data-pitfall findings or user-authored notes; `{ id?, level? ("info"|"success"|"warning"|"error"|"neutral" → semantic role colors), title?, message, source?, dismissible? }`, collapsed into a severity-colored toolbar bell + count badge (bell adopts the most severe visible level's icon/color) that opens a popover of dismissible cards — an overlay, so notices never reflow the plot; sr-only aria-live region announces count + severity, + `onNotificationDismiss`), **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, semanticStatus, semanticDiagnostics, xDomain?, yDomain?, categories?, nodeCount?, edgeCount?, annotationCount, ariaLabel, warnings } — ground truth from the rendered scene; check paint status (`empty`/`markCount`) separately from capability-owned semantic viability (`meaningful` | `degraded` | `degenerate` | `not-assessed`). 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.

**Optional measured note wrapping (React only)**: install `@chenglou/pretext@0.0.9` and import `usePretextAnnotations` from `semiotic/text`. Pass `usePretextAnnotations(notes, { fontFamily: "Arial, sans-serif", fontSize: 14, lineHeight: 20 })` to the chart's `annotations`. Supports `label`, `callout`, `callout-circle`, `callout-rect`, and `bracket` notes; uses each note's `wrap` and shares measured lines with `autoPlaceAnnotations`. Options also include `enabled`, `fontWeight`, and `titleFontWeight`. Use named fonts. The hook waits for fonts after mounting and refreshes on font loading; SSR/hydration and unsupported browsers retain default wrapping. Axes, legends, plain `text` annotations, and HTML tooltips are unaffected. This hook is not a JSON/MCP configuration option; never serialize its internal callback. See `/annotations/text-layout` for the interactive comparison.

**Positioning**: `widget`, `label`, `callout`, `callout-circle`, `callout-rect`, `text`, `bracket`
**Reference lines**: `y-threshold` (`value`, `label`, `color`, `labelColor`, `labelPosition`), `x-threshold`, `band` (`y0`, `y1` — a `null`/omitted bound extends to the axis min/max on that side, e.g. `y1: null` shades "at least `y0`"), `x-band` (`x0`, `x1`, `fill`, `fillOpacity` — full-height vertical region for eras/phases; a `null`/omitted bound likewise extends to the domain edge; only skipped when the axis has no scale at all); on XY frames `band`/`x-band` accept `layer: "under"` to paint the fill beneath the data marks (canvas and static SVG) with the label kept on top

**Serializable chart-adjacent text**: `frame-text` anchors text to the resolved plot rectangle rather than a data scale, so it renders identically in live charts and `renderChart` SVG. Use `position` (`"top-left"|"top-center"|"top-right"|"middle-left"|"center"|"middle-right"|"bottom-left"|"bottom-center"|"bottom-right"`) plus pixel `dx`/`dy`; `label` and `text` are aliases. Example endpoint labels below a compact bar: `annotations={[{ type: "frame-text", text: "0", position: "bottom-left", dy: 16 }, { type: "frame-text", text: "100", position: "bottom-right", dy: 16 }]}`. Reserve enough margin for outward offsets. Prefer this over post-processing SVG strings or estimating the data-area transform.
**Ordinal**: `category-highlight`
**Enclosures**: `enclose`, `rect-enclose`, `highlight`
**Label backgrounds**: every region-bounding annotation (`y-threshold`, `x-threshold`, `band`, `x-band`, `enclose`, `rect-enclose`, `category-highlight`) accepts `labelBackground` for a legibility backdrop behind the label text — `"halo"`/`true` (stroke halo in the plot bg; default for threshold/band labels), `"box"` (semitransparent rounded panel), `"none"`/`false` (plain), or a config `{ type: "halo"|"box", fill?, opacity? (0.85), padding? ({x,y}), radius? (3), stroke?, haloWidth? (3) }`. One shared renderer (`AnnotationLabel`) drives client + SSR, so it works on every frame and in server SVG. `band`/`x-band` `fill` also accepts a `HatchFill` for hatched regions. Threshold and band labels take `labelColor` for the text (default: the annotation `color`), so `labelColor: "#fff"` with `labelBackground: { type: "box", fill: "#1f2937" }` draws a light chip on a colored line.
**Statistical**: `trend`, `envelope`, `anomaly-band`, `forecast`
Trend and forecast fits retain full numeric precision, accept finite numeric x or Date values, and skip missing/non-finite pairs. Ordinal trends follow the displayed category domain. Polynomial forecasts use the fitted polynomial in centered coordinates. Auto `forecast={{ trainEnd, steps, method, bandwidth }}` processes bounded `data`, fits each `lineBy`/`areaBy` series independently, and keeps their envelopes separate. `method: "loess"` continues the last distinct pair of LOESS-smoothed points; `bandwidth` controls smoothing. Prediction bands are approximate normal-error intervals, not calibrated model guarantees; LOESS uses the smoothed residuals and the linear interval approximation. `anomaly-band` uses mean ± threshold × population standard deviation over its rendered data (per series when grouped), including generated forecast points. Push-mode trend/forecast annotations use the frame's retained data; the HOC auto-forecast prop requires bounded data.
**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, legend-font-family, legend-font-weight, title-font-size, title-font-family, title-font-weight, 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`): explicit CategoryColorProvider mappings (including those inherited through LinkedCharts) > `colorScheme` > ThemeProvider `colors.categorical` > `"category10"`. LinkedCharts registration-order defaults do not override `colorScheme`. `colorScheme` accepts a named scheme (`"tableau10"`), an array, or a `{category: color}` **object map** for exact per-category colors (no array ordering to keep in sync).
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)`, `designTokensToTheme(tokens)`, `resolveThemePreset(name)`. Native `semiotic.*` tokens round-trip title/legend size, family, and weight along with the base typography and color roles.

**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 Design Guidance

- **ISOTYPE/icon arrays**: Repeated pictograms, semantic icons, and glyph tokens should be legible in the final rendered layout. Treat 16px as the minimum intended rendered icon dimension for ISOTYPE/icon-array designs. This is design guidance, not a Semiotic-enforced minimum: low-level helpers may default smaller for dense/sparkline contexts, so set `tokenSize`, glyph `size`, SVG slots, or wrapping explicitly. If available width would shrink icons below that visual floor, wrap tokens into multiple rows/columns, reduce visible token count, change the unit value, or choose a non-icon encoding.

## AI Features

Surface APIs: `onObservation`/`useChartObserver`, `toConfig`/`fromConfig`/`toURL`/`fromURL`/`copyConfig`/`configToJSX`, `validateProps`, `diagnoseConfig` (includes `tokenEncoding` warnings when present), `evaluateChart`/`formatEvaluateChart` (one ranked data → deception → accessibility evaluation), `suggestTokenEncoding`/`diagnoseTokenEncoding`, `auditAccessibility`/`accessibilityCaveats` + `describeChart` + `buildNavigationTree`/`AccessibleNavTree`/`useNavigationSync` + `buildReaderGrounding` (a11y audit + descriptions + structured navigation + bidirectional sync + agent-reader grounding — see Accessibility), `exportChart(div, { format })`, `npx -p semiotic semiotic-ai --doctor`/`--audit-a11y`/`--evaluate`.

### 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. Numeric/date types require a majority of recognized scalar values across the column; ties are categorical. Every field reports `observedCount`, `missingCount` (null/absent/blank), and `excludedCount` (nonmissing values omitted from its statistics). Nonfinite and unsupported values do not vote. Decimal strings are numeric; padded integer IDs and base-prefixed strings are categorical. ISO dates/timestamps use UTC when no timezone is supplied; slash dates are not inferred as dates. Numeric health profiles use the same parsing rules.
- **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?, identifiers?, fieldRoles? })`** → `ChartDataProfile`: per-role candidate fields (x/y/series/category/size/time), distinct counts, monotonicity, structure detection, normalized `fieldRoles`, and `identifiers`. Identifier/ignored fields are excluded from every encoding candidate. Semantic hints are `measure`, `dimension`, and `temporal`; exact hints are `x`, `y`, `size`, `category`, `series`, and `time`.
- **`rederiveProfile(profile, { primary? })`** → a new coherent `ChartDataProfile` after candidate edits or explicit primary-role changes. It revalidates assignments and recomputes category/series/x counts, repeated/monotonic x, provenance, and stackability together; assigning an identifier or non-candidate throws.
- **`deriveProfileFields(data, candidates, fieldRoles, { primary? })`** → the lower-level derived-field projection used by `rederiveProfile`; use it when a profiler adapter owns candidates separately from the complete profile object.
- **`suggestCharts(data, { intent?, allow?, deny?, maxResults?, includeVariants?, minScore?, audience?, identifiers?, fieldRoles? })`** → ranked `Suggestion[]` with `{ component, family, importPath, variant?, score, intentScores, rubric, reasons, caveats, props, propContract }`. `props` is spreadable directly. `propContract` declares `componentKind`, whether chart-HOC defaults apply, the heading prop, and the valid mode vocabulary; the same per-component contracts live in `ai/surface-manifest.json#components.suggestionPropContracts`. Capability `fieldPolicy` declarations are enforced after `buildProps`, so custom accessor vocabularies and direct values cannot reintroduce an identifier as a measure. **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?, audience?, identifiers?, fieldRoles? })`** → evaluate a specific chart for a dataset. It applies the same audience familiarity, target, and non-visual receivability policy as `suggestCharts`, so a direct fit score and a ranked recommendation cannot disagree about the intended audience.
- **`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`. A registered descriptor may declare `composes` plus optional `weights`; requested custom scores are blended from existing capability scores without changing no-intent/default rankings. `inferIntent(query, { mode: "schema", fields?, minimumConfidence? })` opts into whole-token field signals and typed data-shape signals; natural-language prose remains the default mode.
- **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)`, and (for novel quantitative prop names or direct computed values) `fieldPolicy.measureAccessorProps` / `fieldPolicy.measureFields`. `semanticViability(props, evidence)` optionally reports stable warning/error diagnostics after paint; errors make render evidence `degenerate` even though `status` remains `ok`.
- **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?)`.
- **`suggestStreamCharts(schema, { intent?, audience?, maxResults? })`** → `{ suggestions, excluded, stretchSuggestions }`. Schema fields may declare `role: "key"`; `shape` is `append` | `keyed` | `aggregate`; `throughput` is a band or rows/sec. Ordinal/value push charts (BarChart, PieChart, BigNumber, GaugeChart, …) are registered alongside the six realtime XY charts. `importPath` is a free string. `explainStreamCapabilityFit` is the same result. **`suggestStreamDashboard(schemas, { audience, intent, budget })`** composes panels; `AudienceProfile.dashboard` holds cell budget, lead families, density, and window preference.
- **`applyChangelog(handle, events, { key })`** maps insert/upsert/update/retract onto `push`/`update`/`remove`. `WindowAccumulator` can emit t-digest percentiles (`p95`, `p99`) and HyperLogLog distinct counts. Ring-buffer `capacity` aliases `windowSize` so "window" can mean `aggregate.window`. `StalenessConfig.state` / `settling` override ingest-age LIVE/STALE. Realtime charts SSR from controlled `data` (and `aggregate`) via `renderChart`. `RealtimeLineChart.seriesAccessor` starts a new line per group in push mode. Scatterplot (and Bubble/ConnectedScatterplot) expose top-level `pulse` / `decay` / `staleness`.
- **`inferIntent` prose mode** scores registered `IntentSignals.phrases`. `communicativeActForIntent` walks composed intents through the heaviest child.

### 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 the maximum finite timestamp anywhere in `dataExtent`, then `Date.now()`. ISO timezone-free timestamps use UTC; numeric times are epoch milliseconds. TTL must be positive finite milliseconds or a fixed-length ISO duration (weeks, days, hours, minutes, seconds; fractions allowed on the smallest unit, e.g. `"P1W"`, `"PT0.5S"`). Invalid/nonpositive TTLs and calendar months/years throw `RangeError` rather than silently disable aging. Use explicit days/ms for calendar-based policies. Default thresholds 1×/1.5×/3× TTL.
- **`annotationFreshnessFor(annotation, nowMs, thresholds?)`** → classifies a single annotation. Existing `lifecycle.freshness` is retained when creation time or TTL is unavailable; otherwise the computed band is used.
- **`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.

- **Accessible chart text uses direct chart props** (`accessibility.description-props`): High-level charts expose title for the visible name, description for a concise accessible description, summary for a screen-reader-only takeaway and interaction guidance, and accessibleTable for the data-table fallback.
  Agent action: Put title, description, summary, and accessibleTable directly on the chart component when they appear in its schema. If a consumer-owned role=img wraps the chart, use accessibleTable: { portalTarget: "element-id" } and render that target outside the image. For generated L1–L3 description or a navigable chart tree, use ChartContainer with chartConfig plus describe and/or navigable; do not invent frameProps fields.
- **Cursor styling does not create behavior** (`interaction.cursor-is-presentation-only`): Cursor values in realtime props, retained mark styles, styleRules, and custom hit targets change pointer presentation only. They do not install click handlers, keyboard activation, observations, or accessibility semantics.
  Agent action: Use an actionable cursor only when the application separately supplies documented click or observation behavior and an accessible activation path. Treat cursor in serialized/static output as visual metadata, never as proof that a mark is interactive.
- **Tooltip behavior needs browser evidence** (`interaction.tooltip-content-and-verification`): Interactive charts keep useful tooltips unless omission is intentional. tooltip=false or an empty callback result (null/undefined, booleans, whitespace-only strings, empty arrays/fragments) suppresses the entire tooltip; numeric zero remains visible. Suppression is independent of hover observations and selection. Verify actual content and placement in a browser; do not silently disable tooltips to avoid a defect.
  Agent action: Use the documented callback shape: custom/network chart tooltip callbacks receive authored data, while frameProps.tooltipContent receives a hover wrapper (unwrapDatum once). Handle node and edge records separately. Prefer default/configured tooltips or TooltipRoot for custom chrome. Use tooltip={{ content: renderer, chrome: "none" }} when consumer content owns chrome through wrapper components, or theme.tooltip.chrome: "none" for a shared policy. Config content receives authored data, with multi-series context when present. markTooltipChrome(renderer) remains supported; leave plain renderers unmarked. Wrapping libraries should call the exported hasTooltipContent on consumer results before adding elements. Hover a real mark and an edge when interactive; assert expected labels/values, one readable tooltip, no empty box or clipping after zoom/pan and resize, and disappearance on leave. Test tooltip=false and null-returning renderers without disabling enableHover when observations or selection are required. An inspector or a mount-only test does not establish tooltip behavior.
- **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, explicit CategoryColorProvider mappings, including those inherited through LinkedCharts, win for mapped categories. Unmapped categories fall back to explicit colorScheme, then ThemeProvider colors.categorical, then the built-in categorical fallback. Generated LinkedCharts colors do not override a child's colorScheme.
  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. Its thresholds use { value, color, label? }; BigNumber thresholds use the distinct { at, level, color?, label? } vocabulary. 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.
- **Serialized proposals keep the initial snapshot** (`streaming.serialized-proposal-snapshot`): A JSON component/props proposal for MCP, SSR, or evaluation is a static snapshot even when the request mentions future pushes. Keep the supplied initial rows in the component's real data prop and do not invent pushRows, pushRequirement, ref, or method props.
  Agent action: Return renderable initial props in JSON. If the requested deliverable is React push code, separately omit the controlled data prop in JSX, attach a ref, and call the documented imperative method from an effect or event handler.
  Example: `JSON snapshot: { "component": "LineChart", "props": { "data": [{"week":1,"users":120}], "xAccessor":"week", "yAccessor":"users" } }. React push code instead omits data and calls ref.current?.push(row).`
- **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, never future ref pushes. semiotic/server accepts bounded data for realtime charts; MCP rendering excludes the five live Realtime components but includes TemporalHistogram.
  Agent action: Use complete static data for snapshots. For a Realtime component snapshot, use semiotic/server directly; for live behavior, return React code with a ref and do not promise MCP-rendered output.
- **Axis formatters are React callbacks** (`serialization.formatters-are-react-callbacks`): xFormat, yFormat, categoryFormat, and valueFormat are callback props, not d3 format strings or axis-title strings. They are intentionally absent from JSON/MCP schemas and string values fail validation.
  Agent action: In serialized props, omit formatter callbacks and use xLabel, yLabel, categoryLabel, or valueLabel for axis titles. In React JSX, pass a function such as xFormat={value => formatAxis(value)}.
  Example: `JSON: { "xLabel": "Payload (KB)", "yLabel": "Response time (ms)" }. React-only: xFormat={value => formatNumber(value, { maximumFractionDigits: 0 })}.`
- **Value components do not inherit chart-HOC props** (`value.bignumber-wire-contract`): BigNumber is a value component, not a chart HOC, so it does not inherit the common chart-HOC prop list. It uses label as its visible heading and supports description and summary; title and accessibleTable are invalid. Its percent format expects a ratio such as 0.97 and renders it as 97%.
  Agent action: Validate BigNumber against its own prop schema and remove inherited chart-HOC props such as title and accessibleTable before returning a proposal. Use label, description, and summary for text. For a 97-of-100 KPI, either pass value={0.97} with format="percent" or pass value={97} with format="number" and suffix="%"; do not combine value={97} with format="percent".
  Example: `{ "component": "BigNumber", "props": { "value": 97, "label": "SLA attainment", "format": "number", "suffix": "%", "target": { "value": 99, "label": "target", "format": "number" } } }`
- **Proportional symbol maps use geographic props** (`geo.proportional-symbol-wire-shape`): ProportionalSymbolMap reads point rows from points, longitude from xAccessor (default lon), latitude from yAccessor (default lat), and radius from sizeBy. sizeRange is the two-number pixel-radius range.
  Agent action: Use points, xAccessor, yAccessor, and sizeBy. Do not rename points to data or sizeBy to valueAccessor merely because the map renders circles.
  Example: `{ "points": [{"city":"A","longitude":-122.4,"latitude":37.8,"incidents":18}], "xAccessor":"longitude", "yAccessor":"latitude", "sizeBy":"incidents", "sizeRange":[5,40] }`
- **Network perspective is presentational** (`network.perspective-is-presentational`): Network perspective is a parallel projection applied after layout. Layout coordinates and data semantics are unchanged, interaction and the accessible table follow the projected marks, and elevation is the only added encoding. Network charts use perspective, not projection, which belongs to geo charts.
  Agent action: Use a preset string ("isometric", "pixel", "dimetric", "military", "cabinet") or a config object in JSON; function elevation/extrude/marks and glyph are React-only. Choose it for categorical or topological positions, not measured ones, and do not describe it as 3D or WebGL. Rename projection to perspective on network charts.
  Example: `{ "component": "ForceDirectedGraph", "props": { "nodes": [{"id":"api","tier":2}], "edges": [], "perspective": { "type": "isometric", "elevation": "tier", "ground": { "grid": true } } } }`
- **Physics charts separate chart mode from simulation input** (`physics.sample-and-mechanical-inputs`): Sample simulations use data plus the chart's accessors. Seeded no-data demonstrations use simulationMode="mechanical" (legacy mode="mechanical" remains accepted); mode otherwise carries chart display modes such as primary or sparkline.
  Agent action: For observed Galton values pass data + valueAccessor. For unit piles pass data + categoryAccessor + valueAccessor. Use seed for reproducibility, bins for Galton columns, and unitValue for the amount represented by one full UnitPile circle; remainders use proportional area.
  Example: `{ "component": "GaltonBoardChart", "props": { "data": [{"id":"a","value":1}], "valueAccessor":"value", "bins":4, "seed":42 } }`
- **Physics push methods ingest source records** (`physics.push-uses-source-records`): Physics HOC refs push source records through the chart's accessors. pushRows and dataIdAccessor are not component props; stable source id fields are retained on spawned bodies without an invented accessor.
  Agent action: For React live code, call ref.current?.push(row) or pushMany(rows). For serialized snapshots, append the rows to data. Keep category/value/time accessors, but omit pushRows and dataIdAccessor.
  Example: `ref.current?.push({ id: "c", team: "Blue", value: 2 }); <UnitPileChart ref={ref} categoryAccessor="team" valueAccessor="value" unitValue={1} />`
- **Distribution physics charts update bodies and projections together** (`physics.live-source-reconciliation`): Bodies, categories, domains, and totals update together without changing React keys. getData() returns source rows. New data replaces live rows; rerunMS restores the seed.
  Agent action: Omit data for push mode; data=[] stays empty and rejects pushes. Use stable IDs and remove for source edits; popBodies only removes marks. Set xExtent/valueExtent/timeExtent for stable domains.
  Example: `ref.current?.update("a", row => ({ ...row, value: 102 })); ref.current?.remove("b");`
- **EventDrop distinguishes admission history from current closure** (`physics.event-admission-history`): Arrival order determines historical lateness with a delay/function watermark. Equal arrival times keep source order. A window is closed when its end is at or below the watermark; previously accepted events stay accepted as later events advance it. An explicit watermark.value instead tests one fixed policy.
  Agent action: Use watermark={{delay: n}} for arrival-ordered admission, or watermarkAtArrivalAccessor for recorded thresholds independent of current closure. Keep all times in the same units. The current board has solid lids: accepted history starts below them, late arrivals roll left. For a chronological demonstration, supply successive data/watermark states and coordinate source time with travel. timeScale does not replay lid changes. Source projection labels describe snapshot totals; readEventDropOccupancy(metadata, bodies) counts current containers and in-flight bodies independently of source flags.
  Example: `{ "data": [{"time":1,"arrivalTime":2,"admission":-3}], "watermarkAtArrivalAccessor":"admission", "watermark":{"value":100} }`

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

## Accessibility

Chart live regions announce keyboard focus without announcing pointer hover across XY, ordinal, network, geographic, and physics frames. `ObservationReadout` also keeps hover visual-only by default; explicit live-region overrides remain available. Accessible tables retain focus while revealing rows, announce paging progress, and restore their opener on close. Verify these interactions in the browser as well as the static descriptions.

`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 keyboard-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 -p semiotic 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.

**Unified chart evaluation**: `evaluateChart(component, props, data?, { dataAudit?, inChartContainer?, describe?, navigable?, render?, notificationMax? })` (from `semiotic/ai`, `semiotic/ai/core`, or `semiotic/utils`) composes `auditData` → configuration/representation diagnoses → `auditAccessibility` into `{ validation, data, deception, accessibility, evidence?, ok, summary, findings, notifications }`. `findings` are ranked by severity (`error`, `warning`, `manual`) and stage; `formatEvaluateChart(result)` produces a compact report. Inject a `RenderFn` when server render evidence is available to turn empty scenes and renderer warnings into findings. Surfaced as the `evaluateChart` MCP tool and `npx -p semiotic semiotic-ai --evaluate`; it is static analysis, so manual assistive-technology testing remains required.

**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 decoration (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. Complex families retain their reading structure: network nodes are grouped with link/flow branches; hierarchy parents announce direct children, total descendants, leaf counts, and leaf-value rollups (including zero/negative values); ChoroplethMap announces numeric coverage/range/average/total and then groups ranked regions into equal-width highest/middle/lowest thirds of the observed numeric range plus a no-value branch; other geo forms separate locations from routes. Empty thirds are omitted and all-equal metrics receive one explicit branch. Caps produce branch-local omission nodes rather than silently truncating. 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. `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 any semantic node carrying that authored datum, including hierarchy parents. GeoJSON `properties` are normalized to the direct-field shape emitted by StreamGeoFrame, so region sync works without nested-path configuration. 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 datum node — `annotatedIds` are the node 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` to select React push mode. `data={[]}` is a bounded empty dataset, not push mode. Hierarchy charts require bounded root objects.
- **Tooltip datum shape**: Custom/network chart `tooltip` callbacks get raw authored node/edge data (for example, `d.label`, not `d.data.label`). Frame `tooltipContent` gets a hover wrapper — use `unwrapDatum(hover)` once from `semiotic/utils` or `semiotic/recipes`, including the extra built-in network wrapper. Check the documented signature for legacy realtime callbacks. Handle nodes and edges separately.
- **Tooltip suppression and verification**: `tooltip={false}` disables the whole tooltip; return `null` from a callback to omit a particular datum. Keep `enableHover` when observations or selection still need it. Custom content gets shared chrome; use `TooltipRoot` to own it explicitly, or `markTooltipChrome(renderer)` when a callback owns chrome through wrappers. Wrapping libraries should call `hasTooltipContent(result)` before adding elements; nullish values, booleans, whitespace-only strings, and recursively empty arrays/fragments suppress the tooltip. Do not substitute an inspector for expected hover behavior or silently disable tooltips. Browser-test actual values, bounds after zoom/pan/resize, dismissal, and absence of empty boxes. Static rendering and mount-only tests cannot verify this.
- **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}]})`. FunnelChart and GaugeChart also apply `valueFormat` inside their bespoke default tooltips. Histogram and LikertChart keep their own tooltip formatting; customize those via `tooltip`. Formatters are React callbacks, not string-valued JSON props.
- **Tooltip chrome**: `tooltip={{ content: d => <ConsumerTooltip datum={d} />, chrome: "none" }}` explicitly leaves the visual surface to the consumer without marking/mutating the renderer. Config `content` receives authored data (or histogram bin data), including `allSeries`/`xValue` when present. Custom multi configs also accept `chrome`. Field configs (`{ fields, title, chrome }`) and `TooltipRoot chrome="none"` honor explicit policy. Set `<ThemeProvider theme={{ tooltip: { chrome: "none" } }}>` to apply the default to custom renderers across charts, including raw frame callbacks. An explicit chart chrome setting overrides the theme. Existing `markTooltipChrome`, element markers, and inline ownership detection remain supported. Empty content still suppresses the entire tooltip.

- **`tooltip="multi"`** / **`tooltip={{ mode: "multi", content? }}`**: multi-series hover-anywhere for LineChart, AreaChart, StackedAreaChart, DifferenceChart (wires `tooltipMode:"multi"`). RealtimeHistogram/TemporalHistogram multi lists every stacked category in the hovered bin (anywhere in the column; empty bins show nothing); other realtime, ordinal, network, geo, and physics charts fall back to one datum. Built-in multi renderer when `content` is omitted. Custom multi: `tooltip={{ mode: "multi", content: (d) => … }}` — the function receives the unwrapped raw datum with hover-root `allSeries` / `xValue` re-attached (`{group, value, valuePx, color, datum}` entries; an ungrouped series has `group: ""`). In x-extent padding outside the data, rows and `xValue` snap to the first/last sample; keyboard focus gets the same rows. Legacy escape hatch: `tooltip={fn}` + `frameProps={{ tooltipMode: "multi" }}`.
- **Legend**: "bottom" expands margin ~80px. Numeric margins are minima: chart-owned legends grow their side when measurement needs more room, while preserving a larger caller baseline. MultiAxisLineChart: use `legendPosition="bottom"`. A **bottom** legend is placed *outside* the bottom axis chrome (tick labels, axis title) so it doesn't overdraw it; the band is auto-measured. Top axes are opt-in, so a **top** legend reserves nothing by default — set `frameProps.legendLayout.axisGutter` for one. `axisGutter` also overrides the measured bottom band (`0` anchors to the plot edge); `sideGutter` is the left/right counterpart for plot-adjacent chrome, and `edgeGutter` defaults to 3px to keep a side legend's focus ring inside the SVG (`0` restores flush placement).
- **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 on series/category charts. `RealtimeHistogram` and `TemporalHistogram` instead match `binStart` and highlight the whole column without a category accessor.
- **`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, extent }]`. `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 `extent: "nice"|"exact"` overrides chart-level `axisExtent` (common: exact x / nice y). Exact on a **value (y)** axis also skips y `extentPadding`; exact on x only affects tick placement.
- **Adaptive time ticks**: `xFormat={adaptiveTimeTicks()}` or `adaptiveTimeTicks("minutes")`. Default formats in **UTC** for deterministic SSR. Timezone: `{ timeZone: "local" }` for the viewer, `{ timeZone: "America/Los_Angeles" }` (any IANA id) for a pinned region, or legacy `{ utc: false }` for local. `timeZone` wins over `utc` when both are set. Opt-in axis-label options: `includeYear` / `includeDate` (`"always"` default, `"auto"` drops the year inside `referenceTime`'s year / the date when every tick shares one day, `"never"` drops them at boundaries too), `referenceTime` (default `Date.now()`; pin it when server and client both render), and `deltaStyle: "clock"` (`"12:10"`, `"Oct 7"`). Value-only calls (tooltips) keep the full label. Horizontal axes pass index-aware formatters the ticks actually rendered after thinning.
- **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.
- **`foregroundGraphics`/`backgroundGraphics` resolved scales**: the function form receives `{ size, margin, scales }` — `scales` is the frame's **resolved** scales (`{x, y}` for XY, `{o, r, projection}` for ordinal; `null` before first layout). Anchor a bespoke SVG overlay to `scales.x(...)`/`scales.y(...)` so it can't drift from the axes the chart drew (the HOC analogue of a custom layout's `ctx.scales`). Fall back to your own mapping while `scales` is null.
- **`interactiveGraphics` (XY and ordinal frames, via `frameProps` on HOCs)**: focusable SVG controls drawn in plot coordinates, in a labelled `role="group"` layer outside the chart's `role="img"` overlay, so sliders stay exposed to assistive technology (a control inside `foregroundGraphics` is hidden). The function form receives `{ size, margin, scales, pointerToPlot }`; `interactiveGraphicsLabel` names the group. Client-only (not in server markup). Pair it with `semiotic/controls`: `DirectManipulationControl` (one value; `pointToValue={(point) => scales.y.invert(point.y)}` receives the pointer in the handle's own coordinate space; `stepOrigin`; `onChangeEnd` fires on pointer release and after each keyboard change; PageUp/PageDown take `largeStep`) or `DirectManipulationMarkers` (ordered handles such as baseline/max via `values`, `markers`, `valueToPoint`, `pointToValue`; each handle is a slider bounded by its neighbors, and coincident handles resolve by drag direction).
- **Theming a custom chart**: prefer `--semiotic-*` tokens for decoration (`stroke="var(--semiotic-text-secondary)"`) and `ctx.resolveColor(key)` / semantic role vars (`var(--semiotic-danger)`) for data, so the chart tracks `ThemeProvider`/dark mode — *or* deliberately paint fixed editorial colors on a `background: "transparent"` frame for art direction. Both are first-class; the kit's decoration helpers default to `--semiotic-*` tokens either way.

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

Threshold annotations (`x-threshold` and `y-threshold`) accept `endCap: "circle"` or `{ radius?, fill? }` at the top (x) or left (y) plot edge in both browser and static SVG. GaugeChart defaults to a native SVG center readout; string, number, and SVG (including fragment) centerContent and `centerLabel` stay native SVG, while other HTML centerContent uses foreignObject for SVG exports. Compact modes (context/sparkline, including via responsiveRules) hide the readout and scale labels on both paths.