@ggts-sh/skill 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,312 @@
1
+ <!-- Source of truth: packages/svelte/src/lib/plot-props.ts, packages/svelte/src/lib/interaction/ (controller, config, diagnostics), packages/svelte/src/lib/index.ts. -->
2
+
3
+ # Svelte interactions
4
+
5
+ `@ggts-sh/svelte` requires Svelte `^5.33.1` (peerDependency). Interactions are
6
+ opt-in host capabilities — they are not PortableSpec fields. Prefer declaration
7
+ children where they exist (`<Inspect>`, `<GuideLegend focus>`). Row identity for
8
+ selection, legend focus, and coordinated intervals **defaults** to an `id`
9
+ column when present, otherwise the row index (order-stable only) — ordinary
10
+ charts omit custom identity. Prefer `identity` on `<Inspect>`, object-form
11
+ `select`, or `createPlotInteraction` for a non-`id` durable field or accessor
12
+ (for example `<Inspect identity="year" />`). Plot-level `key` is deprecated.
13
+
14
+ ## Inspect capability (preferred: child)
15
+
16
+ ```svelte fragment
17
+ <script lang="ts">
18
+ import { GeomPoint, GGPlot, Inspect } from "@ggts-sh/svelte";
19
+ </script>
20
+
21
+ <GGPlot data={rows} aes={{ x: "x", y: "y" }}>
22
+ <GeomPoint />
23
+ <Inspect mode="xy" />
24
+ </GGPlot>
25
+ ```
26
+
27
+ Empty `<Inspect />` enables defaults. Options match `InspectOptions`: `mode`,
28
+ `pin`, `maxDistance`, `contentMode`, `muteSiblings`, `tooltipTotal`, `identity`
29
+ (column or accessor), `content` (Snippet). Prefer the child form in new code;
30
+ the GGPlot `inspect` prop is a dual-read alias, not the primary teaching surface.
31
+
32
+ ### Inspect mode by geom family
33
+
34
+ Leave `mode` as `"auto"` (or set `"exact"`) unless the chart needs axis-group
35
+ hover on a continuous series. **Never put `x` / `y` / `xy` guides on violin,
36
+ boxplot, errorbar, linerange, pointrange, or crossbar** — those marks sit on a
37
+ discrete band; freescrolling guides cut the mark body and often leave the band
38
+ tooltip row blank. Prefer exact mark focus (click/hover the violin or interval
39
+ itself). The same rule already applies to bar/col. Explicit axis guides on
40
+ those families emit advisories (`INTERACTION_INSPECT_AXIS_ON_*`,
41
+ `INTERACTION_INSPECT_X_ON_COL` / `_BAR`).
42
+
43
+ ### Mark eligibility (opt-out)
44
+
45
+ Layers are inspectable by default. Set `inspect={false}` on a geom (or
46
+ `"inspect": false` in the portable layer) so decorative bands, labels, or
47
+ full-panel rects never become tooltip targets (#1065 / #1068). This is portable
48
+ and unrelated to the host `<Inspect>` capability.
49
+
50
+ ## Choosing inspect mode
51
+
52
+ `mode` is a **host** option on `<Inspect>` (and the dual-read plot `inspect`
53
+ alias). It is **not** a PortableSpec field. Empty `<Inspect />` defaults to
54
+ `mode="auto"`. Auto matches common mark families (bar/col/violin/boxplot/
55
+ interval geoms → exact; continuous lines → x). Prefer auto unless geometry
56
+ needs an explicit axis-group or free 2d mode.
57
+
58
+ ### Default preference
59
+
60
+ 1. Prefer `mode="auto"` when library auto matches the mark (bar, col, tile, point,
61
+ violin, boxplot, interval geoms, continuous line/area/freqpoly). Prefer an
62
+ explicit mode when you know the geometry better than auto.
63
+ 2. Use `mode="x"` only for continuous shared-x series: time series, multi-series
64
+ lines/areas/freqpolys at a common continuous x, stacked continuous areas.
65
+ 3. Use `mode="y"` only when the shared continuous axis is vertical and grouping
66
+ by y is the product intent.
67
+ 4. Use `mode="xy"` for free 2d hits (scatters, paths, maps, jitter clouds) when
68
+ nearest-point inspection is better than snapping to one axis.
69
+ 5. **Pin `mode="exact"`** (or leave `auto`) on violin, boxplot, errorbar,
70
+ pointrange, linerange, and crossbar. Never use freescrolling `x` / `y` / `xy`
71
+ on those band geoms (#1528).
72
+
73
+ ### Anti-patterns (do not ship these)
74
+
75
+ | Pattern | Why it is wrong | Use instead |
76
+ | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------- |
77
+ | Violin with `mode="x"` / `"y"` / `"xy"` | Categorical run labels get freescrolling axis guides and blank axis tooltip rows | Pin `mode="exact"` or leave `auto` (auto → exact) |
78
+ | Boxplot with `mode="x"` / `"y"` / `"xy"` | Same discrete-group problem as violin | Pin `mode="exact"` or leave `auto` |
79
+ | Discrete-axis errorbar / pointrange / linerange / crossbar with `x`/`y`/`xy` | Caps and intervals are not continuous shared-x series; freescrolling guides add noise | Pin `mode="exact"` or leave `auto` |
80
+ | Bar / col with explicit `mode="x"` or `"xy"` | Vertical guide cuts filled bands; library already advises exact/auto | `mode="exact"` or leave `auto` (auto → exact for bar/col) |
81
+ | Copying a gallery family's mode without reading the mark geometry | Family-to-mode tables go stale; geometry decides | Match scale type + mark geometry |
82
+
83
+ Treat **violin, boxplot, and discrete-axis errorbar / pointrange / linerange /
84
+ crossbar** the same: exact mark focus (auto or explicit). When in doubt on other
85
+ geoms, ship `auto`.
86
+
87
+ ## Multi-layer hit hygiene
88
+
89
+ Every inspectable layer competes for pointer hits. Decorative furniture that
90
+ stays hit-testable steals focus from the data story.
91
+
92
+ Rules:
93
+
94
+ 1. Mark decorative layers `inspect={false}`: rivers, annotation labels, full-panel
95
+ rects, value labels that are only visual guides, background rugs, and similar
96
+ non-primary marks.
97
+ 2. For multi-layer maps and stories (**Minard-class** charts), keep **one primary
98
+ story family** on the map panel (troop path). Furniture stays quiet with
99
+ `inspect={false}` (rivers, city labels, strength text). Do not invent a second
100
+ hit series just to drive cross-chart selection when the underlying tables are
101
+ different cardinalities (Minard's nine cold readings vs many path vertices).
102
+ 3. When a summary layer sits on a raw scatter (mean–SE errorbar over points), prefer
103
+ inspecting the summary and set `inspect={false}` on the background cloud unless
104
+ both layers are intentional hit targets.
105
+
106
+ Gallery reference: `examples/path/trajectory` (Minard's 1812 map) opts rivers and
107
+ city/strength text out of hits; the troop path is the primary story mark on the
108
+ map. The cold strip is a separate panel with its own path and points — shared
109
+ longitude only, not linked selection between series that do not share row identity.
110
+
111
+ ## CLI cannot catch host inspect behaviour
112
+
113
+ `ggts render` validates PortableSpec and prints pipeline warnings/advisories
114
+ on stderr. It does **not** validate host Inspect mode, tooltip content, or which
115
+ layers steal hover hits. Layer `"inspect": false` is portable and can appear in
116
+ the JSON loop; **mode is not**. Agents that only re-render SVG never see blank
117
+ x-rows, freescrolling guides, or noisy multi-layer hits.
118
+
119
+ After changing interaction, verify with a **real hover and pin** (docs site,
120
+ playground, or browser test) — not only `ggts render` / an SVG dump. Read
121
+ this file before enabling Inspect on any chart.
122
+
123
+ **Skill packaging decision (#1530):** keep a **single skill** (`@ggts-sh/skill`)
124
+ for both PortableSpec agents and Svelte app agents. Do not split until progressive
125
+ disclosure fails (agents ignore `references/`, or the frontmatter description
126
+ cannot route both audiences).
127
+
128
+ ## Capability props
129
+
130
+ | Prop / surface | Input | What it enables |
131
+ | -------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
132
+ | `<Inspect>` | child of `<GGPlot>` (options as props) | Tooltip, semantic crosshair, keyboard traversal, pinning. **Preferred.** Options: `mode` (`"auto" \| "exact" \| "x" \| "y" \| "xy"`), `pin`, `maxDistance` (CSS px), `contentMode` (`"informational" \| "interactive"`), `muteSiblings`, `tooltipTotal` (`"auto" \| "off" \| "top" \| "bottom"`), `identity` (column or accessor), `content` (Snippet, see Tooltip below). Do not put `inspect=` on `<GGPlot>` in new examples. |
133
+ | `select` | `false \| "point" \| "interval" \| SelectOptions` | Point or interval selection. Options: `type` (`"point" \| "interval"`), `mode` (`"x" \| "y" \| "xy"`, interval only), `multiple`, `persistent`, `preset` (faceted intervals, below), `identity` (column or accessor). |
134
+ | `zoom` | `boolean \| ZoomOptions` | Brush zoom. Options: `mode` (`"x" \| "y" \| "xy"`), `trigger` (`"brush"`). Currently requires one unfaceted panel. |
135
+ | `focus` | `boolean \| { preview?: boolean }` on `<GuideLegend>` | Discrete legend preview, focus, and linked emphasis **for that aesthetic channel**. Emphasis only — never changes included rows. Discrete legends only; continuous ramps stay static. Mute de-emphasizes non-focused marks; dashed rings appear only on sparse point marks (≤48 anchors), never on paths/areas/bars/segments/text. Host-only — not a PortableSpec / `guideLegend()` field. |
136
+ | `filter` | `boolean \| LegendFilterOptions` on `<GuideLegend>` | Data-changing filtering on **that aesthetic's** discrete legend: changes included rows and reruns the grammar while preserving stable color identity. Options: `mode` (`"exclude" \| "include"`), `multiple`. Combine with focus: `<GuideLegend channel="color" focus filter />`. |
137
+ | `identity` | column or `(row, index) => PropertyKey` on Inspect / Select / controller | Durable row identity for public interaction payloads. **Default:** `id` column when present, else row index (order-stable only). Prefer this over plot-level `key`. |
138
+ | `key` | column or accessor on `<GGPlot>` (**deprecated** since 0.21) | Dual-read until 0.22. Prefer `identity` on Inspect / Select / `createPlotInteraction`. |
139
+ | `tool` | `"inspect" \| "point" \| "select-area" \| "zoom-area"` | Controlled initial/active tool; observe changes with `ontoolchange`. |
140
+ | `ariaLabel` | `string` | Accessible chart name; falls back to the plot title or a generated label. |
141
+ | `a11y` | `A11yMode` | `"force-svg"` keeps every layer as SVG marks. |
142
+
143
+ ### Deprecated: plot-level `legendFocus` / `legendFilter`
144
+
145
+ Since 0.19.0, use `<GuideLegend channel="color" focus />` and
146
+ `<GuideLegend channel="color" filter />`. The old GGPlot props still dual-read
147
+ and emit `DEPRECATED_PLOT_PROP` — never teach them as the current API.
148
+
149
+ After an interval or zoom commit, accessible controls accept exact bounds.
150
+
151
+ ## Faceted interval presets
152
+
153
+ `select={{ type: "interval", preset }}` coordinates durable intervals across
154
+ facet panels:
155
+
156
+ - `independent` — only the matching panel consumes its interval.
157
+ - `union` — matching rows from every stored panel interval are combined.
158
+ - `cross-panel` — the sole origin interval is projected into compatible panels.
159
+
160
+ `union` and `cross-panel` use the resolved row identity (default `id` / index,
161
+ or an explicit `identity` override on Select / Inspect / controller). The
162
+ engine always supplies an identity, so these presets no longer fail solely
163
+ because identity was omitted.
164
+
165
+ ## Controller: durable shared state
166
+
167
+ `createPlotInteraction<Key>()` creates chart-independent semantic state that
168
+ outlives any one plot. Pass the same controller to every plot that should link,
169
+ via the `interaction` prop. The controller owns stable keys and scoped data
170
+ domains only; each chart translates them to its own render model.
171
+
172
+ - Mutation methods: `setSelection(keys, {scope, source?})`, `toggleSelection`,
173
+ `clearSelection`, `setEmphasis`, `clearEmphasis`, `setInterval`,
174
+ `clearInterval`, `clearIntervals`, `setZoom(domains, {scope, source?})`,
175
+ `resetZoom`, `reconcileKeys` (call after replacing rows).
176
+ - Reads: `selected(scope)`, `emphasized(scope)`, `isSelected(key, scope)`,
177
+ `intervals(scope)`, `zoom(scope)`, `snapshot`, `revision` (reactive),
178
+ `identity` (optional, from create options).
179
+ - `createPlotInteraction({ onchange, identity })` observes every transition
180
+ (`kind`, `source`, `revision`) and may set a shared row-identity override.
181
+
182
+ `interactionScope` (`{ keys, x?, y?, intervals? }`) names the identity
183
+ namespaces used for linking: key state crosses charts only through `keys`;
184
+ data-space zoom crosses one positional channel only when that channel's scope
185
+ also matches; `intervals` defaults to `keys`. Give plots matching scope
186
+ channels only when they should coordinate.
187
+
188
+ A `<GGPlot>` component instance also exports `resetScales()` and
189
+ `setZoom(domains)` for imperative per-chart control.
190
+
191
+ ## Handlers
192
+
193
+ `oninspect`, `onselect`, `onzoom`, `onlegendfocus`, `onlegendfilter`,
194
+ `oninteraction` (union of all five event types), `ondiagnostic`,
195
+ `ontoolchange`, and `onrender(model, spec)` — called after each committed
196
+ render with the RenderModel (warnings, advisories, scales) and the normalized
197
+ PortableSpec.
198
+
199
+ A handler without its matching capability (for example `onselect` without
200
+ `select` or an enabled capability child), or `interactionScope` without an
201
+ `interaction` controller, is inert and emits a development advisory
202
+ (`INTERACTION_HANDLER_WITHOUT_CAPABILITY`, `INTERACTION_SCOPE_WITHOUT_CONTROLLER`).
203
+ Two `<Inspect>` children last-win with
204
+ `INTERACTION_DUPLICATE_INSPECT_CAPABILITY`.
205
+
206
+ ## Tooltip
207
+
208
+ Enabling inspect with `<Inspect />` renders the default HTML tooltip; no extra
209
+ component is needed. Customize its body with the `content` prop on `<Inspect>`,
210
+ which receives a `PlotInspectionChange` (`focus`/`members` are `PlotDatum`
211
+ values whose `fields` are `TooltipField` rows: `{ channel, field, value }`).
212
+ The exported `Tooltip` component is the same positioned shell for advanced
213
+ custom rendering. `TooltipContext` is a deprecated (0.1.0) alias of
214
+ `PlotInspectionChange`.
215
+
216
+ ### Default content policy (high-n stacks)
217
+
218
+ For axis-group inspection (`mode: "x"` / `"y"`), the **default** hover tooltip
219
+ does not dump every series:
220
+
221
+ 1. **Focused series** is always included (the band under the pointer).
222
+ 2. Up to **7 more** members are chosen by **largest absolute value** at that
223
+ axis position (y when grouping by x, x when grouping by y) — not by stacking
224
+ order — so tiny early series cannot crowd out the large contributors.
225
+ 3. A **Total** row appears only when the group includes a **stack** or **fill**
226
+ layer. It sums unique numeric contributions in the full group across layers
227
+ (not just the capped hover window or the focus layer). Multi-layer paints of
228
+ the same series (line+point) count once; a thin overlay over a stack still
229
+ includes every distinct series in the total. Parallel multi-series lines or
230
+ points (`position: identity` / dodge) omit Total — their y-values are not
231
+ parts of a whole. Axis-group modes show this Total automatically. Exact /
232
+ xy inspection does not — set `tooltipTotal="bottom"` or `"top"` on
233
+ `<Inspect>` (or `inspect={{ tooltipTotal: "bottom" }}`) to show one Total
234
+ without switching `mode` to `"x"` / `"y"`. `"off"` hides it even in axis
235
+ mode.
236
+
237
+ 4. An overflow line (`+N more · pin to inspect all`) appears when the group is
238
+ larger than eight display members (full-group unique count, all layers).
239
+
240
+ **Pin** the tooltip (click, or keyboard pin) to scroll the full group inside the
241
+ 320px panel. **Opt into a full custom listing** with
242
+ `<Inspect content={snippet} />` or `oninspect` — public `members` still lists
243
+ the complete group when those paths request complete snapshots. With a large
244
+ discrete color/fill domain and inspect enabled, an advisory
245
+ (`INTERACTION_INSPECT_HIGH_CARDINALITY_DISCRETE`) suggests top-n data prep or a
246
+ custom content snippet.
247
+
248
+ ## Diagnostics
249
+
250
+ `ondiagnostic` receives `PlotDiagnostic`, the union of three catalogs (each
251
+ entry has `severity`, `code`, `message`, `prop`, `suggestions`, `docUrl`).
252
+
253
+ **Agent/CLI path:** inspect mode is host-only. `ggts render --inspect MODE`
254
+ runs the same pure inspect×geom collectors and emits matching codes on stderr
255
+ with `source: "interaction"` (for example `INTERACTION_INSPECT_X_ON_COL` when
256
+ `MODE` is `x`/`xy` and the spec has a `col` layer). Without `--inspect`, the
257
+ CLI does not invent host intent. Full wiring/key/lineage diagnostics still need
258
+ a mounted plot.
259
+
260
+ - `INTERACTION_DIAGNOSTIC_CATALOG` — capability/key/lineage checks, e.g.
261
+ `INTERACTION_POINT_REQUIRES_KEY`, `INTERACTION_DUPLICATE_KEY`,
262
+ `INTERACTION_UNSTABLE_KEY`, `INTERACTION_INTERVAL_FACET_UNSUPPORTED`,
263
+ `INTERACTION_INTERVAL_SCALE_UNSUPPORTED` (continuous linear/log/time only),
264
+ `INTERACTION_LEGEND_DISCRETE_ONLY`, `INTERACTION_TOOL_UNAVAILABLE`,
265
+ `INTERACTION_INSPECT_X_ON_COL` / `INTERACTION_INSPECT_X_ON_BAR` (vertical
266
+ guide through columns/bars), `INTERACTION_INSPECT_X_BISECTS_COL_LABELS` /
267
+ `INTERACTION_INSPECT_X_BISECTS_BAR_LABELS` (same guide through on-bar value
268
+ labels), `INTERACTION_INSPECT_AXIS_ON_VIOLIN` / `_BOXPLOT` / `_ERRORBAR` /
269
+ `_LINERANGE` / `_POINTRANGE` / `_CROSSBAR` (any freescrolling x/y/xy guide
270
+ through distribution or interval band geoms — use exact),
271
+ `INTERACTION_INSPECT_HIGH_CARDINALITY_DISCRETE` (discrete color/fill domain
272
+ ≥ 16 with inspect on — default tooltip is top-k + total, not a full dump),
273
+ and the two wiring advisories named above.
274
+ - `DEPRECATION_DIAGNOSTIC_CATALOG` — one code, `DEPRECATED_PLOT_PROP`, for
275
+ every grammar prop deprecated in 0.11.0 (`prop` carries the name).
276
+ - `COMPOSITION_DIAGNOSTIC_CATALOG` — child-layer composition collisions:
277
+ `DUPLICATE_PLOT_LAYER`, `DUPLICATE_SCALE_CHANNEL`, `DUPLICATE_MERGE_KEY`.
278
+
279
+ ## Worked example: linked faceted selection
280
+
281
+ ```svelte fragment
282
+ <script lang="ts">
283
+ import {
284
+ createPlotInteraction,
285
+ Facet,
286
+ GeomPoint,
287
+ GGPlot,
288
+ GuideLegend,
289
+ } from "@ggts-sh/svelte";
290
+
291
+ const interaction = createPlotInteraction<number>();
292
+ const scope = { keys: "sales-rows", intervals: "sales-range" } as const;
293
+ </script>
294
+
295
+ <GGPlot
296
+ data={rows}
297
+ aes={{ x: "date", y: "value", color: "series" }}
298
+ select={{ type: "interval", mode: "x", preset: "cross-panel" }}
299
+ {interaction}
300
+ interactionScope={scope}
301
+ oninteraction={(event) => console.log(event.type, event)}
302
+ >
303
+ <GeomPoint />
304
+ <Facet wrap="region" ncol={3} />
305
+ <GuideLegend channel="color" focus filter />
306
+ </GGPlot>
307
+ ```
308
+
309
+ Any second plot given the same `interaction` controller and a matching
310
+ `interactionScope` stays linked: brushing an interval in one panel projects it
311
+ cross-panel, external UI can call `interaction.setSelection(...)`, and legend
312
+ filtering reruns the grammar without reshuffling series colors.
@@ -0,0 +1,101 @@
1
+ # React DOM
2
+
3
+ Read this reference for a React target. The shared [grammar](grammar.md) and
4
+ [validation loop](../SKILL.md#the-validation-contract-use-it) apply unchanged.
5
+ Use React DOM 18.2 or 19 and install `@ggts-sh/react`.
6
+ For the bundled dataset example below, also install `@ggts-sh/core`:
7
+
8
+ ```sh fragment
9
+ npm install @ggts-sh/react @ggts-sh/core
10
+ ```
11
+
12
+ In a server-component application, place interactive charts behind a client component boundary.
13
+
14
+ ## Render a portable spec
15
+
16
+ Use the same spec that passed `ggts check`. Spec-driven charts need full
17
+ registration because no geom children self-register.
18
+
19
+ ```tsx complete
20
+ "use client";
21
+
22
+ import { GGPlot, registerAll, type PortableSpec } from "@ggts-sh/react";
23
+
24
+ registerAll();
25
+
26
+ const spec: PortableSpec = {
27
+ data: {
28
+ values: [
29
+ { year: "2023", sales: 12 },
30
+ { year: "2024", sales: 18 },
31
+ { year: "2025", sales: 25 },
32
+ ],
33
+ },
34
+ aes: { x: { field: "year" }, y: { field: "sales" } },
35
+ layers: [{ geom: "col" }],
36
+ scales: { x: { type: "band" } },
37
+ labs: { title: "Annual sales", x: "Year", y: "Sales" },
38
+ };
39
+
40
+ export default function SalesChart() {
41
+ return <GGPlot spec={spec} height={400} />;
42
+ }
43
+ ```
44
+
45
+ ## Compose the grammar in JSX
46
+
47
+ Geom and grammar children declare layers. They render no markup themselves.
48
+ Bare strings in component `aes` map fields; JSON specs use `{ field: name }`.
49
+ `spec` takes precedence over component authoring inputs. Keep mark order as
50
+ the desired drawing order.
51
+
52
+ ```tsx complete
53
+ "use client";
54
+
55
+ import {
56
+ GGPlot,
57
+ GeomPoint,
58
+ GeomSmooth,
59
+ Labs,
60
+ ThemeMinimal,
61
+ } from "@ggts-sh/react";
62
+ import { palmerPenguins } from "@ggts-sh/core/data";
63
+
64
+ export default function PenguinChart() {
65
+ return (
66
+ <GGPlot
67
+ data={palmerPenguins}
68
+ aes={{ x: "flipperLengthMm", y: "bodyMassG", color: "species" }}
69
+ height={400}
70
+ >
71
+ <GeomSmooth method="loess" se={false} />
72
+ <GeomPoint alpha={0.8} />
73
+ <ThemeMinimal />
74
+ <Labs title="Penguin size" x="Flipper length (mm)" y="Body mass (g)" />
75
+ </GGPlot>
76
+ );
77
+ }
78
+ ```
79
+
80
+ ## Interactions and lifecycle
81
+
82
+ Read [Choosing inspect mode and interaction contracts](interactions.md)
83
+ before enabling Inspect, selection, zoom, or linked charts. Its grammar and
84
+ event semantics are shared; translate Svelte template syntax into JSX.
85
+ Pass functions as React props, use React state for application state, and
86
+ use a ref for `resetScales()` and `setZoom()`.
87
+
88
+ - `<Inspect />` declares inspection. Keep its mode, pinning, identity, and
89
+ custom content on this child. Treat Svelte snippets and React render
90
+ callbacks as adapter-specific code.
91
+ - `<GuideLegend channel="color" focus />` and `filter` control legend
92
+ interaction. These are host behavior, not PortableSpec keys.
93
+ - Keep a shared `createPlotInteraction()` controller stable across renders
94
+ with a lazy React state initializer. Match `interactionScope` channels
95
+ between linked plots; separate unrelated datasets.
96
+ - React consumes JSX `key` itself. Use `identity` on Inspect, selection, or
97
+ the interaction controller for durable row keys.
98
+ - Check actual browser hover/pin, keyboard operation, selection, and zoom.
99
+ CLI success verifies the portable chart pipeline, not React event wiring.
100
+
101
+ For lean imports or stat overrides, read [registration](registration.md).