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.
- package/CHANGELOG.md +17 -0
- package/MIGRATING.md +901 -366
- package/README.md +23 -2
- package/agent/rules/basalt-controls.md +34 -34
- package/agent/skills/basalt-app/SKILL.md +4 -2
- package/agent/templates/query-client.ts.tpl +18 -4
- package/configs/lefthook.yml +51 -19
- package/configs/oxlint-plugin.js +210 -84
- package/configs/oxlint-plugin.test.ts +182 -5
- package/configs/oxlint.json +4 -4
- package/dist/charts/index.d.ts +1 -1
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/kinds/BandStrip.d.ts +5 -3
- package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.d.ts +4 -2
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.d.ts +4 -2
- package/dist/charts/kinds/Donut.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.d.ts +4 -2
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/Heatmap.d.ts +5 -2
- package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
- package/dist/charts/kinds/MirroredBars.d.ts +4 -2
- package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.d.ts +4 -2
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.d.ts +4 -2
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.d.ts +4 -2
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.d.ts +4 -1
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.js +2 -2
- package/dist/charts/primitives/ChartFrame.d.ts +25 -4
- package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.js +23 -16
- package/dist/charts/primitives/chart-frame-layout.d.ts +115 -6
- package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
- package/dist/charts/primitives/chart-frame-layout.js +46 -6
- package/dist/cli/check-theme.d.ts.map +1 -1
- package/dist/cli/check-theme.js +6 -0
- package/dist/cli/doctor.d.ts +10 -4
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +28 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +2 -1
- package/dist/cli/index.d.ts +12 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +29 -0
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +4 -15
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +8 -14
- package/dist/controls/actions.d.ts.map +1 -1
- package/dist/controls/actions.js +12 -2
- package/dist/dashboard/section.module.css +15 -7
- package/dist/dashboard/stat-card.d.ts.map +1 -1
- package/dist/dashboard/stat-card.js +7 -2
- package/dist/dashboard/stat-group.d.ts +8 -2
- package/dist/dashboard/stat-group.d.ts.map +1 -1
- package/dist/dashboard/stat-group.js +23 -15
- package/dist/dashboard/stat-group.module.css +85 -9
- package/dist/dashboard/stat-group.module.css.d.ts +1 -0
- package/dist/dashboard/widget-grid.d.ts.map +1 -1
- package/dist/dashboard/widget-grid.js +4 -5
- package/dist/dashboard/widget-grid.module.css +64 -21
- package/dist/dashboard/widget-grid.module.css.d.ts +1 -0
- package/dist/guard/index.d.ts +19 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +27 -4
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/shell/app-brand.module.css +12 -3
- package/dist/shell/app-header.module.css +49 -9
- package/dist/shell/app-main.module.css +24 -4
- package/dist/shell/app-mobile-nav.d.ts.map +1 -1
- package/dist/shell/app-mobile-nav.js +98 -51
- package/dist/shell/app-mobile-nav.module.css +99 -11
- package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
- package/dist/shell/app-sidebar.module.css +55 -2
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +3 -3
- package/dist/shell/mobile-nav-model.d.ts +26 -7
- package/dist/shell/mobile-nav-model.d.ts.map +1 -1
- package/dist/shell/mobile-nav-model.js +12 -3
- package/dist/shell/page-aside.module.css +8 -6
- package/dist/shell/page-bar.d.ts +13 -0
- package/dist/shell/page-bar.d.ts.map +1 -1
- package/dist/shell/page-bar.js +13 -3
- package/dist/shell/page-bar.module.css +20 -12
- package/dist/shell/page-title.module.css +3 -1
- package/dist/theme/use-basalt-spacing.d.ts.map +1 -1
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +51 -7
- package/dist/tokens/palette.d.ts +98 -17
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +110 -18
- package/dist/tokens.css +17 -5
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/charts/index.ts +1 -0
- package/src/charts/kinds/BandStrip.tsx +5 -3
- package/src/charts/kinds/Bars.tsx +4 -2
- package/src/charts/kinds/Donut.tsx +4 -2
- package/src/charts/kinds/DualPanel.tsx +4 -2
- package/src/charts/kinds/Heatmap.tsx +5 -2
- package/src/charts/kinds/MirroredBars.tsx +4 -2
- package/src/charts/kinds/MultiLine.tsx +4 -2
- package/src/charts/kinds/StackedArea.tsx +4 -2
- package/src/charts/kinds/ZonedLine.tsx +4 -2
- package/src/charts/primitives/CartesianChart.tsx +4 -1
- package/src/charts/primitives/ChartCard.tsx +12 -7
- package/src/charts/primitives/ChartFrame.tsx +51 -37
- package/src/charts/primitives/chart-frame-layout.ts +172 -13
- package/src/cli/check-theme.ts +13 -0
- package/src/cli/doctor.ts +49 -8
- package/src/cli/help.ts +5 -3
- package/src/cli/index.ts +49 -0
- package/src/cli/init.ts +6 -27
- package/src/cli/sync.ts +11 -22
- package/src/controls/actions.tsx +51 -0
- package/src/dashboard/section.module.css +15 -7
- package/src/dashboard/stat-card.tsx +7 -2
- package/src/dashboard/stat-group.module.css +85 -9
- package/src/dashboard/stat-group.module.css.d.ts +1 -0
- package/src/dashboard/stat-group.tsx +40 -20
- package/src/dashboard/widget-grid.module.css +64 -21
- package/src/dashboard/widget-grid.module.css.d.ts +1 -0
- package/src/dashboard/widget-grid.tsx +30 -12
- package/src/guard/index.ts +79 -11
- package/src/index.ts +9 -0
- package/src/shell/app-brand.module.css +12 -3
- package/src/shell/app-header.module.css +49 -9
- package/src/shell/app-main.module.css +24 -4
- package/src/shell/app-mobile-nav.module.css +99 -11
- package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
- package/src/shell/app-mobile-nav.tsx +225 -83
- package/src/shell/app-sidebar.module.css +55 -2
- package/src/shell/index.tsx +26 -6
- package/src/shell/mobile-nav-model.ts +37 -9
- package/src/shell/page-aside.module.css +8 -6
- package/src/shell/page-bar.module.css +20 -12
- package/src/shell/page-bar.tsx +39 -5
- package/src/shell/page-title.module.css +3 -1
- package/src/theme/use-basalt-spacing.ts +8 -6
- package/src/tokens/index.ts +90 -6
- 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 {
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
51
|
-
|
|
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`
|
|
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})
|
|
129
|
-
*
|
|
130
|
-
*
|
|
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 {
|
|
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
|
-
/**
|
|
196
|
-
|
|
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,
|
|
199
|
-
if (containerW <= 0 || items.length === 0) return
|
|
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
|
|
367
|
+
return defaultMaxRows
|
|
209
368
|
}
|
|
210
369
|
const fitted = Math.max(1, entriesWithinRows(items, containerW, rows))
|
|
211
|
-
return
|
|
370
|
+
return defaultMaxRows === undefined ? fitted : Math.min(fitted, defaultMaxRows)
|
|
212
371
|
}
|
package/src/cli/check-theme.ts
CHANGED
|
@@ -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}).
|
|
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. `
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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. */
|