basalt-ui 1.13.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +15 -0
  3. package/README.md +38 -2
  4. package/agent/rules/basalt-charts.md +260 -108
  5. package/agent/rules/basalt-mantine.md +4 -2
  6. package/agent/skills/basalt-charts/SKILL.md +114 -64
  7. package/agent/skills/basalt-design/SKILL.md +4 -3
  8. package/configs/oxlint-plugin.js +152 -0
  9. package/configs/oxlint-plugin.test.ts +107 -0
  10. package/configs/oxlint.json +2 -0
  11. package/dist/charts/cursor/resolve.d.ts +33 -0
  12. package/dist/charts/cursor/resolve.d.ts.map +1 -0
  13. package/dist/charts/cursor/resolve.js +61 -0
  14. package/dist/charts/cursor/resolve.test.d.ts +2 -0
  15. package/dist/charts/cursor/resolve.test.d.ts.map +1 -0
  16. package/dist/charts/cursor/scope.d.ts +17 -0
  17. package/dist/charts/cursor/scope.d.ts.map +1 -0
  18. package/dist/charts/cursor/scope.js +20 -0
  19. package/dist/charts/cursor/store.d.ts +31 -0
  20. package/dist/charts/cursor/store.d.ts.map +1 -0
  21. package/dist/charts/cursor/store.js +25 -0
  22. package/dist/charts/cursor/store.test.d.ts +2 -0
  23. package/dist/charts/cursor/store.test.d.ts.map +1 -0
  24. package/dist/charts/hooks/useChartCursor.d.ts +39 -0
  25. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -0
  26. package/dist/charts/hooks/useChartCursor.js +104 -0
  27. package/dist/charts/hooks/useChartSize.d.ts +8 -4
  28. package/dist/charts/hooks/useChartSize.d.ts.map +1 -1
  29. package/dist/charts/index.d.ts +16 -13
  30. package/dist/charts/index.d.ts.map +1 -1
  31. package/dist/charts/index.js +38 -19
  32. package/dist/charts/kinds/Bars.d.ts +25 -47
  33. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  34. package/dist/charts/kinds/Bars.js +230 -390
  35. package/dist/charts/kinds/Donut.d.ts +5 -4
  36. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  37. package/dist/charts/kinds/Donut.js +17 -14
  38. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  39. package/dist/charts/kinds/DualPanel.js +136 -90
  40. package/dist/charts/kinds/Heatmap.d.ts +15 -11
  41. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  42. package/dist/charts/kinds/Heatmap.js +38 -22
  43. package/dist/charts/kinds/MultiLine.d.ts +27 -35
  44. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  45. package/dist/charts/kinds/MultiLine.js +114 -237
  46. package/dist/charts/kinds/StackedArea.d.ts +21 -10
  47. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  48. package/dist/charts/kinds/StackedArea.js +74 -132
  49. package/dist/charts/kinds/ZonedLine.d.ts +24 -40
  50. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  51. package/dist/charts/kinds/ZonedLine.js +103 -184
  52. package/dist/charts/layout/auto-margin.d.ts +60 -0
  53. package/dist/charts/layout/auto-margin.d.ts.map +1 -0
  54. package/dist/charts/layout/auto-margin.js +37 -0
  55. package/dist/charts/layout/auto-margin.test.d.ts +2 -0
  56. package/dist/charts/layout/auto-margin.test.d.ts.map +1 -0
  57. package/dist/charts/primitives/CartesianChart.d.ts +143 -0
  58. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -0
  59. package/dist/charts/primitives/CartesianChart.js +349 -0
  60. package/dist/charts/primitives/CartesianChart.test.d.ts +2 -0
  61. package/dist/charts/primitives/CartesianChart.test.d.ts.map +1 -0
  62. package/dist/charts/primitives/ChartFrame.d.ts +16 -5
  63. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  64. package/dist/charts/primitives/ChartFrame.js +17 -2
  65. package/dist/charts/primitives/ChartLegend.d.ts +12 -1
  66. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  67. package/dist/charts/primitives/ChartLegend.js +21 -3
  68. package/dist/charts/primitives/ChartLegend.test.d.ts +2 -0
  69. package/dist/charts/primitives/ChartLegend.test.d.ts.map +1 -0
  70. package/dist/charts/primitives/ChartTooltip.d.ts +16 -16
  71. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  72. package/dist/charts/primitives/ChartTooltip.js +45 -19
  73. package/dist/charts/primitives/HoverOverlay.d.ts +19 -4
  74. package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
  75. package/dist/charts/primitives/HoverOverlay.js +22 -1
  76. package/dist/charts/primitives/XZoneRects.d.ts +40 -0
  77. package/dist/charts/primitives/XZoneRects.d.ts.map +1 -0
  78. package/dist/charts/primitives/XZoneRects.js +28 -0
  79. package/dist/charts/primitives/XZoneRects.test.d.ts +2 -0
  80. package/dist/charts/primitives/XZoneRects.test.d.ts.map +1 -0
  81. package/dist/charts/series.d.ts +16 -1
  82. package/dist/charts/series.d.ts.map +1 -1
  83. package/dist/charts/series.js +3 -1
  84. package/dist/charts/series.test.d.ts +2 -0
  85. package/dist/charts/series.test.d.ts.map +1 -0
  86. package/dist/charts/utils/measure-text.d.ts +25 -0
  87. package/dist/charts/utils/measure-text.d.ts.map +1 -0
  88. package/dist/charts/utils/measure-text.js +64 -0
  89. package/dist/charts/utils/measure-text.test.d.ts +2 -0
  90. package/dist/charts/utils/measure-text.test.d.ts.map +1 -0
  91. package/dist/content/fence-block.d.ts.map +1 -1
  92. package/dist/shell/app-sidebar.d.ts +19 -1
  93. package/dist/shell/app-sidebar.d.ts.map +1 -1
  94. package/dist/shell/app-sidebar.js +30 -26
  95. package/dist/shell/app-sidebar.module.css +11 -0
  96. package/dist/shell/app-sidebar.module.css.d.ts +1 -0
  97. package/dist/shell/app-sidebar.test.d.ts +2 -0
  98. package/dist/shell/app-sidebar.test.d.ts.map +1 -0
  99. package/dist/shell/index.d.ts +10 -1
  100. package/dist/shell/index.d.ts.map +1 -1
  101. package/dist/shell/index.js +2 -0
  102. package/dist/shell/index.test.d.ts +2 -0
  103. package/dist/shell/index.test.d.ts.map +1 -0
  104. package/dist/surfaces.d.ts +1 -1
  105. package/dist/surfaces.d.ts.map +1 -1
  106. package/dist/surfaces.js +1 -1
  107. package/dist/tokens/chart-margin.test.d.ts +2 -0
  108. package/dist/tokens/chart-margin.test.d.ts.map +1 -0
  109. package/dist/tokens/index.d.ts +29 -0
  110. package/dist/tokens/index.d.ts.map +1 -1
  111. package/dist/tokens/index.js +10 -0
  112. package/dist/tokens/palette.d.ts +4 -0
  113. package/dist/tokens/palette.d.ts.map +1 -1
  114. package/dist/tokens/palette.js +3 -0
  115. package/llms.txt +3 -3
  116. package/package.json +1 -1
  117. package/src/charts/cursor/resolve.test.ts +61 -0
  118. package/src/charts/cursor/resolve.ts +102 -0
  119. package/src/charts/cursor/scope.tsx +32 -0
  120. package/src/charts/cursor/store.test.ts +46 -0
  121. package/src/charts/cursor/store.ts +53 -0
  122. package/src/charts/hooks/useChartCursor.ts +163 -0
  123. package/src/charts/hooks/useChartSize.ts +8 -4
  124. package/src/charts/index.ts +34 -14
  125. package/src/charts/kinds/Bars.tsx +327 -460
  126. package/src/charts/kinds/Donut.tsx +31 -23
  127. package/src/charts/kinds/DualPanel.tsx +228 -134
  128. package/src/charts/kinds/Heatmap.tsx +72 -47
  129. package/src/charts/kinds/MultiLine.tsx +158 -294
  130. package/src/charts/kinds/StackedArea.tsx +116 -175
  131. package/src/charts/kinds/ZonedLine.tsx +148 -255
  132. package/src/charts/layout/auto-margin.test.ts +42 -0
  133. package/src/charts/layout/auto-margin.ts +110 -0
  134. package/src/charts/primitives/CartesianChart.test.ts +99 -0
  135. package/src/charts/primitives/CartesianChart.tsx +566 -0
  136. package/src/charts/primitives/ChartFrame.test.tsx +33 -0
  137. package/src/charts/primitives/ChartFrame.tsx +40 -4
  138. package/src/charts/primitives/ChartLegend.test.tsx +48 -0
  139. package/src/charts/primitives/ChartLegend.tsx +45 -2
  140. package/src/charts/primitives/ChartTooltip.tsx +67 -37
  141. package/src/charts/primitives/HoverOverlay.tsx +39 -3
  142. package/src/charts/primitives/XZoneRects.test.tsx +63 -0
  143. package/src/charts/primitives/XZoneRects.tsx +67 -0
  144. package/src/charts/series.test.ts +61 -0
  145. package/src/charts/series.ts +18 -1
  146. package/src/charts/utils/measure-text.test.ts +36 -0
  147. package/src/charts/utils/measure-text.ts +109 -0
  148. package/src/content/fence-block.tsx +2 -3
  149. package/src/shell/app-sidebar.module.css +11 -0
  150. package/src/shell/app-sidebar.module.css.d.ts +1 -0
  151. package/src/shell/app-sidebar.test.tsx +98 -0
  152. package/src/shell/app-sidebar.tsx +22 -0
  153. package/src/shell/index.test.tsx +35 -0
  154. package/src/shell/index.tsx +11 -0
  155. package/src/surfaces.ts +2 -1
  156. package/src/theme/spacing.test.ts +2 -0
  157. package/src/tokens/chart-margin.test.ts +39 -0
  158. package/src/tokens/density.test.ts +1 -0
  159. package/src/tokens/index.ts +36 -0
  160. package/src/tokens/palette.ts +3 -0
  161. package/dist/charts/hooks/useChartTooltip.d.ts +0 -13
  162. package/dist/charts/hooks/useChartTooltip.d.ts.map +0 -1
  163. package/dist/charts/hooks/useChartTooltip.js +0 -26
  164. package/dist/charts/hooks/useHoverSync.d.ts +0 -46
  165. package/dist/charts/hooks/useHoverSync.d.ts.map +0 -1
  166. package/dist/charts/hooks/useHoverSync.js +0 -75
  167. package/dist/charts/hover-context.d.ts +0 -21
  168. package/dist/charts/hover-context.d.ts.map +0 -1
  169. package/dist/charts/hover-context.js +0 -12
  170. package/dist/charts/hover-sync.d.ts +0 -14
  171. package/dist/charts/hover-sync.d.ts.map +0 -1
  172. package/dist/charts/hover-sync.js +0 -17
  173. package/dist/charts/primitives/ResponsiveChart.d.ts +0 -45
  174. package/dist/charts/primitives/ResponsiveChart.d.ts.map +0 -1
  175. package/dist/charts/primitives/ResponsiveChart.js +0 -24
  176. package/src/charts/hooks/useChartTooltip.ts +0 -31
  177. package/src/charts/hooks/useHoverSync.ts +0 -152
  178. package/src/charts/hover-context.ts +0 -28
  179. package/src/charts/hover-sync.tsx +0 -37
  180. package/src/charts/primitives/ResponsiveChart.tsx +0 -64
@@ -43,6 +43,8 @@ export type ChartLegendConfig = {
43
43
  groups?: boolean
44
44
  /** Wrap cap → "+N more" rollup at high cardinality. */
45
45
  maxRows?: number
46
+ /** Clicking an entry hides that series. Default: on whenever there is more than one entry. */
47
+ toggle?: boolean
46
48
  }
47
49
 
48
50
  /** Visual identity of a series — everything the legend + tooltip swatch need. No accessors, no `T`. */
@@ -58,12 +60,24 @@ export type SeriesStyle = {
58
60
  strokeWidth?: number
59
61
  /** bar/area: the swatch honors this so it cannot lie. */
60
62
  fillOpacity?: number
63
+ /** Which y-axis this series is measured against. Default 'left'. `'right'` is what makes a
64
+ * chart dual-axis — `CartesianChart` reads it for the crosshair dots and the tooltip. */
65
+ axis?: 'left' | 'right'
61
66
  /** Default 'series'. */
62
67
  role?: SeriesRole
63
68
  /** Default true; false = companion folded under `parent`. */
64
69
  legend?: boolean
70
+ /**
71
+ * Default true; false = drawn and legended, but NEVER listed as a tooltip row. The replacement
72
+ * for the per-kind `hideBarTooltipRows` escape the old layer had: it belongs to the series, not to the kind, so
73
+ * one series can opt out without the kind growing a prop for it.
74
+ */
75
+ tooltip?: boolean
65
76
  /** e.g. an MA line names its parent so hover-dimming keeps the pair. */
66
77
  parent?: string
78
+ /** Short qualifier rendered after the label in muted text — e.g. a flat-at-zero series that is
79
+ * invisible in the plot. */
80
+ note?: string
67
81
  }
68
82
 
69
83
  /** Full descriptor = visual identity + data accessors. Generic over the point type. */
@@ -99,6 +113,7 @@ function legendEntryFor(s: SeriesStyle): LegendEntry {
99
113
  ...(s.strokeWidth !== undefined && { strokeWidth: s.strokeWidth }),
100
114
  ...(s.fillOpacity !== undefined && { fillOpacity: s.fillOpacity }),
101
115
  ...(s.role !== undefined && { role: s.role }),
116
+ ...(s.note !== undefined && { note: s.note }),
102
117
  }
103
118
  }
104
119
 
@@ -136,7 +151,8 @@ export function deriveLegend(series: readonly SeriesStyle[]): LegendEntry[] {
136
151
 
137
152
  /**
138
153
  * Derive tooltip rows from a series descriptor array + the hovered datum. Skips rows where
139
- * `getValue(datum) === null`; honors per-series `formatValue` over the shared `fallbackFormat`.
154
+ * `getValue(datum) === null` or `tooltip === false`; honors per-series `formatValue` over the
155
+ * shared `fallbackFormat`.
140
156
  * Kinds pass `series.toReversed()` for stacked charts so the tooltip stack matches the visual stack.
141
157
  */
142
158
  export function deriveTooltipRows<T>(
@@ -146,6 +162,7 @@ export function deriveTooltipRows<T>(
146
162
  ): TooltipRowData[] {
147
163
  const rows: TooltipRowData[] = []
148
164
  for (const s of series) {
165
+ if (s.tooltip === false) continue
149
166
  const value = s.getValue(datum)
150
167
  if (value === null) continue
151
168
  const format = s.formatValue ?? fallbackFormat
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { maxTextWidth, measureText, resetTextMetrics } from './measure-text'
3
+
4
+ describe('measureText', () => {
5
+ test('width grows with the string and with the font size', () => {
6
+ resetTextMetrics()
7
+ expect(measureText('1,250,000', 11)).toBeGreaterThan(measureText('0', 11))
8
+ expect(measureText('0', 22)).toBeGreaterThan(measureText('0', 11))
9
+ })
10
+
11
+ test('an empty string measures zero', () => {
12
+ expect(measureText('', 11)).toBe(0)
13
+ })
14
+
15
+ test('repeated measurement is stable — the memo cannot return a different width', () => {
16
+ resetTextMetrics()
17
+ const first = measureText('2026-08-18', 11)
18
+ expect(measureText('2026-08-18', 11)).toBe(first)
19
+ })
20
+
21
+ test('the same text at a different size is NOT a cache hit', () => {
22
+ resetTextMetrics()
23
+ expect(measureText('88', 11)).not.toBe(measureText('88', 30))
24
+ })
25
+ })
26
+
27
+ describe('maxTextWidth', () => {
28
+ test('returns 0 for no labels — an axis with no ticks reserves nothing extra', () => {
29
+ expect(maxTextWidth([], 11)).toBe(0)
30
+ })
31
+
32
+ test('returns the widest label, not the last', () => {
33
+ const labels = ['1', '1,250,000', '25']
34
+ expect(maxTextWidth(labels, 11)).toBe(measureText('1,250,000', 11))
35
+ })
36
+ })
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Text measurement for chart layout — the input to {@link autoMargin}.
3
+ *
4
+ * A static margin token cannot know how wide a tick label is, which is why charts used to have to be
5
+ * hand-nudged whenever a label grew (`docs/CHARTS-SPEC.md` §1). This measures the strings that
6
+ * will actually be painted, via an OFFSCREEN canvas 2D context: no element is attached, so there
7
+ * is no layout thrash and no reflow — unlike an SVG `getBBox()` probe.
8
+ *
9
+ * Mantine-free and dependency-free by construction (`charts/**` boundary).
10
+ */
11
+
12
+ /** Fallback advance width per character, as a fraction of the font size, when no DOM is
13
+ * available (SSR, `renderToStaticMarkup`). Mono faces sit near 0.6em; being deterministic matters
14
+ * more than being exact, since the measured value is only ever a floor-raiser. */
15
+ const FALLBACK_CHAR_RATIO = 0.6
16
+
17
+ /** Last-resort family when the `--basalt-font-mono` custom property cannot be read. */
18
+ const FALLBACK_MONO = 'ui-monospace, SFMono-Regular, Menlo, monospace'
19
+
20
+ /**
21
+ * Cap on the memo table. Tick labels are low-cardinality per chart, but a long-lived SPA with a
22
+ * live counter or currency axis mints a new string every update, and an unbounded module-level Map
23
+ * would then grow for the life of the tab. On overflow the whole table is dropped rather than
24
+ * evicted one by one: the working set is tiny and re-measuring it costs microseconds, so an LRU's
25
+ * bookkeeping would cost more than the misses it avoids.
26
+ */
27
+ const CACHE_LIMIT = 2000
28
+
29
+ const cache = new Map<string, number>()
30
+
31
+ let ctx: CanvasRenderingContext2D | null | undefined
32
+ let monoFamily: string | undefined
33
+
34
+ function canvasContext(): CanvasRenderingContext2D | null {
35
+ if (ctx !== undefined) return ctx
36
+ if (typeof document === 'undefined') {
37
+ ctx = null
38
+ return ctx
39
+ }
40
+ ctx = document.createElement('canvas').getContext('2d')
41
+ // Webfonts swap in after first paint; measurements taken against the fallback face would then
42
+ // be stale (usually too narrow). Drop the cache once, when the real faces are ready.
43
+ // `?.` on every hop: a non-browser DOM harness can expose `document` with no `fonts`, or a
44
+ // `fonts` with no `ready` — neither is a reason to fail a measurement.
45
+ document.fonts?.ready?.then?.(() => cache.clear()).catch(() => {})
46
+ return ctx
47
+ }
48
+
49
+ /**
50
+ * The resolved value of `--basalt-font-mono` — canvas needs a concrete family list, it cannot
51
+ * resolve a CSS custom property. Read once from the document element.
52
+ */
53
+ export function monoFontFamily(): string {
54
+ if (monoFamily !== undefined) return monoFamily
55
+ if (typeof document === 'undefined' || typeof getComputedStyle !== 'function') {
56
+ monoFamily = FALLBACK_MONO
57
+ return monoFamily
58
+ }
59
+ const value = getComputedStyle(document.documentElement)
60
+ .getPropertyValue('--basalt-font-mono')
61
+ .trim()
62
+ monoFamily = value.length > 0 ? value : FALLBACK_MONO
63
+ return monoFamily
64
+ }
65
+
66
+ /**
67
+ * Width in px of `text` at `fontPx` in the chart tick font. Memoized per `font|text` — a chart
68
+ * re-measures the same ~10 tick labels on every resize, and the cache turns that into a Map hit.
69
+ */
70
+ export function measureText(text: string, fontPx: number, fontFamily?: string): number {
71
+ const family = fontFamily ?? monoFontFamily()
72
+ const font = `${fontPx}px ${family}`
73
+ const key = `${font}|${text}`
74
+ const hit = cache.get(key)
75
+ if (hit !== undefined) return hit
76
+
77
+ const c = canvasContext()
78
+ let width: number
79
+ if (c === null) {
80
+ width = text.length * fontPx * FALLBACK_CHAR_RATIO
81
+ } else {
82
+ c.font = font
83
+ width = c.measureText(text).width
84
+ }
85
+ if (cache.size >= CACHE_LIMIT) cache.clear()
86
+ cache.set(key, width)
87
+ return width
88
+ }
89
+
90
+ /** Widest of `texts` at `fontPx`. Returns 0 for an empty list. */
91
+ export function maxTextWidth(
92
+ texts: readonly string[],
93
+ fontPx: number,
94
+ fontFamily?: string,
95
+ ): number {
96
+ let max = 0
97
+ for (const text of texts) {
98
+ const width = measureText(text, fontPx, fontFamily)
99
+ if (width > max) max = width
100
+ }
101
+ return max
102
+ }
103
+
104
+ /** Test seam — drops the memo table (and the resolved family) so a test can change fonts. */
105
+ export function resetTextMetrics(): void {
106
+ cache.clear()
107
+ ctx = undefined
108
+ monoFamily = undefined
109
+ }
@@ -139,9 +139,8 @@ function lookupFenceRenderer(
139
139
 
140
140
  // ── Throw containment ────────────────────────────────────────────────────────────────────────────
141
141
 
142
- /** A `useRef(false)` handle, mutated in place — same per-instance "warn once" idiom as
143
- * `useHoverSync`'s `warnedRef` (`charts/hooks/useHoverSync.ts`) rather than a module-level
144
- * singleton: a singleton would make the FIRST fence anywhere in the process to throw silence the
142
+ /** A `useRef(false)` handle, mutated in place — a per-instance "warn once" gate rather than a
143
+ * module-level singleton: a singleton would make the FIRST fence anywhere in the process to throw silence the
145
144
  * warning for every OTHER, unrelated broken fence for the rest of the run (and, worse, for every
146
145
  * later test in this suite — this file has module-singleton order-dependence history). Scoping the
147
146
  * gate to one `FenceBlock`'s `useRef` means each rendered fence position gets its own warning, reset
@@ -419,4 +419,15 @@
419
419
  .root[data-collapsed] .childList {
420
420
  display: none;
421
421
  }
422
+
423
+ /* `navExtra` has no sensible icon-rail representation (arbitrary consumer content, not a nav
424
+ * row) — hide it in the rail, same treatment as `.childList`. It stays mounted (never a JS
425
+ * conditional on `collapsed`): `collapsed` is one value shared by the desktop rail AND the
426
+ * mobile drawer (`AppShell.Navbar`'s `collapsed: { mobile: !mobileOpened }` only ever un-collapses
427
+ * the mobile side), and the rail-vs-drawer split lives ENTIRELY in this media query, never in JS —
428
+ * a JS gate on `collapsed` would also drop `navExtra` from the mobile drawer, which opens at full
429
+ * width regardless of the persisted rail state. */
430
+ .root[data-collapsed] .navExtra {
431
+ display: none;
432
+ }
422
433
  }
@@ -29,6 +29,7 @@ declare const classes: {
29
29
  readonly sectionHeader: string
30
30
  readonly subnavDropdown: string
31
31
  readonly childList: string
32
+ readonly navExtra: string
32
33
  readonly ghostIcon: string
33
34
  }
34
35
  export default classes
@@ -0,0 +1,98 @@
1
+ /**
2
+ * `navExtra` — the seam that lets a consumer put arbitrary content (a tree, a filter panel,
3
+ * a project list) inside the sidebar's nav scroll region instead of rendering a second column
4
+ * beside the shell. Four invariants:
5
+ *
6
+ * 1. It renders INSIDE the nav `ScrollArea`'s content (Mantine's own `.mantine-ScrollArea-content`
7
+ * class, stable across runs — CSS-module class hashes are not available under `bun test`, see
8
+ * `stat-card.test.tsx`/`use-basalt-spacing.test.tsx` for the same constraint elsewhere), as the
9
+ * LAST child, so a long tree scrolls with the nav instead of fighting it for height.
10
+ * 2. It STAYS MOUNTED when `collapsed` — `collapsed` is one value shared by the desktop rail AND
11
+ * the mobile drawer (`AppShell.Navbar`'s `collapsed: { mobile: !mobileOpened }` never touches
12
+ * the desktop side), and the rail-vs-drawer split lives entirely in a `min-width: sm` CSS media
13
+ * query, never in JS. A JS gate on `collapsed` would silently drop `navExtra` from the mobile
14
+ * drawer too, which opens at full width regardless of the persisted rail state. jsdom does not
15
+ * evaluate the media query, so this asserts presence in the DOM, not computed visibility — the
16
+ * CSS half (`.root[data-collapsed] .navExtra { display: none }`) is exercised by the guard rules
17
+ * it shares with `.childList`/`.sectionBand`, not by this test.
18
+ * 3. `sections={[]}` plus only `navExtra` renders cleanly: the nav Stack holds exactly the
19
+ * extra content, no orphan section wrapper.
20
+ * 4. Omitting the prop reproduces today's DOM exactly — the nav Stack's child count matches
21
+ * `sections.length`, with no stray wrapper appended for the (absent) slot. This is the
22
+ * regression guard for every existing consumer.
23
+ */
24
+ import { MantineProvider } from '@mantine/core'
25
+ import { render, screen } from '@testing-library/react'
26
+ import { describe, expect, test } from 'bun:test'
27
+ import type { ReactNode } from 'react'
28
+ import { AppSidebar } from './app-sidebar'
29
+ import type { AppSidebarProps } from './app-sidebar'
30
+
31
+ const BRAND = { name: 'Argo' }
32
+ const ONE_SECTION: AppSidebarProps['sections'] = [
33
+ { label: 'Main', items: [{ key: 'home', label: 'Home', icon: null }] },
34
+ ]
35
+
36
+ function renderSidebar(props: Partial<AppSidebarProps> & { navExtra?: ReactNode }) {
37
+ return render(
38
+ <MantineProvider>
39
+ <AppSidebar
40
+ brand={BRAND}
41
+ sections={ONE_SECTION}
42
+ collapsed={false}
43
+ onToggleCollapse={() => {}}
44
+ onClose={() => {}}
45
+ {...props}
46
+ />
47
+ </MantineProvider>,
48
+ )
49
+ }
50
+
51
+ /** The nav `ScrollArea`'s content Stack — the direct parent of `sections` and `navExtra`. */
52
+ function navStack(container: HTMLElement): Element {
53
+ const stack = container.querySelector('.mantine-ScrollArea-content > .mantine-Stack-root')
54
+ if (!stack) throw new Error('expected the nav ScrollArea content Stack to render')
55
+ return stack
56
+ }
57
+
58
+ describe('navExtra', () => {
59
+ test('renders inside the nav scroll region, as the last child, when expanded', () => {
60
+ const { container } = renderSidebar({
61
+ navExtra: <div data-testid="nav-extra">Extra</div>,
62
+ })
63
+ const extra = screen.getByTestId('nav-extra')
64
+ const stack = navStack(container)
65
+ expect(stack.contains(extra)).toBe(true)
66
+ expect(stack.lastElementChild?.contains(extra)).toBe(true)
67
+ })
68
+
69
+ test('stays mounted in the DOM when collapsed — hiding it is CSS-only, not a JS gate', () => {
70
+ const { container } = renderSidebar({
71
+ collapsed: true,
72
+ navExtra: <div data-testid="nav-extra">Extra</div>,
73
+ })
74
+ const extra = screen.getByTestId('nav-extra')
75
+ const stack = navStack(container)
76
+ // Presence, not visibility — jsdom never evaluates the `min-width: sm` media query that hides
77
+ // this in the rail. A regression here (reintroducing `!collapsed &&` in app-sidebar.tsx) would
78
+ // fail this assertion instead of only breaking silently in a real mobile-drawer browser.
79
+ expect(stack.contains(extra)).toBe(true)
80
+ })
81
+
82
+ test('sections={[]} plus only navExtra renders cleanly — no orphan section wrapper', () => {
83
+ const { container } = renderSidebar({
84
+ sections: [],
85
+ navExtra: <div data-testid="nav-extra">Extra</div>,
86
+ })
87
+ const stack = navStack(container)
88
+ expect(screen.getByTestId('nav-extra')).toBeTruthy()
89
+ expect(stack.children.length).toBe(1)
90
+ })
91
+
92
+ test("omitting the prop reproduces today's DOM — no stray wrapper for the unused slot", () => {
93
+ const { container } = renderSidebar({})
94
+ expect(screen.queryByTestId('nav-extra')).toBeNull()
95
+ const stack = navStack(container)
96
+ expect(stack.children.length).toBe(ONE_SECTION.length)
97
+ })
98
+ })
@@ -92,6 +92,24 @@ export type AppSidebarProps = {
92
92
  * fixed, non-scrolling row. Pair with basalt-ui/commands' openSpotlight.
93
93
  */
94
94
  search?: SidebarSearchConfig
95
+ /**
96
+ * Arbitrary content appended after `sections` inside the nav `ScrollArea` — a tree, a filter
97
+ * panel, a project list, anything a set of `SidebarItem`s can't express. Renders as the last
98
+ * child of the scrolling nav column, so a long list scrolls with the rest of the nav instead of
99
+ * fighting it for height. Pass `sections={[]}` to use this slot exclusively; the section-spacing
100
+ * rule only fires between adjacent children, so an empty `sections` produces no orphan divider or
101
+ * dead padding above it.
102
+ *
103
+ * Hidden on the collapsed desktop rail; still present in the mobile drawer, which opens at full
104
+ * width. The rail is ~56px of icon buttons with no sensible representation for arbitrary
105
+ * consumer content, so the CSS media query that drives the rail hides this slot the same way it
106
+ * hides `.childList` — never a JS check on `collapsed`, since that one value is shared by the
107
+ * rail AND the drawer (only the media query tells them apart). `SidebarSearch` gets to adapt
108
+ * itself to the rail (it takes `collapsed` and renders its own icon-only form) because the shell
109
+ * owns that control; it cannot adapt content it knows nothing about, so this slot hides instead
110
+ * of squashing it.
111
+ */
112
+ navExtra?: ReactNode
95
113
  }
96
114
 
97
115
  /** Inline collapse/expand chevrons — keeps the shell icon-dependency-free. */
@@ -457,6 +475,7 @@ export function AppSidebar({
457
475
  footerExtra,
458
476
  account,
459
477
  search,
478
+ navExtra,
460
479
  }: AppSidebarProps) {
461
480
  // Density-tracking Menu dropdown width (`SPACE_STEP.sidebarSettingsMenuWidth`) — read the ACTIVE
462
481
  // resolved level, not the frozen level-0 constant (see that constant's own doc in
@@ -622,6 +641,9 @@ export function AppSidebar({
622
641
  </div>
623
642
  )
624
643
  })}
644
+ {navExtra !== undefined && navExtra !== null && (
645
+ <div className={classes.navExtra}>{navExtra}</div>
646
+ )}
625
647
  </Stack>
626
648
  </ScrollArea>
627
649
 
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `BasaltShell`'s `sidebarNavExtra` forwarding path — `AppSidebar` (exercised directly in
3
+ * `app-sidebar.test.tsx`) is not the only place this prop can break. `BasaltShell` destructures
4
+ * `sidebarNavExtra` and hands it to `AppSidebar` as `navExtra`; a typo'd destructure key, a dropped
5
+ * prop, or a `sidebarFooterExtra`/`sidebarNavExtra` mix-up in that hand-off would ship silently
6
+ * without a test that renders `BasaltShell` itself.
7
+ */
8
+ import { MantineProvider } from '@mantine/core'
9
+ import { render, screen } from '@testing-library/react'
10
+ import { describe, expect, test } from 'bun:test'
11
+ import { BasaltShell } from './index'
12
+ import type { SidebarSection } from './index'
13
+
14
+ const BRAND = { name: 'Argo' }
15
+ const ONE_SECTION: SidebarSection[] = [
16
+ { label: 'Main', items: [{ key: 'home', label: 'Home', icon: null }] },
17
+ ]
18
+
19
+ describe('BasaltShell sidebarNavExtra', () => {
20
+ test("reaches AppSidebar's nav scroll region through the BasaltShell -> AppSidebar hand-off", () => {
21
+ const { container } = render(
22
+ <MantineProvider>
23
+ <BasaltShell
24
+ brand={BRAND}
25
+ sections={ONE_SECTION}
26
+ sidebarNavExtra={<div data-testid="nav-extra">Extra</div>}
27
+ />
28
+ </MantineProvider>,
29
+ )
30
+ const extra = screen.getByTestId('nav-extra')
31
+ const stack = container.querySelector('.mantine-ScrollArea-content > .mantine-Stack-root')
32
+ expect(stack).not.toBeNull()
33
+ expect(stack?.contains(extra)).toBe(true)
34
+ })
35
+ })
@@ -115,6 +115,15 @@ export type BasaltShellProps = {
115
115
  globalActions?: ReactNode
116
116
  /** Extra content appended to the sidebar footer, beside the settings menu (mobile close, etc.). */
117
117
  sidebarFooterExtra?: ReactNode
118
+ /**
119
+ * Arbitrary content appended after `sections` inside the sidebar's nav scroll region (a tree, a
120
+ * filter panel, a project list, …). Hidden on the collapsed desktop rail; still present in the
121
+ * mobile drawer, which opens at full width — see `AppSidebarProps.navExtra`'s JSDoc for why.
122
+ * `AppSidebar` renders once and serves both the desktop rail and the mobile drawer (collapse
123
+ * styling is gated behind a `min-width: sm` media query, not a second render), so this slot
124
+ * reaches both.
125
+ */
126
+ sidebarNavExtra?: ReactNode
118
127
  /** Entries appended to the sidebar settings menu. */
119
128
  settingsMenuItems?: SettingsMenuItem[]
120
129
  /**
@@ -262,6 +271,7 @@ export function BasaltShell({
262
271
  renderNavLink,
263
272
  globalActions,
264
273
  sidebarFooterExtra,
274
+ sidebarNavExtra,
265
275
  settingsMenuItems,
266
276
  storageKey = 'basalt-sidebar-collapsed',
267
277
  collapsed: collapsedProp,
@@ -332,6 +342,7 @@ export function BasaltShell({
332
342
  onToggleCollapse={toggleCollapse}
333
343
  onClose={closeMobile}
334
344
  footerExtra={sidebarFooterExtra}
345
+ navExtra={sidebarNavExtra}
335
346
  {...(renderNavLink !== undefined && { renderNavLink })}
336
347
  {...(settingsMenuItems !== undefined && { settingsMenuItems })}
337
348
  {...(account !== undefined && { account })}
package/src/surfaces.ts CHANGED
@@ -211,7 +211,8 @@ export const SURFACES = {
211
211
  rule: 'charts',
212
212
  skill: ['basalt-charts'],
213
213
  guardKinds: ['raw-hex', 'raw-color-fn', 'raw-visx-axis', 'unframed-chart'],
214
- description: 'visx chart primitives, sparklines, hooks, and token re-exports (Mantine-free)',
214
+ description:
215
+ 'CartesianChart + visx chart primitives, kinds, sparklines, hooks, and token re-exports (Mantine-free)',
215
216
  globs: {
216
217
  shipped: ['**/charts/**'],
217
218
  repo: ['packages/basalt-ui/src/charts/**'],
@@ -369,6 +369,7 @@ const SPACE_STEP_SWEEP: ReadonlyArray<
369
369
  ['chartLegendGap', 22, null],
370
370
  ['chartMarginTop', 12, null],
371
371
  ['chartMarginRight', 16, null],
372
+ ['chartMarginRightAxis', 40, null],
372
373
  ['chartMarginBottom', 30, null],
373
374
  ['chartMarginLeft', 44, null],
374
375
  ['chartDotR', 5, null],
@@ -406,6 +407,7 @@ describe('SPACE_STEP CSS-module spacing-sweep one-offs match the shipped identit
406
407
  'chartLegendGap',
407
408
  'chartMarginTop',
408
409
  'chartMarginRight',
410
+ 'chartMarginRightAxis',
409
411
  'chartMarginBottom',
410
412
  'chartMarginLeft',
411
413
  'chartDotR',
@@ -0,0 +1,39 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { chartMargin, VX } from './index'
3
+
4
+ describe('chartMargin', () => {
5
+ test('a bare call deep-equals VX.margin', () => {
6
+ expect(chartMargin()).toEqual(VX.margin)
7
+ })
8
+
9
+ test('{ rightAxis: true } widens right to the right-axis minimum', () => {
10
+ expect(chartMargin({ rightAxis: true })).toEqual({
11
+ ...VX.margin,
12
+ right: 40,
13
+ })
14
+ })
15
+
16
+ test('{ rightAxis: false } (or omitted) leaves right at VX.margin.right', () => {
17
+ expect(chartMargin({ rightAxis: false })).toEqual(VX.margin)
18
+ })
19
+
20
+ test('an explicit right overrides the rightAxis-widened value', () => {
21
+ expect(chartMargin({ rightAxis: true, right: 12 })).toEqual({
22
+ ...VX.margin,
23
+ right: 12,
24
+ })
25
+ })
26
+
27
+ test('explicit sides override their VX.margin defaults independently', () => {
28
+ expect(chartMargin({ top: 1, left: 2 })).toEqual({
29
+ top: 1,
30
+ right: VX.margin.right,
31
+ bottom: VX.margin.bottom,
32
+ left: 2,
33
+ })
34
+ })
35
+
36
+ test('returns a new object each call', () => {
37
+ expect(chartMargin()).not.toBe(chartMargin())
38
+ })
39
+ })
@@ -242,6 +242,7 @@ describe('buildDensityCss', () => {
242
242
  'space-chart-legend-gap', // VX.legendGap / VX.margin / VX.dotR — visx SVG props
243
243
  'space-chart-margin-top',
244
244
  'space-chart-margin-right',
245
+ 'space-chart-margin-right-axis',
245
246
  'space-chart-margin-bottom',
246
247
  'space-chart-margin-left',
247
248
  'space-chart-dot-r',
@@ -241,6 +241,42 @@ export const VX = {
241
241
  minPxPerTick: 55,
242
242
  } as const
243
243
 
244
+ /** Resolved chart plot-area margins — the return shape of {@link chartMargin}. */
245
+ export type ChartMargin = { top: number; right: number; bottom: number; left: number }
246
+
247
+ /**
248
+ * Chart plot-area margins — `VX.margin` widened for an `AxisRightNumeric`'s tick labels, and/or
249
+ * overridden per side. A token can't express "only when a right axis is rendered", so this is a
250
+ * function, not a second static token — also the seam that could one day track `density`
251
+ * (`VX.margin` itself is frozen at the level-0 snapshot; see `deriveSpacing`'s JSDoc).
252
+ *
253
+ * Law, applied in this order: start from `VX.margin`; when `rightAxis` is true, widen `right` to
254
+ * `Math.max(VX.margin.right, <right-axis minimum>)` (`SPACE_STEP.chartMarginRightAxis`); then any
255
+ * explicitly passed side wins last (including overriding `rightAxis`'s widened `right`).
256
+ * `chartMargin()` with no args deep-equals `VX.margin`.
257
+ *
258
+ * Returns a NEW object every call — memoize at the call site (`useMemo`), the same way `Bars`
259
+ * already memoizes its own margin.
260
+ */
261
+ export function chartMargin(opts?: {
262
+ /** Reserve room for an `AxisRightNumeric`'s tick labels. */
263
+ rightAxis?: boolean
264
+ top?: number
265
+ right?: number
266
+ bottom?: number
267
+ left?: number
268
+ }): ChartMargin {
269
+ const widenedRight = opts?.rightAxis
270
+ ? Math.max(VX.margin.right, SPACE_STEP.chartMarginRightAxis)
271
+ : VX.margin.right
272
+ return {
273
+ top: opts?.top ?? VX.margin.top,
274
+ right: opts?.right ?? widenedRight,
275
+ bottom: opts?.bottom ?? VX.margin.bottom,
276
+ left: opts?.left ?? VX.margin.left,
277
+ }
278
+ }
279
+
244
280
  type Side = 'light' | 'dark'
245
281
 
246
282
  /** A CSS declaration line for a single `--vx-*` custom property. */
@@ -620,6 +620,9 @@ const SPACE_STEP_BASE = {
620
620
  chartMarginTop: 12,
621
621
  /** Chart plot-area margin, right (`VX.margin.right`). */
622
622
  chartMarginRight: 16,
623
+ /** Minimum right margin when an `AxisRightNumeric` is rendered — labels like "100"/"60m" need
624
+ * more room than the no-right-axis default (`chartMargin({ rightAxis: true })`, `tokens/index.ts`). */
625
+ chartMarginRightAxis: 40,
623
626
  /** Chart plot-area margin, bottom (`VX.margin.bottom`). */
624
627
  chartMarginBottom: 30,
625
628
  /** Chart plot-area margin, left (`VX.margin.left`). */
@@ -1,13 +0,0 @@
1
- export type TooltipState<T> = {
2
- data: T;
3
- x: number;
4
- y: number;
5
- } | null;
6
- export declare function useChartTooltip<T>(): {
7
- tip: TooltipState<T>;
8
- show: (data: T, event: React.MouseEvent) => void;
9
- hide: () => void;
10
- tooltipRef: import("react").RefObject<HTMLDivElement | null>;
11
- lastDateRef: import("react").RefObject<string | null>;
12
- };
13
- //# sourceMappingURL=useChartTooltip.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"useChartTooltip.d.ts","sourceRoot":"","sources":["../../../src/charts/hooks/useChartTooltip.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,YAAY,CAAC,CAAC,IAAI;IAAE,IAAI,EAAE,CAAC,CAAC;IAAC,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAA;AAEtE,wBAAgB,eAAe,CAAC,CAAC;;iBAKC,CAAC,SAAS,KAAK,CAAC,UAAU;;;;EAqB3D"}
@@ -1,26 +0,0 @@
1
- import { useCallback, useRef, useState } from "react";
2
- function useChartTooltip() {
3
- const [tip, setTip] = useState(null);
4
- const tooltipRef = useRef(null);
5
- const lastDateRef = useRef(null);
6
- const show = useCallback((data, event) => {
7
- const el = tooltipRef.current;
8
- const width = el?.offsetWidth ?? 0;
9
- const height = el?.offsetHeight ?? 0;
10
- const margin = 8;
11
- const vw = window.innerWidth;
12
- const vh = window.innerHeight;
13
- const overflowsRight = event.clientX + 12 + width + margin > vw;
14
- const x = overflowsRight ? Math.max(margin, event.clientX - 12 - width) : event.clientX + 12;
15
- const y = Math.min(Math.max(margin, event.clientY - 12), vh - height - margin);
16
- setTip({ data, x, y });
17
- }, []);
18
- const hide = useCallback(() => {
19
- setTip(null);
20
- lastDateRef.current = null;
21
- }, []);
22
- return { tip, show, hide, tooltipRef, lastDateRef };
23
- }
24
- export {
25
- useChartTooltip
26
- };