@squadbase/vantage 0.6.2-dev1.5aae04e → 0.6.2-dev1.83509f7

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.
Files changed (40) hide show
  1. package/dist/{chunk-XPQSTZUU.js → chunk-7S52M2SL.js} +4 -3
  2. package/dist/chunk-7S52M2SL.js.map +1 -0
  3. package/dist/{chunk-HKSVYMYJ.js → chunk-MYHFZYS4.js} +168 -82
  4. package/dist/chunk-MYHFZYS4.js.map +1 -0
  5. package/dist/components/index.js +2 -2
  6. package/dist/table/index.js +2 -2
  7. package/dist/ui/index.d.ts +4 -1
  8. package/dist/ui/index.js +3 -3
  9. package/dist/ui/index.js.map +1 -1
  10. package/package.json +2 -2
  11. package/registry/ui/area-chart.tsx +45 -19
  12. package/registry/ui/bar-chart.tsx +45 -19
  13. package/registry/ui/bar-list.tsx +44 -17
  14. package/registry/ui/box-plot-chart.tsx +45 -19
  15. package/registry/ui/category-bar.tsx +44 -17
  16. package/registry/ui/chart-frame.tsx +44 -17
  17. package/registry/ui/delta.tsx +4 -4
  18. package/registry/ui/donut-chart.tsx +46 -19
  19. package/registry/ui/echart.tsx +171 -54
  20. package/registry/ui/heatmap.tsx +45 -19
  21. package/registry/ui/histogram-chart.tsx +45 -19
  22. package/registry/ui/legend.tsx +44 -17
  23. package/registry/ui/line-chart.tsx +45 -19
  24. package/registry/ui/page-shell.tsx +7 -7
  25. package/registry/ui/paired-chart.tsx +45 -19
  26. package/registry/ui/sample-data.tsx +1 -1
  27. package/registry/ui/scatter-chart.tsx +45 -19
  28. package/registry/ui/slider.tsx +1 -1
  29. package/registry/ui/status-badge.tsx +8 -8
  30. package/registry/ui/tooltip.tsx +6 -2
  31. package/registry/ui/tracker.tsx +9 -3
  32. package/registry/ui/trend-indicator.tsx +2 -2
  33. package/registry/ui/waterfall-chart.tsx +45 -19
  34. package/skills/vantage-design/SKILL.md +27 -25
  35. package/skills/vantage-design/references/extract.md +16 -14
  36. package/skills/vantage-design/references/format.md +6 -5
  37. package/skills/vantage-design/references/presets.md +60 -110
  38. package/theme.css +13 -12
  39. package/dist/chunk-HKSVYMYJ.js.map +0 -1
  40. package/dist/chunk-XPQSTZUU.js.map +0 -1
@@ -16,7 +16,7 @@ import { forwardRef, useMemo, type ForwardedRef, type ReactElement, type Ref } f
16
16
  import { ChartFrame } from "@squadbase/vantage/components"
17
17
  import { EChart } from "@squadbase/vantage/components"
18
18
  import type { ChartComparison, ChartForecast, NumericKeys } from "@squadbase/vantage/components"
19
- import { createContext, useCallback, useEffect, useRef, useState } from "react"
19
+ import { createContext, useCallback, useEffect, useRef, useState, type RefObject } from "react"
20
20
  import type { ChartTone } from "@squadbase/vantage/components"
21
21
  import { DEFAULT_LOCALE, EMPTY_VALUE, formatValue, toValueFormatter, type ValueFormat } from "@squadbase/vantage/components"
22
22
  import type { ChartColor, ChartColors, ChartSeriesColor, ValueFormatter } from "@squadbase/vantage/components"
@@ -59,10 +59,23 @@ interface ChartTokens {
59
59
  border: string | null
60
60
  /** Tooltip surface. */
61
61
  popover: string | null
62
+ /** Semantic and surface dependencies used by typed chart options. Optional for hand-built tokens. */
63
+ colors?: Record<string, string>
64
+ /** Actual inherited painting surface (may change without a token changing). */
65
+ surface?: string | null
62
66
  /** Tooltip text. */
63
67
  popoverForeground: string | null
64
68
  }
65
69
 
70
+ // Keep scope out of the public token shape. A resolved snapshot remembers where arbitrary
71
+ // var(--token) expressions must be read, without retaining unmounted charts globally.
72
+ const tokenScopes = new WeakMap<ChartTokens, Element>()
73
+ const OPTION_COLOR_TOKENS = [
74
+ "--background", "--card", "--muted", "--primary", "--destructive",
75
+ ...["positive", "negative", "warning", "neutral", "info"].flatMap((tone) =>
76
+ ["", "-fill", "-subtle"].map((suffix) => `--${tone}${suffix}`)),
77
+ ]
78
+
66
79
  let normalizeContext: CanvasRenderingContext2D | null | undefined
67
80
 
68
81
  /** The forms `fillStyle` serializes legacy colours to, and zrender can parse. */
@@ -118,8 +131,10 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
118
131
  const color = read(token)
119
132
  if (color) series.push(color)
120
133
  }
121
- return {
134
+ const tokens: ChartTokens = {
122
135
  series,
136
+ colors: Object.fromEntries(OPTION_COLOR_TOKENS.map((name) => [name, styles.getPropertyValue(name).trim()])),
137
+ surface: readChartSurface(target),
123
138
  ...(styles.fontFamily ? { fontFamily: styles.fontFamily } : {}),
124
139
  foreground: read("--foreground"),
125
140
  mutedForeground: read("--muted-foreground"),
@@ -127,6 +142,8 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
127
142
  popover: read("--popover"),
128
143
  popoverForeground: read("--popover-foreground"),
129
144
  }
145
+ tokenScopes.set(tokens, target)
146
+ return tokens
130
147
  }
131
148
 
132
149
  /** Structural equality, so a theme toggle that changes nothing doesn't re-init. */
@@ -134,6 +151,10 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
134
151
  if (a === b) return true
135
152
  if (!a || !b) return false
136
153
  return (
154
+ tokenScopes.get(a) === tokenScopes.get(b) &&
155
+ a.surface === b.surface &&
156
+ Object.keys(a.colors ?? {}).length === Object.keys(b.colors ?? {}).length &&
157
+ Object.entries(a.colors ?? {}).every(([name, value]) => b.colors?.[name] === value) &&
137
158
  a.series.length === b.series.length &&
138
159
  a.series.every((color, i) => color === b.series[i]) &&
139
160
  a.fontFamily === b.fontFamily &&
@@ -159,7 +180,7 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
159
180
  * attributes alone leaves the chart painted in the previous palette until a
160
181
  * reload — the tokens in the DOM are new, the canvas is not.
161
182
  */
162
- function watchChartTokens(onChange: () => void): () => void {
183
+ function watchChartTokens(onChange: () => void, scope?: Element | null): () => void {
163
184
  if (typeof document === "undefined") return () => {}
164
185
  // Style edits arrive as bursts of mutations; coalesce them into one read, and
165
186
  // take it after the next frame so a swapped stylesheet has been applied.
@@ -174,8 +195,11 @@ function watchChartTokens(onChange: () => void): () => void {
174
195
 
175
196
  const themeObserver = new MutationObserver(schedule)
176
197
  const attributeFilter = ["class", "style", "data-theme"]
177
- themeObserver.observe(document.documentElement, { attributes: true, attributeFilter })
178
- if (document.body) themeObserver.observe(document.body, { attributes: true, attributeFilter })
198
+ // Only the chart and its ancestors can change its inherited theme. Never observe
199
+ // the document subtree: canvas/tooltip/rendering mutations would trigger every chart.
200
+ for (let node: Element | null = scope ?? document.body ?? document.documentElement; node; node = node.parentElement) {
201
+ themeObserver.observe(node, { attributes: true, attributeFilter })
202
+ }
179
203
 
180
204
  const styleObserver = new MutationObserver(schedule)
181
205
  if (document.head) {
@@ -263,22 +287,23 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
263
287
  * components that build an ECharts option from token colours themselves (the
264
288
  * typed charts), so their palette follows light/dark like `EChart`'s theme.
265
289
  */
266
- function useChartTokens(): ChartTokens | null {
290
+ function useChartTokens(scope?: Element | null | RefObject<Element | null>): ChartTokens | null {
267
291
  const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
268
292
  // Compared here, not inside a `setTokens` updater: an updater that reads the DOM is
269
293
  // replayed by React on every rebase and returns a fresh object each time, so the state
270
294
  // never settles ("Maximum update depth exceeded" once the tokens change after mount).
271
295
  const tokensRef = useRef(tokens)
272
296
  useEffect(() => {
297
+ const target = scope && "current" in scope ? scope.current : scope
273
298
  const sync = () => {
274
- const next = readChartTokens()
299
+ const next = readChartTokens(target)
275
300
  if (sameChartTokens(tokensRef.current, next)) return
276
301
  tokensRef.current = next
277
302
  setTokens(next)
278
303
  }
279
304
  sync()
280
- return watchChartTokens(sync)
281
- }, [])
305
+ return watchChartTokens(sync, target)
306
+ }, [scope])
282
307
  return tokens
283
308
  }
284
309
 
@@ -290,7 +315,7 @@ const EMBEDDED_VAR_RE = /var\(\s*(--[\w-]+)\s*\)/g
290
315
  /**
291
316
  * Turn chart colours into canvas-safe strings. `"chart-1"`〜`"chart-5"` come from
292
317
  * the resolved series tokens; `"positive"` / `"warning"` / `"negative"` / `"neutral"` (and the area
293
- * variants `"positive-fill"`, …) are read off the document root (`--positive`, …); `var(--token)` is read off the document root; any
318
+ * variants `"positive-fill"`, …) are read in the snapshot's inherited scope (`--positive`, …); `var(--token)` uses the same scope; any
294
319
  * other CSS colour is normalized like the tokens are. A colour that can't be
295
320
  * resolved comes back as `""`, which ECharts treats as "use the palette".
296
321
  */
@@ -301,12 +326,13 @@ function resolveChartColors(colors: readonly string[], tokens: ChartTokens | nul
301
326
  // Meaning colours are the theme tokens of the same name: same path as `var(--positive)`.
302
327
  if (TONE_NAME_RE.test(color.trim())) {
303
328
  if (typeof document === "undefined") return ""
304
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue(`--${color.trim()}`)) ?? ""
329
+ const target = (tokens && tokenScopes.get(tokens)) ?? document.documentElement
330
+ return toCanvasColor(getComputedStyle(target).getPropertyValue(`--${color.trim()}`)) ?? ""
305
331
  }
306
332
  if (typeof document === "undefined") return color
307
333
  let value = color.trim()
308
334
  const cssVar = CSS_VAR_RE.exec(value)
309
- const styles = getComputedStyle(document.documentElement)
335
+ const styles = getComputedStyle((tokens && tokenScopes.get(tokens)) ?? document.documentElement)
310
336
  if (cssVar) {
311
337
  value = styles.getPropertyValue(cssVar[1]!).trim() || (cssVar[2] ?? "").trim()
312
338
  } else if (value.includes("var(")) {
@@ -352,7 +378,7 @@ function readChartSurface(el: Element | null): string | null {
352
378
  if (color) return color
353
379
  }
354
380
  }
355
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue("--background"))
381
+ return toCanvasColor(getComputedStyle(el ?? document.documentElement).getPropertyValue("--background"))
356
382
  }
357
383
 
358
384
  /** Widths are bucketed so a resize only rebuilds the option when the label budget can change. */
@@ -366,9 +392,10 @@ const WIDTH_BUCKET = 40
366
392
  */
367
393
  function useChartSurface<E extends Element>(
368
394
  forwarded: ForwardedRef<E>,
369
- tokens: ChartTokens | null,
370
- ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined } {
395
+ tokens?: ChartTokens | null,
396
+ ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined; tokens: ChartTokens | null } {
371
397
  const [node, setNode] = useState<E | null>(null)
398
+ const scopedTokens = useChartTokens(node)
372
399
  const [surface, setSurface] = useState<string | undefined>(undefined)
373
400
  const [width, setWidth] = useState<number | undefined>(undefined)
374
401
  const ref = useCallback(
@@ -382,7 +409,7 @@ function useChartSurface<E extends Element>(
382
409
  useEffect(() => {
383
410
  if (!node) return
384
411
  setSurface(readChartSurface(node) ?? undefined)
385
- }, [node, tokens])
412
+ }, [node, tokens, scopedTokens])
386
413
  useEffect(() => {
387
414
  if (!node || typeof ResizeObserver === "undefined") return
388
415
  const update = (w: number) => setWidth(Math.floor(w / WIDTH_BUCKET) * WIDTH_BUCKET)
@@ -391,7 +418,7 @@ function useChartSurface<E extends Element>(
391
418
  observer.observe(node)
392
419
  return () => observer.disconnect()
393
420
  }, [node])
394
- return { ref, surface, width }
421
+ return { ref, surface, width, tokens: scopedTokens }
395
422
  }
396
423
 
397
424
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-utils.ts (internal to @squadbase/vantage) ---------- */
@@ -3554,9 +3581,8 @@ function LineChartImpl<T extends Record<string, unknown>>(
3554
3581
  }: LineChartProps<T>,
3555
3582
  ref: ForwardedRef<HTMLDivElement>,
3556
3583
  ) {
3557
- const tokens = useChartTokens()
3558
3584
  // Highlight labels are backed by the surface the chart sits on (a card, the page).
3559
- const { ref: frameRef, surface, width: plotWidth } = useChartSurface(ref, tokens)
3585
+ const { ref: frameRef, surface, width: plotWidth, tokens } = useChartSurface(ref)
3560
3586
  // Ticks and on-plot labels read short (¥1億); the tooltip reads the value as `format` says.
3561
3587
  const tooltipFormatter = useMemo(() => resolveValueFormatter(valueFormatter, format), [valueFormatter, format])
3562
3588
  const axisFormatter = useMemo(() => resolveAxisFormatter(valueFormatter, format), [valueFormatter, format])
@@ -72,12 +72,12 @@ export const PageShellHeader = forwardRef<HTMLDivElement, PageShellHeaderProps>(
72
72
  {hasChips ? (
73
73
  <div data-slot="page-shell-chips" className="order-1 flex basis-full flex-wrap gap-1.5">
74
74
  {source ? (
75
- <Badge variant="outline" className="h-auto max-w-full whitespace-normal">
75
+ <Badge variant="outline" className="h-auto max-w-full whitespace-normal text-muted-foreground">
76
76
  出典:{source}
77
77
  </Badge>
78
78
  ) : null}
79
79
  {periodText && periodText !== EMPTY_VALUE ? (
80
- <Badge variant="outline" className="tabular-nums">
80
+ <Badge variant="outline" className="tabular-nums text-muted-foreground">
81
81
  {periodText}
82
82
  </Badge>
83
83
  ) : null}
@@ -204,11 +204,11 @@ PageShellSummary.displayName = "PageShellSummary"
204
204
  * emits classes it can see literally in the source.
205
205
  */
206
206
  const summaryCardTones = {
207
- positive: "border-positive/30 bg-positive-subtle [&>svg]:text-positive",
208
- negative: "border-negative/30 bg-negative-subtle [&>svg]:text-negative",
209
- warning: "border-warning/30 bg-warning-subtle [&>svg]:text-warning",
210
- info: "border-info/30 bg-info-subtle [&>svg]:text-info",
211
- neutral: "border-neutral/30 bg-neutral-subtle [&>svg]:text-neutral",
207
+ positive: "border-positive/20 dark:border-positive/25 bg-positive-subtle [&>svg]:text-positive",
208
+ negative: "border-negative/20 dark:border-negative/25 bg-negative-subtle [&>svg]:text-negative",
209
+ warning: "border-warning/20 dark:border-warning/25 bg-warning-subtle [&>svg]:text-warning",
210
+ info: "border-info/20 dark:border-info/25 bg-info-subtle [&>svg]:text-info",
211
+ neutral: "border-neutral/20 dark:border-neutral/25 bg-neutral-subtle [&>svg]:text-neutral",
212
212
  violet: "border-chart-4/30 bg-chart-4/5 [&>svg]:text-chart-4",
213
213
  pink: "border-chart-5/30 bg-chart-5/5 [&>svg]:text-chart-5",
214
214
  } as const
@@ -17,7 +17,7 @@ import { ChartFrame } from "@squadbase/vantage/components"
17
17
  import { EChart } from "@squadbase/vantage/components"
18
18
  import type { ChartCommonProps, ChartColors, NumericKeys, ValueFormatter } from "@squadbase/vantage/components"
19
19
  import type { ValueFormat } from "@squadbase/vantage/components"
20
- import { createContext, useCallback, useEffect, useRef, useState } from "react"
20
+ import { createContext, useCallback, useEffect, useRef, useState, type RefObject } from "react"
21
21
  import type { ChartTone } from "@squadbase/vantage/components"
22
22
  import { DEFAULT_LOCALE, EMPTY_VALUE, formatValue, toValueFormatter } from "@squadbase/vantage/components"
23
23
  import type { ChartColor, ChartSeriesColor } from "@squadbase/vantage/components"
@@ -59,10 +59,23 @@ interface ChartTokens {
59
59
  border: string | null
60
60
  /** Tooltip surface. */
61
61
  popover: string | null
62
+ /** Semantic and surface dependencies used by typed chart options. Optional for hand-built tokens. */
63
+ colors?: Record<string, string>
64
+ /** Actual inherited painting surface (may change without a token changing). */
65
+ surface?: string | null
62
66
  /** Tooltip text. */
63
67
  popoverForeground: string | null
64
68
  }
65
69
 
70
+ // Keep scope out of the public token shape. A resolved snapshot remembers where arbitrary
71
+ // var(--token) expressions must be read, without retaining unmounted charts globally.
72
+ const tokenScopes = new WeakMap<ChartTokens, Element>()
73
+ const OPTION_COLOR_TOKENS = [
74
+ "--background", "--card", "--muted", "--primary", "--destructive",
75
+ ...["positive", "negative", "warning", "neutral", "info"].flatMap((tone) =>
76
+ ["", "-fill", "-subtle"].map((suffix) => `--${tone}${suffix}`)),
77
+ ]
78
+
66
79
  let normalizeContext: CanvasRenderingContext2D | null | undefined
67
80
 
68
81
  /** The forms `fillStyle` serializes legacy colours to, and zrender can parse. */
@@ -118,8 +131,10 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
118
131
  const color = read(token)
119
132
  if (color) series.push(color)
120
133
  }
121
- return {
134
+ const tokens: ChartTokens = {
122
135
  series,
136
+ colors: Object.fromEntries(OPTION_COLOR_TOKENS.map((name) => [name, styles.getPropertyValue(name).trim()])),
137
+ surface: readChartSurface(target),
123
138
  ...(styles.fontFamily ? { fontFamily: styles.fontFamily } : {}),
124
139
  foreground: read("--foreground"),
125
140
  mutedForeground: read("--muted-foreground"),
@@ -127,6 +142,8 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
127
142
  popover: read("--popover"),
128
143
  popoverForeground: read("--popover-foreground"),
129
144
  }
145
+ tokenScopes.set(tokens, target)
146
+ return tokens
130
147
  }
131
148
 
132
149
  /** Structural equality, so a theme toggle that changes nothing doesn't re-init. */
@@ -134,6 +151,10 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
134
151
  if (a === b) return true
135
152
  if (!a || !b) return false
136
153
  return (
154
+ tokenScopes.get(a) === tokenScopes.get(b) &&
155
+ a.surface === b.surface &&
156
+ Object.keys(a.colors ?? {}).length === Object.keys(b.colors ?? {}).length &&
157
+ Object.entries(a.colors ?? {}).every(([name, value]) => b.colors?.[name] === value) &&
137
158
  a.series.length === b.series.length &&
138
159
  a.series.every((color, i) => color === b.series[i]) &&
139
160
  a.fontFamily === b.fontFamily &&
@@ -159,7 +180,7 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
159
180
  * attributes alone leaves the chart painted in the previous palette until a
160
181
  * reload — the tokens in the DOM are new, the canvas is not.
161
182
  */
162
- function watchChartTokens(onChange: () => void): () => void {
183
+ function watchChartTokens(onChange: () => void, scope?: Element | null): () => void {
163
184
  if (typeof document === "undefined") return () => {}
164
185
  // Style edits arrive as bursts of mutations; coalesce them into one read, and
165
186
  // take it after the next frame so a swapped stylesheet has been applied.
@@ -174,8 +195,11 @@ function watchChartTokens(onChange: () => void): () => void {
174
195
 
175
196
  const themeObserver = new MutationObserver(schedule)
176
197
  const attributeFilter = ["class", "style", "data-theme"]
177
- themeObserver.observe(document.documentElement, { attributes: true, attributeFilter })
178
- if (document.body) themeObserver.observe(document.body, { attributes: true, attributeFilter })
198
+ // Only the chart and its ancestors can change its inherited theme. Never observe
199
+ // the document subtree: canvas/tooltip/rendering mutations would trigger every chart.
200
+ for (let node: Element | null = scope ?? document.body ?? document.documentElement; node; node = node.parentElement) {
201
+ themeObserver.observe(node, { attributes: true, attributeFilter })
202
+ }
179
203
 
180
204
  const styleObserver = new MutationObserver(schedule)
181
205
  if (document.head) {
@@ -263,22 +287,23 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
263
287
  * components that build an ECharts option from token colours themselves (the
264
288
  * typed charts), so their palette follows light/dark like `EChart`'s theme.
265
289
  */
266
- function useChartTokens(): ChartTokens | null {
290
+ function useChartTokens(scope?: Element | null | RefObject<Element | null>): ChartTokens | null {
267
291
  const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
268
292
  // Compared here, not inside a `setTokens` updater: an updater that reads the DOM is
269
293
  // replayed by React on every rebase and returns a fresh object each time, so the state
270
294
  // never settles ("Maximum update depth exceeded" once the tokens change after mount).
271
295
  const tokensRef = useRef(tokens)
272
296
  useEffect(() => {
297
+ const target = scope && "current" in scope ? scope.current : scope
273
298
  const sync = () => {
274
- const next = readChartTokens()
299
+ const next = readChartTokens(target)
275
300
  if (sameChartTokens(tokensRef.current, next)) return
276
301
  tokensRef.current = next
277
302
  setTokens(next)
278
303
  }
279
304
  sync()
280
- return watchChartTokens(sync)
281
- }, [])
305
+ return watchChartTokens(sync, target)
306
+ }, [scope])
282
307
  return tokens
283
308
  }
284
309
 
@@ -290,7 +315,7 @@ const EMBEDDED_VAR_RE = /var\(\s*(--[\w-]+)\s*\)/g
290
315
  /**
291
316
  * Turn chart colours into canvas-safe strings. `"chart-1"`〜`"chart-5"` come from
292
317
  * the resolved series tokens; `"positive"` / `"warning"` / `"negative"` / `"neutral"` (and the area
293
- * variants `"positive-fill"`, …) are read off the document root (`--positive`, …); `var(--token)` is read off the document root; any
318
+ * variants `"positive-fill"`, …) are read in the snapshot's inherited scope (`--positive`, …); `var(--token)` uses the same scope; any
294
319
  * other CSS colour is normalized like the tokens are. A colour that can't be
295
320
  * resolved comes back as `""`, which ECharts treats as "use the palette".
296
321
  */
@@ -301,12 +326,13 @@ function resolveChartColors(colors: readonly string[], tokens: ChartTokens | nul
301
326
  // Meaning colours are the theme tokens of the same name: same path as `var(--positive)`.
302
327
  if (TONE_NAME_RE.test(color.trim())) {
303
328
  if (typeof document === "undefined") return ""
304
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue(`--${color.trim()}`)) ?? ""
329
+ const target = (tokens && tokenScopes.get(tokens)) ?? document.documentElement
330
+ return toCanvasColor(getComputedStyle(target).getPropertyValue(`--${color.trim()}`)) ?? ""
305
331
  }
306
332
  if (typeof document === "undefined") return color
307
333
  let value = color.trim()
308
334
  const cssVar = CSS_VAR_RE.exec(value)
309
- const styles = getComputedStyle(document.documentElement)
335
+ const styles = getComputedStyle((tokens && tokenScopes.get(tokens)) ?? document.documentElement)
310
336
  if (cssVar) {
311
337
  value = styles.getPropertyValue(cssVar[1]!).trim() || (cssVar[2] ?? "").trim()
312
338
  } else if (value.includes("var(")) {
@@ -352,7 +378,7 @@ function readChartSurface(el: Element | null): string | null {
352
378
  if (color) return color
353
379
  }
354
380
  }
355
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue("--background"))
381
+ return toCanvasColor(getComputedStyle(el ?? document.documentElement).getPropertyValue("--background"))
356
382
  }
357
383
 
358
384
  /** Widths are bucketed so a resize only rebuilds the option when the label budget can change. */
@@ -366,9 +392,10 @@ const WIDTH_BUCKET = 40
366
392
  */
367
393
  function useChartSurface<E extends Element>(
368
394
  forwarded: ForwardedRef<E>,
369
- tokens: ChartTokens | null,
370
- ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined } {
395
+ tokens?: ChartTokens | null,
396
+ ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined; tokens: ChartTokens | null } {
371
397
  const [node, setNode] = useState<E | null>(null)
398
+ const scopedTokens = useChartTokens(node)
372
399
  const [surface, setSurface] = useState<string | undefined>(undefined)
373
400
  const [width, setWidth] = useState<number | undefined>(undefined)
374
401
  const ref = useCallback(
@@ -382,7 +409,7 @@ function useChartSurface<E extends Element>(
382
409
  useEffect(() => {
383
410
  if (!node) return
384
411
  setSurface(readChartSurface(node) ?? undefined)
385
- }, [node, tokens])
412
+ }, [node, tokens, scopedTokens])
386
413
  useEffect(() => {
387
414
  if (!node || typeof ResizeObserver === "undefined") return
388
415
  const update = (w: number) => setWidth(Math.floor(w / WIDTH_BUCKET) * WIDTH_BUCKET)
@@ -391,7 +418,7 @@ function useChartSurface<E extends Element>(
391
418
  observer.observe(node)
392
419
  return () => observer.disconnect()
393
420
  }, [node])
394
- return { ref, surface, width }
421
+ return { ref, surface, width, tokens: scopedTokens }
395
422
  }
396
423
 
397
424
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-utils.ts (internal to @squadbase/vantage) ---------- */
@@ -3707,8 +3734,7 @@ function PairedChartImpl<T extends Record<string, unknown>>(
3707
3734
  }: PairedChartProps<T>,
3708
3735
  ref: ForwardedRef<HTMLDivElement>,
3709
3736
  ) {
3710
- const tokens = useChartTokens()
3711
- const { ref: frameRef, width: plotWidth } = useChartSurface(ref, tokens)
3737
+ const { ref: frameRef, width: plotWidth, tokens } = useChartSurface(ref)
3712
3738
  const barKeys = (bars?.categories ?? NO_KEYS) as string[]
3713
3739
  const lineKeys = (line?.categories ?? NO_KEYS) as string[]
3714
3740
  const barFormatter = useMemo(() => resolveValueFormatter(bars?.valueFormatter, bars?.format), [bars?.valueFormatter, bars?.format])
@@ -61,7 +61,7 @@ export function SampleBadge({ label = DEFAULT_SAMPLE_LABEL, className, ...props
61
61
  <Badge
62
62
  data-slot="sample-badge"
63
63
  title={SAMPLE_TITLE}
64
- className={cn("border-warning/30 bg-warning-subtle text-warning align-middle", className)}
64
+ className={cn("border-warning/20 dark:border-warning/25 bg-warning-subtle text-warning align-middle", className)}
65
65
  {...props}
66
66
  >
67
67
  {label}
@@ -29,7 +29,7 @@ import { ChartFrame } from "@squadbase/vantage/components"
29
29
  import { EChart } from "@squadbase/vantage/components"
30
30
  import { EMPTY_VALUE, type ValueFormat } from "@squadbase/vantage/components"
31
31
  import type { ChartCommonProps, ChartEmptyState, ChartSeriesColor, NumericKeys, ValueFormatter } from "@squadbase/vantage/components"
32
- import { createContext, useCallback, useEffect, useRef, useState } from "react"
32
+ import { createContext, useCallback, useEffect, useRef, useState, type RefObject } from "react"
33
33
  import type { ChartTone } from "@squadbase/vantage/components"
34
34
  import { type ReactNode } from "react"
35
35
  import { computeComparison, defaultChangeDisplay, formatChange, toneForValue, type ComparisonKind, type PeriodGrain } from "@squadbase/vantage/components"
@@ -72,10 +72,23 @@ interface ChartTokens {
72
72
  border: string | null
73
73
  /** Tooltip surface. */
74
74
  popover: string | null
75
+ /** Semantic and surface dependencies used by typed chart options. Optional for hand-built tokens. */
76
+ colors?: Record<string, string>
77
+ /** Actual inherited painting surface (may change without a token changing). */
78
+ surface?: string | null
75
79
  /** Tooltip text. */
76
80
  popoverForeground: string | null
77
81
  }
78
82
 
83
+ // Keep scope out of the public token shape. A resolved snapshot remembers where arbitrary
84
+ // var(--token) expressions must be read, without retaining unmounted charts globally.
85
+ const tokenScopes = new WeakMap<ChartTokens, Element>()
86
+ const OPTION_COLOR_TOKENS = [
87
+ "--background", "--card", "--muted", "--primary", "--destructive",
88
+ ...["positive", "negative", "warning", "neutral", "info"].flatMap((tone) =>
89
+ ["", "-fill", "-subtle"].map((suffix) => `--${tone}${suffix}`)),
90
+ ]
91
+
79
92
  let normalizeContext: CanvasRenderingContext2D | null | undefined
80
93
 
81
94
  /** The forms `fillStyle` serializes legacy colours to, and zrender can parse. */
@@ -131,8 +144,10 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
131
144
  const color = read(token)
132
145
  if (color) series.push(color)
133
146
  }
134
- return {
147
+ const tokens: ChartTokens = {
135
148
  series,
149
+ colors: Object.fromEntries(OPTION_COLOR_TOKENS.map((name) => [name, styles.getPropertyValue(name).trim()])),
150
+ surface: readChartSurface(target),
136
151
  ...(styles.fontFamily ? { fontFamily: styles.fontFamily } : {}),
137
152
  foreground: read("--foreground"),
138
153
  mutedForeground: read("--muted-foreground"),
@@ -140,6 +155,8 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
140
155
  popover: read("--popover"),
141
156
  popoverForeground: read("--popover-foreground"),
142
157
  }
158
+ tokenScopes.set(tokens, target)
159
+ return tokens
143
160
  }
144
161
 
145
162
  /** Structural equality, so a theme toggle that changes nothing doesn't re-init. */
@@ -147,6 +164,10 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
147
164
  if (a === b) return true
148
165
  if (!a || !b) return false
149
166
  return (
167
+ tokenScopes.get(a) === tokenScopes.get(b) &&
168
+ a.surface === b.surface &&
169
+ Object.keys(a.colors ?? {}).length === Object.keys(b.colors ?? {}).length &&
170
+ Object.entries(a.colors ?? {}).every(([name, value]) => b.colors?.[name] === value) &&
150
171
  a.series.length === b.series.length &&
151
172
  a.series.every((color, i) => color === b.series[i]) &&
152
173
  a.fontFamily === b.fontFamily &&
@@ -172,7 +193,7 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
172
193
  * attributes alone leaves the chart painted in the previous palette until a
173
194
  * reload — the tokens in the DOM are new, the canvas is not.
174
195
  */
175
- function watchChartTokens(onChange: () => void): () => void {
196
+ function watchChartTokens(onChange: () => void, scope?: Element | null): () => void {
176
197
  if (typeof document === "undefined") return () => {}
177
198
  // Style edits arrive as bursts of mutations; coalesce them into one read, and
178
199
  // take it after the next frame so a swapped stylesheet has been applied.
@@ -187,8 +208,11 @@ function watchChartTokens(onChange: () => void): () => void {
187
208
 
188
209
  const themeObserver = new MutationObserver(schedule)
189
210
  const attributeFilter = ["class", "style", "data-theme"]
190
- themeObserver.observe(document.documentElement, { attributes: true, attributeFilter })
191
- if (document.body) themeObserver.observe(document.body, { attributes: true, attributeFilter })
211
+ // Only the chart and its ancestors can change its inherited theme. Never observe
212
+ // the document subtree: canvas/tooltip/rendering mutations would trigger every chart.
213
+ for (let node: Element | null = scope ?? document.body ?? document.documentElement; node; node = node.parentElement) {
214
+ themeObserver.observe(node, { attributes: true, attributeFilter })
215
+ }
192
216
 
193
217
  const styleObserver = new MutationObserver(schedule)
194
218
  if (document.head) {
@@ -276,22 +300,23 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
276
300
  * components that build an ECharts option from token colours themselves (the
277
301
  * typed charts), so their palette follows light/dark like `EChart`'s theme.
278
302
  */
279
- function useChartTokens(): ChartTokens | null {
303
+ function useChartTokens(scope?: Element | null | RefObject<Element | null>): ChartTokens | null {
280
304
  const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
281
305
  // Compared here, not inside a `setTokens` updater: an updater that reads the DOM is
282
306
  // replayed by React on every rebase and returns a fresh object each time, so the state
283
307
  // never settles ("Maximum update depth exceeded" once the tokens change after mount).
284
308
  const tokensRef = useRef(tokens)
285
309
  useEffect(() => {
310
+ const target = scope && "current" in scope ? scope.current : scope
286
311
  const sync = () => {
287
- const next = readChartTokens()
312
+ const next = readChartTokens(target)
288
313
  if (sameChartTokens(tokensRef.current, next)) return
289
314
  tokensRef.current = next
290
315
  setTokens(next)
291
316
  }
292
317
  sync()
293
- return watchChartTokens(sync)
294
- }, [])
318
+ return watchChartTokens(sync, target)
319
+ }, [scope])
295
320
  return tokens
296
321
  }
297
322
 
@@ -303,7 +328,7 @@ const EMBEDDED_VAR_RE = /var\(\s*(--[\w-]+)\s*\)/g
303
328
  /**
304
329
  * Turn chart colours into canvas-safe strings. `"chart-1"`〜`"chart-5"` come from
305
330
  * the resolved series tokens; `"positive"` / `"warning"` / `"negative"` / `"neutral"` (and the area
306
- * variants `"positive-fill"`, …) are read off the document root (`--positive`, …); `var(--token)` is read off the document root; any
331
+ * variants `"positive-fill"`, …) are read in the snapshot's inherited scope (`--positive`, …); `var(--token)` uses the same scope; any
307
332
  * other CSS colour is normalized like the tokens are. A colour that can't be
308
333
  * resolved comes back as `""`, which ECharts treats as "use the palette".
309
334
  */
@@ -314,12 +339,13 @@ function resolveChartColors(colors: readonly string[], tokens: ChartTokens | nul
314
339
  // Meaning colours are the theme tokens of the same name: same path as `var(--positive)`.
315
340
  if (TONE_NAME_RE.test(color.trim())) {
316
341
  if (typeof document === "undefined") return ""
317
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue(`--${color.trim()}`)) ?? ""
342
+ const target = (tokens && tokenScopes.get(tokens)) ?? document.documentElement
343
+ return toCanvasColor(getComputedStyle(target).getPropertyValue(`--${color.trim()}`)) ?? ""
318
344
  }
319
345
  if (typeof document === "undefined") return color
320
346
  let value = color.trim()
321
347
  const cssVar = CSS_VAR_RE.exec(value)
322
- const styles = getComputedStyle(document.documentElement)
348
+ const styles = getComputedStyle((tokens && tokenScopes.get(tokens)) ?? document.documentElement)
323
349
  if (cssVar) {
324
350
  value = styles.getPropertyValue(cssVar[1]!).trim() || (cssVar[2] ?? "").trim()
325
351
  } else if (value.includes("var(")) {
@@ -365,7 +391,7 @@ function readChartSurface(el: Element | null): string | null {
365
391
  if (color) return color
366
392
  }
367
393
  }
368
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue("--background"))
394
+ return toCanvasColor(getComputedStyle(el ?? document.documentElement).getPropertyValue("--background"))
369
395
  }
370
396
 
371
397
  /** Widths are bucketed so a resize only rebuilds the option when the label budget can change. */
@@ -379,9 +405,10 @@ const WIDTH_BUCKET = 40
379
405
  */
380
406
  function useChartSurface<E extends Element>(
381
407
  forwarded: ForwardedRef<E>,
382
- tokens: ChartTokens | null,
383
- ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined } {
408
+ tokens?: ChartTokens | null,
409
+ ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined; tokens: ChartTokens | null } {
384
410
  const [node, setNode] = useState<E | null>(null)
411
+ const scopedTokens = useChartTokens(node)
385
412
  const [surface, setSurface] = useState<string | undefined>(undefined)
386
413
  const [width, setWidth] = useState<number | undefined>(undefined)
387
414
  const ref = useCallback(
@@ -395,7 +422,7 @@ function useChartSurface<E extends Element>(
395
422
  useEffect(() => {
396
423
  if (!node) return
397
424
  setSurface(readChartSurface(node) ?? undefined)
398
- }, [node, tokens])
425
+ }, [node, tokens, scopedTokens])
399
426
  useEffect(() => {
400
427
  if (!node || typeof ResizeObserver === "undefined") return
401
428
  const update = (w: number) => setWidth(Math.floor(w / WIDTH_BUCKET) * WIDTH_BUCKET)
@@ -404,7 +431,7 @@ function useChartSurface<E extends Element>(
404
431
  observer.observe(node)
405
432
  return () => observer.disconnect()
406
433
  }, [node])
407
- return { ref, surface, width }
434
+ return { ref, surface, width, tokens: scopedTokens }
408
435
  }
409
436
 
410
437
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-options.ts (internal to @squadbase/vantage) ---------- */
@@ -4090,8 +4117,7 @@ function ScatterChartImpl<T extends Record<string, unknown>>(
4090
4117
  }: ScatterChartProps<T>,
4091
4118
  ref: ForwardedRef<HTMLDivElement>,
4092
4119
  ) {
4093
- const tokens = useChartTokens()
4094
- const { ref: frameRef, width: plotWidth } = useChartSurface(ref, tokens)
4120
+ const { ref: frameRef, width: plotWidth, tokens } = useChartSurface(ref)
4095
4121
  // Tooltips and the table read in full; axis ticks read short.
4096
4122
  const formatters = useMemo(
4097
4123
  () => ({
@@ -45,7 +45,7 @@ function Slider({
45
45
  <SliderPrimitive.Thumb
46
46
  data-slot="slider-thumb"
47
47
  key={index}
48
- className="relative block size-3 shrink-0 rounded-full border border-ring bg-white ring-ring/50 transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 disabled:pointer-events-none disabled:opacity-50"
48
+ className="relative block size-3 shrink-0 rounded-full border border-ring bg-background ring-ring/50 transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 disabled:pointer-events-none disabled:opacity-50"
49
49
  />
50
50
  ))}
51
51
  </SliderPrimitive.Control>
@@ -75,12 +75,12 @@ const legacyColorTones: Record<string, StatusTone> = {
75
75
  * One literal class set per tone — Tailwind only emits classes it can see
76
76
  * literally in the source.
77
77
  */
78
- const toneStyles: Record<StatusTone, { dot: string; bg: string; text: string }> = {
79
- positive: { dot: "bg-positive", bg: "bg-positive-subtle", text: "text-positive" },
80
- negative: { dot: "bg-negative", bg: "bg-negative-subtle", text: "text-negative" },
81
- warning: { dot: "bg-warning", bg: "bg-warning-subtle", text: "text-warning" },
82
- info: { dot: "bg-info", bg: "bg-info-subtle", text: "text-info" },
83
- neutral: { dot: "bg-neutral", bg: "bg-neutral-subtle", text: "text-neutral" },
78
+ const toneStyles: Record<StatusTone, { dot: string; bg: string; text: string; border: string }> = {
79
+ positive: { dot: "bg-positive", bg: "bg-positive-subtle", text: "text-positive", border: "border-positive/20 dark:border-positive/25" },
80
+ negative: { dot: "bg-negative", bg: "bg-negative-subtle", text: "text-negative", border: "border-negative/20 dark:border-negative/25" },
81
+ warning: { dot: "bg-warning", bg: "bg-warning-subtle", text: "text-warning", border: "border-warning/20 dark:border-warning/25" },
82
+ info: { dot: "bg-info", bg: "bg-info-subtle", text: "text-info", border: "border-info/20 dark:border-info/25" },
83
+ neutral: { dot: "bg-neutral", bg: "bg-neutral-subtle", text: "text-neutral", border: "border-neutral/20 dark:border-neutral/25" },
84
84
  }
85
85
 
86
86
  const resolveTone = (color: string | undefined): StatusTone => {
@@ -110,10 +110,10 @@ export const StatusBadge = forwardRef<HTMLSpanElement, StatusBadgeProps>(functio
110
110
  ref={ref}
111
111
  data-slot="status-badge"
112
112
  variant="outline"
113
- className={cn("gap-1.5 border-transparent", style.bg, style.text, className)}
113
+ className={cn("gap-1.5 rounded-md", style.border, style.bg, style.text, className)}
114
114
  aria-label={displayLabel}
115
115
  >
116
- <span className={cn("inline-block size-2 rounded-full", style.dot)} aria-hidden="true" />
116
+ <span className={cn("inline-block size-1.5 rounded-full", style.dot)} aria-hidden="true" />
117
117
  {displayLabel}
118
118
  </Badge>
119
119
  )