basalt-ui 1.8.0 → 1.10.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 (99) hide show
  1. package/AGENTS.md +1 -0
  2. package/CHANGELOG.md +25 -0
  3. package/README.md +64 -34
  4. package/agent/rules/basalt-agent.md +49 -6
  5. package/agent/rules/basalt-charts.md +74 -5
  6. package/configs/oxlint-plugin.js +279 -0
  7. package/configs/oxlint-plugin.test.ts +216 -1
  8. package/configs/oxlint.json +5 -2
  9. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  10. package/dist/agent/use-agent-thread-runs.js +1 -0
  11. package/dist/agent-chat/index.d.ts +4 -3
  12. package/dist/agent-chat/index.d.ts.map +1 -1
  13. package/dist/charts/hooks/useHoverSync.d.ts +18 -2
  14. package/dist/charts/hooks/useHoverSync.d.ts.map +1 -1
  15. package/dist/charts/hooks/useHoverSync.js +12 -7
  16. package/dist/charts/index.d.ts +1 -0
  17. package/dist/charts/index.d.ts.map +1 -1
  18. package/dist/charts/index.js +6 -0
  19. package/dist/charts/kinds/Bars.d.ts +2 -0
  20. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  21. package/dist/charts/kinds/Bars.js +3 -1
  22. package/dist/charts/kinds/Donut.d.ts +2 -0
  23. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  24. package/dist/charts/kinds/Donut.js +8 -4
  25. package/dist/charts/kinds/DualPanel.d.ts +2 -0
  26. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  27. package/dist/charts/kinds/DualPanel.js +2 -1
  28. package/dist/charts/kinds/Heatmap.d.ts +8 -0
  29. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  30. package/dist/charts/kinds/Heatmap.js +7 -3
  31. package/dist/charts/kinds/MultiLine.d.ts +2 -0
  32. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  33. package/dist/charts/kinds/MultiLine.js +2 -1
  34. package/dist/charts/kinds/StackedArea.d.ts +2 -0
  35. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  36. package/dist/charts/kinds/StackedArea.js +2 -1
  37. package/dist/charts/kinds/ZonedLine.d.ts +2 -0
  38. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  39. package/dist/charts/kinds/ZonedLine.js +2 -1
  40. package/dist/charts/primitives/Axes.d.ts +6 -1
  41. package/dist/charts/primitives/Axes.d.ts.map +1 -1
  42. package/dist/charts/primitives/Axes.js +3 -2
  43. package/dist/charts/primitives/ChartFrame.d.ts +8 -1
  44. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  45. package/dist/charts/primitives/ChartFrame.js +8 -4
  46. package/dist/charts/primitives/ChartPending.d.ts +34 -0
  47. package/dist/charts/primitives/ChartPending.d.ts.map +1 -0
  48. package/dist/charts/primitives/ChartPending.js +23 -0
  49. package/dist/charts/primitives/ChartTooltip.d.ts +5 -2
  50. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  51. package/dist/charts/primitives/ChartTooltip.js +6 -1
  52. package/dist/charts/primitives/HoverOverlay.d.ts +5 -4
  53. package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
  54. package/dist/charts/primitives/HoverOverlay.js +4 -2
  55. package/dist/cli/index.d.ts +55 -1
  56. package/dist/cli/index.d.ts.map +1 -1
  57. package/dist/cli/index.js +217 -5
  58. package/dist/dashboard/stat-card.d.ts.map +1 -1
  59. package/dist/guard/index.d.ts.map +1 -1
  60. package/dist/guard/index.js +13 -5
  61. package/dist/surfaces.d.ts +10 -0
  62. package/dist/surfaces.d.ts.map +1 -1
  63. package/dist/surfaces.js +21 -0
  64. package/llms.txt +9 -2
  65. package/package.json +8 -5
  66. package/src/agent/ai-sdk-transport.test.ts +269 -0
  67. package/src/agent/parts.test.ts +261 -0
  68. package/src/agent/thread.test.ts +147 -0
  69. package/src/agent/use-agent-stream.test.tsx +231 -0
  70. package/src/agent/use-agent-thread-runs.resume.test.tsx +261 -0
  71. package/src/agent/use-agent-thread-runs.test.tsx +398 -0
  72. package/src/agent/use-agent-thread-runs.ts +7 -1
  73. package/src/agent/use-agent-thread-runs.wedge.test.tsx +299 -0
  74. package/src/agent-chat/index.ts +4 -3
  75. package/src/charts/hooks/useHoverSync.ts +43 -7
  76. package/src/charts/index.ts +6 -0
  77. package/src/charts/kinds/Bars.tsx +4 -0
  78. package/src/charts/kinds/Donut.tsx +10 -4
  79. package/src/charts/kinds/DualPanel.tsx +5 -1
  80. package/src/charts/kinds/Heatmap.tsx +19 -2
  81. package/src/charts/kinds/MultiLine.tsx +4 -1
  82. package/src/charts/kinds/StackedArea.tsx +4 -1
  83. package/src/charts/kinds/ZonedLine.tsx +4 -1
  84. package/src/charts/primitives/Axes.tsx +7 -1
  85. package/src/charts/primitives/ChartFrame.test.tsx +67 -0
  86. package/src/charts/primitives/ChartFrame.tsx +17 -4
  87. package/src/charts/primitives/ChartPending.tsx +59 -0
  88. package/src/charts/primitives/ChartTooltip.tsx +15 -3
  89. package/src/charts/primitives/HoverOverlay.tsx +12 -6
  90. package/src/cli/index.ts +439 -5
  91. package/src/cli/placement-engine.test.ts +104 -0
  92. package/src/dashboard/stat-card.test.tsx +5 -3
  93. package/src/dashboard/stat-card.tsx +12 -0
  94. package/src/guard/check-source.test.ts +20 -0
  95. package/src/guard/index.ts +20 -9
  96. package/src/provider/index.test.tsx +6 -5
  97. package/src/surfaces.ts +22 -0
  98. package/src/theme/use-basalt-spacing.test.tsx +7 -4
  99. package/src/tokens/build-fonts-css.test.ts +3 -2
@@ -73,17 +73,23 @@ export function AxisBottomDate({
73
73
  scale,
74
74
  top,
75
75
  tickValues,
76
+ tickFormat = fmtAxisDate,
76
77
  }: {
77
78
  scale: AxisScale
78
79
  top: number
79
80
  tickValues: string[]
81
+ /**
82
+ * Defaults to `fmtAxisDate` (DD.MM). Override for a sub-day window, where DD.MM collapses every
83
+ * tick to the same label.
84
+ */
85
+ tickFormat?: TickFormatter<string>
80
86
  }) {
81
87
  return (
82
88
  <AxisBottom
83
89
  top={top}
84
90
  scale={scale}
85
91
  tickValues={tickValues}
86
- tickFormat={fmtAxisDate}
92
+ tickFormat={tickFormat}
87
93
  tickLabelProps={{
88
94
  fill: VX.faint,
89
95
  fontFamily: TICK_FONT_FAMILY,
@@ -0,0 +1,67 @@
1
+ /**
2
+ * `ChartFrame`'s `isPending` invariant — the one behavior here that regresses silently. A pending
3
+ * chart that quietly fell back to rendering its legend + body would put a densified "not measured"
4
+ * shape back on screen exactly where `ChartPending` is supposed to reserve a static, contentless
5
+ * placeholder (see `ChartPending`'s JSDoc for the three-state "nothing to draw" rationale).
6
+ *
7
+ * A DOM harness now exists (`tests/setup/dom.ts`, preloaded via the root `bunfig.toml`; see
8
+ * `theme/use-basalt-spacing.test.tsx`'s doc) — `renderToStaticMarkup` is used deliberately here
9
+ * instead: `ChartFrame`/`ChartPending` live in `src/charts/**`, which is Mantine-free, so no
10
+ * `MantineProvider` wrapper is needed either. `useChartSize` never measures under SSR (no
11
+ * `ResizeObserver`), so the plot rect falls back to `minWidth` × the resolved fixed height —
12
+ * non-zero, which is what lets `children`/`ChartPending` render at all in this harness. Converting
13
+ * to the DOM harness would only be worth it if a future assertion here needed a real measured size
14
+ * (a live `ResizeObserver` reading) rather than this SSR fallback rect.
15
+ */
16
+ import { describe, expect, test } from 'bun:test'
17
+ import { renderToStaticMarkup } from 'react-dom/server'
18
+ import { ChartFrame } from './ChartFrame'
19
+ import type { SeriesStyle } from '../series'
20
+
21
+ const series: SeriesStyle[] = [{ key: 'a', label: 'Series A', color: '#000', mark: 'line' }]
22
+
23
+ const CHART_BODY_MARKER = 'CHART_BODY_MARKER'
24
+
25
+ function renderFrame(isPending: boolean): string {
26
+ return renderToStaticMarkup(
27
+ <ChartFrame series={series} legend={{}} isPending={isPending}>
28
+ {() => <svg>{CHART_BODY_MARKER}</svg>}
29
+ </ChartFrame>,
30
+ )
31
+ }
32
+
33
+ describe('a pending ChartFrame renders neither the legend nor the chart body', () => {
34
+ const markup = renderFrame(true)
35
+
36
+ test('the legend is absent — no series label anywhere in the markup', () => {
37
+ expect(markup).not.toContain('Series A')
38
+ })
39
+
40
+ test('the chart body (children) never runs', () => {
41
+ expect(markup).not.toContain(CHART_BODY_MARKER)
42
+ })
43
+
44
+ test('the outer container is marked aria-busy', () => {
45
+ expect(markup).toContain('aria-busy="true"')
46
+ })
47
+
48
+ test('the placeholder label renders in its place', () => {
49
+ expect(markup).toContain('Loading…')
50
+ })
51
+ })
52
+
53
+ describe('a non-pending ChartFrame is unaffected — the legend and body both render', () => {
54
+ const markup = renderFrame(false)
55
+
56
+ test('the legend renders the series label', () => {
57
+ expect(markup).toContain('Series A')
58
+ })
59
+
60
+ test('the chart body renders', () => {
61
+ expect(markup).toContain(CHART_BODY_MARKER)
62
+ })
63
+
64
+ test('no aria-busy attribute is present', () => {
65
+ expect(markup).not.toContain('aria-busy')
66
+ })
67
+ })
@@ -3,6 +3,7 @@ import { useChartSize } from '../hooks/useChartSize'
3
3
  import { deriveLegend } from '../series'
4
4
  import type { ChartLegendConfig, LegendPlacement, SeriesStyle } from '../series'
5
5
  import { ChartLegend } from './ChartLegend'
6
+ import { ChartPending } from './ChartPending'
6
7
 
7
8
  const DEFAULT_HEIGHT = 240
8
9
  const DEFAULT_MIN_WIDTH = 200
@@ -36,6 +37,13 @@ export type ChartFrameProps = {
36
37
  chartId?: string
37
38
  /** `false` only for the sparkline exemption — every other chart gets a legend by default. */
38
39
  legend?: ChartFrameLegend | false
40
+ /**
41
+ * The query behind this chart hasn't resolved yet. Renders `ChartPending` (see its JSDoc for the
42
+ * three-state "nothing to draw" rationale) over the full plot rect in place of `children`,
43
+ * suppresses the legend entirely (a legend naming a series with nothing yet to point at is its
44
+ * own small lie), and marks the outer container `aria-busy="true"`.
45
+ */
46
+ isPending?: boolean
39
47
  /**
40
48
  * Accessible text alternative for the chart, applied as `aria-label` (+ `role="img"`) on the
41
49
  * outer container so screen readers announce something other than an unlabeled graphic. Every
@@ -106,6 +114,7 @@ export function ChartFrame({
106
114
  chartId,
107
115
  legend = {},
108
116
  ariaLabel,
117
+ isPending = false,
109
118
  children,
110
119
  }: ChartFrameProps): ReactNode {
111
120
  const { ref: containerRef, width: containerW, height: containerH } = useChartSize()
@@ -113,6 +122,7 @@ export function ChartFrame({
113
122
 
114
123
  const placement = legend === false ? 'bottom' : (legend.placement ?? 'bottom')
115
124
  const vertical = placement === 'left' || placement === 'right'
125
+ const legendVisible = legend !== false && !isPending
116
126
 
117
127
  const resolvedHeight = fill
118
128
  ? containerH
@@ -120,8 +130,8 @@ export function ChartFrame({
120
130
  ? Math.round(containerW / aspectRatio)
121
131
  : (height ?? DEFAULT_HEIGHT)
122
132
 
123
- const sideLegendWidth = legend !== false && vertical ? legendW : 0
124
- const topBottomLegendHeight = legend !== false && !vertical ? legendH : 0
133
+ const sideLegendWidth = legendVisible && vertical ? legendW : 0
134
+ const topBottomLegendHeight = legendVisible && !vertical ? legendH : 0
125
135
 
126
136
  const plot = {
127
137
  width: Math.max(containerW - sideLegendWidth, minWidth),
@@ -129,7 +139,7 @@ export function ChartFrame({
129
139
  }
130
140
 
131
141
  const legendNode =
132
- legend === false ? null : (
142
+ legend === false || isPending ? null : (
133
143
  <div ref={legendRef} style={legendWrapperStyle(vertical)}>
134
144
  <ChartLegend
135
145
  items={deriveLegend(series)}
@@ -148,9 +158,12 @@ export function ChartFrame({
148
158
  ref={containerRef}
149
159
  style={outerStyle(fill, vertical)}
150
160
  {...(ariaLabel !== undefined && { role: 'img', 'aria-label': ariaLabel })}
161
+ {...(isPending && { 'aria-busy': 'true' })}
151
162
  >
152
163
  {legendNode !== null && (placement === 'top' || placement === 'left') && legendNode}
153
- {plot.width > 0 && plot.height > 0 ? children(plot) : null}
164
+ {plot.width > 0 &&
165
+ plot.height > 0 &&
166
+ (isPending ? <ChartPending width={plot.width} height={plot.height} /> : children(plot))}
154
167
  {legendNode !== null && (placement === 'bottom' || placement === 'right') && legendNode}
155
168
  </div>
156
169
  )
@@ -0,0 +1,59 @@
1
+ import type { CSSProperties, ReactNode } from 'react'
2
+ import { VX } from '../../tokens'
3
+
4
+ export type ChartCenterProps = {
5
+ width: number
6
+ height: number
7
+ children: ReactNode
8
+ }
9
+
10
+ const centerStyle = (width: number, height: number): CSSProperties => ({
11
+ width,
12
+ height,
13
+ display: 'flex',
14
+ alignItems: 'center',
15
+ justifyContent: 'center',
16
+ })
17
+
18
+ /**
19
+ * Minimal layout primitive: centers `children` inside a `width` × `height` box. Exists because
20
+ * `src/charts/**` cannot import Mantine's `Flex`/`Group`/`Center` (the Mantine-free boundary), so
21
+ * there was previously nothing to reach for when a chart file needed to center something. Not a
22
+ * general layout system — width, height, children, nothing else.
23
+ */
24
+ export function ChartCenter({ width, height, children }: ChartCenterProps): ReactNode {
25
+ return <div style={centerStyle(width, height)}>{children}</div>
26
+ }
27
+
28
+ export type ChartPendingProps = {
29
+ width: number
30
+ height: number
31
+ /** Default `'Loading…'`. */
32
+ label?: string
33
+ }
34
+
35
+ const labelStyle: CSSProperties = {
36
+ color: VX.faint,
37
+ fontSize: VX.text.sm,
38
+ }
39
+
40
+ /**
41
+ * The placeholder for a chart whose data hasn't arrived yet — the third "nothing to draw" state
42
+ * alongside measured-and-empty and measured-and-absent (a real gap in coverage). Collapsing an
43
+ * in-flight query into either of those (the `data ?? []` idiom) densifies it into a fully-hatched
44
+ * "not measured" window: a positive claim that the series WAS watched and carried nothing, when in
45
+ * fact it was never asked. `ChartPending` makes "not asked yet" its own rendered state instead of
46
+ * borrowing one of the other two.
47
+ *
48
+ * Reserves exactly the plot's footprint and draws NOTHING that could be mistaken for a
49
+ * measurement — no axes, no gridlines, no hatching, no data marks — just a faint, static, centered
50
+ * label. No animation: the package's motion doctrine bans looping/pulsing idle motion, so a static
51
+ * reserved box is the correct answer here, not a compromise.
52
+ */
53
+ export function ChartPending({ width, height, label = 'Loading…' }: ChartPendingProps): ReactNode {
54
+ return (
55
+ <ChartCenter width={width} height={height}>
56
+ <span style={labelStyle}>{label}</span>
57
+ </ChartCenter>
58
+ )
59
+ }
@@ -1,4 +1,5 @@
1
1
  import type { CSSProperties, ReactNode, RefObject } from 'react'
2
+ import { createPortal } from 'react-dom'
2
3
  import { VX } from '../../tokens'
3
4
  import { fmtTooltipDate } from '../utils/format'
4
5
 
@@ -24,7 +25,10 @@ export function useTooltipStyles(): CSSProperties {
24
25
  return TOOLTIP_STYLES
25
26
  }
26
27
 
27
- /** Outer tooltip shell. Renders nothing when tip is null. */
28
+ /**
29
+ * Outer tooltip shell. Renders nothing when tip is null. Renders to `document.body` via a
30
+ * portal, so it may be authored anywhere in the tree, including inside `<svg>`.
31
+ */
28
32
  export function ChartTooltip({
29
33
  tip,
30
34
  tooltipRef,
@@ -37,10 +41,18 @@ export function ChartTooltip({
37
41
  children: ReactNode
38
42
  }) {
39
43
  if (!tip) return null
40
- return (
44
+ // SSR guard: react-dom/server (renderToStaticMarkup, used by this repo's tests — see
45
+ // dashboard/stat-card.test.tsx) has no jsdom and does not support portals.
46
+ if (typeof document === 'undefined') return null
47
+ // Portal to document.body for two reasons: (1) authoring this inside an <svg> tree would
48
+ // otherwise create the div in the SVG namespace, where it mounts, accepts every prop, and
49
+ // is never painted; (2) TOOLTIP_STYLES' position: 'fixed' breaks inside any transformed
50
+ // ancestor unless the node is portalled out to the body.
51
+ return createPortal(
41
52
  <div ref={tooltipRef} style={{ ...styles, left: tip.x, top: tip.y }}>
42
53
  {children}
43
- </div>
54
+ </div>,
55
+ document.body,
44
56
  )
45
57
  }
46
58
 
@@ -1,6 +1,7 @@
1
- import type { MouseEventHandler } from 'react'
1
+ import type { PointerEventHandler } from 'react'
2
2
 
3
- /** Transparent <rect> that captures mouse events for tooltip + crosshair sync. */
3
+ /** Transparent <rect> that captures pointer events (mouse + touch + pen) for tooltip + crosshair
4
+ * sync. */
4
5
  export function HoverOverlay({
5
6
  width,
6
7
  height,
@@ -9,16 +10,21 @@ export function HoverOverlay({
9
10
  }: {
10
11
  width: number
11
12
  height: number
12
- onMove: MouseEventHandler<SVGRectElement>
13
- onLeave: MouseEventHandler<SVGRectElement>
13
+ onMove: PointerEventHandler<SVGRectElement>
14
+ onLeave: PointerEventHandler<SVGRectElement>
14
15
  }) {
15
16
  return (
16
17
  <rect
17
18
  width={width}
18
19
  height={height}
19
20
  fill="transparent"
20
- onMouseMove={onMove}
21
- onMouseLeave={onLeave}
21
+ // `pan-y`, not `none`: `none` would turn a full-width chart into a scroll dead zone on a
22
+ // phone. `pan-y` lets vertical page scroll pass through while a horizontal drag still
23
+ // reaches this overlay to scrub the chart.
24
+ style={{ touchAction: 'pan-y' }}
25
+ onPointerMove={onMove}
26
+ onPointerLeave={onLeave}
27
+ onPointerCancel={onLeave}
22
28
  />
23
29
  )
24
30
  }