basalt-ui 1.29.2 → 1.30.1

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 (149) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/MIGRATING.md +901 -366
  3. package/README.md +23 -2
  4. package/agent/rules/basalt-controls.md +34 -34
  5. package/agent/skills/basalt-app/SKILL.md +4 -2
  6. package/agent/templates/query-client.ts.tpl +18 -4
  7. package/configs/lefthook.yml +51 -19
  8. package/configs/oxlint-plugin.js +210 -84
  9. package/configs/oxlint-plugin.test.ts +182 -5
  10. package/configs/oxlint.json +4 -4
  11. package/dist/charts/index.d.ts +1 -1
  12. package/dist/charts/index.d.ts.map +1 -1
  13. package/dist/charts/kinds/BandStrip.d.ts +5 -3
  14. package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
  15. package/dist/charts/kinds/Bars.d.ts +4 -2
  16. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  17. package/dist/charts/kinds/Donut.d.ts +4 -2
  18. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  19. package/dist/charts/kinds/DualPanel.d.ts +4 -2
  20. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  21. package/dist/charts/kinds/Heatmap.d.ts +5 -2
  22. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  23. package/dist/charts/kinds/MirroredBars.d.ts +4 -2
  24. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
  25. package/dist/charts/kinds/MultiLine.d.ts +4 -2
  26. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  27. package/dist/charts/kinds/StackedArea.d.ts +4 -2
  28. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  29. package/dist/charts/kinds/ZonedLine.d.ts +4 -2
  30. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  31. package/dist/charts/primitives/CartesianChart.d.ts +4 -1
  32. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  33. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  34. package/dist/charts/primitives/ChartCard.js +2 -2
  35. package/dist/charts/primitives/ChartFrame.d.ts +25 -4
  36. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  37. package/dist/charts/primitives/ChartFrame.js +23 -16
  38. package/dist/charts/primitives/chart-frame-layout.d.ts +115 -6
  39. package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
  40. package/dist/charts/primitives/chart-frame-layout.js +46 -6
  41. package/dist/cli/check-theme.d.ts.map +1 -1
  42. package/dist/cli/check-theme.js +6 -0
  43. package/dist/cli/doctor.d.ts +10 -4
  44. package/dist/cli/doctor.d.ts.map +1 -1
  45. package/dist/cli/doctor.js +28 -1
  46. package/dist/cli/help.d.ts.map +1 -1
  47. package/dist/cli/help.js +2 -1
  48. package/dist/cli/index.d.ts +12 -0
  49. package/dist/cli/index.d.ts.map +1 -1
  50. package/dist/cli/index.js +29 -0
  51. package/dist/cli/init.d.ts.map +1 -1
  52. package/dist/cli/init.js +4 -15
  53. package/dist/cli/sync.d.ts.map +1 -1
  54. package/dist/cli/sync.js +8 -14
  55. package/dist/controls/actions.d.ts.map +1 -1
  56. package/dist/controls/actions.js +12 -2
  57. package/dist/dashboard/section.module.css +15 -7
  58. package/dist/dashboard/stat-card.d.ts.map +1 -1
  59. package/dist/dashboard/stat-card.js +7 -2
  60. package/dist/dashboard/stat-group.d.ts +8 -2
  61. package/dist/dashboard/stat-group.d.ts.map +1 -1
  62. package/dist/dashboard/stat-group.js +23 -15
  63. package/dist/dashboard/stat-group.module.css +85 -9
  64. package/dist/dashboard/stat-group.module.css.d.ts +1 -0
  65. package/dist/dashboard/widget-grid.d.ts.map +1 -1
  66. package/dist/dashboard/widget-grid.js +4 -5
  67. package/dist/dashboard/widget-grid.module.css +64 -21
  68. package/dist/dashboard/widget-grid.module.css.d.ts +1 -0
  69. package/dist/guard/index.d.ts +19 -2
  70. package/dist/guard/index.d.ts.map +1 -1
  71. package/dist/guard/index.js +27 -4
  72. package/dist/index.d.ts +9 -0
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +2 -0
  75. package/dist/shell/app-brand.module.css +12 -3
  76. package/dist/shell/app-header.module.css +49 -9
  77. package/dist/shell/app-main.module.css +24 -4
  78. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  79. package/dist/shell/app-mobile-nav.js +98 -51
  80. package/dist/shell/app-mobile-nav.module.css +99 -11
  81. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  82. package/dist/shell/app-sidebar.module.css +55 -2
  83. package/dist/shell/index.d.ts.map +1 -1
  84. package/dist/shell/index.js +3 -3
  85. package/dist/shell/mobile-nav-model.d.ts +26 -7
  86. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  87. package/dist/shell/mobile-nav-model.js +12 -3
  88. package/dist/shell/page-aside.module.css +8 -6
  89. package/dist/shell/page-bar.d.ts +13 -0
  90. package/dist/shell/page-bar.d.ts.map +1 -1
  91. package/dist/shell/page-bar.js +13 -3
  92. package/dist/shell/page-bar.module.css +20 -12
  93. package/dist/shell/page-title.module.css +3 -1
  94. package/dist/theme/use-basalt-spacing.d.ts.map +1 -1
  95. package/dist/tokens/index.d.ts.map +1 -1
  96. package/dist/tokens/index.js +51 -7
  97. package/dist/tokens/palette.d.ts +98 -17
  98. package/dist/tokens/palette.d.ts.map +1 -1
  99. package/dist/tokens/palette.js +110 -18
  100. package/dist/tokens.css +17 -5
  101. package/llms.txt +2 -2
  102. package/package.json +1 -1
  103. package/src/charts/index.ts +1 -0
  104. package/src/charts/kinds/BandStrip.tsx +5 -3
  105. package/src/charts/kinds/Bars.tsx +4 -2
  106. package/src/charts/kinds/Donut.tsx +4 -2
  107. package/src/charts/kinds/DualPanel.tsx +4 -2
  108. package/src/charts/kinds/Heatmap.tsx +5 -2
  109. package/src/charts/kinds/MirroredBars.tsx +4 -2
  110. package/src/charts/kinds/MultiLine.tsx +4 -2
  111. package/src/charts/kinds/StackedArea.tsx +4 -2
  112. package/src/charts/kinds/ZonedLine.tsx +4 -2
  113. package/src/charts/primitives/CartesianChart.tsx +4 -1
  114. package/src/charts/primitives/ChartCard.tsx +12 -7
  115. package/src/charts/primitives/ChartFrame.tsx +51 -37
  116. package/src/charts/primitives/chart-frame-layout.ts +172 -13
  117. package/src/cli/check-theme.ts +13 -0
  118. package/src/cli/doctor.ts +49 -8
  119. package/src/cli/help.ts +5 -3
  120. package/src/cli/index.ts +49 -0
  121. package/src/cli/init.ts +6 -27
  122. package/src/cli/sync.ts +11 -22
  123. package/src/controls/actions.tsx +51 -0
  124. package/src/dashboard/section.module.css +15 -7
  125. package/src/dashboard/stat-card.tsx +7 -2
  126. package/src/dashboard/stat-group.module.css +85 -9
  127. package/src/dashboard/stat-group.module.css.d.ts +1 -0
  128. package/src/dashboard/stat-group.tsx +40 -20
  129. package/src/dashboard/widget-grid.module.css +64 -21
  130. package/src/dashboard/widget-grid.module.css.d.ts +1 -0
  131. package/src/dashboard/widget-grid.tsx +30 -12
  132. package/src/guard/index.ts +79 -11
  133. package/src/index.ts +9 -0
  134. package/src/shell/app-brand.module.css +12 -3
  135. package/src/shell/app-header.module.css +49 -9
  136. package/src/shell/app-main.module.css +24 -4
  137. package/src/shell/app-mobile-nav.module.css +99 -11
  138. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  139. package/src/shell/app-mobile-nav.tsx +225 -83
  140. package/src/shell/app-sidebar.module.css +55 -2
  141. package/src/shell/index.tsx +26 -6
  142. package/src/shell/mobile-nav-model.ts +37 -9
  143. package/src/shell/page-aside.module.css +8 -6
  144. package/src/shell/page-bar.module.css +20 -12
  145. package/src/shell/page-bar.tsx +39 -5
  146. package/src/shell/page-title.module.css +3 -1
  147. package/src/theme/use-basalt-spacing.ts +8 -6
  148. package/src/tokens/index.ts +90 -6
  149. package/src/tokens/palette.ts +111 -18
@@ -6,11 +6,12 @@ import { useChartSize } from '../hooks/useChartSize'
6
6
  import { deriveLegend } from '../series'
7
7
  import type { ChartLegendConfig, LegendPlacement, SeriesStyle } from '../series'
8
8
  import {
9
- chartTierMetrics,
10
- legendEntryCap,
11
9
  resolveChartTier,
10
+ resolveFrameHeight,
11
+ resolveLegendRollup,
12
12
  resolvePlotRect,
13
13
  } from './chart-frame-layout'
14
+ import type { ResponsiveChartHeight } from './chart-frame-layout'
14
15
  import { ChartTierProvider } from './chart-tier'
15
16
  import { ChartLegend } from './ChartLegend'
16
17
  import { ChartEmpty, ChartError, ChartPending, resolveChartState } from './ChartPending'
@@ -18,7 +19,13 @@ import type { ChartState } from './ChartPending'
18
19
 
19
20
  /** Re-exported for `ChartFrame.test.tsx` and any other consumer that previously reached these
20
21
  * through `ChartFrame` — the layout math itself lives in `./chart-frame-layout` (pure, DOM-free). */
21
- export { legendEntryCap, resolvePlotRect, resolveChartTier } from './chart-frame-layout'
22
+ export {
23
+ legendEntryCap,
24
+ resolveLegendRollup,
25
+ resolvePlotRect,
26
+ resolveChartTier,
27
+ } from './chart-frame-layout'
28
+ export type { ResponsiveChartHeight } from './chart-frame-layout'
22
29
 
23
30
  const DEFAULT_HEIGHT = 240
24
31
  const DEFAULT_MIN_WIDTH = 200
@@ -28,7 +35,18 @@ const DEFAULT_MIN_WIDTH = 200
28
35
  export type ChartFrameLegend = {
29
36
  /** Default 'bottom'. */
30
37
  placement?: LegendPlacement
31
- /** Wrap cap → "+N more" rollup at high cardinality. */
38
+ /**
39
+ * Wrap cap → "+N more" rollup at high cardinality.
40
+ *
41
+ * An explicit value WINS OUTRIGHT — over the phone tier's two-row default (`chartTierMetrics`)
42
+ * AND over a `fill` frame's measured fit (`legendEntryCap`). 1.30.0 fixed only the first half:
43
+ * the fit still ran `Math.min(fitted, caller)` on top, so meteo's 7-entry meteogram legend in a
44
+ * 150px docked row rendered 2 entries at `maxRows` 3, 6 AND 99 — five series drawn in colours
45
+ * nothing named. Both losers are DEFAULTS (the tier's, and `VX.minPlotHeight`); this is the one
46
+ * number a caller stated, so under `fill` the PLOT yields the height instead
47
+ * ({@link resolvePlotRect}'s `legendWins`). State a number too large for the box and the plot
48
+ * gets what is left — visible, and yours.
49
+ */
32
50
  maxRows?: number
33
51
  /** Visually separate role: series | overlay | reference. */
34
52
  groups?: boolean
@@ -47,8 +65,16 @@ export type ChartFrameLegend = {
47
65
  export type ChartFrameProps = BasaltProps & {
48
66
  /** Series identity — drives the derived legend. Pass the SAME array the kind draws + tooltips from. */
49
67
  series: readonly SeriesStyle[]
50
- /** Fixed height in pixels. Used when neither `aspectRatio` nor `fill` is set. Default 240. */
51
- height?: number
68
+ /**
69
+ * Height in pixels, or one per size step. Used when neither `aspectRatio` nor `fill` is set.
70
+ * Default 240.
71
+ *
72
+ * `{ base: 180, md: 260 }` is the declarative escape from a page of fixed-height charts: the
73
+ * steps are compared against the frame's own MEASURED width, not the viewport, so a chart squeezed
74
+ * by a `PageAside` gets the short height on a desktop too. A plain number is unchanged and stays
75
+ * supported ({@link resolveFrameHeight}).
76
+ */
77
+ height?: number | ResponsiveChartHeight
52
78
  /** height = Math.round(containerWidth / aspectRatio). Ignored when `fill` is set. */
53
79
  aspectRatio?: number
54
80
  /** Fill the parent flex/grid cell's measured height instead of a fixed/derived one. */
@@ -206,49 +232,37 @@ export function ChartFrame({
206
232
  ? containerH
207
233
  : aspectRatio !== undefined
208
234
  ? Math.round(containerW / aspectRatio)
209
- : (height ?? DEFAULT_HEIGHT)
235
+ : resolveFrameHeight(height ?? DEFAULT_HEIGHT, containerW)
210
236
 
211
237
  const sideLegendWidth = legendVisible && vertical ? legendW : 0
212
238
  const topBottomLegendHeight = legendVisible && !vertical ? legendH : 0
213
239
 
240
+ const legendItems = legend === false ? [] : deriveLegend(series)
241
+ const togglable = legend !== false && (legend.toggle ?? legendItems.length > 1)
242
+
243
+ // The cap, and who pays for it — one pure decision ({@link resolveLegendRollup} states the three
244
+ // cases and why the composition, not either half, is what shipped broken). A fixed-height frame
245
+ // grows around its legend; a `fill` one cannot, so its legend rolls up to the measured fit
246
+ // instead of eating the plot — unless the caller stated a number, which nothing trims and the
247
+ // plot pays for. Top/bottom only: a side legend costs width, not height.
248
+ const { maxRows, legendWins } = resolveLegendRollup({
249
+ ...(legend !== false && legend.maxRows !== undefined && { statedMaxRows: legend.maxRows }),
250
+ tier,
251
+ fillBand: legendVisible && fill && !vertical,
252
+ items: legendItems,
253
+ containerW,
254
+ available: resolvedHeight - VX.minPlotHeight,
255
+ })
256
+
214
257
  const plot = resolvePlotRect({
215
258
  containerW,
216
259
  resolvedHeight,
217
260
  minWidth,
218
261
  sideLegendWidth,
219
262
  topBottomLegendHeight,
263
+ ...(legendWins && { legendWins }),
220
264
  })
221
265
 
222
- const legendItems = legend === false ? [] : deriveLegend(series)
223
- const togglable = legend !== false && (legend.toggle ?? legendItems.length > 1)
224
-
225
- // A fixed-height frame grows around its legend; a `fill` one cannot, so its legend rolls up
226
- // instead of eating the plot (see `legendEntryCap`). Top/bottom only — a side legend costs
227
- // width, not height.
228
- // The tier's own cap folds in as an upper bound BEFORE the fill measurement, so a phone-width
229
- // `fill` frame can only ever roll up further, never less (`chartTierMetrics`).
230
- const tierMaxRows = chartTierMetrics(tier).legendMaxRows
231
- const callerMaxRows =
232
- legend === false
233
- ? undefined
234
- : legend.maxRows === undefined
235
- ? tierMaxRows
236
- : tierMaxRows === undefined
237
- ? legend.maxRows
238
- : Math.min(legend.maxRows, tierMaxRows)
239
-
240
- const maxRows =
241
- legend === false
242
- ? undefined
243
- : fill && !vertical
244
- ? legendEntryCap({
245
- items: legendItems,
246
- containerW,
247
- available: resolvedHeight - VX.minPlotHeight,
248
- ...(callerMaxRows !== undefined && { callerMaxRows }),
249
- })
250
- : callerMaxRows
251
-
252
266
  const legendNode =
253
267
  legend === false || resolvedState !== null ? null : (
254
268
  <div ref={legendRef} style={legendWrapperStyle(vertical)}>
@@ -39,6 +39,70 @@ export function resolveChartTier(containerW: number): ChartTier {
39
39
  return containerW > 0 && containerW < VX.phoneChartWidth ? 'phone' : 'desktop'
40
40
  }
41
41
 
42
+ /**
43
+ * A chart height stated per size step instead of as one number — the declarative escape from "every
44
+ * chart on this page is 260px tall on a 375px phone too".
45
+ *
46
+ * `base` is required and is the height a box narrower than every named step gets; each further key
47
+ * is the height from that step UP. Keys are Mantine's breakpoint names so a consumer writes the
48
+ * vocabulary they already write, and nothing more than `sm`/`md`/`lg` is offered: `xs`/`xl` would
49
+ * be two more rungs nobody has asked a chart to have.
50
+ *
51
+ * ```tsx
52
+ * <ChartFrame series={series} height={{ base: 180, md: 260 }}>{…}</ChartFrame>
53
+ * ```
54
+ */
55
+ export type ResponsiveChartHeight = {
56
+ /** Below the narrowest STATED step — and NOT the first-frame value; see {@link resolveFrameHeight}. */
57
+ base: number
58
+ /** From 768px of MEASURED container width up. */
59
+ sm?: number
60
+ /** From 992px up. */
61
+ md?: number
62
+ /** From 1200px up. */
63
+ lg?: number
64
+ }
65
+
66
+ /**
67
+ * The measured CONTAINER width, in px, each {@link ResponsiveChartHeight} key takes effect at.
68
+ *
69
+ * The numbers are what Mantine's `sm`/`md`/`lg` breakpoints resolve to at the 16px initial font
70
+ * size, so a consumer's mental model transfers — but they are compared against the element's own
71
+ * MEASURED box, never the viewport, for the reason {@link resolveChartTier} states: a chart in a
72
+ * `PageAside`-squeezed grid cell on a 1440px desktop is as narrow as one on a phone, and the
73
+ * viewport agrees with neither. Keeping them as plain numbers here is also what keeps this file
74
+ * Mantine-free — `theme.breakpoints` is on the coupled side of the boundary.
75
+ */
76
+ const HEIGHT_STEP_WIDTH = { sm: 768, md: 992, lg: 1200 } as const
77
+
78
+ /** Widest-first, so the first step that fits is the answer. */
79
+ const HEIGHT_STEPS = ['lg', 'md', 'sm'] as const
80
+
81
+ /**
82
+ * Resolve a `height` prop — a plain number, or a {@link ResponsiveChartHeight} — against the
83
+ * measured container width.
84
+ *
85
+ * A plain number passes through untouched: this is a WIDENING of the prop, not a replacement, and
86
+ * every existing `height={240}` must keep meaning exactly 240.
87
+ *
88
+ * An UNMEASURED box (`containerW <= 0` — SSR, or before the `ResizeObserver`'s first callback)
89
+ * resolves to the LARGEST stated step, matching {@link resolveChartTier}'s first-frame rule: the
90
+ * first paint must not be phone-shaped chrome that grows one frame later. Falling to `base` there
91
+ * would make every SSR'd chart short and then jump.
92
+ */
93
+ export function resolveFrameHeight(
94
+ height: number | ResponsiveChartHeight,
95
+ containerW: number,
96
+ ): number {
97
+ if (typeof height === 'number') return height
98
+ const widest = HEIGHT_STEPS.find((step) => height[step] !== undefined)
99
+ if (containerW <= 0) return widest === undefined ? height.base : (height[widest] ?? height.base)
100
+ const step = HEIGHT_STEPS.find(
101
+ (name) => containerW >= HEIGHT_STEP_WIDTH[name] && height[name] !== undefined,
102
+ )
103
+ return step === undefined ? height.base : (height[step] ?? height.base)
104
+ }
105
+
42
106
  /**
43
107
  * How much of `VX.margin` a phone-tier chart keeps as its FLOOR. The measured law is unchanged —
44
108
  * a side may still only grow past its floor (`autoMargin`) — this only stops a static token from
@@ -73,7 +137,7 @@ export type ChartTierMetrics = {
73
137
  /** Floating tooltip `minWidth`, px. */
74
138
  tooltipMinWidth: number
75
139
  /** Default entry cap on the legend, or `undefined` for no default cap. An explicit
76
- * `legend.maxRows` still wins as the upper bound. */
140
+ * `legend.maxRows` replaces it outright ({@link resolveLegendMaxRows}). */
77
141
  legendMaxRows: number | undefined
78
142
  /** Per-side margin FLOORS (`VX.margin` is a floor, never a ceiling — §1). */
79
143
  margin: ChartMargin
@@ -111,6 +175,75 @@ export function chartTierMetrics(tier: ChartTier): ChartTierMetrics {
111
175
  return tier === 'phone' ? PHONE_METRICS : DESKTOP_METRICS
112
176
  }
113
177
 
178
+ /**
179
+ * Which legend cap applies: the caller's, or the tier's own default.
180
+ *
181
+ * **An explicit `callerMaxRows` wins OUTRIGHT.** This used to be `Math.min(caller, tier)`, which
182
+ * made the documented per-chart opt-out ("opt back out with an explicit `legend.maxRows`") false in
183
+ * the one direction anybody needs it. The tier's `PHONE_LEGEND_MAX_ROWS` is a DEFAULT for charts
184
+ * that said nothing — a chart that names a number has already answered the question the default
185
+ * exists to answer, and it matters because the tier keys on the measured box: a 380px inspector
186
+ * panel on a 1440px desktop is "a phone", so a six-series legend rolled up to two rows + `+4 more`
187
+ * with no way to say no. `margin` and `xLabelRotate` always had that escape; the legend did not.
188
+ *
189
+ * The measured {@link legendEntryCap} a `fill` frame applies is NOT a second ceiling on top of
190
+ * this: it resolves an ABSENT cap only. 1.30.0 ran it over the caller's number as well
191
+ * (`Math.min(fitted, caller)`), which made "wins outright" false again in the one place it was
192
+ * needed — meteo measured a 7-entry legend pinned to 2 entries at `maxRows` 3, 6 AND 99, with 5
193
+ * series drawn in colours the legend refused to name. The fit is not physics either: it derives
194
+ * from `VX.minPlotHeight`, which is a framework DEFAULT about the plot. Two defaults do not
195
+ * outvote the one number a caller stated, so the PLOT yields the height instead
196
+ * ({@link resolvePlotRect}'s `legendWins`) — visibly, in the frame the caller sized.
197
+ */
198
+ export function resolveLegendMaxRows(input: {
199
+ callerMaxRows?: number | undefined
200
+ tier: ChartTier
201
+ }): number | undefined {
202
+ return input.callerMaxRows ?? chartTierMetrics(input.tier).legendMaxRows
203
+ }
204
+
205
+ /**
206
+ * The whole legend-cap decision in one place: which cap `ChartLegend` gets, and whether the plot
207
+ * pays for it.
208
+ *
209
+ * It lives here rather than inline in `ChartFrame` because the COMPOSITION is what broke. 1.30.0
210
+ * had both halves right on their own — {@link resolveLegendMaxRows} honoured a stated cap,
211
+ * {@link legendEntryCap} measured a fit — and then ran the second over the first, which no test
212
+ * could see because neither pure function was wrong and the frame that combined them needs a
213
+ * `ResizeObserver` to test. Now the combination is a pure function too.
214
+ *
215
+ * Three cases, in order:
216
+ * 1. The caller STATED a number → it is the cap, and on a `fill` band the plot yields
217
+ * (`legendWins`). Nothing measured or defaulted trims it.
218
+ * 2. No `fill` band → the tier's default (or none), because the frame simply grows.
219
+ * 3. A `fill` band with nothing stated → the measured fit, bounded by the tier's default.
220
+ */
221
+ export function resolveLegendRollup(input: {
222
+ /** The caller's own `legend.maxRows`. */
223
+ statedMaxRows?: number | undefined
224
+ tier: ChartTier
225
+ /** A visible top/bottom legend on a `fill` frame — the one shape whose box cannot grow. */
226
+ fillBand: boolean
227
+ items: readonly LegendEntry[]
228
+ containerW: number
229
+ /** Frame height the legend may consume — `resolvedHeight - VX.minPlotHeight`. */
230
+ available: number
231
+ }): { maxRows: number | undefined; legendWins: boolean } {
232
+ const { statedMaxRows, tier, fillBand, items, containerW, available } = input
233
+ if (statedMaxRows !== undefined) return { maxRows: statedMaxRows, legendWins: fillBand }
234
+ const tierMaxRows = resolveLegendMaxRows({ callerMaxRows: undefined, tier })
235
+ if (!fillBand) return { maxRows: tierMaxRows, legendWins: false }
236
+ return {
237
+ maxRows: legendEntryCap({
238
+ items,
239
+ containerW,
240
+ available,
241
+ ...(tierMaxRows !== undefined && { defaultMaxRows: tierMaxRows }),
242
+ }),
243
+ legendWins: false,
244
+ }
245
+ }
246
+
114
247
  /**
115
248
  * Resolve the plot rect from the measured box and the measured legend band. Pure and exported so
116
249
  * the two floors below are testable without a `ResizeObserver`.
@@ -125,9 +258,11 @@ export function chartTierMetrics(tier: ChartTier): ChartTierMetrics {
125
258
  * `height={240}` toward zero and the body stopped rendering entirely. The plot stops at
126
259
  * `VX.minPlotHeight` and the frame's own box grows by the difference instead — it is a flex
127
260
  * column with `height: auto`, so that growth is automatic. Under `fill` the box CANNOT grow, so
128
- * the legend is capped instead ({@link legendEntryCap}). An unmeasured box (`resolvedHeight <=
129
- * 0`, i.e. a `fill` frame before its first measurement) stays at 0 and renders nothing, exactly
130
- * as before — the floor must never invent a height for a box nobody has measured.
261
+ * the legend is capped instead ({@link legendEntryCap}) — UNLESS the caller stated
262
+ * `legend.maxRows`, which is `legendWins`: there the floor itself yields, because a floor is a
263
+ * default and the caller's number is not. An unmeasured box (`resolvedHeight <= 0`, i.e. a
264
+ * `fill` frame before its first measurement) stays at 0 and renders nothing, exactly as before —
265
+ * the floor must never invent a height for a box nobody has measured.
131
266
  */
132
267
  export function resolvePlotRect(input: {
133
268
  containerW: number
@@ -135,12 +270,28 @@ export function resolvePlotRect(input: {
135
270
  minWidth: number
136
271
  sideLegendWidth: number
137
272
  topBottomLegendHeight: number
273
+ /**
274
+ * The caller stated `legend.maxRows` on a `fill` frame, so the legend takes the rows it asked
275
+ * for and the plot takes what is left — `VX.minPlotHeight` stops being a floor. Without this the
276
+ * honoured cap would push the frame's content PAST its own cell (the plot holds 120px, the legend
277
+ * band adds its own) and paint over whatever sits below it; with it, the cost of the caller's
278
+ * number lands where they can see it. Spend the whole box on legend rows and there is no plot
279
+ * left to draw — that is their arithmetic, made visible rather than silently rolled up.
280
+ */
281
+ legendWins?: boolean
138
282
  }): { width: number; height: number } {
139
- const { containerW, resolvedHeight, minWidth, sideLegendWidth, topBottomLegendHeight } = input
283
+ const {
284
+ containerW,
285
+ resolvedHeight,
286
+ minWidth,
287
+ sideLegendWidth,
288
+ topBottomLegendHeight,
289
+ legendWins = false,
290
+ } = input
291
+ const plotFloor = legendWins ? 0 : VX.minPlotHeight
140
292
  return {
141
293
  width: containerW === 0 ? minWidth : Math.max(containerW - sideLegendWidth, 1),
142
- height:
143
- resolvedHeight <= 0 ? 0 : Math.max(resolvedHeight - topBottomLegendHeight, VX.minPlotHeight),
294
+ height: resolvedHeight <= 0 ? 0 : Math.max(resolvedHeight - topBottomLegendHeight, plotFloor),
144
295
  }
145
296
  }
146
297
 
@@ -186,17 +337,25 @@ function entriesWithinRows(items: readonly LegendEntry[], width: number, rows: n
186
337
  * MEASURING the labels, not from assuming one entry per row — otherwise a five-entry legend that
187
338
  * fits on one line would roll up to `+2 more` in every 240px cell, moving rendering for charts
188
339
  * that never had the bug. Returns `undefined` when the whole legend already fits.
340
+ *
341
+ * This resolves an ABSENT cap. A caller who stated `legend.maxRows` never reaches here at all —
342
+ * `ChartFrame` routes around it — because `Math.min`-ing a stated number against this one made the
343
+ * documented escape inert in exactly the narrow `fill` panel it exists for
344
+ * ({@link resolveLegendMaxRows}).
189
345
  */
190
346
  export function legendEntryCap(input: {
191
347
  items: readonly LegendEntry[]
192
348
  containerW: number
193
349
  /** Frame height the legend may consume — `resolvedHeight - VX.minPlotHeight`. */
194
350
  available: number
195
- /** An explicit caller cap always wins as the upper bound. */
196
- callerMaxRows?: number
351
+ /**
352
+ * The cap that applies when nothing explicit was stated — today only the tier's own default
353
+ * ({@link chartTierMetrics}). Two defaults contending: the smaller wins.
354
+ */
355
+ defaultMaxRows?: number
197
356
  }): number | undefined {
198
- const { items, containerW, available, callerMaxRows } = input
199
- if (containerW <= 0 || items.length === 0) return callerMaxRows
357
+ const { items, containerW, available, defaultMaxRows } = input
358
+ if (containerW <= 0 || items.length === 0) return defaultMaxRows
200
359
  const rows = Math.max(
201
360
  1,
202
361
  Math.floor((available - LEGEND_PAD_Y + VX.legendGap) / (LEGEND_LINE_H + VX.legendGap)),
@@ -205,8 +364,8 @@ export function legendEntryCap(input: {
205
364
  legendBandHeight(rows) <= available &&
206
365
  entriesWithinRows(items, containerW, rows) >= items.length
207
366
  ) {
208
- return callerMaxRows
367
+ return defaultMaxRows
209
368
  }
210
369
  const fitted = Math.max(1, entriesWithinRows(items, containerW, rows))
211
- return callerMaxRows === undefined ? fitted : Math.min(fitted, callerMaxRows)
370
+ return defaultMaxRows === undefined ? fitted : Math.min(fitted, defaultMaxRows)
212
371
  }
@@ -19,6 +19,7 @@ import {
19
19
  conflictingProfileFlags,
20
20
  declaredProfile,
21
21
  DEFAULT_ROOTS,
22
+ hasBasaltKey,
22
23
  readBasaltConfig,
23
24
  resolveExemptRules,
24
25
  resolveProjectDir,
@@ -96,6 +97,18 @@ export function checkTheme(
96
97
  // A configured-but-wrong root is never intentional, and silently scanning 0 files under the
97
98
  // built-in defaults (argo's pre-migration layout) is the same failure mode for every other
98
99
  // consumer — both cases fail loud instead of warn-plus-green.
100
+ // The WRONG-DIRECTORY case leads, and since 1.29.0's single resolver it is the common one:
101
+ // lefthook and GitHub Actions both run at the REPO ROOT while the config lives in a package
102
+ // below it. Only the roots messages used to print, sending a consumer to edit `basalt.roots` in
103
+ // a package.json that has no `basalt` block at all.
104
+ if (!hasBasaltKey(cwd)) {
105
+ console.error(
106
+ `✖ basalt-ui check-theme: ${cwd}/package.json has no "basalt" key — very likely not the ` +
107
+ 'package you meant to scan (BASALT_CWD, else the invocation cwd; nothing is inferred). ' +
108
+ 'At a repo root? Give the command `root: <pkg>/` (lefthook) or `working-directory: ' +
109
+ '<pkg>` (Actions), or set BASALT_CWD=<pkg>. If this directory IS it, run `basalt-ui init`.',
110
+ )
111
+ }
99
112
  if (cfg.roots === undefined) {
100
113
  console.error(
101
114
  `✖ basalt-ui check-theme: 0 files scanned — no "basalt.roots" configured in package.json, and ` +
package/src/cli/doctor.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * managed-file manifest path) back from `./index`, this package's shared CLI helpers.
5
5
  */
6
6
  import { existsSync } from 'node:fs'
7
- import { resolve } from 'node:path'
7
+ import { relative, resolve } from 'node:path'
8
8
 
9
9
  import type { BasaltConfig } from './index'
10
10
  import {
@@ -19,7 +19,10 @@ import {
19
19
  parseJsonc,
20
20
  readBasaltConfig,
21
21
  readIfExists,
22
+ readManifest,
22
23
  resolveProjectDir,
24
+ resolveRoots,
25
+ scannableFiles,
23
26
  shippedAssetPath,
24
27
  } from './index'
25
28
  import { findManifestAbove, parentInstallAdvice } from './sync'
@@ -102,16 +105,22 @@ function resolveAiMajorSkewReason(cfg: BasaltConfig): {
102
105
  * Check a consumer repo's basalt integration and print a pass/warn report.
103
106
  *
104
107
  * Narrowed to what a single-directory-scoped run can actually verify — no workspace-glob walking,
105
- * no ascend/descend project discovery (see {@link resolveProjectDir}). Four checks, numbered in
108
+ * no ascend/descend project discovery (see {@link resolveProjectDir}). Six checks, numbered in
106
109
  * the order they run and print:
107
110
  *
108
111
  * Hard failures (exit non-zero):
109
112
  * 1. `.basalt/manifest.json` exists (`basalt-ui init` was run).
110
- * 2. `oxlint-preset` ("rules in sync"): the consumer's `.oxlintrc.json` extends the shipped
113
+ * 2. `install-parity`: basalt-ui resolves, doctor prints WHERE and at what version, and that
114
+ * version agrees with the manifest's `basaltVersion`.
115
+ * 3. `guard-scan`: `check-theme` would scan more than zero files, sharing `scannableFiles` so the
116
+ * two cannot disagree. Both RESTORED in 1.30.0 after four minors gone with no MIGRATING row —
117
+ * README § `doctor` carries what their absence cost (a no-op CI gate, a green guard scanning
118
+ * nothing, and two basalt-WRITTEN docs describing output the CLI no longer produced).
119
+ * 4. `oxlint-preset` ("rules in sync"): the consumer's `.oxlintrc.json` extends the shipped
111
120
  * preset AND that path resolves. `init` keeps an existing config, so the framework's whole
112
121
  * lint half can be silently off — one repo carried six real violations invisibly across five
113
122
  * minors this way.
114
- * 4. `ai-major-parity`: within THIS package.json, `dependencies`/`devDependencies`/
123
+ * 6. `ai-major-parity`: within THIS package.json, `dependencies`/`devDependencies`/
115
124
  * `peerDependencies` agree on the `ai` package's major. `basalt/ai-sdk-major` (the oxlint
116
125
  * plugin rule) already catches this per linted FILE against its nearest package.json; this
117
126
  * check exists for the shape a lint run cannot see — a package whose own manifest asks its
@@ -121,7 +130,7 @@ function resolveAiMajorSkewReason(cfg: BasaltConfig): {
121
130
  * already agree warns that the exemption is stale.
122
131
  *
123
132
  * Warning (non-fatal):
124
- * 3. `lefthook-preset` ("lefthook wired"): a pre-commit command runs `check-theme`, read from
133
+ * 5. `lefthook-preset` ("lefthook wired"): a pre-commit command runs `check-theme`, read from
125
134
  * `lefthook dump --format json` (resolves `extends`/`include`/`root:` the way lefthook itself
126
135
  * does — see {@link inspectLefthookGate}). No lefthook config at all is a pass (a consumer may
127
136
  * gate elsewhere); a config that resolves but wires no guard command warns; a config lefthook
@@ -189,7 +198,39 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
189
198
 
190
199
  const install = findBasaltInstall(cwd)
191
200
 
192
- // ── Hard check 2: the consumer's oxlint config extends the shipped preset ──
201
+ // ── Hard check 2: install matches the manifest, and doctor says WHERE (the CLAUDE-block's claim)
202
+ const manifestVersion = profile === 'tokens-only' ? undefined : readManifest(cwd).basaltVersion
203
+ const at = install.dir === null ? '' : `${relative(cwd, install.dir)} (${install.version})`
204
+ if (install.dir === null) {
205
+ fail(
206
+ 'install-parity: basalt-ui does not resolve here, above, or in a sibling workspace package.',
207
+ )
208
+ } else if (manifestVersion === undefined || install.version === manifestVersion) {
209
+ const matches = manifestVersion === undefined ? '' : " and matches the manifest's basaltVersion"
210
+ pass(`basalt-ui resolves at ${at}${matches}`)
211
+ } else {
212
+ fail(
213
+ `install-parity: basalt-ui resolves at ${at}, but ${MANIFEST_PATH} was last written by ` +
214
+ `${manifestVersion} — the managed layer is from a different release than the code. Run ` +
215
+ '`basalt-ui sync` (or `bun install`, if the lockfile is what is stale).',
216
+ )
217
+ }
218
+
219
+ // ── Hard check 3: the guard sees files — zero-file roots are a green guard enforcing nothing ──
220
+ const scanned = profile === 'tokens-only' ? null : scannableFiles(cwd, cfg).length
221
+ if (scanned === null) {
222
+ pass('guard-scan: n/a — a tokens-only consumer runs the token lane, not the file walk')
223
+ } else if (scanned === 0) {
224
+ fail(
225
+ 'guard-scan: `check-theme` would scan ZERO files here — either `basalt.roots` points at ' +
226
+ 'paths that do not exist, or this is not the package the config lives in (BASALT_CWD, ' +
227
+ 'else the cwd; nothing is inferred).',
228
+ )
229
+ } else {
230
+ pass(`guard-scan: check-theme covers ${scanned} file(s) under ${resolveRoots(cfg).join(', ')}`)
231
+ }
232
+
233
+ // ── Hard check 4: the consumer's oxlint config extends the shipped preset ──
193
234
  // `init` KEEPS an existing `.oxlintrc.json`, so a repo can carry the whole scaffold with the
194
235
  // framework's lint half switched off and nothing anywhere saying so. One repo ran five minors
195
236
  // that way and surfaced six real `basalt/no-raw-font-size` errors the moment it was wired.
@@ -235,7 +276,7 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
235
276
  }
236
277
  }
237
278
 
238
- // ── Warn check 3: the pre-commit hook actually runs the guard ──────────────
279
+ // ── Warn check 5: the pre-commit hook actually runs the guard ──────────────
239
280
  // Read via `lefthook dump --format json`, which resolves `extends`/`include`/remote configs/
240
281
  // `root:` the way lefthook itself does — see {@link inspectLefthookGate}.
241
282
  const repoRoot = findRepoRoot(cwd)
@@ -262,7 +303,7 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
262
303
  )
263
304
  }
264
305
 
265
- // ── Hard check 4: ai package major version parity within THIS package.json ─
306
+ // ── Hard check 6: ai package major version parity within THIS package.json ─
266
307
  // basalt/ai-sdk-major (the lint rule) is per-file against the NEAREST package.json, so a lint
267
308
  // run scoped to one workspace package only ever sees that package's own `ai` major and is
268
309
  // perfectly happy. This check reads the SAME package.json across its dependencies/
package/src/cli/help.ts CHANGED
@@ -21,6 +21,7 @@ export const COMMAND_OPTIONS: Record<string, Record<string, { type: 'string' | '
21
21
  sync: {
22
22
  force: { type: 'boolean' },
23
23
  check: { type: 'boolean' },
24
+ verbose: { type: 'boolean' },
24
25
  'tokens-only': { type: 'boolean' },
25
26
  framework: { type: 'boolean' },
26
27
  },
@@ -65,7 +66,7 @@ export function unknownFlag(cmd: string, flags: readonly string[]): string | nul
65
66
  /** The one usage string — printed by `basalt help` / `--help` / `-h` AND the unknown-command fallback. */
66
67
  export const USAGE =
67
68
  'Usage: basalt-ui <--version | init [--with-router] [--with-query] [--merge-lint] |\n' +
68
- ' sync [--force] [--check] [--tokens-only|--framework] |\n' +
69
+ ' sync [--force] [--check] [--verbose] [--tokens-only|--framework] |\n' +
69
70
  ' check-theme [--audit-allows] |\n' +
70
71
  ' doctor [--tokens-only|--framework] | guard-hook | tokens:css | fonts:css | help>\n\n' +
71
72
  'check-theme [--tokens-only|--framework] [--audit-allows]\n' +
@@ -89,9 +90,10 @@ export const USAGE =
89
90
  ' Checks THIS project only: the manifest exists, the oxlint preset is wired, the pre-commit\n' +
90
91
  ' hook runs check-theme (via `lefthook dump`), and the ai package major is consistent within\n' +
91
92
  ' this package.json. Profile is DECLARED, same as check-theme — never inferred.\n\n' +
92
- 'sync [--force] [--check] [--tokens-only|--framework]\n' +
93
+ 'sync [--force] [--check] [--verbose] [--tokens-only|--framework]\n' +
93
94
  ' Refreshes an EXISTING install; it never creates one (that is init). A tokens-only consumer\n' +
94
- ' has no scaffold to reconcile, so sync reports n/a and exits 0 there — as doctor does.\n\n' +
95
+ ' has no scaffold to reconcile, so sync reports n/a and exits 0 there — as doctor does.\n' +
96
+ ' --check prints the permanent placement skips as a COUNT; --verbose spells them out again.\n\n' +
95
97
  'check-theme / doctor / sync all resolve their project the same way: BASALT_CWD, else the\n' +
96
98
  'invocation cwd — nothing inferred, nothing ascended or descended into.\n' +
97
99
  '--tokens-only and --framework are alternatives; passing both is an error.\n\n' +
package/src/cli/index.ts CHANGED
@@ -333,6 +333,17 @@ function sanitizeSeverity(cfg: BasaltConfig): BasaltConfig {
333
333
  return { ...cfg, severity: kept as Partial<Record<GuardKind, GuardSeverity>> }
334
334
  }
335
335
 
336
+ /** Does `cwd`'s own `package.json` carry a `basalt` key? — "misconfigured roots" vs "wrong
337
+ * directory", the failure a root-invoked hook hits since 1.29.0's single resolver. Never a walk. */
338
+ export function hasBasaltKey(cwd: string): boolean {
339
+ const raw = readIfExists(resolve(cwd, 'package.json'))
340
+ try {
341
+ return raw !== null && (JSON.parse(raw) as { basalt?: unknown }).basalt !== undefined
342
+ } catch {
343
+ return false
344
+ }
345
+ }
346
+
336
347
  export function readBasaltConfig(cwd: string): BasaltConfig {
337
348
  try {
338
349
  const pkg = JSON.parse(readFileSync(resolve(cwd, 'package.json'), 'utf8')) as {
@@ -1114,6 +1125,44 @@ export function managedFiles(
1114
1125
  return [...rules, ...skills, claudeBlock, design, oxfmt, ...rootTooling, oxlintrc, ...scaffolds]
1115
1126
  }
1116
1127
 
1128
+ /**
1129
+ * The PERMANENT placement skips as sentences — one text for `init` and `sync`, which carried two
1130
+ * near-copies that had already drifted apart. Correct and unchanging, which is why `sync --check`
1131
+ * hides them behind `--verbose`. `extends` alone is NOT the recipe for the hook, and saying only
1132
+ * that shipped a broken one: lefthook merges the preset's commands but runs every one at the REPO
1133
+ * ROOT, with no oxlint config, no local binary and no basalt config. `root:` moves the invocation
1134
+ * cwd per command and merges (the preset does not define it) — see the preset's MONOREPO RECIPE.
1135
+ */
1136
+ export function placementNotices(cwd: string, placement: PlacementFlags): string[] {
1137
+ const out: string[] = []
1138
+ const install = findBasaltInstall(cwd)
1139
+ const asset = (n: string): string => shippedAssetPath(install, placement.repoRoot, n)
1140
+ // Bare, no `./` — a lefthook `root:` and an Actions `working-directory:` are already repo-relative.
1141
+ const pkg = relativePosix(placement.repoRoot, cwd).replace(/^\.\//, '')
1142
+ const notRoot = `(not the repo root — repo root detected at ${placement.repoRoot})`
1143
+ if (!placement.isPackageRepoRoot) {
1144
+ out.push(
1145
+ `skipped lefthook.yml ${notRoot} — lefthook only reads config at the repo root. Extend ` +
1146
+ `${asset('configs/lefthook.yml')} from your root lefthook.yml AND give every merged ` +
1147
+ `command \`root: '${pkg}/'\` — \`pre-commit.commands.{oxlint,oxfmt,check-theme}.root: ` +
1148
+ `'${pkg}/'\` — or they run at the root where your oxlint config, your binaries and your ` +
1149
+ 'basalt config are not.',
1150
+ `skipped .github/workflows/check.yml ${notRoot} — GitHub Actions only reads .github/ at the ` +
1151
+ `repo root; copy the steps from ${asset('configs/check.yml')} into your root CI workflow ` +
1152
+ `instead, and give each one \`working-directory: ${pkg}\` — the same relocation \`root:\` ` +
1153
+ 'performs for lefthook, and needed for the same reason.',
1154
+ )
1155
+ }
1156
+ if (placement.relocatedQueryClient !== null) {
1157
+ out.push(
1158
+ `skipped src/query-client.ts (found an existing query client at ` +
1159
+ `${relative(cwd, placement.relocatedQueryClient)}) — import it from there instead of ` +
1160
+ 're-seeding at the original path.',
1161
+ )
1162
+ }
1163
+ return out
1164
+ }
1165
+
1117
1166
  export type Manifest = {
1118
1167
  version: 1
1119
1168
  /** The basalt-ui version that last wrote this manifest — `doctor`'s one reconciliation axis. */