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
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 { relative, resolve } from 'node:path'
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
- // lefthook.yml / .github/workflows/check.yml are repo-root-shaped — neither lefthook nor GitHub
170
- // Actions reads config from anywhere but the repo root, so a package living in a subdirectory
171
- // skips both rather than relocating them into a spot nothing reads.
172
- if (!placement.isPackageRepoRoot) {
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 { relative, resolve } from 'node:path'
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 affect the exit code (a skipped tooling seed or a
236
- // relocated scaffold is a legitimate consumer choice, not a sync failure).
237
- if (!placement.isPackageRepoRoot) {
238
- console.log(
239
- `basalt-ui sync: skipped lefthook.yml (this package is not the repo root — repo root ` +
240
- `detected at ${placement.repoRoot}) — lefthook only reads config at the repo root; extend ` +
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
- console.log('✓ basalt-ui sync --check: all managed files current.')
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
 
@@ -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-stack-md`) instead of the card-inset scale
10
- * (`--mantine-spacing-sm`, 13px). A Section is a page-level GROUPING, so its gaps belong to the
11
- * vertical rhythm, not to the inset ladder its cards use.
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-stack-md, 0.75rem);
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-stack-md, 0.75rem);
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. 30px
52
- * square, glyph centred, no padding — the box IS the tier.
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 = spacing xs / sm, matching every other basalt card. `overflow: hidden` clips
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(--mantine-spacing-xs) var(--mantine-spacing-sm)',
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). The literals are Mantine's
3
- * `--mantine-breakpoint-sm` (48em) and `--mantine-breakpoint-lg` (75em) — a `@media` condition
4
- * cannot read a custom property, so the number has to be written; the COUNTS are custom properties
5
- * the component sets per instance.
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
- @media (min-width: 48em) {
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
- @media (min-width: 75em) {
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
- @media (max-width: 47.99375em) {
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
- @media (min-width: 48em) and (max-width: 74.99375em) {
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
- @media (min-width: 75em) {
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
+ }
@@ -3,6 +3,7 @@
3
3
  * signature — so dot access stays verbatim under the package's strict flags.
4
4
  */
5
5
  declare const classes: {
6
+ readonly container: string
6
7
  readonly root: string
7
8
  }
8
9
  export default classes
@@ -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)`, a phone gets 2.
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
- const smCols = Math.min(cols, 3)
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
- <div
56
- className={cx(classes.root, className)}
57
- data-cols={cols}
58
- data-sm-cols={smCols}
59
- // An ATTRIBUTE, not a second class: the rail's rules key off the resolved column count
60
- // (`[data-cols]`/`[data-sm-cols]`) already, so one selector shape carries the whole law —
61
- // same idiom `stat-card.module.css` uses for `[data-placement]`.
62
- {...(divided && { 'data-divided': 'true' })}
63
- style={
64
- {
65
- '--basalt-stat-group-cols-sm': smCols,
66
- '--basalt-stat-group-cols-lg': cols,
67
- ...style,
68
- } as CSSProperties
69
- }
70
- >
71
- {children}
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). The two literals below are Mantine's own `--mantine-breakpoint-sm` (48em) and
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 COUNTS are custom properties instead, set once per instance by the component: three
9
- * hand-written `repeat()` blocks would have to enumerate every `cols` value, and the whole point of
10
- * this primitive is that a consumer states one desktop number and basalt owns the rest.
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
- @media (min-width: 48em) {
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
- @media (min-width: 75em) {
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
+ }
@@ -3,6 +3,7 @@
3
3
  * signature — so dot access stays verbatim under the package's strict flags.
4
4
  */
5
5
  declare const classes: {
6
+ readonly container: string
6
7
  readonly root: string
7
8
  readonly item: string
8
9
  }