@squadbase/vantage 0.7.1-dev1.c17b6ee → 0.7.1-dev1.c83f631

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 (64) hide show
  1. package/dist/{build-U7GO26HR.js → build-YQGJBR3G.js} +3 -3
  2. package/dist/{build-U7GO26HR.js.map → build-YQGJBR3G.js.map} +1 -1
  3. package/dist/{chunk-EG4PX726.js → chunk-GWXKENNH.js} +122 -5
  4. package/dist/chunk-GWXKENNH.js.map +1 -0
  5. package/dist/{chunk-SC7K2RD6.js → chunk-I2GWIPTD.js} +101 -27
  6. package/dist/chunk-I2GWIPTD.js.map +1 -0
  7. package/dist/{chunk-SDJTTMLK.js → chunk-K2ZA547X.js} +3 -3
  8. package/dist/chunk-K2ZA547X.js.map +1 -0
  9. package/dist/{chunk-P2KIHF65.js → chunk-U6MWBCM6.js} +28 -7
  10. package/dist/chunk-U6MWBCM6.js.map +1 -0
  11. package/dist/cli.js +2 -2
  12. package/dist/components/index.d.ts +206 -4
  13. package/dist/components/index.js +3 -3
  14. package/dist/{dev-O42JFQ64.js → dev-5PHW3KNJ.js} +3 -3
  15. package/dist/{dev-O42JFQ64.js.map → dev-5PHW3KNJ.js.map} +1 -1
  16. package/dist/doc/index.js +1 -1
  17. package/dist/slide/index.js +1 -1
  18. package/dist/table/index.js +3 -3
  19. package/dist/ui/index.d.ts +28 -1
  20. package/dist/ui/index.js +172 -9
  21. package/dist/ui/index.js.map +1 -1
  22. package/dist/vite/index.js +1 -1
  23. package/package.json +2 -2
  24. package/registry/parts.json +81 -2
  25. package/registry/ui/area-chart.tsx +43 -8
  26. package/registry/ui/bar-chart.tsx +43 -8
  27. package/registry/ui/bar-list.tsx +43 -8
  28. package/registry/ui/box-plot-chart.tsx +42 -7
  29. package/registry/ui/category-bar.tsx +25 -5
  30. package/registry/ui/chart-frame.tsx +47 -14
  31. package/registry/ui/checkbox.tsx +4 -3
  32. package/registry/ui/code-block.tsx +44 -0
  33. package/registry/ui/combo-chart.tsx +20 -3
  34. package/registry/ui/combobox.tsx +7 -0
  35. package/registry/ui/context-menu.tsx +71 -0
  36. package/registry/ui/dashboard-card.tsx +20 -9
  37. package/registry/ui/data-table.tsx +7 -1
  38. package/registry/ui/date-range-picker.tsx +51 -4
  39. package/registry/ui/delta.tsx +36 -11
  40. package/registry/ui/donut-chart.tsx +43 -8
  41. package/registry/ui/echart.tsx +167 -7
  42. package/registry/ui/file-dropzone.tsx +143 -0
  43. package/registry/ui/filter-bar.tsx +74 -1
  44. package/registry/ui/forecast-chart.tsx +42 -7
  45. package/registry/ui/funnel-steps.tsx +22 -4
  46. package/registry/ui/heatmap.tsx +20 -3
  47. package/registry/ui/histogram-chart.tsx +42 -7
  48. package/registry/ui/kpi-card.tsx +12 -8
  49. package/registry/ui/legend.tsx +73 -15
  50. package/registry/ui/line-chart.tsx +43 -8
  51. package/registry/ui/page-shell.tsx +2 -0
  52. package/registry/ui/paired-chart.tsx +43 -8
  53. package/registry/ui/progress.tsx +1 -1
  54. package/registry/ui/scatter-chart.tsx +50 -12
  55. package/registry/ui/sparkline.tsx +43 -8
  56. package/registry/ui/states.tsx +34 -1
  57. package/registry/ui/status-badge.tsx +7 -7
  58. package/registry/ui/tabs.tsx +1 -0
  59. package/registry/ui/treemap.tsx +20 -3
  60. package/registry/ui/waterfall-chart.tsx +64 -11
  61. package/dist/chunk-EG4PX726.js.map +0 -1
  62. package/dist/chunk-P2KIHF65.js.map +0 -1
  63. package/dist/chunk-SC7K2RD6.js.map +0 -1
  64. package/dist/chunk-SDJTTMLK.js.map +0 -1
@@ -171,10 +171,25 @@ function useLocale(locale?: string | null): string {
171
171
  const CHART_OTHER_COLOR = "color-mix(in oklab, var(--neutral) 50%, var(--background))"
172
172
 
173
173
  /**
174
- * True inside `ChartFrame`. The frame's plot area carries the chart's single
175
- * `role="img"` and label, so `EChart` drops its own when it sees this.
176
- */
177
- const ChartFrameContext = createContext(false)
174
+ * What a `ChartFrame` tells the plot inside it. A frame whose plot is an image
175
+ * (`image`) puts `role="img"` on its plot area, unless an `EChart` inside it
176
+ * `claim`s the image: then the canvas alone carries `role="img"` with this name
177
+ * and description (`claimed`), so the action buttons beside the canvas are not
178
+ * inside the image (an interactive control inside `role="img"` is a
179
+ * nested-interactive violation and is not exposed). A `group` frame never
180
+ * hands the role to the canvas. `false` outside a frame.
181
+ */
182
+ interface ChartFramePlot {
183
+ image: boolean
184
+ /** An `EChart` inside has taken over the `role="img"`. */
185
+ claimed: boolean
186
+ /** Take over the image role; returns the release. */
187
+ claim: () => () => void
188
+ ariaLabel?: string
189
+ ariaLabelledBy?: string
190
+ ariaDescribedBy?: string
191
+ }
192
+ const ChartFrameContext = createContext<ChartFramePlot | false>(false)
178
193
 
179
194
  /** Series palette, in order. Missing tokens are skipped, not defaulted. */
180
195
  const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5"] as const
@@ -398,6 +413,8 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
398
413
  tooltip: {
399
414
  // ECharts' default 0.4s transform lags the tooltip ~300ms behind the cursor.
400
415
  transitionDuration: 0,
416
+ // Stay inside the chart: cards are often narrow and overflow-hidden.
417
+ confine: true,
401
418
  backgroundColor: tokens.popover ?? undefined,
402
419
  borderColor: line,
403
420
  borderWidth: 1,
@@ -2273,6 +2290,10 @@ function legendLines(items: readonly string[], plotWidth?: number, reserve = 0):
2273
2290
 
2274
2291
  /** Width (px) kept free at the legend's right for the PNG action buttons (two 32px buttons + gaps). */
2275
2292
  const ACTIONS_RESERVE = 80
2293
+ /** Bars stay slim on wide cards (px); stacked segments round all corners and are parted by a hairline of the surface colour (px). */
2294
+ const BAR_MAX_WIDTH = 40
2295
+ const BAR_RADIUS = 3
2296
+ const STACK_GAP = 1.5
2276
2297
 
2277
2298
  /** Room (px) one time label (`2026年1月`) needs, gap included. */
2278
2299
  const TIME_LABEL_WIDTH = 80
@@ -2486,6 +2507,8 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2486
2507
  const compared = cartesianSeriesValues(data, [categories[compIdx]!], "default")[0]!
2487
2508
  return categories.map((_, i) => (i === compIdx ? compared : main[mainIdx.indexOf(i)]!))
2488
2509
  })()
2510
+ // Any negative bar: the part lays out the diverging view itself (a solid zero line, category labels kept at the axis end).
2511
+ const diverging = kind === "bar" && values.some((s) => s.some((v) => typeof v === "number" && v < 0))
2489
2512
  const comparisonColor = guideColor ? withAlpha(guideColor, COMPARISON_OPACITY) : undefined
2490
2513
 
2491
2514
  /* -- x labels: the actual rows, then the forecast rows that extend them */
@@ -2695,15 +2718,17 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2695
2718
  const seriesData = pad(values[c]!)
2696
2719
  if (kind === "bar") {
2697
2720
  const rounded = horizontalBars ? [0, 4, 4, 0] : [4, 4, 0, 0]
2698
- // Stacked: only the topmost segment is rounded.
2699
- const radius = stacked && c !== categories.length - 1 ? 0 : rounded
2721
+ // Stacked: every segment is rounded, with a hairline of the surface colour between segments (the gap).
2722
+ const radius = stacked ? BAR_RADIUS : rounded
2723
+ const gapStyle = stacked && labelBackground ? { borderColor: labelBackground, borderWidth: STACK_GAP } : {}
2700
2724
  return {
2701
2725
  id: SERIES_ID_PREFIX + category,
2702
2726
  type: "bar" as const,
2703
2727
  name: category,
2704
2728
  stack: stacked ? "total" : undefined,
2705
2729
  barCategoryGap,
2706
- itemStyle: { color, borderRadius: radius },
2730
+ barMaxWidth: BAR_MAX_WIDTH,
2731
+ itemStyle: { color, borderRadius: radius, ...gapStyle },
2707
2732
  markPoint: markPointFor(c, solid),
2708
2733
  markLine: undefined,
2709
2734
  data: seriesData.map((value, i) => {
@@ -2999,6 +3024,14 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2999
3024
  },
3000
3025
  })
3001
3026
  }
3027
+ if (diverging) {
3028
+ markLines.push({
3029
+ name: "zero",
3030
+ [horizontalBars ? "xAxis" : "yAxis"]: 0,
3031
+ lineStyle: { type: "solid", width: 1, color: guideColor },
3032
+ label: { show: false },
3033
+ })
3034
+ }
3002
3035
  if (forecast) {
3003
3036
  const forecastSpan =
3004
3037
  plotWidth && labels.length > 1
@@ -3144,6 +3177,8 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
3144
3177
  show: horizontalBars ? showYAxis : showXAxis,
3145
3178
  boundaryGap: kind === "bar",
3146
3179
  inverse: horizontalBars,
3180
+ // Diverging: the axis stays at the plot's edge (not at 0, where labels would hit the negative bars); the zero line is a mark line.
3181
+ axisLine: diverging ? { show: false, onZero: false } : undefined,
3147
3182
  axisTick: { show: false },
3148
3183
  axisLabel: {
3149
3184
  hideOverlap: true,
@@ -3246,7 +3281,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
3246
3281
  // Narrow: left-aligned and wrapping above the plot, so nothing is cut off at the right edge.
3247
3282
  ...(plotWidth !== undefined && plotWidth < NARROW_PLOT_WIDTH
3248
3283
  ? { left: 0, right: 0 }
3249
- : { right: reserveActions ? ACTIONS_RESERVE : 0 }),
3284
+ : { left: 0, right: reserveActions ? ACTIONS_RESERVE : 0 }),
3250
3285
  // Plain wraps onto a second line at narrow widths instead of hiding items behind arrows.
3251
3286
  type: "plain",
3252
3287
  icon: "circle",
@@ -197,6 +197,8 @@ export interface PageShellHeaderProps extends HTMLAttributes<HTMLDivElement> {
197
197
  }
198
198
 
199
199
  /**
200
+ * PageShellHeader — the page header band (title and update time, source / period, overall status); inside `PageShell` without `title`, with the title in `PageShellHeading`.
201
+ *
200
202
  * A wrapping flex row, not a two-column grid: when the heading cannot keep its
201
203
  * 20rem basis next to `PageShellHeaderEnd`, the end block drops below it. A
202
204
  * `1fr auto` grid would instead hand the heading whatever the end block leaves,
@@ -171,10 +171,25 @@ function useLocale(locale?: string | null): string {
171
171
  const CHART_OTHER_COLOR = "color-mix(in oklab, var(--neutral) 50%, var(--background))"
172
172
 
173
173
  /**
174
- * True inside `ChartFrame`. The frame's plot area carries the chart's single
175
- * `role="img"` and label, so `EChart` drops its own when it sees this.
174
+ * What a `ChartFrame` tells the plot inside it. A frame whose plot is an image
175
+ * (`image`) puts `role="img"` on its plot area, unless an `EChart` inside it
176
+ * `claim`s the image: then the canvas alone carries `role="img"` with this name
177
+ * and description (`claimed`), so the action buttons beside the canvas are not
178
+ * inside the image (an interactive control inside `role="img"` is a
179
+ * nested-interactive violation and is not exposed). A `group` frame never
180
+ * hands the role to the canvas. `false` outside a frame.
176
181
  */
177
- const ChartFrameContext = createContext(false)
182
+ interface ChartFramePlot {
183
+ image: boolean
184
+ /** An `EChart` inside has taken over the `role="img"`. */
185
+ claimed: boolean
186
+ /** Take over the image role; returns the release. */
187
+ claim: () => () => void
188
+ ariaLabel?: string
189
+ ariaLabelledBy?: string
190
+ ariaDescribedBy?: string
191
+ }
192
+ const ChartFrameContext = createContext<ChartFramePlot | false>(false)
178
193
 
179
194
  /** Series palette, in order. Missing tokens are skipped, not defaulted. */
180
195
  const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5"] as const
@@ -398,6 +413,8 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
398
413
  tooltip: {
399
414
  // ECharts' default 0.4s transform lags the tooltip ~300ms behind the cursor.
400
415
  transitionDuration: 0,
416
+ // Stay inside the chart: cards are often narrow and overflow-hidden.
417
+ confine: true,
401
418
  backgroundColor: tokens.popover ?? undefined,
402
419
  borderColor: line,
403
420
  borderWidth: 1,
@@ -2273,6 +2290,10 @@ function legendLines(items: readonly string[], plotWidth?: number, reserve = 0):
2273
2290
 
2274
2291
  /** Width (px) kept free at the legend's right for the PNG action buttons (two 32px buttons + gaps). */
2275
2292
  const ACTIONS_RESERVE = 80
2293
+ /** Bars stay slim on wide cards (px); stacked segments round all corners and are parted by a hairline of the surface colour (px). */
2294
+ const BAR_MAX_WIDTH = 40
2295
+ const BAR_RADIUS = 3
2296
+ const STACK_GAP = 1.5
2276
2297
 
2277
2298
  /** Room (px) one time label (`2026年1月`) needs, gap included. */
2278
2299
  const TIME_LABEL_WIDTH = 80
@@ -2486,6 +2507,8 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2486
2507
  const compared = cartesianSeriesValues(data, [categories[compIdx]!], "default")[0]!
2487
2508
  return categories.map((_, i) => (i === compIdx ? compared : main[mainIdx.indexOf(i)]!))
2488
2509
  })()
2510
+ // Any negative bar: the part lays out the diverging view itself (a solid zero line, category labels kept at the axis end).
2511
+ const diverging = kind === "bar" && values.some((s) => s.some((v) => typeof v === "number" && v < 0))
2489
2512
  const comparisonColor = guideColor ? withAlpha(guideColor, COMPARISON_OPACITY) : undefined
2490
2513
 
2491
2514
  /* -- x labels: the actual rows, then the forecast rows that extend them */
@@ -2695,15 +2718,17 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2695
2718
  const seriesData = pad(values[c]!)
2696
2719
  if (kind === "bar") {
2697
2720
  const rounded = horizontalBars ? [0, 4, 4, 0] : [4, 4, 0, 0]
2698
- // Stacked: only the topmost segment is rounded.
2699
- const radius = stacked && c !== categories.length - 1 ? 0 : rounded
2721
+ // Stacked: every segment is rounded, with a hairline of the surface colour between segments (the gap).
2722
+ const radius = stacked ? BAR_RADIUS : rounded
2723
+ const gapStyle = stacked && labelBackground ? { borderColor: labelBackground, borderWidth: STACK_GAP } : {}
2700
2724
  return {
2701
2725
  id: SERIES_ID_PREFIX + category,
2702
2726
  type: "bar" as const,
2703
2727
  name: category,
2704
2728
  stack: stacked ? "total" : undefined,
2705
2729
  barCategoryGap,
2706
- itemStyle: { color, borderRadius: radius },
2730
+ barMaxWidth: BAR_MAX_WIDTH,
2731
+ itemStyle: { color, borderRadius: radius, ...gapStyle },
2707
2732
  markPoint: markPointFor(c, solid),
2708
2733
  markLine: undefined,
2709
2734
  data: seriesData.map((value, i) => {
@@ -2999,6 +3024,14 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2999
3024
  },
3000
3025
  })
3001
3026
  }
3027
+ if (diverging) {
3028
+ markLines.push({
3029
+ name: "zero",
3030
+ [horizontalBars ? "xAxis" : "yAxis"]: 0,
3031
+ lineStyle: { type: "solid", width: 1, color: guideColor },
3032
+ label: { show: false },
3033
+ })
3034
+ }
3002
3035
  if (forecast) {
3003
3036
  const forecastSpan =
3004
3037
  plotWidth && labels.length > 1
@@ -3144,6 +3177,8 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
3144
3177
  show: horizontalBars ? showYAxis : showXAxis,
3145
3178
  boundaryGap: kind === "bar",
3146
3179
  inverse: horizontalBars,
3180
+ // Diverging: the axis stays at the plot's edge (not at 0, where labels would hit the negative bars); the zero line is a mark line.
3181
+ axisLine: diverging ? { show: false, onZero: false } : undefined,
3147
3182
  axisTick: { show: false },
3148
3183
  axisLabel: {
3149
3184
  hideOverlap: true,
@@ -3246,7 +3281,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
3246
3281
  // Narrow: left-aligned and wrapping above the plot, so nothing is cut off at the right edge.
3247
3282
  ...(plotWidth !== undefined && plotWidth < NARROW_PLOT_WIDTH
3248
3283
  ? { left: 0, right: 0 }
3249
- : { right: reserveActions ? ACTIONS_RESERVE : 0 }),
3284
+ : { left: 0, right: reserveActions ? ACTIONS_RESERVE : 0 }),
3250
3285
  // Plain wraps onto a second line at narrow widths instead of hiding items behind arrows.
3251
3286
  type: "plain",
3252
3287
  icon: "circle",
@@ -3939,7 +3974,7 @@ export function buildPairedOption(cfg: PairedOptionConfig): EChartsOption {
3939
3974
 
3940
3975
  const option: AnyRecord = {
3941
3976
  ...barOpt,
3942
- legend: { ...barOpt.legend, show: showLegend, data: legendNames, right: cfg.reserveActions ? 80 : 0 },
3977
+ legend: { ...barOpt.legend, show: showLegend, data: legendNames, left: 0, right: cfg.reserveActions ? 80 : 0 },
3943
3978
  grid: [
3944
3979
  { top, height: upper, left: gridLeft, right, containLabel: true },
3945
3980
  { top: top + upper + PANEL_GAP, bottom: 4, left: gridLeft, right, containLabel: true },
@@ -31,7 +31,7 @@ function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
31
31
  return (
32
32
  <ProgressPrimitive.Track
33
33
  className={cn(
34
- "relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
34
+ "relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-border",
35
35
  className
36
36
  )}
37
37
  data-slot="progress-track"
@@ -183,10 +183,25 @@ function useLocale(locale?: string | null): string {
183
183
  const CHART_OTHER_COLOR = "color-mix(in oklab, var(--neutral) 50%, var(--background))"
184
184
 
185
185
  /**
186
- * True inside `ChartFrame`. The frame's plot area carries the chart's single
187
- * `role="img"` and label, so `EChart` drops its own when it sees this.
186
+ * What a `ChartFrame` tells the plot inside it. A frame whose plot is an image
187
+ * (`image`) puts `role="img"` on its plot area, unless an `EChart` inside it
188
+ * `claim`s the image: then the canvas alone carries `role="img"` with this name
189
+ * and description (`claimed`), so the action buttons beside the canvas are not
190
+ * inside the image (an interactive control inside `role="img"` is a
191
+ * nested-interactive violation and is not exposed). A `group` frame never
192
+ * hands the role to the canvas. `false` outside a frame.
188
193
  */
189
- const ChartFrameContext = createContext(false)
194
+ interface ChartFramePlot {
195
+ image: boolean
196
+ /** An `EChart` inside has taken over the `role="img"`. */
197
+ claimed: boolean
198
+ /** Take over the image role; returns the release. */
199
+ claim: () => () => void
200
+ ariaLabel?: string
201
+ ariaLabelledBy?: string
202
+ ariaDescribedBy?: string
203
+ }
204
+ const ChartFrameContext = createContext<ChartFramePlot | false>(false)
190
205
 
191
206
  /** Series palette, in order. Missing tokens are skipped, not defaulted. */
192
207
  const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5"] as const
@@ -410,6 +425,8 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
410
425
  tooltip: {
411
426
  // ECharts' default 0.4s transform lags the tooltip ~300ms behind the cursor.
412
427
  transitionDuration: 0,
428
+ // Stay inside the chart: cards are often narrow and overflow-hidden.
429
+ confine: true,
413
430
  backgroundColor: tokens.popover ?? undefined,
414
431
  borderColor: line,
415
432
  borderWidth: 1,
@@ -1446,6 +1463,10 @@ function legendLines(items: readonly string[], plotWidth?: number, reserve = 0):
1446
1463
 
1447
1464
  /** Width (px) kept free at the legend's right for the PNG action buttons (two 32px buttons + gaps). */
1448
1465
  const ACTIONS_RESERVE = 80
1466
+ /** Bars stay slim on wide cards (px); stacked segments round all corners and are parted by a hairline of the surface colour (px). */
1467
+ const BAR_MAX_WIDTH = 40
1468
+ const BAR_RADIUS = 3
1469
+ const STACK_GAP = 1.5
1449
1470
 
1450
1471
  /** Room (px) one time label (`2026年1月`) needs, gap included. */
1451
1472
  const TIME_LABEL_WIDTH = 80
@@ -1659,6 +1680,8 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1659
1680
  const compared = cartesianSeriesValues(data, [categories[compIdx]!], "default")[0]!
1660
1681
  return categories.map((_, i) => (i === compIdx ? compared : main[mainIdx.indexOf(i)]!))
1661
1682
  })()
1683
+ // Any negative bar: the part lays out the diverging view itself (a solid zero line, category labels kept at the axis end).
1684
+ const diverging = kind === "bar" && values.some((s) => s.some((v) => typeof v === "number" && v < 0))
1662
1685
  const comparisonColor = guideColor ? withAlpha(guideColor, COMPARISON_OPACITY) : undefined
1663
1686
 
1664
1687
  /* -- x labels: the actual rows, then the forecast rows that extend them */
@@ -1868,15 +1891,17 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1868
1891
  const seriesData = pad(values[c]!)
1869
1892
  if (kind === "bar") {
1870
1893
  const rounded = horizontalBars ? [0, 4, 4, 0] : [4, 4, 0, 0]
1871
- // Stacked: only the topmost segment is rounded.
1872
- const radius = stacked && c !== categories.length - 1 ? 0 : rounded
1894
+ // Stacked: every segment is rounded, with a hairline of the surface colour between segments (the gap).
1895
+ const radius = stacked ? BAR_RADIUS : rounded
1896
+ const gapStyle = stacked && labelBackground ? { borderColor: labelBackground, borderWidth: STACK_GAP } : {}
1873
1897
  return {
1874
1898
  id: SERIES_ID_PREFIX + category,
1875
1899
  type: "bar" as const,
1876
1900
  name: category,
1877
1901
  stack: stacked ? "total" : undefined,
1878
1902
  barCategoryGap,
1879
- itemStyle: { color, borderRadius: radius },
1903
+ barMaxWidth: BAR_MAX_WIDTH,
1904
+ itemStyle: { color, borderRadius: radius, ...gapStyle },
1880
1905
  markPoint: markPointFor(c, solid),
1881
1906
  markLine: undefined,
1882
1907
  data: seriesData.map((value, i) => {
@@ -2172,6 +2197,14 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2172
2197
  },
2173
2198
  })
2174
2199
  }
2200
+ if (diverging) {
2201
+ markLines.push({
2202
+ name: "zero",
2203
+ [horizontalBars ? "xAxis" : "yAxis"]: 0,
2204
+ lineStyle: { type: "solid", width: 1, color: guideColor },
2205
+ label: { show: false },
2206
+ })
2207
+ }
2175
2208
  if (forecast) {
2176
2209
  const forecastSpan =
2177
2210
  plotWidth && labels.length > 1
@@ -2317,6 +2350,8 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2317
2350
  show: horizontalBars ? showYAxis : showXAxis,
2318
2351
  boundaryGap: kind === "bar",
2319
2352
  inverse: horizontalBars,
2353
+ // Diverging: the axis stays at the plot's edge (not at 0, where labels would hit the negative bars); the zero line is a mark line.
2354
+ axisLine: diverging ? { show: false, onZero: false } : undefined,
2320
2355
  axisTick: { show: false },
2321
2356
  axisLabel: {
2322
2357
  hideOverlap: true,
@@ -2419,7 +2454,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2419
2454
  // Narrow: left-aligned and wrapping above the plot, so nothing is cut off at the right edge.
2420
2455
  ...(plotWidth !== undefined && plotWidth < NARROW_PLOT_WIDTH
2421
2456
  ? { left: 0, right: 0 }
2422
- : { right: reserveActions ? ACTIONS_RESERVE : 0 }),
2457
+ : { left: 0, right: reserveActions ? ACTIONS_RESERVE : 0 }),
2423
2458
  // Plain wraps onto a second line at narrow widths instead of hiding items behind arrows.
2424
2459
  type: "plain",
2425
2460
  icon: "circle",
@@ -4226,8 +4261,8 @@ export function buildScatterOption(config: ScatterOptionConfig): EChartsOption {
4226
4261
  legend: {
4227
4262
  show: showLegend,
4228
4263
  ...(legendBelow ? { bottom: 0 } : { top: 0 }),
4229
- // With a heading the legend reads from the left, after it; otherwise it sits at the right edge.
4230
- ...(headingWidth > 0 ? { left: headingWidth } : legendBelow ? { left: 0 } : { right: config.reserveActions ? 72 : 0 }),
4264
+ // With a heading the legend reads from the left, after it; otherwise it sits at the left edge (as in every chart).
4265
+ ...(headingWidth > 0 ? { left: headingWidth } : { left: 0, ...(legendBelow ? {} : { right: config.reserveActions ? 72 : 0 }) }),
4231
4266
  type: "plain",
4232
4267
  icon: "circle",
4233
4268
  itemWidth: 8,
@@ -4242,7 +4277,9 @@ export function buildScatterOption(config: ScatterOptionConfig): EChartsOption {
4242
4277
  (guides.some((line) => line.axis === "x" && line.label) && (!showLegend || legendBelow) ? 14 : 0),
4243
4278
  right: 24,
4244
4279
  bottom: legendBelow ? 24 + 24 * legendLineCount : 24,
4245
- left: 24,
4280
+ // The rotated y axis name lies outside `containLabel`'s reach (its right edge is `nameGap` left of the axis line): the
4281
+ // margin holds its height, so the name is never cut at the chart's left edge.
4282
+ left: 36,
4246
4283
  containLabel: true,
4247
4284
  },
4248
4285
  ...(headingWidth > 0
@@ -4300,7 +4337,7 @@ export function buildScatterOption(config: ScatterOptionConfig): EChartsOption {
4300
4337
  label: {
4301
4338
  show: keptLabels === null,
4302
4339
  position: "right" as const,
4303
- distance: 6,
4340
+ distance: 8,
4304
4341
  fontSize: 11,
4305
4342
  color: config.textColors?.label,
4306
4343
  // Plain text: an outline / shadow reads as a halo on a dark surface.
@@ -4308,7 +4345,8 @@ export function buildScatterOption(config: ScatterOptionConfig): EChartsOption {
4308
4345
  textShadowBlur: 0,
4309
4346
  formatter: (p: { data?: { point?: ScatterPoint } }) => p.data?.point?.label ?? "",
4310
4347
  },
4311
- labelLayout: { hideOverlap: true, moveOverlap: "shiftY" as const },
4348
+ // A name stays beside its own dot: one that would collide with another is hidden, not pushed onto a neighbouring dot.
4349
+ labelLayout: { hideOverlap: true },
4312
4350
  }
4313
4351
  : {}),
4314
4352
  // The guide lines hang off the first series (a markLine belongs to one).
@@ -11,7 +11,7 @@
11
11
  import { forwardRef, useEffect, useState, type SVGAttributes } from "react"
12
12
 
13
13
  import { cn } from "@squadbase/vantage/ui"
14
- import type { ChartHighlight } from "@squadbase/vantage/components"
14
+ import type { ChartColor, ChartHighlight } from "@squadbase/vantage/components"
15
15
 
16
16
  export type SparklineVariant = "line" | "bar"
17
17
 
@@ -24,8 +24,14 @@ export interface SparklineProps extends SVGAttributes<SVGSVGElement> {
24
24
  data: SparklineDataPoint[]
25
25
  variant?: SparklineVariant
26
26
  height?: number
27
- /** Tailwind text-colour class; drives both stroke and fill via `currentColor`. */
28
- color?: string
27
+ /**
28
+ * Line / bar colour: a theme series token (`"chart-1"`〜`"chart-5"`), a meaning colour
29
+ * (`"positive"` / `"negative"` / `"warning"` / `"neutral"` / `"info"`), or any CSS colour
30
+ * (`"#16a34a"`, `"var(--destructive)"`) — the same {@link ChartColor} the other charts take.
31
+ * Drives both stroke and fill via `currentColor`. Defaults to `"chart-1"`.
32
+ * Tailwind text-colour classes (`"text-primary"`) still work for compatibility; prefer the tokens above.
33
+ */
34
+ color?: ChartColor
29
35
  area?: boolean
30
36
  /** マウント時に線を引く / 棒を伸ばす。既定 `false`(`prefers-reduced-motion` では常に無効)。 */
31
37
  animate?: boolean
@@ -41,6 +47,32 @@ export interface SparklineProps extends SVGAttributes<SVGSVGElement> {
41
47
 
42
48
  const VB_WIDTH = 100
43
49
 
50
+ /** Literal class names (not built from a template) so Tailwind's scanner keeps them. */
51
+ const TOKEN_CLASS: Record<string, string> = {
52
+ "chart-1": "text-chart-1",
53
+ "chart-2": "text-chart-2",
54
+ "chart-3": "text-chart-3",
55
+ "chart-4": "text-chart-4",
56
+ "chart-5": "text-chart-5",
57
+ positive: "text-positive",
58
+ warning: "text-warning",
59
+ negative: "text-negative",
60
+ neutral: "text-neutral",
61
+ info: "text-info",
62
+ }
63
+
64
+ /**
65
+ * `color` を class か inline の CSS colour に振り分ける。トークン・意味色は `text-*` に、
66
+ * `text-` で始まる旧来の class 指定はそのまま class に、それ以外(`#hex` / `var(--x)` / `rgb()` …)は CSS colour として扱う。
67
+ */
68
+ function resolveSparklineColor(color: string): { className?: string; css?: string } {
69
+ const value = color.trim()
70
+ const tokenClass = TOKEN_CLASS[value]
71
+ if (tokenClass) return { className: tokenClass }
72
+ if (/^(?:[\w-]+:)*!?text-/.test(value) || (/\s/.test(value) && !value.includes("("))) return { className: color }
73
+ return { css: value }
74
+ }
75
+
44
76
  /** Map raw values onto SVG y-coordinates (inverted, padded). */
45
77
  function normalizeData(values: number[], height: number, vPad = 2): number[] {
46
78
  const min = Math.min(...values)
@@ -117,7 +149,7 @@ export const Sparkline = forwardRef<SVGSVGElement, SparklineProps>(function Spar
117
149
  data = NO_ROWS,
118
150
  variant = "line",
119
151
  height = 40,
120
- color = "text-chart-1",
152
+ color = "chart-1",
121
153
  area = false,
122
154
  animate = false,
123
155
  highlight = "auto",
@@ -135,6 +167,8 @@ export const Sparkline = forwardRef<SVGSVGElement, SparklineProps>(function Spar
135
167
 
136
168
  if (data.length === 0) return null
137
169
 
170
+ const { className: colorClass, css: colorCss } = resolveSparklineColor(color)
171
+
138
172
  const values = data.map((d) => d.value)
139
173
  const ys = normalizeData(values, height)
140
174
  const step = ys.length > 1 ? VB_WIDTH / (ys.length - 1) : 0
@@ -160,6 +194,7 @@ export const Sparkline = forwardRef<SVGSVGElement, SparklineProps>(function Spar
160
194
  aria-hidden={ariaLabel ? undefined : true}
161
195
  className={cn("overflow-visible", className)}
162
196
  {...props}
197
+ style={colorCss ? { color: colorCss, ...props.style } : props.style}
163
198
  >
164
199
  {variant === "line" && (
165
200
  <>
@@ -167,7 +202,7 @@ export const Sparkline = forwardRef<SVGSVGElement, SparklineProps>(function Spar
167
202
  <path
168
203
  d={buildAreaPath(ys, height)}
169
204
  strokeWidth={0}
170
- className={cn("fill-current opacity-15", color)}
205
+ className={cn("fill-current opacity-15", colorClass)}
171
206
  />
172
207
  )}
173
208
  <path
@@ -180,7 +215,7 @@ export const Sparkline = forwardRef<SVGSVGElement, SparklineProps>(function Spar
180
215
  pathLength={animate ? 1 : undefined}
181
216
  strokeDasharray={animate ? 1 : undefined}
182
217
  strokeDashoffset={animate ? (mounted ? 0 : 1) : undefined}
183
- className={cn("stroke-current", animate && "motion-reduce:transition-none!", color)}
218
+ className={cn("stroke-current", animate && "motion-reduce:transition-none!", colorClass)}
184
219
  style={animate ? { transition: "stroke-dashoffset 0.6s ease-in-out" } : undefined}
185
220
  />
186
221
  {/* A zero-length round-capped stroke with a non-scaling width stays a
@@ -198,7 +233,7 @@ export const Sparkline = forwardRef<SVGSVGElement, SparklineProps>(function Spar
198
233
  strokeWidth={p.kind === "latest" ? 5 : 4}
199
234
  strokeLinecap="round"
200
235
  vectorEffect="non-scaling-stroke"
201
- className={cn("stroke-current", p.kind === "latest" ? color : "text-muted-foreground")}
236
+ className={cn("stroke-current", p.kind === "latest" ? colorClass : "text-muted-foreground")}
202
237
  />
203
238
  )
204
239
  })}
@@ -214,7 +249,7 @@ export const Sparkline = forwardRef<SVGSVGElement, SparklineProps>(function Spar
214
249
  width={rect.w}
215
250
  height={animate ? (mounted ? rect.h : 0) : rect.h}
216
251
  rx={0.5}
217
- className={cn("fill-current", animate && "motion-reduce:transition-none!", color)}
252
+ className={cn("fill-current", animate && "motion-reduce:transition-none!", colorClass)}
218
253
  style={
219
254
  animate
220
255
  ? {
@@ -7,6 +7,7 @@
7
7
  * business users. Defaults follow the page language (`<html lang>`: Japanese for `ja`, else
8
8
  * English) and say what is happening, not why the system failed: keep raw errors
9
9
  * ("upstream timeout after 30s") in `details`, which stays folded behind "Show details" (ja: "詳細を表示").
10
+ * `variant="inline"` renders one line of muted text (no panel, no action, no `details`) for a card row or table cell.
10
11
  *
11
12
  * @example
12
13
  * import { ErrorState } from "@/components/ui/states"
@@ -175,8 +176,26 @@ const TEXT = {
175
176
 
176
177
  const useText = () => TEXT[uiLanguage(useLocale())]
177
178
 
178
- export function Loading({ label, className }: { label?: string; className?: string }) {
179
+ export type StateVariant = "default" | "inline"
180
+
181
+ export function Loading({
182
+ label,
183
+ variant = "default",
184
+ className,
185
+ }: {
186
+ label?: string
187
+ variant?: StateVariant
188
+ className?: string
189
+ }) {
179
190
  const t = useText()
191
+ if (variant === "inline") {
192
+ return (
193
+ <span className={cn("inline-flex items-center gap-2 text-sm text-muted-foreground", className)} role="status" aria-live="polite">
194
+ <span className="size-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
195
+ {label ?? t.loading}
196
+ </span>
197
+ )
198
+ }
180
199
  return (
181
200
  <div className={cn("flex min-h-[30vh] items-center justify-center", className)} role="status" aria-live="polite">
182
201
  <div className="flex items-center gap-3 text-sm text-muted-foreground">
@@ -192,15 +211,20 @@ export function Empty({
192
211
  description,
193
212
  icon,
194
213
  action,
214
+ variant = "default",
195
215
  className,
196
216
  }: {
197
217
  title?: string
198
218
  description?: string
199
219
  icon?: ReactNode
200
220
  action?: ReactNode
221
+ variant?: StateVariant
201
222
  className?: string
202
223
  }) {
203
224
  const t = useText()
225
+ if (variant === "inline") {
226
+ return <span className={cn("text-sm text-muted-foreground", className)}>{title ?? t.empty}</span>
227
+ }
204
228
  return (
205
229
  <div className={cn("flex flex-col items-center justify-center gap-2 p-10 text-center", className)}>
206
230
  {icon ? <div className="text-3xl text-muted-foreground">{icon}</div> : null}
@@ -216,6 +240,7 @@ export function ErrorState({
216
240
  message,
217
241
  details,
218
242
  action,
243
+ variant = "default",
219
244
  className,
220
245
  }: {
221
246
  title?: string
@@ -224,9 +249,17 @@ export function ErrorState({
224
249
  /** Technical detail (error message, status code), folded behind "Show details" (ja: "詳細を表示"). */
225
250
  details?: ReactNode
226
251
  action?: ReactNode
252
+ variant?: StateVariant
227
253
  className?: string
228
254
  }) {
229
255
  const t = useText()
256
+ if (variant === "inline") {
257
+ return (
258
+ <span className={cn("text-sm text-destructive", className)} role="alert">
259
+ {title ?? t.errorTitle}
260
+ </span>
261
+ )
262
+ }
230
263
  const shownMessage = message === undefined ? t.errorMessage : message
231
264
  return (
232
265
  <div
@@ -239,12 +239,12 @@ const legacyColorTones: Record<string, StatusTone> = {
239
239
  * One literal class set per tone — Tailwind only emits classes it can see
240
240
  * literally in the source.
241
241
  */
242
- const toneStyles: Record<StatusTone, { dot: string; bg: string; text: string; border: string }> = {
243
- positive: { dot: "bg-positive", bg: "bg-positive-subtle", text: "text-positive", border: "border-positive/20 dark:border-positive/25" },
244
- negative: { dot: "bg-negative", bg: "bg-negative-subtle", text: "text-negative", border: "border-negative/20 dark:border-negative/25" },
245
- warning: { dot: "bg-warning", bg: "bg-warning-subtle", text: "text-warning", border: "border-warning/20 dark:border-warning/25" },
246
- info: { dot: "bg-info", bg: "bg-info-subtle", text: "text-info", border: "border-info/20 dark:border-info/25" },
247
- neutral: { dot: "bg-neutral", bg: "bg-neutral-subtle", text: "text-neutral", border: "border-neutral/20 dark:border-neutral/25" },
242
+ const toneStyles: Record<StatusTone, { dot: string; bg: string; text: string }> = {
243
+ positive: { dot: "bg-positive", bg: "bg-positive-subtle", text: "text-positive" },
244
+ negative: { dot: "bg-negative", bg: "bg-negative-subtle", text: "text-negative" },
245
+ warning: { dot: "bg-warning", bg: "bg-warning-subtle", text: "text-warning" },
246
+ info: { dot: "bg-info", bg: "bg-info-subtle", text: "text-info" },
247
+ neutral: { dot: "bg-neutral", bg: "bg-neutral-subtle", text: "text-neutral" },
248
248
  }
249
249
 
250
250
  const resolveTone = (color: string | undefined): StatusTone => {
@@ -275,7 +275,7 @@ export const StatusBadge = forwardRef<HTMLSpanElement, StatusBadgeProps>(functio
275
275
  ref={ref}
276
276
  data-slot="status-badge"
277
277
  variant="outline"
278
- className={cn("gap-1.5 rounded-md", style.border, style.bg, style.text, className)}
278
+ className={cn("gap-1.5 rounded-md border-transparent", style.bg, style.text, className)}
279
279
  aria-label={displayLabel}
280
280
  >
281
281
  <span className={cn("inline-block size-1.5 rounded-full", style.dot)} aria-hidden="true" />
@@ -63,6 +63,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
63
63
  "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-[color,background-color,border-color,box-shadow] group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
64
64
  "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
65
65
  "data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
66
+ "[&_[data-slot=badge]]:h-4 [&_[data-slot=badge]]:min-w-4 [&_[data-slot=badge]]:px-1.5 [&_[data-slot=badge]]:tabular-nums [&_[data-slot=badge][data-variant=secondary]]:bg-foreground/10 [&_[data-slot=badge][data-variant=secondary]]:text-current",
66
67
  "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
67
68
  className
68
69
  )}