@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
@@ -27,7 +27,7 @@ import { ChartFrame } from "@squadbase/vantage/components"
27
27
  import { EChart } from "@squadbase/vantage/components"
28
28
  import { EMPTY_VALUE, type ValueFormat } from "@squadbase/vantage/components"
29
29
  import type { ChartActionsOptions, ChartEmptyState, ChartRootAttributes, ChartSeriesColor, ValueFormatter } from "@squadbase/vantage/components"
30
- import { createContext, useCallback, useEffect, useRef, useState } from "react"
30
+ import { createContext, useCallback, useEffect, useRef, useState, type RefObject } from "react"
31
31
  import type { ChartTone } from "@squadbase/vantage/components"
32
32
  import { DEFAULT_LOCALE, formatValue, toValueFormatter } from "@squadbase/vantage/components"
33
33
  import type { ChartColor, ChartColors } from "@squadbase/vantage/components"
@@ -66,10 +66,23 @@ interface ChartTokens {
66
66
  border: string | null
67
67
  /** Tooltip surface. */
68
68
  popover: string | null
69
+ /** Semantic and surface dependencies used by typed chart options. Optional for hand-built tokens. */
70
+ colors?: Record<string, string>
71
+ /** Actual inherited painting surface (may change without a token changing). */
72
+ surface?: string | null
69
73
  /** Tooltip text. */
70
74
  popoverForeground: string | null
71
75
  }
72
76
 
77
+ // Keep scope out of the public token shape. A resolved snapshot remembers where arbitrary
78
+ // var(--token) expressions must be read, without retaining unmounted charts globally.
79
+ const tokenScopes = new WeakMap<ChartTokens, Element>()
80
+ const OPTION_COLOR_TOKENS = [
81
+ "--background", "--card", "--muted", "--primary", "--destructive",
82
+ ...["positive", "negative", "warning", "neutral", "info"].flatMap((tone) =>
83
+ ["", "-fill", "-subtle"].map((suffix) => `--${tone}${suffix}`)),
84
+ ]
85
+
73
86
  let normalizeContext: CanvasRenderingContext2D | null | undefined
74
87
 
75
88
  /** The forms `fillStyle` serializes legacy colours to, and zrender can parse. */
@@ -125,8 +138,10 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
125
138
  const color = read(token)
126
139
  if (color) series.push(color)
127
140
  }
128
- return {
141
+ const tokens: ChartTokens = {
129
142
  series,
143
+ colors: Object.fromEntries(OPTION_COLOR_TOKENS.map((name) => [name, styles.getPropertyValue(name).trim()])),
144
+ surface: readChartSurface(target),
130
145
  ...(styles.fontFamily ? { fontFamily: styles.fontFamily } : {}),
131
146
  foreground: read("--foreground"),
132
147
  mutedForeground: read("--muted-foreground"),
@@ -134,6 +149,8 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
134
149
  popover: read("--popover"),
135
150
  popoverForeground: read("--popover-foreground"),
136
151
  }
152
+ tokenScopes.set(tokens, target)
153
+ return tokens
137
154
  }
138
155
 
139
156
  /** Structural equality, so a theme toggle that changes nothing doesn't re-init. */
@@ -141,6 +158,10 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
141
158
  if (a === b) return true
142
159
  if (!a || !b) return false
143
160
  return (
161
+ tokenScopes.get(a) === tokenScopes.get(b) &&
162
+ a.surface === b.surface &&
163
+ Object.keys(a.colors ?? {}).length === Object.keys(b.colors ?? {}).length &&
164
+ Object.entries(a.colors ?? {}).every(([name, value]) => b.colors?.[name] === value) &&
144
165
  a.series.length === b.series.length &&
145
166
  a.series.every((color, i) => color === b.series[i]) &&
146
167
  a.fontFamily === b.fontFamily &&
@@ -166,7 +187,7 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
166
187
  * attributes alone leaves the chart painted in the previous palette until a
167
188
  * reload — the tokens in the DOM are new, the canvas is not.
168
189
  */
169
- function watchChartTokens(onChange: () => void): () => void {
190
+ function watchChartTokens(onChange: () => void, scope?: Element | null): () => void {
170
191
  if (typeof document === "undefined") return () => {}
171
192
  // Style edits arrive as bursts of mutations; coalesce them into one read, and
172
193
  // take it after the next frame so a swapped stylesheet has been applied.
@@ -181,8 +202,11 @@ function watchChartTokens(onChange: () => void): () => void {
181
202
 
182
203
  const themeObserver = new MutationObserver(schedule)
183
204
  const attributeFilter = ["class", "style", "data-theme"]
184
- themeObserver.observe(document.documentElement, { attributes: true, attributeFilter })
185
- if (document.body) themeObserver.observe(document.body, { attributes: true, attributeFilter })
205
+ // Only the chart and its ancestors can change its inherited theme. Never observe
206
+ // the document subtree: canvas/tooltip/rendering mutations would trigger every chart.
207
+ for (let node: Element | null = scope ?? document.body ?? document.documentElement; node; node = node.parentElement) {
208
+ themeObserver.observe(node, { attributes: true, attributeFilter })
209
+ }
186
210
 
187
211
  const styleObserver = new MutationObserver(schedule)
188
212
  if (document.head) {
@@ -270,22 +294,23 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
270
294
  * components that build an ECharts option from token colours themselves (the
271
295
  * typed charts), so their palette follows light/dark like `EChart`'s theme.
272
296
  */
273
- function useChartTokens(): ChartTokens | null {
297
+ function useChartTokens(scope?: Element | null | RefObject<Element | null>): ChartTokens | null {
274
298
  const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
275
299
  // Compared here, not inside a `setTokens` updater: an updater that reads the DOM is
276
300
  // replayed by React on every rebase and returns a fresh object each time, so the state
277
301
  // never settles ("Maximum update depth exceeded" once the tokens change after mount).
278
302
  const tokensRef = useRef(tokens)
279
303
  useEffect(() => {
304
+ const target = scope && "current" in scope ? scope.current : scope
280
305
  const sync = () => {
281
- const next = readChartTokens()
306
+ const next = readChartTokens(target)
282
307
  if (sameChartTokens(tokensRef.current, next)) return
283
308
  tokensRef.current = next
284
309
  setTokens(next)
285
310
  }
286
311
  sync()
287
- return watchChartTokens(sync)
288
- }, [])
312
+ return watchChartTokens(sync, target)
313
+ }, [scope])
289
314
  return tokens
290
315
  }
291
316
 
@@ -297,7 +322,7 @@ const EMBEDDED_VAR_RE = /var\(\s*(--[\w-]+)\s*\)/g
297
322
  /**
298
323
  * Turn chart colours into canvas-safe strings. `"chart-1"`〜`"chart-5"` come from
299
324
  * the resolved series tokens; `"positive"` / `"warning"` / `"negative"` / `"neutral"` (and the area
300
- * variants `"positive-fill"`, …) are read off the document root (`--positive`, …); `var(--token)` is read off the document root; any
325
+ * variants `"positive-fill"`, …) are read in the snapshot's inherited scope (`--positive`, …); `var(--token)` uses the same scope; any
301
326
  * other CSS colour is normalized like the tokens are. A colour that can't be
302
327
  * resolved comes back as `""`, which ECharts treats as "use the palette".
303
328
  */
@@ -308,12 +333,13 @@ function resolveChartColors(colors: readonly string[], tokens: ChartTokens | nul
308
333
  // Meaning colours are the theme tokens of the same name: same path as `var(--positive)`.
309
334
  if (TONE_NAME_RE.test(color.trim())) {
310
335
  if (typeof document === "undefined") return ""
311
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue(`--${color.trim()}`)) ?? ""
336
+ const target = (tokens && tokenScopes.get(tokens)) ?? document.documentElement
337
+ return toCanvasColor(getComputedStyle(target).getPropertyValue(`--${color.trim()}`)) ?? ""
312
338
  }
313
339
  if (typeof document === "undefined") return color
314
340
  let value = color.trim()
315
341
  const cssVar = CSS_VAR_RE.exec(value)
316
- const styles = getComputedStyle(document.documentElement)
342
+ const styles = getComputedStyle((tokens && tokenScopes.get(tokens)) ?? document.documentElement)
317
343
  if (cssVar) {
318
344
  value = styles.getPropertyValue(cssVar[1]!).trim() || (cssVar[2] ?? "").trim()
319
345
  } else if (value.includes("var(")) {
@@ -359,7 +385,7 @@ function readChartSurface(el: Element | null): string | null {
359
385
  if (color) return color
360
386
  }
361
387
  }
362
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue("--background"))
388
+ return toCanvasColor(getComputedStyle(el ?? document.documentElement).getPropertyValue("--background"))
363
389
  }
364
390
 
365
391
  /** Widths are bucketed so a resize only rebuilds the option when the label budget can change. */
@@ -373,9 +399,10 @@ const WIDTH_BUCKET = 40
373
399
  */
374
400
  function useChartSurface<E extends Element>(
375
401
  forwarded: ForwardedRef<E>,
376
- tokens: ChartTokens | null,
377
- ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined } {
402
+ tokens?: ChartTokens | null,
403
+ ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined; tokens: ChartTokens | null } {
378
404
  const [node, setNode] = useState<E | null>(null)
405
+ const scopedTokens = useChartTokens(node)
379
406
  const [surface, setSurface] = useState<string | undefined>(undefined)
380
407
  const [width, setWidth] = useState<number | undefined>(undefined)
381
408
  const ref = useCallback(
@@ -389,7 +416,7 @@ function useChartSurface<E extends Element>(
389
416
  useEffect(() => {
390
417
  if (!node) return
391
418
  setSurface(readChartSurface(node) ?? undefined)
392
- }, [node, tokens])
419
+ }, [node, tokens, scopedTokens])
393
420
  useEffect(() => {
394
421
  if (!node || typeof ResizeObserver === "undefined") return
395
422
  const update = (w: number) => setWidth(Math.floor(w / WIDTH_BUCKET) * WIDTH_BUCKET)
@@ -398,7 +425,7 @@ function useChartSurface<E extends Element>(
398
425
  observer.observe(node)
399
426
  return () => observer.disconnect()
400
427
  }, [node])
401
- return { ref, surface, width }
428
+ return { ref, surface, width, tokens: scopedTokens }
402
429
  }
403
430
 
404
431
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-utils.ts (internal to @squadbase/vantage) ---------- */
@@ -1665,8 +1692,7 @@ function HeatmapImpl<T extends Record<string, unknown>>(
1665
1692
  }: HeatmapProps<T>,
1666
1693
  ref: ForwardedRef<HTMLDivElement>,
1667
1694
  ) {
1668
- const tokens = useChartTokens()
1669
- const { ref: frameRef, surface, width: plotWidth } = useChartSurface(ref, tokens)
1695
+ const { ref: frameRef, surface, width: plotWidth, tokens } = useChartSurface(ref)
1670
1696
  const formatter = useMemo(() => resolveValueFormatter(valueFormatter, format ?? "number"), [valueFormatter, format])
1671
1697
  const model = useMemo(
1672
1698
  () => buildHeatmapModel({ data, x, y, value, xLabels, yLabels, flag, scale, minValue, maxValue }),
@@ -19,7 +19,7 @@ import { ChartFrame } from "@squadbase/vantage/components"
19
19
  import { EChart } from "@squadbase/vantage/components"
20
20
  import { toValueFormatter } from "@squadbase/vantage/components"
21
21
  import type { ChartCommonProps, ValueFormatter } from "@squadbase/vantage/components"
22
- import { createContext, useCallback, useEffect, useRef, useState } from "react"
22
+ import { createContext, useCallback, useEffect, useRef, useState, type RefObject } from "react"
23
23
  import type { ChartTone } from "@squadbase/vantage/components"
24
24
  import { type ReactNode } from "react"
25
25
  import { computeComparison, defaultChangeDisplay, formatChange, toneForValue, type ComparisonKind, type PeriodGrain } from "@squadbase/vantage/components"
@@ -62,10 +62,23 @@ interface ChartTokens {
62
62
  border: string | null
63
63
  /** Tooltip surface. */
64
64
  popover: string | null
65
+ /** Semantic and surface dependencies used by typed chart options. Optional for hand-built tokens. */
66
+ colors?: Record<string, string>
67
+ /** Actual inherited painting surface (may change without a token changing). */
68
+ surface?: string | null
65
69
  /** Tooltip text. */
66
70
  popoverForeground: string | null
67
71
  }
68
72
 
73
+ // Keep scope out of the public token shape. A resolved snapshot remembers where arbitrary
74
+ // var(--token) expressions must be read, without retaining unmounted charts globally.
75
+ const tokenScopes = new WeakMap<ChartTokens, Element>()
76
+ const OPTION_COLOR_TOKENS = [
77
+ "--background", "--card", "--muted", "--primary", "--destructive",
78
+ ...["positive", "negative", "warning", "neutral", "info"].flatMap((tone) =>
79
+ ["", "-fill", "-subtle"].map((suffix) => `--${tone}${suffix}`)),
80
+ ]
81
+
69
82
  let normalizeContext: CanvasRenderingContext2D | null | undefined
70
83
 
71
84
  /** The forms `fillStyle` serializes legacy colours to, and zrender can parse. */
@@ -121,8 +134,10 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
121
134
  const color = read(token)
122
135
  if (color) series.push(color)
123
136
  }
124
- return {
137
+ const tokens: ChartTokens = {
125
138
  series,
139
+ colors: Object.fromEntries(OPTION_COLOR_TOKENS.map((name) => [name, styles.getPropertyValue(name).trim()])),
140
+ surface: readChartSurface(target),
126
141
  ...(styles.fontFamily ? { fontFamily: styles.fontFamily } : {}),
127
142
  foreground: read("--foreground"),
128
143
  mutedForeground: read("--muted-foreground"),
@@ -130,6 +145,8 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
130
145
  popover: read("--popover"),
131
146
  popoverForeground: read("--popover-foreground"),
132
147
  }
148
+ tokenScopes.set(tokens, target)
149
+ return tokens
133
150
  }
134
151
 
135
152
  /** Structural equality, so a theme toggle that changes nothing doesn't re-init. */
@@ -137,6 +154,10 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
137
154
  if (a === b) return true
138
155
  if (!a || !b) return false
139
156
  return (
157
+ tokenScopes.get(a) === tokenScopes.get(b) &&
158
+ a.surface === b.surface &&
159
+ Object.keys(a.colors ?? {}).length === Object.keys(b.colors ?? {}).length &&
160
+ Object.entries(a.colors ?? {}).every(([name, value]) => b.colors?.[name] === value) &&
140
161
  a.series.length === b.series.length &&
141
162
  a.series.every((color, i) => color === b.series[i]) &&
142
163
  a.fontFamily === b.fontFamily &&
@@ -162,7 +183,7 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
162
183
  * attributes alone leaves the chart painted in the previous palette until a
163
184
  * reload — the tokens in the DOM are new, the canvas is not.
164
185
  */
165
- function watchChartTokens(onChange: () => void): () => void {
186
+ function watchChartTokens(onChange: () => void, scope?: Element | null): () => void {
166
187
  if (typeof document === "undefined") return () => {}
167
188
  // Style edits arrive as bursts of mutations; coalesce them into one read, and
168
189
  // take it after the next frame so a swapped stylesheet has been applied.
@@ -177,8 +198,11 @@ function watchChartTokens(onChange: () => void): () => void {
177
198
 
178
199
  const themeObserver = new MutationObserver(schedule)
179
200
  const attributeFilter = ["class", "style", "data-theme"]
180
- themeObserver.observe(document.documentElement, { attributes: true, attributeFilter })
181
- if (document.body) themeObserver.observe(document.body, { attributes: true, attributeFilter })
201
+ // Only the chart and its ancestors can change its inherited theme. Never observe
202
+ // the document subtree: canvas/tooltip/rendering mutations would trigger every chart.
203
+ for (let node: Element | null = scope ?? document.body ?? document.documentElement; node; node = node.parentElement) {
204
+ themeObserver.observe(node, { attributes: true, attributeFilter })
205
+ }
182
206
 
183
207
  const styleObserver = new MutationObserver(schedule)
184
208
  if (document.head) {
@@ -266,22 +290,23 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
266
290
  * components that build an ECharts option from token colours themselves (the
267
291
  * typed charts), so their palette follows light/dark like `EChart`'s theme.
268
292
  */
269
- function useChartTokens(): ChartTokens | null {
293
+ function useChartTokens(scope?: Element | null | RefObject<Element | null>): ChartTokens | null {
270
294
  const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
271
295
  // Compared here, not inside a `setTokens` updater: an updater that reads the DOM is
272
296
  // replayed by React on every rebase and returns a fresh object each time, so the state
273
297
  // never settles ("Maximum update depth exceeded" once the tokens change after mount).
274
298
  const tokensRef = useRef(tokens)
275
299
  useEffect(() => {
300
+ const target = scope && "current" in scope ? scope.current : scope
276
301
  const sync = () => {
277
- const next = readChartTokens()
302
+ const next = readChartTokens(target)
278
303
  if (sameChartTokens(tokensRef.current, next)) return
279
304
  tokensRef.current = next
280
305
  setTokens(next)
281
306
  }
282
307
  sync()
283
- return watchChartTokens(sync)
284
- }, [])
308
+ return watchChartTokens(sync, target)
309
+ }, [scope])
285
310
  return tokens
286
311
  }
287
312
 
@@ -293,7 +318,7 @@ const EMBEDDED_VAR_RE = /var\(\s*(--[\w-]+)\s*\)/g
293
318
  /**
294
319
  * Turn chart colours into canvas-safe strings. `"chart-1"`〜`"chart-5"` come from
295
320
  * the resolved series tokens; `"positive"` / `"warning"` / `"negative"` / `"neutral"` (and the area
296
- * variants `"positive-fill"`, …) are read off the document root (`--positive`, …); `var(--token)` is read off the document root; any
321
+ * variants `"positive-fill"`, …) are read in the snapshot's inherited scope (`--positive`, …); `var(--token)` uses the same scope; any
297
322
  * other CSS colour is normalized like the tokens are. A colour that can't be
298
323
  * resolved comes back as `""`, which ECharts treats as "use the palette".
299
324
  */
@@ -304,12 +329,13 @@ function resolveChartColors(colors: readonly string[], tokens: ChartTokens | nul
304
329
  // Meaning colours are the theme tokens of the same name: same path as `var(--positive)`.
305
330
  if (TONE_NAME_RE.test(color.trim())) {
306
331
  if (typeof document === "undefined") return ""
307
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue(`--${color.trim()}`)) ?? ""
332
+ const target = (tokens && tokenScopes.get(tokens)) ?? document.documentElement
333
+ return toCanvasColor(getComputedStyle(target).getPropertyValue(`--${color.trim()}`)) ?? ""
308
334
  }
309
335
  if (typeof document === "undefined") return color
310
336
  let value = color.trim()
311
337
  const cssVar = CSS_VAR_RE.exec(value)
312
- const styles = getComputedStyle(document.documentElement)
338
+ const styles = getComputedStyle((tokens && tokenScopes.get(tokens)) ?? document.documentElement)
313
339
  if (cssVar) {
314
340
  value = styles.getPropertyValue(cssVar[1]!).trim() || (cssVar[2] ?? "").trim()
315
341
  } else if (value.includes("var(")) {
@@ -355,7 +381,7 @@ function readChartSurface(el: Element | null): string | null {
355
381
  if (color) return color
356
382
  }
357
383
  }
358
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue("--background"))
384
+ return toCanvasColor(getComputedStyle(el ?? document.documentElement).getPropertyValue("--background"))
359
385
  }
360
386
 
361
387
  /** Widths are bucketed so a resize only rebuilds the option when the label budget can change. */
@@ -369,9 +395,10 @@ const WIDTH_BUCKET = 40
369
395
  */
370
396
  function useChartSurface<E extends Element>(
371
397
  forwarded: ForwardedRef<E>,
372
- tokens: ChartTokens | null,
373
- ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined } {
398
+ tokens?: ChartTokens | null,
399
+ ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined; tokens: ChartTokens | null } {
374
400
  const [node, setNode] = useState<E | null>(null)
401
+ const scopedTokens = useChartTokens(node)
375
402
  const [surface, setSurface] = useState<string | undefined>(undefined)
376
403
  const [width, setWidth] = useState<number | undefined>(undefined)
377
404
  const ref = useCallback(
@@ -385,7 +412,7 @@ function useChartSurface<E extends Element>(
385
412
  useEffect(() => {
386
413
  if (!node) return
387
414
  setSurface(readChartSurface(node) ?? undefined)
388
- }, [node, tokens])
415
+ }, [node, tokens, scopedTokens])
389
416
  useEffect(() => {
390
417
  if (!node || typeof ResizeObserver === "undefined") return
391
418
  const update = (w: number) => setWidth(Math.floor(w / WIDTH_BUCKET) * WIDTH_BUCKET)
@@ -394,7 +421,7 @@ function useChartSurface<E extends Element>(
394
421
  observer.observe(node)
395
422
  return () => observer.disconnect()
396
423
  }, [node])
397
- return { ref, surface, width }
424
+ return { ref, surface, width, tokens: scopedTokens }
398
425
  }
399
426
 
400
427
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-options.ts (internal to @squadbase/vantage) ---------- */
@@ -3894,8 +3921,7 @@ export const HistogramChart = forwardRef(function HistogramChart(
3894
3921
  }: HistogramChartProps,
3895
3922
  ref: ForwardedRef<HTMLDivElement>,
3896
3923
  ) {
3897
- const tokens = useChartTokens()
3898
- const { ref: frameRef } = useChartSurface(ref, tokens)
3924
+ const { ref: frameRef, tokens } = useChartSurface(ref)
3899
3925
  // Class names read in full (`10〜15日`); the format default is a plain number, not the compact form.
3900
3926
  const valueFormatter = useMemo(
3901
3927
  () => (valueFormatterProp ?? (format === undefined ? toValueFormatter("number") : resolveValueFormatter(undefined, format))),
@@ -17,7 +17,7 @@ import { forwardRef } from "react"
17
17
 
18
18
  import { cn } from "@squadbase/vantage/ui"
19
19
  import type { ChartSeriesColor } from "@squadbase/vantage/components"
20
- import { createContext, useCallback, useEffect, useRef, useState, type ForwardedRef } from "react"
20
+ import { createContext, useCallback, useEffect, useRef, useState, type ForwardedRef, type RefObject } from "react"
21
21
  import type { ChartTone } from "@squadbase/vantage/components"
22
22
  import { DEFAULT_LOCALE, EMPTY_VALUE, formatValue, toValueFormatter, type ValueFormat } from "@squadbase/vantage/components"
23
23
  import type { ChartColor, ChartColors, ValueFormatter } from "@squadbase/vantage/components"
@@ -60,10 +60,23 @@ interface ChartTokens {
60
60
  border: string | null
61
61
  /** Tooltip surface. */
62
62
  popover: string | null
63
+ /** Semantic and surface dependencies used by typed chart options. Optional for hand-built tokens. */
64
+ colors?: Record<string, string>
65
+ /** Actual inherited painting surface (may change without a token changing). */
66
+ surface?: string | null
63
67
  /** Tooltip text. */
64
68
  popoverForeground: string | null
65
69
  }
66
70
 
71
+ // Keep scope out of the public token shape. A resolved snapshot remembers where arbitrary
72
+ // var(--token) expressions must be read, without retaining unmounted charts globally.
73
+ const tokenScopes = new WeakMap<ChartTokens, Element>()
74
+ const OPTION_COLOR_TOKENS = [
75
+ "--background", "--card", "--muted", "--primary", "--destructive",
76
+ ...["positive", "negative", "warning", "neutral", "info"].flatMap((tone) =>
77
+ ["", "-fill", "-subtle"].map((suffix) => `--${tone}${suffix}`)),
78
+ ]
79
+
67
80
  let normalizeContext: CanvasRenderingContext2D | null | undefined
68
81
 
69
82
  /** The forms `fillStyle` serializes legacy colours to, and zrender can parse. */
@@ -119,8 +132,10 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
119
132
  const color = read(token)
120
133
  if (color) series.push(color)
121
134
  }
122
- return {
135
+ const tokens: ChartTokens = {
123
136
  series,
137
+ colors: Object.fromEntries(OPTION_COLOR_TOKENS.map((name) => [name, styles.getPropertyValue(name).trim()])),
138
+ surface: readChartSurface(target),
124
139
  ...(styles.fontFamily ? { fontFamily: styles.fontFamily } : {}),
125
140
  foreground: read("--foreground"),
126
141
  mutedForeground: read("--muted-foreground"),
@@ -128,6 +143,8 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
128
143
  popover: read("--popover"),
129
144
  popoverForeground: read("--popover-foreground"),
130
145
  }
146
+ tokenScopes.set(tokens, target)
147
+ return tokens
131
148
  }
132
149
 
133
150
  /** Structural equality, so a theme toggle that changes nothing doesn't re-init. */
@@ -135,6 +152,10 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
135
152
  if (a === b) return true
136
153
  if (!a || !b) return false
137
154
  return (
155
+ tokenScopes.get(a) === tokenScopes.get(b) &&
156
+ a.surface === b.surface &&
157
+ Object.keys(a.colors ?? {}).length === Object.keys(b.colors ?? {}).length &&
158
+ Object.entries(a.colors ?? {}).every(([name, value]) => b.colors?.[name] === value) &&
138
159
  a.series.length === b.series.length &&
139
160
  a.series.every((color, i) => color === b.series[i]) &&
140
161
  a.fontFamily === b.fontFamily &&
@@ -160,7 +181,7 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
160
181
  * attributes alone leaves the chart painted in the previous palette until a
161
182
  * reload — the tokens in the DOM are new, the canvas is not.
162
183
  */
163
- function watchChartTokens(onChange: () => void): () => void {
184
+ function watchChartTokens(onChange: () => void, scope?: Element | null): () => void {
164
185
  if (typeof document === "undefined") return () => {}
165
186
  // Style edits arrive as bursts of mutations; coalesce them into one read, and
166
187
  // take it after the next frame so a swapped stylesheet has been applied.
@@ -175,8 +196,11 @@ function watchChartTokens(onChange: () => void): () => void {
175
196
 
176
197
  const themeObserver = new MutationObserver(schedule)
177
198
  const attributeFilter = ["class", "style", "data-theme"]
178
- themeObserver.observe(document.documentElement, { attributes: true, attributeFilter })
179
- if (document.body) themeObserver.observe(document.body, { attributes: true, attributeFilter })
199
+ // Only the chart and its ancestors can change its inherited theme. Never observe
200
+ // the document subtree: canvas/tooltip/rendering mutations would trigger every chart.
201
+ for (let node: Element | null = scope ?? document.body ?? document.documentElement; node; node = node.parentElement) {
202
+ themeObserver.observe(node, { attributes: true, attributeFilter })
203
+ }
180
204
 
181
205
  const styleObserver = new MutationObserver(schedule)
182
206
  if (document.head) {
@@ -264,22 +288,23 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
264
288
  * components that build an ECharts option from token colours themselves (the
265
289
  * typed charts), so their palette follows light/dark like `EChart`'s theme.
266
290
  */
267
- function useChartTokens(): ChartTokens | null {
291
+ function useChartTokens(scope?: Element | null | RefObject<Element | null>): ChartTokens | null {
268
292
  const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
269
293
  // Compared here, not inside a `setTokens` updater: an updater that reads the DOM is
270
294
  // replayed by React on every rebase and returns a fresh object each time, so the state
271
295
  // never settles ("Maximum update depth exceeded" once the tokens change after mount).
272
296
  const tokensRef = useRef(tokens)
273
297
  useEffect(() => {
298
+ const target = scope && "current" in scope ? scope.current : scope
274
299
  const sync = () => {
275
- const next = readChartTokens()
300
+ const next = readChartTokens(target)
276
301
  if (sameChartTokens(tokensRef.current, next)) return
277
302
  tokensRef.current = next
278
303
  setTokens(next)
279
304
  }
280
305
  sync()
281
- return watchChartTokens(sync)
282
- }, [])
306
+ return watchChartTokens(sync, target)
307
+ }, [scope])
283
308
  return tokens
284
309
  }
285
310
 
@@ -291,7 +316,7 @@ const EMBEDDED_VAR_RE = /var\(\s*(--[\w-]+)\s*\)/g
291
316
  /**
292
317
  * Turn chart colours into canvas-safe strings. `"chart-1"`〜`"chart-5"` come from
293
318
  * the resolved series tokens; `"positive"` / `"warning"` / `"negative"` / `"neutral"` (and the area
294
- * variants `"positive-fill"`, …) are read off the document root (`--positive`, …); `var(--token)` is read off the document root; any
319
+ * variants `"positive-fill"`, …) are read in the snapshot's inherited scope (`--positive`, …); `var(--token)` uses the same scope; any
295
320
  * other CSS colour is normalized like the tokens are. A colour that can't be
296
321
  * resolved comes back as `""`, which ECharts treats as "use the palette".
297
322
  */
@@ -302,12 +327,13 @@ function resolveChartColors(colors: readonly string[], tokens: ChartTokens | nul
302
327
  // Meaning colours are the theme tokens of the same name: same path as `var(--positive)`.
303
328
  if (TONE_NAME_RE.test(color.trim())) {
304
329
  if (typeof document === "undefined") return ""
305
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue(`--${color.trim()}`)) ?? ""
330
+ const target = (tokens && tokenScopes.get(tokens)) ?? document.documentElement
331
+ return toCanvasColor(getComputedStyle(target).getPropertyValue(`--${color.trim()}`)) ?? ""
306
332
  }
307
333
  if (typeof document === "undefined") return color
308
334
  let value = color.trim()
309
335
  const cssVar = CSS_VAR_RE.exec(value)
310
- const styles = getComputedStyle(document.documentElement)
336
+ const styles = getComputedStyle((tokens && tokenScopes.get(tokens)) ?? document.documentElement)
311
337
  if (cssVar) {
312
338
  value = styles.getPropertyValue(cssVar[1]!).trim() || (cssVar[2] ?? "").trim()
313
339
  } else if (value.includes("var(")) {
@@ -353,7 +379,7 @@ function readChartSurface(el: Element | null): string | null {
353
379
  if (color) return color
354
380
  }
355
381
  }
356
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue("--background"))
382
+ return toCanvasColor(getComputedStyle(el ?? document.documentElement).getPropertyValue("--background"))
357
383
  }
358
384
 
359
385
  /** Widths are bucketed so a resize only rebuilds the option when the label budget can change. */
@@ -367,9 +393,10 @@ const WIDTH_BUCKET = 40
367
393
  */
368
394
  function useChartSurface<E extends Element>(
369
395
  forwarded: ForwardedRef<E>,
370
- tokens: ChartTokens | null,
371
- ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined } {
396
+ tokens?: ChartTokens | null,
397
+ ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined; tokens: ChartTokens | null } {
372
398
  const [node, setNode] = useState<E | null>(null)
399
+ const scopedTokens = useChartTokens(node)
373
400
  const [surface, setSurface] = useState<string | undefined>(undefined)
374
401
  const [width, setWidth] = useState<number | undefined>(undefined)
375
402
  const ref = useCallback(
@@ -383,7 +410,7 @@ function useChartSurface<E extends Element>(
383
410
  useEffect(() => {
384
411
  if (!node) return
385
412
  setSurface(readChartSurface(node) ?? undefined)
386
- }, [node, tokens])
413
+ }, [node, tokens, scopedTokens])
387
414
  useEffect(() => {
388
415
  if (!node || typeof ResizeObserver === "undefined") return
389
416
  const update = (w: number) => setWidth(Math.floor(w / WIDTH_BUCKET) * WIDTH_BUCKET)
@@ -392,7 +419,7 @@ function useChartSurface<E extends Element>(
392
419
  observer.observe(node)
393
420
  return () => observer.disconnect()
394
421
  }, [node])
395
- return { ref, surface, width }
422
+ return { ref, surface, width, tokens: scopedTokens }
396
423
  }
397
424
 
398
425
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-utils.ts (internal to @squadbase/vantage) ---------- */