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
package/src/cli/init.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* oxlint-preset plugin reader from `./sync` (also called there).
|
|
5
5
|
*/
|
|
6
6
|
import { existsSync } from 'node:fs'
|
|
7
|
-
import {
|
|
7
|
+
import { resolve } from 'node:path'
|
|
8
8
|
|
|
9
9
|
import { deriveSpacing } from '../tokens'
|
|
10
10
|
import type { MergeLintResult, ScaffoldFlags } from './index'
|
|
@@ -15,17 +15,16 @@ import {
|
|
|
15
15
|
mergeOxlintExtends,
|
|
16
16
|
normalizeForLedger,
|
|
17
17
|
patchPackageJson,
|
|
18
|
+
placementNotices,
|
|
18
19
|
pruneRetiredManagedFiles,
|
|
19
20
|
readManifest,
|
|
20
21
|
reconcileRoots,
|
|
21
|
-
relativePosix,
|
|
22
22
|
renderContext,
|
|
23
23
|
reportPrune,
|
|
24
24
|
resolvePeerFlags,
|
|
25
25
|
resolvePlacement,
|
|
26
26
|
rootsNotices,
|
|
27
27
|
sha256,
|
|
28
|
-
shippedAssetPath,
|
|
29
28
|
unitState,
|
|
30
29
|
writeFileEnsuringDir,
|
|
31
30
|
writeUnit,
|
|
@@ -166,30 +165,10 @@ export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags =
|
|
|
166
165
|
if (seededLintScript) {
|
|
167
166
|
console.log(`basalt-ui init: added the "lint:basalt" script (${lintScript}) to package.json.`)
|
|
168
167
|
}
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
console.log(
|
|
174
|
-
`basalt-ui init: skipped lefthook.yml (this package is not the repo root — repo root ` +
|
|
175
|
-
`detected at ${placement.repoRoot}) — lefthook only reads config at the repo root; extend ` +
|
|
176
|
-
`${ctx.vars.LEFTHOOK_PRESET_PATH} from your root lefthook.yml instead, and give its ` +
|
|
177
|
-
`check-theme command \`env: { BASALT_CWD: ${relativePosix(placement.repoRoot, cwd)} }\` ` +
|
|
178
|
-
'so the guard runs where your basalt config actually lives.',
|
|
179
|
-
)
|
|
180
|
-
console.log(
|
|
181
|
-
`basalt-ui init: skipped .github/workflows/check.yml (this package is not the repo root — ` +
|
|
182
|
-
`repo root detected at ${placement.repoRoot}) — GitHub Actions only reads .github/ at the ` +
|
|
183
|
-
`repo root; copy the steps from ${shippedAssetPath(install, placement.repoRoot, 'configs/check.yml')} ` +
|
|
184
|
-
'into your root CI workflow instead.',
|
|
185
|
-
)
|
|
186
|
-
}
|
|
187
|
-
if (placement.relocatedQueryClient !== null) {
|
|
188
|
-
console.log(
|
|
189
|
-
`basalt-ui init: skipped src/query-client.ts (found an existing query client at ` +
|
|
190
|
-
`${relative(cwd, placement.relocatedQueryClient)}) — import it from there instead of ` +
|
|
191
|
-
're-seeding at the original path.',
|
|
192
|
-
)
|
|
168
|
+
// The permanent placement skips (repo-root-shaped tooling, a relocated scaffold) — one text,
|
|
169
|
+
// shared with `sync`. See {@link placementNotices}.
|
|
170
|
+
for (const line of placementNotices(cwd, placement)) {
|
|
171
|
+
console.log(`basalt-ui init: ${line}`)
|
|
193
172
|
}
|
|
194
173
|
// query-client.ts / __root.tsx reference the optional TanStack peers directly — seeding them
|
|
195
174
|
// without the peer installed would ship an unresolved import. Hint how to opt in instead.
|
package/src/cli/sync.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* resolution back from `./index`, this package's shared CLI helpers.
|
|
6
6
|
*/
|
|
7
7
|
import { existsSync } from 'node:fs'
|
|
8
|
-
import {
|
|
8
|
+
import { resolve } from 'node:path'
|
|
9
9
|
|
|
10
10
|
import { deriveSpacing } from '../tokens'
|
|
11
11
|
import {
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
managedFiles,
|
|
22
22
|
normalizeForLedger,
|
|
23
23
|
parseJsonc,
|
|
24
|
+
placementNotices,
|
|
24
25
|
pruneRetiredManagedFiles,
|
|
25
26
|
readBasaltConfig,
|
|
26
27
|
readIfExists,
|
|
@@ -232,26 +233,12 @@ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd
|
|
|
232
233
|
})
|
|
233
234
|
staleForCheck += pruned.removed.length + pruned.drifted.length
|
|
234
235
|
|
|
235
|
-
// Placement notices — informational, never
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
'node_modules/basalt-ui/configs/lefthook.yml from your root lefthook.yml instead.',
|
|
242
|
-
)
|
|
243
|
-
console.log(
|
|
244
|
-
`basalt-ui sync: skipped .github/workflows/check.yml (this package is not the repo root — ` +
|
|
245
|
-
`repo root detected at ${placement.repoRoot}) — GitHub Actions only reads .github/ at the ` +
|
|
246
|
-
'repo root; extend node_modules/basalt-ui/configs/check.yml from your root CI workflow instead.',
|
|
247
|
-
)
|
|
248
|
-
}
|
|
249
|
-
if (placement.relocatedQueryClient !== null) {
|
|
250
|
-
console.log(
|
|
251
|
-
`basalt-ui sync: skipped src/query-client.ts (found an existing query client at ` +
|
|
252
|
-
`${relative(cwd, placement.relocatedQueryClient)}) — import it from there instead of ` +
|
|
253
|
-
're-seeding at the original path.',
|
|
254
|
-
)
|
|
236
|
+
// Placement notices — informational, never affecting the exit code, and PERMANENT: `--check`
|
|
237
|
+
// hides them behind `--verbose` (it sits in consumers' CI chains, where three unchanging
|
|
238
|
+
// "skipped" paragraphs per run are noise indistinguishable from drift) but still prints the COUNT.
|
|
239
|
+
const placementSkips = placementNotices(cwd, placement)
|
|
240
|
+
if (opts.check !== true || syncFlags.includes('--verbose')) {
|
|
241
|
+
for (const line of placementSkips) console.log(`basalt-ui sync: ${line}`)
|
|
255
242
|
}
|
|
256
243
|
|
|
257
244
|
const rootsMessages = rootsNotices(rootsState)
|
|
@@ -298,7 +285,9 @@ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd
|
|
|
298
285
|
console.error(`basalt-ui sync --check: ${staleForCheck} managed file(s) out of date.`)
|
|
299
286
|
return 1
|
|
300
287
|
}
|
|
301
|
-
|
|
288
|
+
const n = placementSkips.length
|
|
289
|
+
const skips = n === 0 ? '' : ` (${n} permanent placement skip(s) — --verbose for detail)`
|
|
290
|
+
console.log(`✓ basalt-ui sync --check: all managed files current.${skips}`)
|
|
302
291
|
return 0
|
|
303
292
|
}
|
|
304
293
|
|
package/src/controls/actions.tsx
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* media query, so there is no first-paint flash and no hook that re-renders on resize.
|
|
14
14
|
*/
|
|
15
15
|
import { ActionIcon, Button, Group, Menu } from '@mantine/core'
|
|
16
|
+
import type { MantineBreakpoint } from '@mantine/core'
|
|
16
17
|
import { createContext, isValidElement, useContext, useEffect } from 'react'
|
|
17
18
|
import type { ReactNode } from 'react'
|
|
18
19
|
import type { BasaltProps } from '../common/props'
|
|
@@ -187,6 +188,19 @@ const MOBILE_PRIMARY_PADDING_X = 10
|
|
|
187
188
|
*/
|
|
188
189
|
const BAR_GAP = 6
|
|
189
190
|
|
|
191
|
+
/**
|
|
192
|
+
* A declared WIDTH TIER for one rendered form of an action — Mantine's own `visibleFrom`/`hiddenFrom`
|
|
193
|
+
* style props, forwarded verbatim, which is the same CSS-only mechanism this file's `sm` swap uses
|
|
194
|
+
* (see the module header). Never a JS media query: no first-paint flash, no resize re-render.
|
|
195
|
+
*
|
|
196
|
+
* It exists because `sm` is not the only width at which this row runs out of room — see
|
|
197
|
+
* {@link BarEntry}'s `md` label fold for the arithmetic.
|
|
198
|
+
*/
|
|
199
|
+
type TierVisibility = {
|
|
200
|
+
visibleFrom?: MantineBreakpoint
|
|
201
|
+
hiddenFrom?: MantineBreakpoint
|
|
202
|
+
}
|
|
203
|
+
|
|
190
204
|
/**
|
|
191
205
|
* One action as a button. No `size` prop anywhere: the enclosing `CtlSlot` (mounted by the home,
|
|
192
206
|
* never by the action) resolves the `ctl` tier — law C5.
|
|
@@ -195,15 +209,19 @@ function BarButton({
|
|
|
195
209
|
action,
|
|
196
210
|
variant,
|
|
197
211
|
px,
|
|
212
|
+
tier,
|
|
198
213
|
}: {
|
|
199
214
|
action: BarActionItem
|
|
200
215
|
variant: 'filled' | 'default'
|
|
201
216
|
/** Overrides the tier's horizontal inset — only the compact mobile primary passes it. */
|
|
202
217
|
px?: number
|
|
218
|
+
/** The declared width tier this form paints at — see {@link TierVisibility}. */
|
|
219
|
+
tier?: TierVisibility
|
|
203
220
|
}): ReactNode {
|
|
204
221
|
const shared = {
|
|
205
222
|
variant,
|
|
206
223
|
...(px !== undefined && { px }),
|
|
224
|
+
...tier,
|
|
207
225
|
disabled: action.disabled === true,
|
|
208
226
|
loading: action.loading === true,
|
|
209
227
|
// EVERY icon in this file goes through `IconSlot` — the box is the framework's, never the
|
|
@@ -234,13 +252,17 @@ function BarButton({
|
|
|
234
252
|
function BarIconButton({
|
|
235
253
|
action,
|
|
236
254
|
variant,
|
|
255
|
+
tier,
|
|
237
256
|
}: {
|
|
238
257
|
action: BarActionItem
|
|
239
258
|
/** `default` is the bordered desktop form — a joined `ControlGroup` member (see {@link BarEntry}). */
|
|
240
259
|
variant: 'filled' | 'subtle' | 'default'
|
|
260
|
+
/** The declared width tier this form paints at — see {@link TierVisibility}. */
|
|
261
|
+
tier?: TierVisibility
|
|
241
262
|
}): ReactNode {
|
|
242
263
|
const shared = {
|
|
243
264
|
variant,
|
|
265
|
+
...tier,
|
|
244
266
|
'aria-label': action.label,
|
|
245
267
|
disabled: action.disabled === true,
|
|
246
268
|
loading: action.loading === true,
|
|
@@ -698,6 +720,35 @@ function BarEntry({
|
|
|
698
720
|
if (iconOnly === true && action.icon !== undefined) {
|
|
699
721
|
return <BarIconButton action={action} variant="default" />
|
|
700
722
|
}
|
|
723
|
+
// THE `md` LABEL FOLD — the second declared tier, and the only fold this row had between `sm`
|
|
724
|
+
// (48em, where the whole mobile swap happens) and the width at which a desktop header is
|
|
725
|
+
// genuinely wide. Above `sm` the only fold was `DESKTOP_SECONDARY_MAX`, which is a COUNT and
|
|
726
|
+
// never a width: three labelled secondaries are three labelled secondaries at 768px and at
|
|
727
|
+
// 2560px.
|
|
728
|
+
//
|
|
729
|
+
// The arithmetic, on /dashboard at exactly 768px — the width where the navbar appears and takes
|
|
730
|
+
// 256px: 512px of header left, against ~554px of row 1 + ~102px of globals + ~48px of gaps ≈
|
|
731
|
+
// 704px. ~192px over, and nothing above `sm` folded — the breadcrumb absorbed it by shrinking to
|
|
732
|
+
// zero (`shell/app-header.module.css`'s `.lead`, now floored at 96px, which this tier pays for).
|
|
733
|
+
// Dropping up to `DESKTOP_SECONDARY_MAX` labelled secondaries to their 30px icon form returns
|
|
734
|
+
// roughly 180-240px on that row: a labelled `ctl` button is its icon slot + label + insets
|
|
735
|
+
// (~90-110px depending on the word), an icon-only one is the tier's 30px square.
|
|
736
|
+
//
|
|
737
|
+
// The PRIMARY keeps its label at every width — it is the one action a reader must be able to
|
|
738
|
+
// name without hovering (law C6: exactly one per home), so `emphasis === 'primary'` never enters
|
|
739
|
+
// this branch. An icon-LESS secondary keeps its label too: there is nothing to fall back to, the
|
|
740
|
+
// same reason `MobileBarEntry` renders a compact labelled button below `sm`.
|
|
741
|
+
//
|
|
742
|
+
// Both forms are rendered and CSS picks one, the mechanism the `sm` swap already uses — that is
|
|
743
|
+
// law C9 being HONOURED, not breached: the swap belongs to the control, and this is the control.
|
|
744
|
+
if (emphasis === 'secondary' && action.icon !== undefined) {
|
|
745
|
+
return (
|
|
746
|
+
<>
|
|
747
|
+
<BarIconButton action={action} variant="default" tier={{ hiddenFrom: 'md' }} />
|
|
748
|
+
<BarButton action={action} variant="default" tier={{ visibleFrom: 'md' }} />
|
|
749
|
+
</>
|
|
750
|
+
)
|
|
751
|
+
}
|
|
701
752
|
return <BarButton action={action} variant={emphasis === 'primary' ? 'filled' : 'default'} />
|
|
702
753
|
}
|
|
703
754
|
|
|
@@ -6,20 +6,27 @@
|
|
|
6
6
|
|
|
7
7
|
/*
|
|
8
8
|
* 12px from the header to the body, and the same between two body children — one rhythm, read from
|
|
9
|
-
* the stack scale (`--vx-space-
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* the stack scale (`SPACE.stackMd`, via the `--vx-space-section-gap` role token) instead of the
|
|
10
|
+
* card-inset scale (13px). A Section is a page-level GROUPING, so its gaps belong to the vertical
|
|
11
|
+
* rhythm, not to the inset ladder its cards use.
|
|
12
|
+
*
|
|
13
|
+
* Below `sm` it steps to `stackSm` (8px), together with the cards' own horizontal inset. That step
|
|
14
|
+
* is NOT written here: it belongs to the mobile inset ladder, whose ONE home is `spaceMobileDecls`
|
|
15
|
+
* in `tokens/index.ts` — this rule only reads the role token and gets the responsive value for
|
|
16
|
+
* free. See that function's doc for why the law lives in the emitter rather than in the three
|
|
17
|
+
* stylesheets that consume it (two of the three are inline styles, which cannot hold a media
|
|
18
|
+
* query at all).
|
|
12
19
|
*/
|
|
13
20
|
.root {
|
|
14
21
|
display: flex;
|
|
15
22
|
flex-direction: column;
|
|
16
|
-
gap: var(--vx-space-
|
|
23
|
+
gap: var(--vx-space-section-gap, 0.75rem);
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
.body {
|
|
20
27
|
display: flex;
|
|
21
28
|
flex-direction: column;
|
|
22
|
-
gap: var(--vx-space-
|
|
29
|
+
gap: var(--vx-space-section-gap, 0.75rem);
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
/*
|
|
@@ -48,8 +55,9 @@
|
|
|
48
55
|
* The collapse chevron — a plain control, not a Mantine ActionIcon, so it sits directly in the header
|
|
49
56
|
* row (its own CtlSlot fragment would nest a theme provider around one glyph). It is sized to the
|
|
50
57
|
* `ctl` tier BY HAND for exactly that reason: a 12px glyph in a 20px box was a different height from
|
|
51
|
-
* every other control in the same header, which is what made the section header read as ragged.
|
|
52
|
-
* square, glyph centred, no padding — the box IS
|
|
58
|
+
* every other control in the same header, which is what made the section header read as ragged. 32px
|
|
59
|
+
* square (the `ctl` tier, 30 before the desktop-shell pass), glyph centred, no padding — the box IS
|
|
60
|
+
* the tier.
|
|
53
61
|
*/
|
|
54
62
|
.chevron {
|
|
55
63
|
/* `relative` is the anchor for the C15 hit-area overlay in the `< sm` block at the bottom. */
|
|
@@ -378,7 +378,12 @@ export function StatCard({
|
|
|
378
378
|
<Card
|
|
379
379
|
className={cx(classNames?.root, className)}
|
|
380
380
|
style={{
|
|
381
|
-
// Card inset =
|
|
381
|
+
// Card inset = the `cardInsetY`/`cardInsetX` role tokens (11/13 at level 0 — the same
|
|
382
|
+
// `xs`/`sm` rungs this used to spell as `--mantine-spacing-*`), matching every other basalt
|
|
383
|
+
// card because `ChartCard` reads the SAME two vars. Below `sm` the horizontal one steps a
|
|
384
|
+
// rung down on its own: the responsive half of the ladder lives in `spaceMobileDecls`
|
|
385
|
+
// (`tokens/index.ts`), which is the only home that can reach an inline style like this one.
|
|
386
|
+
// `overflow: hidden` clips
|
|
382
387
|
// the full-bleed sparkline to the card's rounded corners; an element's own `box-shadow`
|
|
383
388
|
// renders outside its border box and is NOT clipped by its own `overflow`, so the
|
|
384
389
|
// shadow-card ring is unaffected (verified — only an ANCESTOR's overflow clips a
|
|
@@ -390,7 +395,7 @@ export function StatCard({
|
|
|
390
395
|
// body is `height: 100%` and the sparkline is `margin-top: auto`. The content is what states
|
|
391
396
|
// the height now (~88-96px), and four cards in a `SimpleGrid` still match because a grid row
|
|
392
397
|
// stretches its items.
|
|
393
|
-
padding: 'var(--
|
|
398
|
+
padding: 'var(--vx-space-card-inset-y, 0.6875rem) var(--vx-space-card-inset-x, 0.8125rem)',
|
|
394
399
|
overflow: 'hidden',
|
|
395
400
|
// Anchors the tone rail. Set unconditionally so a card's stacking context does not change
|
|
396
401
|
// depending on whether it happens to have crossed a threshold this render.
|
|
@@ -1,10 +1,41 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* StatGroup's column law and its optional rail, in CSS (law C9).
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* the
|
|
2
|
+
* StatGroup's column law and its optional rail, in CSS (law C9).
|
|
3
|
+
*
|
|
4
|
+
* **The law keys on the CONTAINER, not the viewport.** A viewport breakpoint answers the wrong
|
|
5
|
+
* question — the same one `resolveChartTier` refuses to ask (`charts/primitives/chart-frame-layout.
|
|
6
|
+
* ts`): the shell has regions that change the AVAILABLE width with the viewport held still (a
|
|
7
|
+
* `PageAside` claiming its 300px, the sidebar collapsing 256px → its 48px rail, a consumer's own
|
|
8
|
+
* split layout), and `@media` cannot see any of them. Measured defect: a `PageAside` mounted on a
|
|
9
|
+
* 1512px desktop left the KPI row ~956px wide, `@media` still resolved `lg`, and four cells of
|
|
10
|
+
* 239px truncated their own values mid-word ("8,7…", "Organic se…").
|
|
11
|
+
*
|
|
12
|
+
* `.container` is what makes the queries answerable — `StatGroup` renders it around the grid, so
|
|
13
|
+
* the container context ALWAYS exists and is ALWAYS the box the grid actually fills, whatever the
|
|
14
|
+
* shell or the consumer wrapped it in. It deliberately does not depend on the shell establishing
|
|
15
|
+
* one: a query with no container resolves to false, so a shell-only context would have silently
|
|
16
|
+
* pinned every non-shell consumer to the base count.
|
|
17
|
+
*
|
|
18
|
+
* The `@supports not` block below it is the pre-2023 fallback and the ONLY place the old viewport
|
|
19
|
+
* law survives. It is written as an exclusive alternative rather than as a base the container
|
|
20
|
+
* queries override, because the rail's `:nth-child` suppressors are MORE specific than the rule
|
|
21
|
+
* that draws the border: two overlapping laws would each suppress a different set of leading cells
|
|
22
|
+
* and the rail would lose hairlines in the middle of a row. One law at a time, chosen by support.
|
|
23
|
+
*
|
|
24
|
+
* The literals: `@container` conditions are stated in px, `@media` ones in Mantine's own `em`
|
|
25
|
+
* (`--mantine-breakpoint-sm` 48em, `--mantine-breakpoint-lg` 75em). Both spellings name the same
|
|
26
|
+
* boundary at the 16px initial font size — 768px and 1200px — but `em` inside a `@container`
|
|
27
|
+
* condition resolves against the CONTAINER's font-size rather than the root's, so a consumer panel
|
|
28
|
+
* with smaller text would silently move the breakpoint. px is the only spelling that cannot drift.
|
|
29
|
+
* (`47.99375em` → `767.9px`, `74.99375em` → `1199.9px`; the same complements `stat-card.module.css`
|
|
30
|
+
* uses.) A condition still cannot read a custom property either way, so the numbers are written;
|
|
31
|
+
* the COUNTS are custom properties the component sets per instance.
|
|
6
32
|
*/
|
|
7
33
|
|
|
34
|
+
.container {
|
|
35
|
+
container-type: inline-size;
|
|
36
|
+
container-name: basalt-stat-group;
|
|
37
|
+
}
|
|
38
|
+
|
|
8
39
|
.root {
|
|
9
40
|
display: grid;
|
|
10
41
|
/* Two KPIs abreast on a phone, never one: a KPI is a short pre-formatted number, and a single
|
|
@@ -13,13 +44,13 @@
|
|
|
13
44
|
gap: var(--vx-space-stack-md);
|
|
14
45
|
}
|
|
15
46
|
|
|
16
|
-
@
|
|
47
|
+
@container basalt-stat-group (min-width: 768px) {
|
|
17
48
|
.root {
|
|
18
49
|
grid-template-columns: repeat(var(--basalt-stat-group-cols-sm), minmax(0, 1fr));
|
|
19
50
|
}
|
|
20
51
|
}
|
|
21
52
|
|
|
22
|
-
@
|
|
53
|
+
@container basalt-stat-group (min-width: 1200px) {
|
|
23
54
|
.root {
|
|
24
55
|
grid-template-columns: repeat(var(--basalt-stat-group-cols-lg), minmax(0, 1fr));
|
|
25
56
|
}
|
|
@@ -40,14 +71,14 @@
|
|
|
40
71
|
padding-inline-start: var(--vx-space-stack-md);
|
|
41
72
|
}
|
|
42
73
|
|
|
43
|
-
@
|
|
74
|
+
@container basalt-stat-group (max-width: 767.9px) {
|
|
44
75
|
.root[data-divided] > *:nth-child(2n + 1) {
|
|
45
76
|
border-inline-start: 0;
|
|
46
77
|
padding-inline-start: 0;
|
|
47
78
|
}
|
|
48
79
|
}
|
|
49
80
|
|
|
50
|
-
@
|
|
81
|
+
@container basalt-stat-group (min-width: 768px) and (max-width: 1199.9px) {
|
|
51
82
|
.root[data-divided][data-sm-cols='2'] > *:nth-child(2n + 1),
|
|
52
83
|
.root[data-divided][data-sm-cols='3'] > *:nth-child(3n + 1) {
|
|
53
84
|
border-inline-start: 0;
|
|
@@ -55,7 +86,7 @@
|
|
|
55
86
|
}
|
|
56
87
|
}
|
|
57
88
|
|
|
58
|
-
@
|
|
89
|
+
@container basalt-stat-group (min-width: 1200px) {
|
|
59
90
|
.root[data-divided][data-cols='2'] > *:nth-child(2n + 1),
|
|
60
91
|
.root[data-divided][data-cols='3'] > *:nth-child(3n + 1),
|
|
61
92
|
.root[data-divided][data-cols='4'] > *:nth-child(4n + 1),
|
|
@@ -64,3 +95,48 @@
|
|
|
64
95
|
padding-inline-start: 0;
|
|
65
96
|
}
|
|
66
97
|
}
|
|
98
|
+
|
|
99
|
+
/*
|
|
100
|
+
* The pre-container-query fallback — Safari < 16, Chrome < 106, Firefox < 110. Verbatim the law
|
|
101
|
+
* this file shipped before, keyed on the viewport, and reachable ONLY where `@container` is not.
|
|
102
|
+
* A browser that cannot parse `container-type` also cannot parse the `@container` blocks above, so
|
|
103
|
+
* the two sets are never live at once.
|
|
104
|
+
*/
|
|
105
|
+
@supports not (container-type: inline-size) {
|
|
106
|
+
@media (min-width: 48em) {
|
|
107
|
+
.root {
|
|
108
|
+
grid-template-columns: repeat(var(--basalt-stat-group-cols-sm), minmax(0, 1fr));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@media (min-width: 75em) {
|
|
113
|
+
.root {
|
|
114
|
+
grid-template-columns: repeat(var(--basalt-stat-group-cols-lg), minmax(0, 1fr));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@media (max-width: 47.99375em) {
|
|
119
|
+
.root[data-divided] > *:nth-child(2n + 1) {
|
|
120
|
+
border-inline-start: 0;
|
|
121
|
+
padding-inline-start: 0;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@media (min-width: 48em) and (max-width: 74.99375em) {
|
|
126
|
+
.root[data-divided][data-sm-cols='2'] > *:nth-child(2n + 1),
|
|
127
|
+
.root[data-divided][data-sm-cols='3'] > *:nth-child(3n + 1) {
|
|
128
|
+
border-inline-start: 0;
|
|
129
|
+
padding-inline-start: 0;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@media (min-width: 75em) {
|
|
134
|
+
.root[data-divided][data-cols='2'] > *:nth-child(2n + 1),
|
|
135
|
+
.root[data-divided][data-cols='3'] > *:nth-child(3n + 1),
|
|
136
|
+
.root[data-divided][data-cols='4'] > *:nth-child(4n + 1),
|
|
137
|
+
.root[data-divided][data-cols='5'] > *:nth-child(5n + 1) {
|
|
138
|
+
border-inline-start: 0;
|
|
139
|
+
padding-inline-start: 0;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -4,12 +4,18 @@
|
|
|
4
4
|
* packs tighter than a chart card and the law starts at TWO on a phone.
|
|
5
5
|
*
|
|
6
6
|
* **`cols` is the DESKTOP count** and the only number a consumer states; the law is
|
|
7
|
-
* `base 2 → sm min(cols, 3) → lg cols`. Five is the ceiling — past that the cells are narrower than
|
|
7
|
+
* `base 2 → sm min(cols, 3), except 4 → 2 → lg cols`. Five is the ceiling — past that the cells are narrower than
|
|
8
8
|
* a formatted currency value and the row is a table.
|
|
9
9
|
*
|
|
10
10
|
* **`lg` enters the package here and in `WidgetGrid`, nowhere else.** `sm` stays the only
|
|
11
11
|
* breakpoint a consumer writes (`docs/CONTROLS-SPEC.md` §2).
|
|
12
12
|
*
|
|
13
|
+
* **The column law keys on the CONTAINER, not the viewport.** `StatGroup` renders its own
|
|
14
|
+
* `container-type: inline-size` box around the grid, so the counts follow the width the row
|
|
15
|
+
* ACTUALLY has — a `PageAside` claiming 300px, a collapsed sidebar giving 208px back, a consumer's
|
|
16
|
+
* split pane — rather than the viewport width, which changes for none of those. See
|
|
17
|
+
* `stat-group.module.css`'s header for the measured defect and the pre-2023 fallback.
|
|
18
|
+
*
|
|
13
19
|
* `divided` swaps the column gap for a hairline rail between cells — the bare-metric-row look, for
|
|
14
20
|
* KPIs that are one reading split several ways rather than several independent cards. It draws the
|
|
15
21
|
* rule on the GROUP, so the cells stay whatever they are; on `StatCard`s (which carry their own
|
|
@@ -33,7 +39,7 @@ import classes from './stat-group.module.css'
|
|
|
33
39
|
export type StatGroupCols = 2 | 3 | 4 | 5
|
|
34
40
|
|
|
35
41
|
export type StatGroupProps = BasaltProps & {
|
|
36
|
-
/** The DESKTOP column count (`lg` and up). Default `4`. `sm` gets `min(cols, 3)`,
|
|
42
|
+
/** The DESKTOP column count (`lg` and up). Default `4`. `sm` gets `min(cols, 3)` — except `4`, which gets 2 rather than an orphan row. A phone gets 2.
|
|
37
43
|
* Never a responsive object — the law is basalt's, see the module docblock. */
|
|
38
44
|
cols?: StatGroupCols
|
|
39
45
|
/** Swap the column gap for a hairline rail between cells. Default `false`. */
|
|
@@ -49,26 +55,40 @@ export function StatGroup({
|
|
|
49
55
|
className,
|
|
50
56
|
style,
|
|
51
57
|
}: StatGroupProps) {
|
|
52
|
-
|
|
58
|
+
// `4 -> 2`, not `4 -> 3`. `min(cols, 3)` is the general rule and it is right for 2, 3 and 5, but
|
|
59
|
+
// FOUR is the default and the overwhelmingly common count, and three columns leave a four-KPI row
|
|
60
|
+
// as 3 + 1 — one orphan cell against two thirds of empty track. MEASURED on the playground
|
|
61
|
+
// dashboard with a `PageAside` claimed (group 916px): three cells of ~273px plus an orphan, where
|
|
62
|
+
// two of ~450px read as one balanced block. A KPI row is a set of peers; a ragged last row says
|
|
63
|
+
// one of them is different, which is the one thing it must not say.
|
|
64
|
+
const smCols = cols === 4 ? 2 : Math.min(cols, 3)
|
|
53
65
|
|
|
54
66
|
return (
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
{
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
67
|
+
// Two boxes, and the outer one is not decoration: a size container is a container for its
|
|
68
|
+
// DESCENDANTS, never for itself, so the grid whose tracks the query decides has to sit inside
|
|
69
|
+
// the box being measured. `className`/`style` land on the OUTER box because that is the root
|
|
70
|
+
// element a consumer positions (`BasaltProps`) — and because measuring the box the consumer
|
|
71
|
+
// sized is the whole point. `container-type: inline-size` contains the inline axis only, so the
|
|
72
|
+
// wrapper's block size still comes from the grid: no percentage height resolves differently,
|
|
73
|
+
// and there is no paint containment, so nothing inside is clipped.
|
|
74
|
+
<div className={cx(classes.container, className)} style={style}>
|
|
75
|
+
<div
|
|
76
|
+
className={classes.root}
|
|
77
|
+
data-cols={cols}
|
|
78
|
+
data-sm-cols={smCols}
|
|
79
|
+
// An ATTRIBUTE, not a second class: the rail's rules key off the resolved column count
|
|
80
|
+
// (`[data-cols]`/`[data-sm-cols]`) already, so one selector shape carries the whole law —
|
|
81
|
+
// same idiom `stat-card.module.css` uses for `[data-placement]`.
|
|
82
|
+
{...(divided && { 'data-divided': 'true' })}
|
|
83
|
+
style={
|
|
84
|
+
{
|
|
85
|
+
'--basalt-stat-group-cols-sm': smCols,
|
|
86
|
+
'--basalt-stat-group-cols-lg': cols,
|
|
87
|
+
} as CSSProperties
|
|
88
|
+
}
|
|
89
|
+
>
|
|
90
|
+
{children}
|
|
91
|
+
</div>
|
|
72
92
|
</div>
|
|
73
93
|
)
|
|
74
94
|
}
|
|
@@ -1,15 +1,34 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* WidgetGrid's column law, in CSS (law C9 — a responsive swap belongs to the control, never to a JS
|
|
3
|
-
* media query).
|
|
4
|
-
* `--mantine-breakpoint-lg` (75em); a `@media` condition is resolved before custom properties
|
|
5
|
-
* exist, so the number has to be written — same standing constraint `content/article-layout.
|
|
6
|
-
* module.css` documents.
|
|
3
|
+
* media query).
|
|
7
4
|
*
|
|
8
|
-
* The
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* **The law keys on the CONTAINER, not the viewport**, for the reason `stat-group.module.css`'s
|
|
6
|
+
* header states in full: the shell moves the available width without moving the viewport (a
|
|
7
|
+
* `PageAside` claiming 300px, the sidebar collapsing to its 48px rail, a consumer's split layout),
|
|
8
|
+
* and `@media` is blind to all of it. `.container` — rendered by `WidgetGrid` around the grid —
|
|
9
|
+
* is what makes the queries answerable and is always the box the grid actually fills; a query with
|
|
10
|
+
* no container resolves to false, so leaning on the shell to establish one would have pinned every
|
|
11
|
+
* non-shell consumer to a single column.
|
|
12
|
+
*
|
|
13
|
+
* `.item`'s span law is in the same blocks for the same reason: `grid-column: span <n>` must be
|
|
14
|
+
* clamped against the count the ROOT is actually rendering, and the root's count now follows the
|
|
15
|
+
* container. A span resolved against the viewport while the tracks follow the container is exactly
|
|
16
|
+
* the phantom-track defect `WidgetGrid.Item`'s context clamp exists to prevent.
|
|
17
|
+
*
|
|
18
|
+
* The literals: px in `@container`, Mantine's own `em` in the `@supports not` fallback — same
|
|
19
|
+
* boundary (768px / 1200px at the 16px initial font size), but `em` inside a `@container`
|
|
20
|
+
* condition resolves against the CONTAINER's font-size, so px is the only spelling that cannot
|
|
21
|
+
* drift. A condition cannot read a custom property either way, so the numbers are written; the
|
|
22
|
+
* COUNTS are custom properties, set once per instance by the component — three hand-written
|
|
23
|
+
* `repeat()` blocks would have to enumerate every `cols` value, and the whole point of this
|
|
24
|
+
* primitive is that a consumer states one desktop number and basalt owns the rest.
|
|
11
25
|
*/
|
|
12
26
|
|
|
27
|
+
.container {
|
|
28
|
+
container-type: inline-size;
|
|
29
|
+
container-name: basalt-widget-grid;
|
|
30
|
+
}
|
|
31
|
+
|
|
13
32
|
.root {
|
|
14
33
|
display: grid;
|
|
15
34
|
/* Below `sm` a widget column is always 1 — a dashboard card holds a chart, and two of them on a
|
|
@@ -18,18 +37,6 @@
|
|
|
18
37
|
gap: var(--vx-space-stack-md);
|
|
19
38
|
}
|
|
20
39
|
|
|
21
|
-
@media (min-width: 48em) {
|
|
22
|
-
.root {
|
|
23
|
-
grid-template-columns: repeat(var(--basalt-widget-grid-cols-sm), minmax(0, 1fr));
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
@media (min-width: 75em) {
|
|
28
|
-
.root {
|
|
29
|
-
grid-template-columns: repeat(var(--basalt-widget-grid-cols-lg), minmax(0, 1fr));
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
40
|
/*
|
|
34
41
|
* `min-width: 0` on every cell: a grid track's default `min-width: auto` is its content's intrinsic
|
|
35
42
|
* width, so one wide `<table>` or one long unbroken value blows the whole row out instead of
|
|
@@ -40,14 +47,50 @@
|
|
|
40
47
|
min-width: 0;
|
|
41
48
|
}
|
|
42
49
|
|
|
43
|
-
@
|
|
50
|
+
@container basalt-widget-grid (min-width: 768px) {
|
|
51
|
+
.root {
|
|
52
|
+
grid-template-columns: repeat(var(--basalt-widget-grid-cols-sm), minmax(0, 1fr));
|
|
53
|
+
}
|
|
54
|
+
|
|
44
55
|
.item {
|
|
45
56
|
grid-column: span var(--basalt-widget-grid-span-sm);
|
|
46
57
|
}
|
|
47
58
|
}
|
|
48
59
|
|
|
49
|
-
@
|
|
60
|
+
@container basalt-widget-grid (min-width: 1200px) {
|
|
61
|
+
.root {
|
|
62
|
+
grid-template-columns: repeat(var(--basalt-widget-grid-cols-lg), minmax(0, 1fr));
|
|
63
|
+
}
|
|
64
|
+
|
|
50
65
|
.item {
|
|
51
66
|
grid-column: span var(--basalt-widget-grid-span-lg);
|
|
52
67
|
}
|
|
53
68
|
}
|
|
69
|
+
|
|
70
|
+
/*
|
|
71
|
+
* The pre-container-query fallback — Safari < 16, Chrome < 106, Firefox < 110. Verbatim the law
|
|
72
|
+
* this file shipped before, keyed on the viewport, and reachable ONLY where `@container` is not: a
|
|
73
|
+
* browser that cannot parse `container-type` cannot parse the blocks above either, so the two sets
|
|
74
|
+
* are never live at once.
|
|
75
|
+
*/
|
|
76
|
+
@supports not (container-type: inline-size) {
|
|
77
|
+
@media (min-width: 48em) {
|
|
78
|
+
.root {
|
|
79
|
+
grid-template-columns: repeat(var(--basalt-widget-grid-cols-sm), minmax(0, 1fr));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.item {
|
|
83
|
+
grid-column: span var(--basalt-widget-grid-span-sm);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@media (min-width: 75em) {
|
|
88
|
+
.root {
|
|
89
|
+
grid-template-columns: repeat(var(--basalt-widget-grid-cols-lg), minmax(0, 1fr));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.item {
|
|
93
|
+
grid-column: span var(--basalt-widget-grid-span-lg);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|