handleKeyDown(handle, event)}
+ />
+ )
+ }
+
+ return (
+
+ {playable && (
+
+ {playing ? (
+
+ ) : (
+
+ )}
+
+ }
+ />
+ )}
+
+
+ {times.map((time) => (
+
+ ))}
+ {mode === "range" && (
+
+ )}
+ {mode === "range" && renderHandle("start", startFraction, "Start time")}
+ {renderHandle("end", endFraction, mode === "range" ? "End time" : "Time")}
+
+ {readout}
+
+ )
+}
diff --git a/packages/charts2/src/react/chrome/Tooltip.test.tsx b/packages/charts2/src/react/chrome/Tooltip.test.tsx
new file mode 100644
index 00000000000..3ebc1d65efa
--- /dev/null
+++ b/packages/charts2/src/react/chrome/Tooltip.test.tsx
@@ -0,0 +1,119 @@
+import { afterEach, describe, expect, it } from "vitest"
+import { cleanup, render } from "@testing-library/react"
+
+import type { TooltipModel } from "../../core/scene/nodes.ts"
+import { computeTooltipPlacement, Tooltip, TOOLTIP_CURSOR_OFFSET } from "./Tooltip.tsx"
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
+
+afterEach(cleanup)
+
+const CARD = { width: 100, height: 60 }
+const BOUNDS = { width: 400, height: 300 }
+
+describe("computeTooltipPlacement (spec 06 §3)", () => {
+ it("places the card right and below the cursor by default (center)", () => {
+ expect(computeTooltipPlacement(200, 150, CARD, BOUNDS)).toEqual({ left: 212, top: 162 })
+ })
+
+ it("stays inside bounds at the top-left corner", () => {
+ expect(computeTooltipPlacement(0, 0, CARD, BOUNDS)).toEqual({
+ left: TOOLTIP_CURSOR_OFFSET,
+ top: TOOLTIP_CURSOR_OFFSET,
+ })
+ })
+
+ it("flips left of the cursor at the top-right corner", () => {
+ expect(computeTooltipPlacement(395, 5, CARD, BOUNDS)).toEqual({ left: 283, top: 17 })
+ })
+
+ it("flips above the cursor at the bottom-left corner", () => {
+ expect(computeTooltipPlacement(5, 295, CARD, BOUNDS)).toEqual({ left: 17, top: 223 })
+ })
+
+ it("flips both axes at the bottom-right corner", () => {
+ expect(computeTooltipPlacement(395, 295, CARD, BOUNDS)).toEqual({ left: 283, top: 223 })
+ })
+
+ it("clamps to the frame when the card cannot fit either side", () => {
+ const placement = computeTooltipPlacement(50, 150, CARD, { width: 90, height: 300 })
+ expect(placement.left).toBe(0)
+ })
+})
+
+const model: TooltipModel = {
+ title: "2021–22",
+ titleAnnotation: "fiscal year",
+ subtitle: "Total spending (billion CAD)",
+ rows: [
+ { seriesKey: "Ontario", label: "Ontario", swatch: "#112233", valueText: "$186.4 billion", emphasized: true },
+ { seriesKey: "Quebec", label: "Quebec", swatch: "#445566", valueText: "$140.5 billion", emphasized: false },
+ { seriesKey: "Nova Scotia", label: "Nova Scotia", swatch: "#778899", valueText: "No data", emphasized: false, notice: "missing" },
+ { seriesKey: "Alberta", label: "Alberta", swatch: "#99aabb", valueText: "—", emphasized: false, notice: "missing" },
+ ],
+ totalRow: { seriesKey: "_total", label: "Total", swatch: "#000000", valueText: "$326.9 billion", emphasized: false },
+ footers: [
+ { icon: "notice", text: "Data from 2019" },
+ { icon: "projection", text: "Projected data" },
+ ],
+}
+
+describe("Tooltip rendering (spec 06 §1)", () => {
+ it("renders the model verbatim: title, annotation, subtitle, rows in order, total, footers", () => {
+ const { container } = render(
)
+
+ const title = container.querySelector(".bcds2-tooltip__title")
+ expect(title?.textContent).toBe("2021–22 fiscal year")
+ expect(container.querySelector(".bcds2-tooltip__subtitle")?.textContent).toBe("Total spending (billion CAD)")
+
+ const rows = [...container.querySelectorAll(".bcds2-tooltip__rows .bcds2-tooltip__row")]
+ expect(rows.map((row) => row.querySelector(".bcds2-tooltip__label")?.textContent)).toEqual([
+ "Ontario",
+ "Quebec",
+ "Nova Scotia",
+ "Alberta",
+ ])
+ expect(rows.map((row) => row.querySelector(".bcds2-tooltip__value")?.textContent)).toEqual([
+ "$186.4 billion",
+ "$140.5 billion",
+ "No data",
+ "—",
+ ])
+
+ const total = container.querySelector(".bcds2-tooltip__row--total")
+ expect(total?.querySelector(".bcds2-tooltip__label")?.textContent).toBe("Total")
+ expect(total?.querySelector(".bcds2-tooltip__value")?.textContent).toBe("$326.9 billion")
+
+ const footers = [...container.querySelectorAll(".bcds2-tooltip__footer-text")]
+ expect(footers.map((footer) => footer.textContent)).toEqual(["Data from 2019", "Projected data"])
+ expect(container.querySelector(".bcds2-tooltip__footer--notice .bcds2-tooltip__footer-icon")).not.toBeNull()
+ expect(container.querySelector(".bcds2-tooltip__footer--projection svg")).not.toBeNull()
+ })
+
+ it("emphasizes the hovered row and mutes missing rows", () => {
+ const { container } = render(
)
+
+ const ontario = container.querySelector('[data-series-key="Ontario"]')
+ expect(ontario?.className).toContain("bcds2-tooltip__row--emphasized")
+
+ const novaScotia = container.querySelector('[data-series-key="Nova Scotia"]')
+ expect(novaScotia?.className).toContain("bcds2-tooltip__row--missing")
+ expect(novaScotia?.className).not.toContain("emphasized")
+ })
+
+ it("positions itself via computeTooltipPlacement", () => {
+ const { container } = render(
)
+ const card = container.querySelector(".bcds2-tooltip") as HTMLElement
+ // happy-dom measures 0×0, so the card sits at cursor + offset.
+ expect(card.style.left).toBe("42px")
+ expect(card.style.top).toBe("52px")
+ })
+
+ it("omits subtitle, total, and footers when absent", () => {
+ const sparse: TooltipModel = { title: "2020", rows: model.rows.slice(0, 1), footers: [] }
+ const { container } = render(
)
+ expect(container.querySelector(".bcds2-tooltip__subtitle")).toBeNull()
+ expect(container.querySelector(".bcds2-tooltip__row--total")).toBeNull()
+ expect(container.querySelector(".bcds2-tooltip__footers")).toBeNull()
+ })
+})
diff --git a/packages/charts2/src/react/chrome/Tooltip.tsx b/packages/charts2/src/react/chrome/Tooltip.tsx
new file mode 100644
index 00000000000..b7ea22bc3ba
--- /dev/null
+++ b/packages/charts2/src/react/chrome/Tooltip.tsx
@@ -0,0 +1,139 @@
+/**
+ * Tooltip card (spec 06). Renders a precomputed TooltipModel verbatim —
+ * all formatting happened upstream in the hover model — and positions
+ * itself near the cursor with smart flipping so the card always stays
+ * inside the chart frame. Pointer-events are disabled (CSS) so the card
+ * never steals hover from the plot.
+ */
+
+import { useLayoutEffect, useRef, useState } from "react"
+import type { TooltipModel, TooltipRow } from "../../core/scene/nodes.ts"
+
+export interface TooltipBounds {
+ width: number
+ height: number
+}
+
+export interface TooltipSize {
+ width: number
+ height: number
+}
+
+export interface TooltipPlacement {
+ left: number
+ top: number
+}
+
+export interface TooltipProps {
+ model: TooltipModel
+ /** Cursor position, in the same coordinate space as `bounds`. */
+ x: number
+ y: number
+ /** The frame the card must stay inside (usually the chart frame). */
+ bounds: TooltipBounds
+}
+
+/** Gap between the cursor and the near edge of the card. */
+export const TOOLTIP_CURSOR_OFFSET = 12
+
+/**
+ * Place the card beside the cursor, flipping left/above when the default
+ * right/below placement would overflow `bounds`, then clamping into the
+ * frame (spec 06 §3: tooltip remains within frame bounds at all corners).
+ */
+export function computeTooltipPlacement(x: number, y: number, cardSize: TooltipSize, bounds: TooltipBounds): TooltipPlacement {
+ let left = x + TOOLTIP_CURSOR_OFFSET
+ if (left + cardSize.width > bounds.width) {
+ left = x - TOOLTIP_CURSOR_OFFSET - cardSize.width
+ }
+ let top = y + TOOLTIP_CURSOR_OFFSET
+ if (top + cardSize.height > bounds.height) {
+ top = y - TOOLTIP_CURSOR_OFFSET - cardSize.height
+ }
+ left = Math.max(0, Math.min(left, bounds.width - cardSize.width))
+ top = Math.max(0, Math.min(top, bounds.height - cardSize.height))
+ return { left, top }
+}
+
+function ProjectionIcon() {
+ return (
+
+ )
+}
+
+function rowClassName(row: TooltipRow, total: boolean): string {
+ const classes = ["bcds2-tooltip__row"]
+ if (total) classes.push("bcds2-tooltip__row--total")
+ if (row.emphasized) classes.push("bcds2-tooltip__row--emphasized")
+ if (row.notice === "missing") classes.push("bcds2-tooltip__row--missing")
+ if (row.notice === "toleranced") classes.push("bcds2-tooltip__row--toleranced")
+ if (row.notice === "projected") classes.push("bcds2-tooltip__row--projected")
+ return classes.join(" ")
+}
+
+function TooltipValueRow({ row, total = false }: { row: TooltipRow; total?: boolean }) {
+ return (
+
+
+ {row.label}
+ {row.valueText}
+
+ )
+}
+
+export function Tooltip({ model, x, y, bounds }: TooltipProps) {
+ const cardRef = useRef
(null)
+ const [size, setSize] = useState({ width: 0, height: 0 })
+
+ useLayoutEffect(() => {
+ const card = cardRef.current
+ if (card === null) return
+ const width = card.offsetWidth
+ const height = card.offsetHeight
+ setSize((prev) => (prev.width === width && prev.height === height ? prev : { width, height }))
+ })
+
+ const placement = computeTooltipPlacement(x, y, size, bounds)
+
+ return (
+
+
+ {model.title}
+ {model.titleAnnotation !== undefined && (
+ {model.titleAnnotation}
+ )}
+
+ {model.subtitle !== undefined &&
{model.subtitle}
}
+ {model.rows.length > 0 && (
+
+ {model.rows.map((row, index) => (
+ // Key by index, not seriesKey: a single series can own several
+ // rows (e.g. slope/dumbbell start/end), so seriesKey is not unique
+ // and duplicate keys corrupt reconciliation (stale rows retained).
+
+ ))}
+
+ )}
+ {model.totalRow !== undefined &&
}
+ {model.footers.length > 0 && (
+
+ {model.footers.map((footer, index) => (
+
+ {footer.icon === "projection" ? (
+
+ ) : (
+
+ ⓘ
+
+ )}
+
{footer.text}
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/packages/charts2/src/react/chrome/fuzzySearch.test.ts b/packages/charts2/src/react/chrome/fuzzySearch.test.ts
new file mode 100644
index 00000000000..50c608f2c53
--- /dev/null
+++ b/packages/charts2/src/react/chrome/fuzzySearch.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it } from "vitest"
+
+import { createFuzzySearch, foldAccents, fuzzyMatches, fuzzyScore } from "./fuzzySearch.ts"
+
+interface Entity {
+ name: string
+ aliases: string[]
+}
+
+const entities: Entity[] = [
+ { name: "Québec", aliases: ["QC"] },
+ { name: "Ontario", aliases: ["ON"] },
+ { name: "Global Affairs Canada", aliases: ["Foreign Affairs and International Trade", "DFAIT"] },
+ { name: "Innovation, Science and Economic Development Canada", aliases: ["Industry Canada", "ISED"] },
+]
+
+function searcher() {
+ return createFuzzySearch(entities, (entity) => [entity.name, ...entity.aliases])
+}
+
+describe("foldAccents", () => {
+ it("strips combining diacritics", () => {
+ expect(foldAccents("Québec")).toBe("Quebec")
+ expect(foldAccents("Î.-P.-É.")).toBe("I.-P.-E.")
+ expect(foldAccents("plain")).toBe("plain")
+ })
+})
+
+describe("fuzzy search (spec 07 §2)", () => {
+ it("matches accented names from unaccented queries", () => {
+ const results = searcher().search("quebec")
+ expect(results.map((entity) => entity.name)).toEqual(["Québec"])
+ })
+
+ it("matches via aliases and dedupes to one result per entity", () => {
+ const dfait = searcher().search("DFAIT")
+ expect(dfait.map((entity) => entity.name)).toEqual(["Global Affairs Canada"])
+
+ const industry = searcher().search("industry")
+ expect(industry.map((entity) => entity.name)).toContain("Innovation, Science and Economic Development Canada")
+ expect(industry.length).toBe(new Set(industry).size)
+ })
+
+ it("ranks substring matches above subsequence matches", () => {
+ const substring = fuzzyScore("ontario", "ontario")
+ const subsequence = fuzzyScore("onro", "ontario")
+ expect(substring).not.toBeNull()
+ expect(subsequence).not.toBeNull()
+ expect(substring as number).toBeGreaterThan(subsequence as number)
+ })
+
+ it("returns no results for empty or whitespace queries", () => {
+ expect(searcher().search("")).toEqual([])
+ expect(searcher().search(" ")).toEqual([])
+ })
+
+ it("returns no results when nothing matches", () => {
+ expect(searcher().search("zzzz")).toEqual([])
+ })
+})
+
+describe("fuzzyMatches", () => {
+ it("treats empty queries as matching and respects accents/aliases", () => {
+ expect(fuzzyMatches("", ["Québec"])).toBe(true)
+ expect(fuzzyMatches("quebec", ["Québec"])).toBe(true)
+ expect(fuzzyMatches("dfait", ["Global Affairs Canada", "DFAIT"])).toBe(true)
+ expect(fuzzyMatches("xyzq", ["Ontario"])).toBe(false)
+ })
+})
diff --git a/packages/charts2/src/react/chrome/fuzzySearch.ts b/packages/charts2/src/react/chrome/fuzzySearch.ts
new file mode 100644
index 00000000000..300183c7fe1
--- /dev/null
+++ b/packages/charts2/src/react/chrome/fuzzySearch.ts
@@ -0,0 +1,89 @@
+/**
+ * Accent- and alias-tolerant fuzzy search (spec 07 §2, spec 22 §3).
+ *
+ * Port of charts v1 `utils/FuzzySearch.ts`, stripped of its fuzzysort and
+ * lodash dependencies: a pure module with no imports. Matching is
+ * case-insensitive and accent-folded ("quebec" finds "Québec") and works
+ * over multiple keys per item (canonical name, French name, aliases,
+ * codes), deduping to the best-scoring key per item:
+ * 1. substring matches rank highest (earlier and tighter is better)
+ * 2. in-order subsequence matches rank below, with a word-start bonus
+ */
+
+/** Strip combining diacritics: "Québec" → "Quebec", "Î.-P.-É." → "I.-P.-E.". */
+export function foldAccents(input: string): string {
+ return input.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
+}
+
+function normalize(input: string): string {
+ return foldAccents(input).toLowerCase()
+}
+
+/**
+ * Score a normalized query against a normalized target. Higher is better;
+ * null means no match. Substring matches always outrank subsequence matches.
+ */
+export function fuzzyScore(query: string, target: string): number | null {
+ if (query.length === 0) return null
+
+ const index = target.indexOf(query)
+ if (index >= 0) {
+ return 1000 - index - (target.length - query.length) * 0.01
+ }
+
+ let score = 0
+ let at = 0
+ for (const ch of query) {
+ if (ch === " ") continue
+ const found = target.indexOf(ch, at)
+ if (found < 0) return null
+ const atWordStart = found === 0 || target[found - 1] === " " || target[found - 1] === "-"
+ score += atWordStart ? 4 : 1
+ score -= (found - at) * 0.01
+ at = found + 1
+ }
+ return score
+}
+
+export interface FuzzySearcher {
+ /** Best-first matches; empty queries return no results. */
+ search: (query: string) => T[]
+}
+
+/**
+ * Build a searcher over `items`, each exposing one or more searchable keys
+ * (e.g. name + aliases). Each item appears at most once in results, ranked
+ * by its best-matching key. Result order is stable for equal scores.
+ */
+export function createFuzzySearch(items: readonly T[], keysOf: (item: T) => readonly string[]): FuzzySearcher {
+ const entries = items.map((item) => ({
+ item,
+ keys: keysOf(item).map(normalize),
+ }))
+
+ return {
+ search(rawQuery: string): T[] {
+ const query = normalize(rawQuery.trim())
+ if (query.length === 0) return []
+
+ const scored: { item: T; score: number }[] = []
+ for (const entry of entries) {
+ let best: number | null = null
+ for (const key of entry.keys) {
+ const score = fuzzyScore(query, key)
+ if (score !== null && (best === null || score > best)) best = score
+ }
+ if (best !== null) scored.push({ item: entry.item, score: best })
+ }
+ scored.sort((a, b) => b.score - a.score)
+ return scored.map((s) => s.item)
+ },
+ }
+}
+
+/** Convenience predicate: does the query match any of the keys? Empty queries match. */
+export function fuzzyMatches(query: string, keys: readonly string[]): boolean {
+ const normalized = normalize(query.trim())
+ if (normalized.length === 0) return true
+ return keys.some((key) => fuzzyScore(normalized, normalize(key)) !== null)
+}
diff --git a/packages/charts2/src/react/chrome/index.ts b/packages/charts2/src/react/chrome/index.ts
new file mode 100644
index 00000000000..dbf1492a950
--- /dev/null
+++ b/packages/charts2/src/react/chrome/index.ts
@@ -0,0 +1,11 @@
+// Interactive chrome (M9): tooltip, timeline, entity selector, tabs,
+// settings, data table. Self-contained components — data in via props,
+// state changes out via callbacks. Styles live in ../styles/charts.scss.
+
+export * from "./DataTable.tsx"
+export * from "./EntitySelector.tsx"
+export * from "./fuzzySearch.ts"
+export * from "./SettingsMenu.tsx"
+export * from "./Tabs.tsx"
+export * from "./Timeline.tsx"
+export * from "./Tooltip.tsx"
diff --git a/packages/charts2/src/react/index.ts b/packages/charts2/src/react/index.ts
new file mode 100644
index 00000000000..9570877d244
--- /dev/null
+++ b/packages/charts2/src/react/index.ts
@@ -0,0 +1,15 @@
+// React renderer + interaction layer (M7). SceneSVG is THE single
+// scene→SVG renderer (browser and renderToStaticMarkup — spec 28 §1).
+
+export * from "./chrome/index.ts" // M9: Tooltip, Timeline, EntitySelector, Tabs, SettingsMenu, DataTable
+export { Chart, type ChartProps, type RenderTooltipArgs } from "./Chart.tsx"
+export {
+ emphasisFor,
+ emphasisReducer,
+ initialEmphasisState,
+ type EmphasisEvent,
+ type EmphasisModel,
+ type EmphasisState,
+} from "./interaction/emphasisReducer.ts"
+export { useUrlState, type SetViewState, type UseUrlStateOptions } from "./interaction/useUrlState.ts"
+export { SceneSVG, type SceneSVGProps } from "./SceneSVG.tsx"
diff --git a/packages/charts2/src/react/interaction/emphasisReducer.test.ts b/packages/charts2/src/react/interaction/emphasisReducer.test.ts
new file mode 100644
index 00000000000..bd6ed809b51
--- /dev/null
+++ b/packages/charts2/src/react/interaction/emphasisReducer.test.ts
@@ -0,0 +1,135 @@
+/**
+ * Property tests for the emphasis state machine (spec 07 §3, spec 26 §3):
+ * random event sequences from a seeded PRNG (no Math.random — determinism)
+ * must never strand a state that references unknown keys or a dimmed chart
+ * with nothing emphasized.
+ */
+
+import { describe, expect, it } from "vitest"
+
+import type { SeriesKey } from "../../core/types.ts"
+import {
+ emphasisFor,
+ emphasisReducer,
+ initialEmphasisState,
+ type EmphasisEvent,
+ type EmphasisState,
+} from "./emphasisReducer.ts"
+
+// ---------------------------------------------------------------------------
+// Seeded PRNG — tiny LCG (numerical recipes constants), deterministic
+// ---------------------------------------------------------------------------
+
+function lcg(seed: number): () => number {
+ let state = seed >>> 0
+ return () => {
+ state = (Math.imul(state, 1664525) + 1013904223) >>> 0
+ return state / 4294967296
+ }
+}
+
+const KEYS: SeriesKey[] = ["Ontario", "Quebec", "Alberta", "Nova Scotia"]
+
+function randomEvent(next: () => number): EmphasisEvent {
+ const key = KEYS[Math.floor(next() * KEYS.length)]
+ const roll = next()
+ if (roll < 0.35) return { type: "hover-series", key }
+ if (roll < 0.55) return { type: "hover-clear" }
+ if (roll < 0.85) return { type: "toggle-focus", key }
+ if (roll < 0.93) return { type: "clear-focus" }
+ return { type: "escape" }
+}
+
+function setEquals(a: ReadonlySet, b: ReadonlySet): boolean {
+ if (a.size !== b.size) return false
+ for (const key of a) if (!b.has(key)) return false
+ return true
+}
+
+describe("emphasisReducer properties", () => {
+ it("random event sequences never strand an invalid state", () => {
+ const next = lcg(20260611)
+ for (let run = 0; run < 200; run++) {
+ let state = initialEmphasisState
+ for (let step = 0; step < 60; step++) {
+ const event = randomEvent(next)
+ const previous = state
+ state = emphasisReducer(state, event)
+
+ // States only reference known keys.
+ expect(state.hover === null || KEYS.includes(state.hover)).toBe(true)
+ for (const key of state.focus) expect(KEYS).toContain(key)
+
+ // Event-specific invariants.
+ if (event.type === "hover-series") {
+ expect(state.hover).toBe(event.key)
+ expect(setEquals(state.focus, previous.focus)).toBe(true)
+ }
+ if (event.type === "hover-clear") {
+ expect(state.hover).toBeNull()
+ expect(setEquals(state.focus, previous.focus)).toBe(true)
+ }
+ if (event.type === "toggle-focus") {
+ expect(state.focus.has(event.key)).toBe(!previous.focus.has(event.key))
+ expect(state.hover).toBe(previous.hover)
+ }
+ if (event.type === "escape" || event.type === "clear-focus") {
+ expect(state.focus.size).toBe(0)
+ expect(state.hover).toBe(previous.hover)
+ }
+
+ // Derived emphasis = focus ∪ hover; never an empty emphasis set.
+ const emphasis = emphasisFor(state)
+ if (state.hover === null && state.focus.size === 0) {
+ expect(emphasis.mode).toBe("idle")
+ } else {
+ expect(emphasis.mode).toBe("emphasis")
+ if (emphasis.mode === "emphasis") {
+ expect(emphasis.keys.size).toBeGreaterThan(0)
+ const expected = new Set(state.focus)
+ if (state.hover !== null) expected.add(state.hover)
+ expect(setEquals(emphasis.keys, expected)).toBe(true)
+ }
+ }
+ }
+ }
+ })
+
+ it("hover is transient and never alters focus", () => {
+ let state: EmphasisState = initialEmphasisState
+ state = emphasisReducer(state, { type: "toggle-focus", key: "Ontario" })
+ const focusBefore = state.focus
+ state = emphasisReducer(state, { type: "hover-series", key: "Quebec" })
+ expect(state.focus).toBe(focusBefore)
+ state = emphasisReducer(state, { type: "hover-clear" })
+ expect(state.focus).toBe(focusBefore)
+ expect(state.hover).toBeNull()
+ })
+
+ it("escape clears focus only, leaving hover untouched", () => {
+ let state: EmphasisState = initialEmphasisState
+ state = emphasisReducer(state, { type: "toggle-focus", key: "Ontario" })
+ state = emphasisReducer(state, { type: "hover-series", key: "Quebec" })
+ state = emphasisReducer(state, { type: "escape" })
+ expect(state.focus.size).toBe(0)
+ expect(state.hover).toBe("Quebec")
+ })
+
+ it("toggling focus twice round-trips to an empty set", () => {
+ let state: EmphasisState = initialEmphasisState
+ state = emphasisReducer(state, { type: "toggle-focus", key: "Alberta" })
+ expect(state.focus.has("Alberta")).toBe(true)
+ state = emphasisReducer(state, { type: "toggle-focus", key: "Alberta" })
+ expect(state.focus.size).toBe(0)
+ expect(emphasisFor(state).mode).toBe("idle")
+ })
+
+ it("no-op events return the same state reference (cheap renders)", () => {
+ const cleared = emphasisReducer(initialEmphasisState, { type: "hover-clear" })
+ expect(cleared).toBe(initialEmphasisState)
+ const escaped = emphasisReducer(initialEmphasisState, { type: "escape" })
+ expect(escaped).toBe(initialEmphasisState)
+ const hovered = emphasisReducer(initialEmphasisState, { type: "hover-series", key: "Quebec" })
+ expect(emphasisReducer(hovered, { type: "hover-series", key: "Quebec" })).toBe(hovered)
+ })
+})
diff --git a/packages/charts2/src/react/interaction/emphasisReducer.ts b/packages/charts2/src/react/interaction/emphasisReducer.ts
new file mode 100644
index 00000000000..565c77716a3
--- /dev/null
+++ b/packages/charts2/src/react/interaction/emphasisReducer.ts
@@ -0,0 +1,60 @@
+/**
+ * Emphasis state machine (spec 07 §3): hover / focus / dimming.
+ *
+ * Pure functions — no React. The Chart component drives this through
+ * useReducer; tests exercise it directly. Hover is transient and never
+ * alters focus; Escape clears focus only. Emphasis styling is applied by
+ * SceneSVG via seriesKey opacity — it NEVER triggers relayout.
+ */
+
+import type { SeriesKey } from "../../core/types.ts"
+
+export interface EmphasisState {
+ /** Series under the pointer, or null. Transient. */
+ hover: SeriesKey | null
+ /** Clicked-in focus set. Persists in the URL (`focus=`). */
+ focus: ReadonlySet
+}
+
+export type EmphasisEvent =
+ | { type: "hover-series"; key: SeriesKey }
+ | { type: "hover-clear" }
+ | { type: "toggle-focus"; key: SeriesKey }
+ | { type: "clear-focus" }
+ | { type: "escape" }
+
+export const initialEmphasisState: EmphasisState = { hover: null, focus: new Set() }
+
+export function emphasisReducer(state: EmphasisState, event: EmphasisEvent): EmphasisState {
+ switch (event.type) {
+ case "hover-series":
+ return state.hover === event.key ? state : { hover: event.key, focus: state.focus }
+ case "hover-clear":
+ return state.hover === null ? state : { hover: null, focus: state.focus }
+ case "toggle-focus": {
+ const focus = new Set(state.focus)
+ if (focus.has(event.key)) focus.delete(event.key)
+ else focus.add(event.key)
+ return { hover: state.hover, focus }
+ }
+ case "clear-focus":
+ case "escape":
+ // Escape clears focus only — hover is owned by the pointer.
+ return state.focus.size === 0 ? state : { hover: state.hover, focus: new Set() }
+ }
+}
+
+/**
+ * What SceneSVG consumes: idle (everything full opacity) or an emphasized
+ * key set (everything else dimmed). Derived, never stored.
+ */
+export type EmphasisModel =
+ | { mode: "idle" }
+ | { mode: "emphasis"; keys: ReadonlySet }
+
+export function emphasisFor(state: EmphasisState): EmphasisModel {
+ if (state.hover === null && state.focus.size === 0) return { mode: "idle" }
+ const keys = new Set(state.focus)
+ if (state.hover !== null) keys.add(state.hover)
+ return { mode: "emphasis", keys }
+}
diff --git a/packages/charts2/src/react/interaction/useUrlState.test.ts b/packages/charts2/src/react/interaction/useUrlState.test.ts
new file mode 100644
index 00000000000..39903bd6a47
--- /dev/null
+++ b/packages/charts2/src/react/interaction/useUrlState.test.ts
@@ -0,0 +1,115 @@
+/**
+ * useUrlState round-trips ViewState through window.location.search using the
+ * core codec: read once on mount, debounced history.replaceState writes,
+ * foreign params untouched (spec 02 §3).
+ */
+
+import { act, renderHook } from "@testing-library/react"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+
+import { useUrlState } from "./useUrlState.ts"
+
+function setUrl(search: string): void {
+ window.history.replaceState(null, "", `/page${search}`)
+}
+
+beforeEach(() => {
+ vi.useFakeTimers()
+ setUrl("")
+})
+
+afterEach(() => {
+ vi.useRealTimers()
+})
+
+describe("useUrlState", () => {
+ it("reads owned params from the URL once on mount, layered over the initial state", () => {
+ setUrl("?time=2019..2024&entities=Ontario~Quebec&foreign=1&yScale=log")
+ const { result } = renderHook(() =>
+ useUrlState("year", { initial: { stackMode: "relative" } }),
+ )
+ const [state] = result.current
+ expect(state.time).toEqual({ start: 2019, end: 2024 })
+ expect(state.entities).toEqual(["Ontario", "Quebec"])
+ expect(state.yScale).toBe("log")
+ // Initial state survives where the URL says nothing.
+ expect(state.stackMode).toBe("relative")
+ })
+
+ it("does not write the URL back on mount", () => {
+ setUrl("?time=2020&foreign=1")
+ renderHook(() => useUrlState("year"))
+ act(() => {
+ vi.advanceTimersByTime(500)
+ })
+ expect(window.location.search).toBe("?time=2020&foreign=1")
+ })
+
+ it("writes state changes via debounced replaceState, preserving foreign params", () => {
+ setUrl("?foreign=1&time=2019..2024")
+ const { result } = renderHook(() => useUrlState("year"))
+ act(() => {
+ const [, setState] = result.current
+ setState((prev) => ({ ...prev, yScale: "log", entities: ["Ontario"] }))
+ })
+ // Debounced: nothing yet.
+ expect(window.location.search).toBe("?foreign=1&time=2019..2024")
+ act(() => {
+ vi.advanceTimersByTime(200)
+ })
+ const params = new URLSearchParams(window.location.search)
+ expect(params.get("foreign")).toBe("1")
+ expect(params.get("yScale")).toBe("log")
+ expect(params.get("entities")).toBe("Ontario")
+ expect(params.get("time")).toBe("2019..2024")
+ })
+
+ it("round-trips: written params decode back to the same state", () => {
+ const first = renderHook(() => useUrlState("year"))
+ act(() => {
+ const [, setState] = first.result.current
+ setState({
+ time: { start: 2019, end: "latest" },
+ entities: ["Nova Scotia", "Ontario"],
+ focus: ["Ontario"],
+ yScale: "log",
+ })
+ })
+ act(() => {
+ vi.advanceTimersByTime(200)
+ })
+ first.unmount()
+
+ const second = renderHook(() => useUrlState("year"))
+ expect(second.result.current[0]).toEqual({
+ time: { start: 2019, end: "latest" },
+ entities: ["Nova Scotia", "Ontario"],
+ focus: ["Ontario"],
+ yScale: "log",
+ })
+ })
+
+ it("drops unknown owned-param values with a clean state (codec never throws)", () => {
+ setUrl("?yScale=banana&tab=line")
+ const { result } = renderHook(() => useUrlState("year"))
+ expect(result.current[0].yScale).toBeUndefined()
+ expect(result.current[0].tab).toBe("line")
+ })
+
+ it("is inert when disabled: no URL read, no URL write", () => {
+ setUrl("?time=2020&foreign=1")
+ const { result } = renderHook(() =>
+ useUrlState("year", { initial: { yScale: "log" }, enabled: false }),
+ )
+ expect(result.current[0]).toEqual({ yScale: "log" })
+ act(() => {
+ const [, setState] = result.current
+ setState({ yScale: "linear" })
+ })
+ act(() => {
+ vi.advanceTimersByTime(500)
+ })
+ expect(window.location.search).toBe("?time=2020&foreign=1")
+ expect(result.current[0]).toEqual({ yScale: "linear" })
+ })
+})
diff --git a/packages/charts2/src/react/interaction/useUrlState.ts b/packages/charts2/src/react/interaction/useUrlState.ts
new file mode 100644
index 00000000000..e88d8c57396
--- /dev/null
+++ b/packages/charts2/src/react/interaction/useUrlState.ts
@@ -0,0 +1,71 @@
+/**
+ * useUrlState — ViewState ↔ window.location.search (spec 02 §3).
+ *
+ * Reads once on mount via paramsToViewState (which ignores unknown values
+ * with diagnostics and unknown names silently), writes via a debounced
+ * history.replaceState. Params the codec does not own are preserved on
+ * write — chart params share the page URL with the host application.
+ * SSR-safe: no window access during render beyond a typeof guard.
+ */
+
+import { useEffect, useRef, useState } from "react"
+
+import { paramsToViewState, viewStateToParams } from "../../core/definition/urlState.ts"
+import type { TimeGrain, ViewState } from "../../core/types.ts"
+
+/** Param names written by viewStateToParams; cleared before each write. */
+const OWNED_PARAMS = [
+ "tab",
+ "time",
+ "entities",
+ "focus",
+ "yScale",
+ "stackMode",
+ "facet",
+ "tableSort",
+ "tableScope",
+] as const
+
+const WRITE_DEBOUNCE_MS = 150
+
+export type SetViewState = (next: ViewState | ((prev: ViewState) => ViewState)) => void
+
+export interface UseUrlStateOptions {
+ /** Base state; URL params layer on top of it at mount. */
+ initial?: ViewState
+ /** When false, behaves as plain local state (no URL reads or writes). */
+ enabled?: boolean
+}
+
+export function useUrlState(grain: TimeGrain, options: UseUrlStateOptions = {}): [ViewState, SetViewState] {
+ const enabled = options.enabled ?? true
+
+ const [state, setState] = useState(() => {
+ const initial = options.initial ?? {}
+ if (!enabled || typeof window === "undefined") return initial
+ const { state: fromUrl } = paramsToViewState(new URLSearchParams(window.location.search), grain)
+ return { ...initial, ...fromUrl }
+ })
+
+ const mounted = useRef(false)
+ useEffect(() => {
+ if (!enabled || typeof window === "undefined") return
+ if (!mounted.current) {
+ // The first state came FROM the URL — writing it back would be a no-op
+ // at best and would clobber host params present before hydration.
+ mounted.current = true
+ return
+ }
+ const timer = window.setTimeout(() => {
+ const params = new URLSearchParams(window.location.search)
+ for (const name of OWNED_PARAMS) params.delete(name)
+ for (const [name, value] of viewStateToParams(state, grain)) params.set(name, value)
+ const query = params.toString()
+ const url = `${window.location.pathname}${query === "" ? "" : `?${query}`}${window.location.hash}`
+ window.history.replaceState(window.history.state, "", url)
+ }, WRITE_DEBOUNCE_MS)
+ return () => window.clearTimeout(timer)
+ }, [state, grain, enabled])
+
+ return [state, setState]
+}
diff --git a/packages/charts2/src/react/styles/charts.scss b/packages/charts2/src/react/styles/charts.scss
new file mode 100644
index 00000000000..6cd06f564e4
--- /dev/null
+++ b/packages/charts2/src/react/styles/charts.scss
@@ -0,0 +1,825 @@
+// =============================================================================
+// @buildcanada/charts2 — chrome styles
+//
+// Includes Build Canada component primitive styles used by chart chrome.
+// All classes are BEM under the `bcds2-` prefix.
+//
+// Theming contract — override these CSS custom properties on any ancestor
+// (every colour in this sheet derives from one of them, with the listed
+// fallback used when unset). The defaults are the Build Canada theme, drawn
+// from @buildcanada/colours to match the SVG chart chrome in themes.ts:
+//
+// --bcds2-bg surfaces: tooltip card, popovers, table cells (#fbf6f1 linen-50)
+// --bcds2-text primary text (#272727 charcoal-1000)
+// --bcds2-grid hairlines: borders, tracks, ticks, gridlines (#cbc9c4 nickel-200)
+// --bcds2-accent interactive accents: handles, active tab,
+// focus rings, sort arrows (#932f2f auburn-800)
+// --bcds2-font chrome typography (Söhne Kräftig stack)
+//
+// Muted/secondary text is the text colour at reduced opacity, so a single
+// --bcds2-text override restyles every label consistently.
+// =============================================================================
+
+@use "@buildcanada/components/src/primitives/Button/Button";
+@use "@buildcanada/components/src/primitives/TextField/TextField";
+@use "@buildcanada/components/src/primitives/Checkbox/Checkbox";
+@use "@buildcanada/components/src/primitives/RadioGroup/RadioGroup";
+@use "@buildcanada/components/src/primitives/Select/Select";
+@use "@buildcanada/components/src/primitives/SegmentedControl/SegmentedControl";
+@use "@buildcanada/components/src/primitives/Popover/Popover";
+
+// -----------------------------------------------------------------------------
+// Chrome typography
+//
+// The SVG plot sets font-family inline from the theme, but the HTML chrome
+// (tooltip, menus, table) inherits from the host — without this it falls back
+// to the browser serif default. Default to the Build Canada brand stack;
+// override via --bcds2-font, like the colour custom properties above.
+// -----------------------------------------------------------------------------
+
+$bcds2-font: "Söhne Kräftig", "Helvetica Neue", Arial, sans-serif;
+
+.bcds2-tooltip,
+.bcds2-timeline,
+.bcds2-entity-selector,
+.bcds2-tabs,
+.bcds2-settings,
+.bcds2-settings__popover,
+.bcds2-data-table {
+ font-family: var(--bcds2-font, #{$bcds2-font});
+}
+
+// -----------------------------------------------------------------------------
+// Tooltip (spec 06)
+// -----------------------------------------------------------------------------
+
+.bcds2-tooltip {
+ position: absolute;
+ z-index: 10;
+ pointer-events: none;
+ // Size to content (clamped to min/max below) rather than the default
+ // shrink-to-fit, which narrows the card when it's positioned near a frame
+ // edge — making the subtitle/rows wrap before the card reaches max-width.
+ // max-content keeps width independent of placement: content (including the
+ // subtitle) widens the card up to max-width, then wraps.
+ width: max-content;
+ min-width: 160px;
+ max-width: 350px;
+ padding: 8px 10px;
+ background: var(--bcds2-bg, #fbf6f1);
+ color: var(--bcds2-text, #272727);
+ border: 1px solid var(--bcds2-grid, #cbc9c4);
+ border-radius: 0;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
+ font-size: 13px;
+ line-height: 1.35;
+
+ &__title {
+ font-weight: 700;
+ margin-bottom: 2px;
+ }
+
+ &__title-annotation {
+ font-weight: 400;
+ opacity: 0.6;
+ }
+
+ &__subtitle {
+ opacity: 0.6;
+ font-size: 12px;
+ margin-bottom: 4px;
+ }
+
+ &__rows {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ margin: 4px 0;
+ }
+
+ &__row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+
+ &--emphasized {
+ font-weight: 700;
+ }
+
+ &--missing {
+ opacity: 0.55;
+ }
+
+ &--total {
+ margin-top: 4px;
+ padding-top: 4px;
+ border-top: 1px solid var(--bcds2-grid, #cbc9c4);
+ font-weight: 600;
+ }
+ }
+
+ &__swatch {
+ flex: none;
+ width: 10px;
+ height: 10px;
+ border-radius: 0;
+ }
+
+ &__label {
+ // Grow to fill the row; truncate with an ellipsis when the name is
+ // longer than the card (up to max-width) rather than wrapping. min-width:0
+ // lets the flex item shrink below its content width so ellipsis engages.
+ flex: 1 1 auto;
+ min-width: 0;
+ margin-right: 12px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ &__value {
+ // Never shrink or truncate the amount; it stays right-aligned so
+ // values line up in a column across rows even when labels ellipsize.
+ flex: none;
+ margin-left: auto;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+ }
+
+ &__footers {
+ margin-top: 6px;
+ padding-top: 6px;
+ border-top: 1px solid var(--bcds2-grid, #cbc9c4);
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ font-size: 12px;
+ opacity: 0.7;
+ }
+
+ &__footer {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ &__footer-icon {
+ flex: none;
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Timeline (spec 08 §2–3)
+// -----------------------------------------------------------------------------
+
+.bcds2-timeline {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 6px 0;
+ color: var(--bcds2-text, #272727);
+ font-size: 12px;
+
+ &__play {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ border: 1px solid var(--bcds2-grid, #cbc9c4);
+ border-radius: 0;
+ background: var(--bcds2-bg, #fbf6f1);
+ color: var(--bcds2-accent, #932f2f);
+ cursor: pointer;
+
+ &:hover {
+ border-color: var(--bcds2-accent, #932f2f);
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--bcds2-accent, #932f2f);
+ outline-offset: 1px;
+ }
+ }
+
+ &__track {
+ position: relative;
+ flex: 1;
+ height: 24px;
+ cursor: pointer;
+ touch-action: none;
+ }
+
+ &__rail {
+ position: absolute;
+ top: 50%;
+ left: 0;
+ right: 0;
+ height: 2px;
+ transform: translateY(-50%);
+ background: var(--bcds2-grid, #cbc9c4);
+ border-radius: 0;
+ }
+
+ &__tick {
+ position: absolute;
+ top: 50%;
+ width: 1px;
+ height: 8px;
+ transform: translate(-50%, -50%);
+ background: var(--bcds2-grid, #cbc9c4);
+ }
+
+ &__range {
+ position: absolute;
+ top: 50%;
+ height: 4px;
+ transform: translateY(-50%);
+ background: var(--bcds2-accent, #932f2f);
+ opacity: 0.35;
+ border-radius: 0;
+ pointer-events: none;
+ }
+
+ &__handle {
+ position: absolute;
+ top: 50%;
+ width: 12px;
+ height: 12px;
+ transform: translate(-50%, -50%);
+ background: var(--bcds2-accent, #932f2f);
+ border: 2px solid var(--bcds2-bg, #fbf6f1);
+ border-radius: 0;
+ box-shadow: 0 0 0 1px var(--bcds2-grid, #cbc9c4);
+ cursor: grab;
+
+ &:focus-visible {
+ outline: 2px solid var(--bcds2-accent, #932f2f);
+ outline-offset: 2px;
+ }
+ }
+
+ &__readout {
+ flex: none;
+ white-space: nowrap;
+ font-variant-numeric: tabular-nums;
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Entity selector (spec 07 §2)
+// -----------------------------------------------------------------------------
+
+.bcds2-entity-selector {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ color: var(--bcds2-text, #272727);
+ font-size: 13px;
+
+ &__search {
+ gap: 3px;
+
+ .bc-textfield__label {
+ color: inherit;
+ font: inherit;
+ font-size: 12px;
+ font-weight: 600;
+ }
+
+ .bc-textfield__input {
+ padding: 6px 8px;
+ border: 1px solid var(--bcds2-grid, #cbc9c4);
+ border-radius: 0;
+ background: var(--bcds2-bg, #fbf6f1);
+ color: inherit;
+ font: inherit;
+
+ &:focus {
+ border-color: var(--bcds2-accent, #932f2f);
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--bcds2-accent, #932f2f) 18%, transparent);
+ }
+ }
+ }
+
+ &__controls {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ }
+
+ &__sort {
+ display: grid;
+ grid-template-columns: auto minmax(92px, 1fr);
+ align-items: center;
+ gap: 4px;
+
+ .bc-select__label {
+ color: inherit;
+ font: inherit;
+ font-size: 12px;
+ }
+
+ .bc-select__input {
+ min-height: 24px;
+ padding: 2px 18px 2px 4px;
+ border: 1px solid var(--bcds2-grid, #cbc9c4);
+ border-radius: 0;
+ background: var(--bcds2-bg, #fbf6f1);
+ color: inherit;
+ font: inherit;
+ font-size: 12px;
+ }
+
+ .bc-select__chevron {
+ right: 5px;
+ font-size: 12px;
+ }
+ }
+
+ &__order {
+ padding: 2px 6px;
+ border: 1px solid var(--bcds2-grid, #cbc9c4);
+ border-radius: 0;
+ background: var(--bcds2-bg, #fbf6f1);
+ color: inherit;
+ min-height: 24px;
+ min-width: 28px;
+ font: inherit;
+ font-size: 12px;
+ text-transform: none;
+ letter-spacing: 0;
+ cursor: pointer;
+ }
+
+ &__bulk {
+ margin-left: auto;
+ display: inline-flex;
+ gap: 8px;
+ }
+
+ &__action {
+ padding: 0;
+ border: none;
+ min-height: auto;
+ background: none;
+ color: var(--bcds2-accent, #932f2f);
+ font: inherit;
+ font-size: 12px;
+ text-transform: none;
+ letter-spacing: 0;
+ cursor: pointer;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ &__groups {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ overflow-y: auto;
+ }
+
+ &__group-header {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-top: 4px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ opacity: 0.65;
+
+ .bc-checkbox__label {
+ gap: 6px;
+ }
+
+ .bc-checkbox__text {
+ color: inherit;
+ font: inherit;
+ font-size: inherit;
+ font-weight: inherit;
+ text-transform: inherit;
+ letter-spacing: inherit;
+ }
+ }
+
+ &__row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 4px;
+ border-radius: 0;
+ cursor: pointer;
+
+ &:hover {
+ background: var(--bcds2-grid, #cbc9c4);
+ }
+
+ &--no-data .bcds2-entity-selector__name {
+ opacity: 0.5;
+ }
+
+ &--no-data .bc-checkbox__text {
+ opacity: 0.5;
+ }
+ }
+
+ &__row-checkbox {
+ min-width: 0;
+
+ .bc-checkbox__label {
+ min-width: 0;
+ gap: 6px;
+ }
+
+ .bc-checkbox__text {
+ color: inherit;
+ font: inherit;
+ min-width: 0;
+ }
+ }
+
+ &__radio-group {
+ border: none;
+ padding: 0;
+
+ .bc-radio-group__options {
+ gap: 0;
+ }
+
+ .bc-radio-group__label {
+ align-items: center;
+ gap: 6px;
+ padding: 3px 4px;
+ border-radius: 0;
+ cursor: pointer;
+
+ &:hover {
+ background: var(--bcds2-grid, #cbc9c4);
+ }
+ }
+
+ .bc-radio-group__control {
+ width: 14px;
+ height: 14px;
+ }
+
+ .bc-radio-group__body,
+ .bc-radio-group__text {
+ min-width: 0;
+ flex: 1;
+ }
+
+ .bcds2-entity-selector__row {
+ padding: 0;
+ width: 100%;
+ }
+ }
+
+ &__tag {
+ font-size: 10px;
+ padding: 0 4px;
+ border: 1px solid var(--bcds2-grid, #cbc9c4);
+ border-radius: 0;
+ opacity: 0.65;
+ white-space: nowrap;
+ }
+
+ &__value {
+ margin-left: auto;
+ font-variant-numeric: tabular-nums;
+ opacity: 0.75;
+ white-space: nowrap;
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Tabs (spec 10 §3)
+// -----------------------------------------------------------------------------
+
+.bcds2-tabs {
+ border-bottom: 1px solid var(--bcds2-accent, #932f2f);
+
+ .bc-segmented-control__list {
+ display: flex;
+ gap: 0;
+ border-color: var(--bcds2-accent, #932f2f);
+ }
+
+ .bc-segmented-control__item {
+ padding: 6px 12px;
+ border: none;
+ background: var(--bcds2-bg, #fbf6f1);
+ color: var(--bcds2-text, #272727);
+ font: inherit;
+ font-size: 13px;
+ cursor: pointer;
+
+ &:hover {
+ background: color-mix(in srgb, var(--bcds2-text, #272727) 6%, transparent);
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--bcds2-accent, #932f2f);
+ outline-offset: -2px;
+ }
+
+ &[aria-selected="true"] {
+ font-weight: 600;
+ background: var(--bcds2-accent, #932f2f);
+ color: var(--bcds2-bg, #fbf6f1);
+
+ &:hover {
+ background: color-mix(in srgb, var(--bcds2-accent, #932f2f) 85%, #000);
+ }
+ }
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Settings menu (spec 10 §4)
+// -----------------------------------------------------------------------------
+
+.bcds2-settings {
+ display: inline-block;
+
+ &__button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: 1px solid var(--bcds2-grid, #cbc9c4);
+ border-radius: 0;
+ background: var(--bcds2-bg, #fbf6f1);
+ color: var(--bcds2-text, #272727);
+ cursor: pointer;
+
+ &:hover {
+ border-color: var(--bcds2-accent, #932f2f);
+ }
+
+ &[aria-expanded="true"] {
+ color: var(--bcds2-accent, #932f2f);
+ border-color: var(--bcds2-accent, #932f2f);
+ }
+ }
+
+ &__popover {
+ z-index: 20;
+ min-width: 200px;
+ padding: 8px;
+ background: var(--bcds2-bg, #fbf6f1);
+ color: var(--bcds2-text, #272727);
+ border: 1px solid var(--bcds2-grid, #cbc9c4);
+ border-radius: 0;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
+ font-size: 13px;
+ }
+
+ &__panel {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ &__item {
+ margin: 0;
+
+ &--toggle {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ cursor: pointer;
+ }
+
+ &--radio {
+ border: none;
+ padding: 0;
+ }
+ }
+
+ .bc-radio-group__legend {
+ padding: 0;
+ margin-bottom: 2px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ opacity: 0.65;
+ }
+
+ .bc-radio-group__options {
+ gap: 3px;
+ }
+
+ .bc-radio-group__label,
+ .bc-checkbox__label {
+ gap: 6px;
+ }
+
+ .bc-radio-group__control {
+ width: 14px;
+ height: 14px;
+ }
+
+ .bc-radio-group__text,
+ .bc-checkbox__text {
+ color: inherit;
+ font: inherit;
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Data table (spec 22)
+// -----------------------------------------------------------------------------
+
+.bcds2-data-table {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ color: var(--bcds2-text, #272727);
+ font-size: 13px;
+
+ &__toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ }
+
+ &__scope {
+ display: inline-flex;
+ border: 1px solid var(--bcds2-grid, #cbc9c4);
+ border-radius: 0;
+ overflow: hidden;
+
+ .bc-segmented-control__list {
+ display: inline-flex;
+ }
+
+ .bc-segmented-control__item {
+ padding: 4px 10px;
+ border: none;
+ background: var(--bcds2-bg, #fbf6f1);
+ color: inherit;
+ font: inherit;
+ font-size: 12px;
+ cursor: pointer;
+
+ & + .bc-segmented-control__item {
+ border-left: 1px solid var(--bcds2-grid, #cbc9c4);
+ }
+
+ &[aria-pressed="true"] {
+ background: var(--bcds2-accent, #932f2f);
+ color: var(--bcds2-bg, #fbf6f1);
+ }
+ }
+ }
+
+ &__search {
+ gap: 3px;
+
+ .bc-textfield__label {
+ color: inherit;
+ font: inherit;
+ font-size: 12px;
+ font-weight: 600;
+ }
+
+ .bc-textfield__input {
+ padding: 4px 8px;
+ border: 1px solid var(--bcds2-grid, #cbc9c4);
+ border-radius: 0;
+ background: var(--bcds2-bg, #fbf6f1);
+ color: inherit;
+ font: inherit;
+ font-size: 12px;
+
+ &:focus {
+ border-color: var(--bcds2-accent, #932f2f);
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--bcds2-accent, #932f2f) 18%, transparent);
+ }
+ }
+ }
+
+ &__scroll {
+ overflow: auto;
+ border: 1px solid var(--bcds2-grid, #cbc9c4);
+ border-radius: 0;
+ }
+
+ &__table {
+ border-collapse: separate;
+ border-spacing: 0;
+ width: 100%;
+ font-variant-numeric: tabular-nums;
+ }
+
+ &__header {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ padding: 6px 10px;
+ background: var(--bcds2-bg, #fbf6f1);
+ border-bottom: 1px solid var(--bcds2-grid, #cbc9c4);
+ text-align: left;
+ font-weight: 600;
+ white-space: nowrap;
+
+ &--numeric {
+ text-align: right;
+ }
+
+ // Entity header pinned both top and left.
+ &--entity {
+ left: 0;
+ z-index: 2;
+ }
+ }
+
+ &__sort-button {
+ padding: 0;
+ border: none;
+ min-height: auto;
+ background: none;
+ color: inherit;
+ font: inherit;
+ font-weight: inherit;
+ text-align: inherit;
+ text-transform: none;
+ letter-spacing: 0;
+ cursor: pointer;
+
+ &:focus-visible {
+ outline: 2px solid var(--bcds2-accent, #932f2f);
+ outline-offset: 1px;
+ }
+ }
+
+ &__sort-arrow {
+ margin-left: 4px;
+ font-size: 9px;
+ color: var(--bcds2-accent, #932f2f);
+ }
+
+ &__metric {
+ display: inline-flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 1px;
+ }
+
+ &__metric-unit,
+ &__metric-time {
+ font-weight: 400;
+ font-size: 11px;
+ opacity: 0.65;
+ }
+
+ &__cell {
+ padding: 5px 10px;
+ border-bottom: 1px solid var(--bcds2-grid, #cbc9c4);
+ text-align: left;
+ font-weight: 400;
+ white-space: nowrap;
+
+ &--numeric {
+ text-align: right;
+ }
+
+ &--missing {
+ opacity: 0.45;
+ }
+
+ // Entity column pinned left while the table scrolls horizontally.
+ &--entity {
+ position: sticky;
+ left: 0;
+ z-index: 1;
+ background: var(--bcds2-bg, #fbf6f1);
+ font-weight: 600;
+ }
+ }
+
+ &__marker {
+ margin-left: 4px;
+ font-size: 10px;
+ opacity: 0.65;
+ cursor: help;
+ }
+
+ &__row:hover &__cell {
+ background: var(--bcds2-bg, #fbf6f1);
+ filter: brightness(0.97);
+ }
+}
diff --git a/packages/charts2/src/samples.test.ts b/packages/charts2/src/samples.test.ts
new file mode 100644
index 00000000000..dd0dbc0b1bd
--- /dev/null
+++ b/packages/charts2/src/samples.test.ts
@@ -0,0 +1,52 @@
+import { readFileSync, readdirSync } from "node:fs"
+import { dirname, join, resolve } from "node:path"
+import { fileURLToPath } from "node:url"
+import { describe, expect, it } from "vitest"
+
+import { renderDefinitionToSvg, XML_DECLARATION } from "./cli/render.ts"
+import { validateInput } from "./cli/validate.ts"
+
+const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..")
+const samplesDir = join(packageRoot, "samples")
+const sampleFiles = readdirSync(samplesDir)
+ .filter((file) => file.endsWith(".json"))
+ .sort()
+
+describe("samples", () => {
+ it("has committed sample definitions", () => {
+ expect(sampleFiles).toEqual([
+ "discrete-bar-population.json",
+ "line-comparison-provincial-budgets.json",
+ "line-faceted-provincial-budgets.json",
+ "line-federal-departments.json",
+ "line-provincial-budgets.json",
+ "stacked-area-government-debt.json",
+ "stacked-bar-government-debt.json",
+ "stacked-discrete-bar-provincial-composition.json",
+ ])
+ })
+
+ it.each(sampleFiles)("%s validates and renders deterministically", (file) => {
+ const path = join(samplesDir, file)
+ const raw = JSON.parse(readFileSync(path, "utf8")) as { subtitle?: unknown }
+ const subtitle = typeof raw.subtitle === "string" ? raw.subtitle : ""
+
+ expect(typeof raw.subtitle).toBe("string")
+ expect(subtitle.trim()).not.toBe("")
+
+ const validation = validateInput(path)
+ expect(validation.errors, JSON.stringify(validation.diagnostics, null, 2)).toBe(0)
+ expect(validation.diagnostics).toEqual([])
+
+ const first = renderDefinitionToSvg({ definitionPath: path })
+ const second = renderDefinitionToSvg({ definitionPath: path })
+
+ expect(first.diagnostics.filter((diagnostic) => diagnostic.severity === "error")).toEqual([])
+ expect(first.svg).not.toBeNull()
+ expect(first.svg).toBe(second.svg)
+ expect(first.svg?.startsWith(`${XML_DECLARATION}\n