basalt-ui 1.31.0 → 1.32.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 (102) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/MIGRATING.md +212 -20
  3. package/agent/rules/basalt-mantine.md +4 -4
  4. package/configs/oxlint-plugin.js +240 -59
  5. package/configs/oxlint-plugin.test.ts +329 -1
  6. package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
  7. package/dist/charts/kinds/BandStrip.js +18 -66
  8. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  9. package/dist/charts/kinds/DualPanel.js +5 -26
  10. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
  11. package/dist/charts/kinds/MirroredBars.js +10 -68
  12. package/dist/charts/kinds/band-chrome.d.ts +31 -0
  13. package/dist/charts/kinds/band-chrome.d.ts.map +1 -0
  14. package/dist/charts/kinds/band-chrome.js +71 -0
  15. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  16. package/dist/charts/primitives/CartesianChart.js +49 -76
  17. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  18. package/dist/charts/primitives/ChartCard.js +5 -23
  19. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  20. package/dist/charts/primitives/ChartFrame.js +23 -40
  21. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  22. package/dist/charts/primitives/ChartLegend.js +5 -133
  23. package/dist/charts/primitives/ChartTooltip.d.ts +5 -0
  24. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  25. package/dist/charts/primitives/ChartTooltip.js +16 -1
  26. package/dist/charts/primitives/HoverOverlay.d.ts +24 -9
  27. package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
  28. package/dist/charts/primitives/HoverOverlay.js +17 -1
  29. package/dist/charts/primitives/chart-card-context.d.ts +17 -13
  30. package/dist/charts/primitives/chart-card-context.d.ts.map +1 -1
  31. package/dist/charts/primitives/chart-card-context.js +10 -5
  32. package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
  33. package/dist/charts/primitives/chart-frame-layout.js +25 -31
  34. package/dist/charts/primitives/legend-disclosure.d.ts +27 -0
  35. package/dist/charts/primitives/legend-disclosure.d.ts.map +1 -0
  36. package/dist/charts/primitives/legend-disclosure.js +153 -0
  37. package/dist/controls/controls.module.css +5 -0
  38. package/dist/controls/sync-button.module.css +1 -0
  39. package/dist/dashboard/stat-group.module.css +19 -64
  40. package/dist/dashboard/widget-grid.module.css +11 -38
  41. package/dist/data/data-table-state.d.ts +83 -0
  42. package/dist/data/data-table-state.d.ts.map +1 -0
  43. package/dist/data/data-table-state.js +139 -0
  44. package/dist/data/data-table.d.ts +40 -2
  45. package/dist/data/data-table.d.ts.map +1 -1
  46. package/dist/data/data-table.js +179 -277
  47. package/dist/forms/form-layout.d.ts +6 -1
  48. package/dist/forms/form-layout.d.ts.map +1 -1
  49. package/dist/forms/form-layout.module.css +6 -13
  50. package/dist/guard/index.d.ts +2 -2
  51. package/dist/guard/index.d.ts.map +1 -1
  52. package/dist/guard/index.js +10 -14
  53. package/dist/notifications/center.d.ts.map +1 -1
  54. package/dist/provider/index.d.ts.map +1 -1
  55. package/dist/provider/index.js +12 -10
  56. package/dist/shell/aside-docking.d.ts +31 -0
  57. package/dist/shell/aside-docking.d.ts.map +1 -0
  58. package/dist/shell/aside-docking.js +19 -0
  59. package/dist/shell/index.d.ts +0 -11
  60. package/dist/shell/index.d.ts.map +1 -1
  61. package/dist/shell/index.js +13 -14
  62. package/dist/shell/page-bar.d.ts +24 -0
  63. package/dist/shell/page-bar.d.ts.map +1 -1
  64. package/dist/shell/use-breakpoint.d.ts +1 -1
  65. package/dist/theme/floating.module.css +1 -0
  66. package/dist/theme-lab/derive-controls.d.ts.map +1 -1
  67. package/dist/tokens/index.d.ts +2 -0
  68. package/dist/tokens/index.d.ts.map +1 -1
  69. package/dist/tokens/index.js +4 -0
  70. package/llms.txt +2 -2
  71. package/package.json +2 -1
  72. package/src/charts/kinds/BandStrip.tsx +18 -64
  73. package/src/charts/kinds/DualPanel.tsx +4 -24
  74. package/src/charts/kinds/MirroredBars.tsx +12 -66
  75. package/src/charts/kinds/band-chrome.tsx +91 -0
  76. package/src/charts/primitives/CartesianChart.tsx +79 -122
  77. package/src/charts/primitives/ChartCard.tsx +5 -27
  78. package/src/charts/primitives/ChartFrame.tsx +37 -76
  79. package/src/charts/primitives/ChartLegend.tsx +7 -154
  80. package/src/charts/primitives/ChartTooltip.tsx +16 -0
  81. package/src/charts/primitives/HoverOverlay.tsx +52 -21
  82. package/src/charts/primitives/chart-card-context.ts +25 -16
  83. package/src/charts/primitives/chart-frame-layout.ts +44 -56
  84. package/src/charts/primitives/legend-disclosure.tsx +177 -0
  85. package/src/controls/controls.module.css +5 -0
  86. package/src/controls/sync-button.module.css +1 -0
  87. package/src/dashboard/stat-group.module.css +19 -64
  88. package/src/dashboard/widget-grid.module.css +11 -38
  89. package/src/data/data-table-state.ts +218 -0
  90. package/src/data/data-table.tsx +134 -218
  91. package/src/forms/form-layout.module.css +6 -13
  92. package/src/forms/form-layout.tsx +6 -1
  93. package/src/guard/index.ts +38 -34
  94. package/src/notifications/center.tsx +3 -0
  95. package/src/provider/index.tsx +18 -17
  96. package/src/shell/aside-docking.ts +54 -0
  97. package/src/shell/index.tsx +15 -37
  98. package/src/shell/page-bar.tsx +24 -0
  99. package/src/shell/use-breakpoint.ts +2 -2
  100. package/src/theme/floating.module.css +1 -0
  101. package/src/theme-lab/derive-controls.tsx +2 -0
  102. package/src/tokens/index.ts +5 -0
@@ -152,7 +152,7 @@ const CSS_WIDTH_MEDIA_QUERY = new RegExp(
152
152
  * An `@container` condition — the declared NAME (optional: `@container (min-width: …)` with no
153
153
  * name is legal CSS, scoping to the nearest ancestor container regardless of its name) plus every
154
154
  * raw boundary across the WHOLE at-rule, chained `and (...)` clauses included
155
- * (`@container basalt-stat-group (min-width: 768px) and (max-width: 1199.9px)` —
155
+ * (`@container basalt-grid (min-width: 800px) and (max-width: 1199.9px)` —
156
156
  * `stat-group.module.css` ships exactly this shape). Capturing only the first parenthesized clause
157
157
  * would silently skip every boundary after the first `and`; group 2 here is the full run of
158
158
  * `(...)`/`and` text so `CSS_CONTAINER_LITERAL`'s own `matchAll` below finds all of them.
@@ -177,30 +177,23 @@ const CSS_NON_COARSE_POINTER_QUERY =
177
177
  * The `@container` names the container-class axis declares — a consumer container carrying any
178
178
  * other name has invented its own container-query breakpoint instead of using the framework's.
179
179
  * `basalt-card` is the one shipped today (`src/dashboard/section.module.css`);
180
- * `basalt-form`/`basalt-main`/`basalt-grid` are the declared remaining three, reserved here so a
181
- * consumer adopting one ahead of the framework does not immediately trip this guard.
182
- *
183
- * Two container names already shipped ahead of this table — `basalt-stat-group` and
184
- * `basalt-widget-grid` (`src/tokens/breakpoint-literals.test.ts`'s own `LEGACY_CONTAINER_LITERALS`
185
- * names them) — predate the declared table and are deliberately NOT in this set: they surface as
186
- * `warn` findings against basalt's own source the same way any other kind's incumbent violations do
187
- * at grace time, rather than being silently grandfathered into a shipped rule's allowlist.
188
- */
189
- const DECLARED_CONTAINER_NAMES = new Set([
190
- 'basalt-card',
191
- 'basalt-form',
192
- 'basalt-main',
193
- 'basalt-grid',
194
- ])
180
+ * `basalt-form`/`basalt-grid` are the declared remaining two, reserved here so a consumer adopting
181
+ * one ahead of the framework does not immediately trip this guard. `basalt-main` was a third until
182
+ * 1.32.1 dropped it: nothing ever declared it, so the guard accepted a query that never matched.
183
+ *
184
+ * `basalt-grid` is also what `StatGroup`/`WidgetGrid` declare (`src/dashboard/*.module.css`) —
185
+ * they shipped as `basalt-stat-group`/`basalt-widget-grid` until 1.32.0 moved them onto this table.
186
+ */
187
+ const DECLARED_CONTAINER_NAMES = new Set(['basalt-card', 'basalt-form', 'basalt-grid'])
195
188
 
196
189
  /**
197
190
  * `CONTAINER_CLASSES` (`src/tokens/size-classes.ts`) boundaries, duplicated per the `CTL_THEME_TAGS`
198
191
  * convention in the oxlint plugin — this module is dependency-free by contract (file header) and
199
192
  * must not import `../tokens`. Pinned against the real table by `check-source.test.ts`. Judged only
200
- * against `basalt-card`: the other three declared names have no shipped numeric table yet. `0` is
201
- * the container's own floor, not a class boundary, but `(width >= 0px)` is a legitimate idiom for
202
- * "scope this rule to the container, whatever its width" (`widget-header.module.css`'s `cqi` clamp)
203
- * — allowed for that reason, not because it names a real `CONTAINER_CLASSES` step.
193
+ * against `basalt-card` and unnamed queries: the other declared names have no shipped numeric table
194
+ * yet. `0` is the container's own floor, not a class boundary, but `(width >= 0px)` is a legitimate
195
+ * idiom for "scope this rule to the container, whatever its width" (`widget-header.module.css`'s
196
+ * `cqi` clamp) — allowed for that reason, not because it names a real `CONTAINER_CLASSES` step.
204
197
  */
205
198
  const CONTAINER_CLASS_LITERALS_PX = new Set([0, 240, 239.9, 480, 479.9, 800, 799.9])
206
199
 
@@ -229,7 +222,14 @@ function isSanctionedShellMediaQuery(relPath: string, query: string): boolean {
229
222
  // the comparison/chained spellings too, with no shape-specific regex of its own. All four units
230
223
  // are captured (not `em` only) so a `px` width — never a `SIZE_CLASS_LITERALS` member — correctly
231
224
  // fails the check below, rather than `.every()` vacuously passing on an empty match.
232
- const widths = query.match(new RegExp(CSS_WIDTH_NUM, 'g')) ?? []
225
+ //
226
+ // But `query` is the whole at-rule prelude up to the width condition, so it can also carry a
227
+ // NON-width clause — the shell's own landscape rule is `(max-height: 480px) and (orientation:
228
+ // landscape) and (max-width: 52.49375em)`, and reading its `480px` height as an off-table width
229
+ // flagged the framework's own sanctioned implementation. Only the parenthesized clauses that name
230
+ // `width` are read.
231
+ const widthClauses = query.match(/\([^()]*\bwidth\b[^()]*\)/g) ?? []
232
+ const widths = widthClauses.flatMap((c) => c.match(new RegExp(CSS_WIDTH_NUM, 'g')) ?? [])
233
233
  return widths.length > 0 && widths.every((w) => SIZE_CLASS_LITERALS.has(w))
234
234
  }
235
235
 
@@ -687,9 +687,12 @@ const PROSE_CONTEXT_TAG = /<(?:Prose|ArticleLayout|Modal|Drawer)(?![\w])/
687
687
  * read differently in the two lanes. The plugin gates them on the tag's basalt IMPORT; a 12-line
688
688
  * regex window has no import graph, so this lane matches by name and accepts the missed warn on a
689
689
  * consumer's own `PanelRow` — the same direction every approximation in this scan already leans.
690
+ *
691
+ * The name set is the plugin's `CONTROL_HOST_TAGS` ∪ `BASALT_HOST_TAGS`; `oxlint-plugin.test.ts` ›
692
+ * lane parity asserts the two agree (1.32.1 — `FormRow`/`FormGroup` had drifted out of this lane).
690
693
  */
691
694
  const CONTROL_HOST_TAG =
692
- /<(?:SettingsRow|Modal|Drawer|Popover\.Dropdown|Menu\.Dropdown|Composer|FilterSet|PageAside|PanelRow)(?![\w])/
695
+ /<(?:SettingsRow|FormRow|FormGroup|Modal|Drawer|Popover\.Dropdown|Menu\.Dropdown|Composer|FilterSet|PageAside|PanelRow)(?![\w])/
693
696
 
694
697
  /**
695
698
  * How far ABOVE a control the host-tag window reaches, in lines.
@@ -860,8 +863,9 @@ export type GraceEntry = { since: string; promote: string; why: string }
860
863
  export const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, GraceEntry>> = {
861
864
  'raw-selection-control': {
862
865
  since: '1.26.0',
863
- promote: '1.32.0',
866
+ promote: '1.33.0',
864
867
  why:
868
+ "Extended 1.32.0 -> 1.33.0 at the 1.32.0 release, the last extension: basalt's own tree is at 0 (consumer-loop r1 dogfood) and argo is at 2, but RE-MEASURED 2026-09-30 the fleet carries ~33 (image-gen 11, rb 7, image-share 6, weatherorb 5, email-gateway 4, argo 2). The 1.32.0 fleet upgrade clears them while they warn, together with raw-breakpoint, so 1.33.0 promotes all four ids against a measured-clean fleet. " +
865
869
  'Extended 1.31.0 -> 1.32.0 at the 1.31.0 release: the basalt tree still carries incumbents (data-table, notifications center, theme-lab) and the argo sweep has not run. ' +
866
870
  'new in the wave-6 control guards (docs/CONTROLS-SPEC.md §6, law C1). The text lane cannot ' +
867
871
  'see ancestry, so "no home" is approximated by a 12-line host-tag window — the loosest ' +
@@ -880,15 +884,15 @@ export const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, GraceEntry>> = {
880
884
  },
881
885
  'raw-media-query': {
882
886
  since: '1.31.0',
883
- promote: '1.32.0',
887
+ promote: '1.33.0',
884
888
  why:
885
- 'new in the wave-11 responsive/touch guards — the CSS-text ' +
886
- "twin of the oxlint plugin's `basalt/raw-breakpoint`. Reaching CSS is a NEW file type for this " +
887
- "check, same reasoning as `css-raw-surface`, and basalt's own `src/dashboard/stat-group.module" +
888
- '.css` / `widget-grid.module.css` already declare `@container` names (`basalt-stat-group`, ' +
889
- "`basalt-widget-grid`) outside the declared four-name table, plus `shell/**`'s own " +
890
- "legitimate width `@media` rules — real incumbents in basalt's own tree at ship time, so this " +
891
- 'ships warn rather than error from the start. Promotes with its AST twin at 1.32.0.',
889
+ "The CSS-text twin of the oxlint plugin's `basalt/raw-breakpoint` — ONE LAW, TWO LANES, ONE " +
890
+ 'PROMOTION, so it moves with that rule and never alone (`configs/oxlint-plugin.test.ts` › twin lanes share one promotion pins the pair). New in ' +
891
+ 'the wave-11 responsive/touch guards; reaching CSS is a NEW file type for this check, same ' +
892
+ "reasoning as `css-raw-surface`, with incumbents in basalt's own tree at ship time, so it " +
893
+ 'shipped warn. Extended 1.32.0 -> 1.33.0 with its twin: 1.31.0 named ' +
894
+ 'no reachable container primitive, and 1.32.0 is the first minor that documents one ' +
895
+ '(`CONTAINER_CLASSES` + the declared `@container` names, MIGRATING § `basalt/raw-breakpoint`).',
892
896
  },
893
897
  }
894
898
 
@@ -2195,7 +2199,7 @@ export const GUARD_RULES = {
2195
2199
  // JSX-tag-shaped (`<Select …>`) — never appears in CSS text.
2196
2200
  appliesTo: (relPath) => !relPath.endsWith('.css'),
2197
2201
  message:
2198
- 'Raw selection control with no home — a filter or tab belongs in a PageBar / Section / WidgetHeader slot and takes a `field` (a FieldHandle), so it owns the URL write and the localStorage mirror instead of carrying value/onChange (docs/CONTROLS-SPEC.md laws C1–C3). Use the bound control from basalt-ui/controls. A settings row, an overlay and an @mantine/form file are the declared non-homes and never report. No shell? PageBar needs no BasaltShell: outside one it renders in flow, sticky, with its own title — so its slots are a reachable home for a provider-only app. This is the TEXT lane of basalt/control-outside-home, which answers the same question against the real AST — where the two disagree, the plugin is right.',
2202
+ 'Raw selection control with no home — a filter or tab belongs in a PageBar / Section / WidgetHeader slot and takes a `field` (a FieldHandle), so it owns the URL write and the localStorage mirror instead of carrying value/onChange (docs/CONTROLS-SPEC.md laws C1–C3). Use the bound control from basalt-ui/controls. A settings row, a FormRow / FormGroup, an overlay and an @mantine/form file are the declared non-homes and never report. No shell? PageBar needs no BasaltShell: outside one it renders in flow, sticky, with its own title — so its slots are a reachable home for a provider-only app. This is the TEXT lane of basalt/control-outside-home, which answers the same question against the real AST — where the two disagree, the plugin is right.',
2199
2203
  },
2200
2204
  'inline-font-size': {
2201
2205
  kind: 'inline-font-size',
@@ -2209,7 +2213,7 @@ export const GUARD_RULES = {
2209
2213
  // CSS-only: a width @media, an @container name/literal or a non-coarse pointer query has no JSX
2210
2214
  // spelling — the AST-side twin of this law is the oxlint plugin's basalt/raw-breakpoint.
2211
2215
  appliesTo: (relPath) => relPath.endsWith('.css'),
2212
- message: `Raw viewport/container breakpoint in CSS — a width @media belongs to shell/** against SIZE_CLASSES (useSizeClass() is the JS seam); an @container name must be one of ${[...DECLARED_CONTAINER_NAMES].join(' / ')} at a boundary in ${[...CONTAINER_CLASS_LITERALS_PX].map((px) => `${px}px`).join(' / ')}; and a pointer query other than (pointer: coarse) or (pointer: fine) has no framework meaning. Same law the oxlint plugin enforces as basalt/raw-breakpoint over JSX; this is the CSS half.`,
2216
+ message: `Raw viewport/container breakpoint in CSS — a width @media belongs to shell/** against SIZE_CLASSES (useSizeClass() is the JS seam); an @container name must be one of ${[...DECLARED_CONTAINER_NAMES].join(' / ')}, and a basalt-card or unnamed @container query must sit at a boundary in ${[...CONTAINER_CLASS_LITERALS_PX].map((px) => `${px}px`).join(' / ')} (the other declared names have no numeric table yet, so their literals are not judged); and a pointer query other than (pointer: coarse) or (pointer: fine) has no framework meaning. Same law the oxlint plugin enforces as basalt/raw-breakpoint over JSX; this is the CSS half.`,
2213
2217
  },
2214
2218
  } as const satisfies Record<GuardKind, GuardRule>
2215
2219
 
@@ -2476,7 +2480,7 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
2476
2480
  }
2477
2481
  // Literal boundaries are judged against `basalt-card`'s own table (see
2478
2482
  // `CONTAINER_CLASS_LITERALS_PX`'s doc) — an unnamed query has no OTHER table to judge
2479
- // against, so it is judged against this one too; the other three declared names stay
2483
+ // against, so it is judged against this one too; the other declared names stay
2480
2484
  // unjudged until they ship their own.
2481
2485
  if (name !== '' && name !== 'basalt-card') continue
2482
2486
  for (const lit of body.matchAll(CSS_CONTAINER_LITERAL)) {
@@ -205,6 +205,9 @@ export function NotificationCenter({ maxHeight = 320, className, style }: Notifi
205
205
  </Group>
206
206
 
207
207
  {/* View switch */}
208
+ {/* theme-allow control-outside-home — the center's own inbox/all view, rendered inside
209
+ `NotificationBell`'s Popover (an overlay, a declared non-home the AST cannot see across
210
+ the component boundary). */}
208
211
  <SegmentedControl
209
212
  fullWidth
210
213
  size="xs"
@@ -242,8 +242,13 @@ export function composeInjectedCss(
242
242
 
243
243
  // ── Host attribute ────────────────────────────────────────────────────────────────────────────────
244
244
 
245
- /** Mount count backing `useHostAttribute`'s ref-counted removal — see that function's doc. */
246
- let hostAttributeCount = 0
245
+ /** A module-level mount count across every `BasaltProvider`; each call returns the new count. */
246
+ function createMountCounter(): { mount: () => number; unmount: () => number } {
247
+ let count = 0
248
+ return { mount: () => ++count, unmount: () => --count }
249
+ }
250
+
251
+ const hostAttributeMounts = createMountCounter()
247
252
 
248
253
  /**
249
254
  * `data-basalt-host` on `<html>` — the one place a `display-mode` query is read; CSS keys off it.
@@ -261,17 +266,16 @@ export function useHostAttribute(): void {
261
266
  document.documentElement.setAttribute('data-basalt-host', isPwa ? 'pwa' : 'web')
262
267
  }, [isPwa])
263
268
 
264
- // Ref-counted across every mounted `BasaltProvider` — mirrors `useDuplicateProviderGuard`'s own
265
- // module-level counter just below. A second provider (a nested route wrapping its own, two trees
266
- // on one page) unmounting must not rip the attribute out from under a first one still mounted;
267
- // only the LAST unmount removes it. Deliberately a SEPARATE effect from the one above: sharing one
268
- // effect keyed on `[isPwa]` would decrement (and possibly zero out) this counter on every
269
- // `isPwa` change, not only on a genuine unmount.
269
+ // Ref-counted across every mounted `BasaltProvider`: a second provider (a nested route wrapping
270
+ // its own, two trees on one page) unmounting must not rip the attribute out from under a first
271
+ // one still mounted; only the LAST unmount removes it. Deliberately a SEPARATE effect from the one
272
+ // above: sharing one keyed on `[isPwa]` would decrement this counter on every `isPwa` change.
270
273
  useIsomorphicLayoutEffect(() => {
271
- hostAttributeCount++
274
+ hostAttributeMounts.mount()
272
275
  return () => {
273
- hostAttributeCount--
274
- if (hostAttributeCount === 0) document.documentElement.removeAttribute('data-basalt-host')
276
+ if (hostAttributeMounts.unmount() === 0) {
277
+ document.documentElement.removeAttribute('data-basalt-host')
278
+ }
275
279
  }
276
280
  }, [])
277
281
  }
@@ -374,21 +378,18 @@ function BasaltBridge({
374
378
  * misuse, not a false positive from React 18 StrictMode's double-invoke (that mounts/unmounts the
375
379
  * SAME instance in sequence, so the count returns to 0 before a second instance could ever exist).
376
380
  */
377
- let mountedProviderCount = 0
381
+ const providerMounts = createMountCounter()
378
382
 
379
383
  function useDuplicateProviderGuard(): void {
380
384
  useEffect(() => {
381
- mountedProviderCount++
382
- if (mountedProviderCount > 1 && isDev()) {
385
+ if (providerMounts.mount() > 1 && isDev()) {
383
386
  console.warn(
384
387
  '[basalt] BasaltProvider: more than one instance is mounted at once — nested/duplicate ' +
385
388
  'BasaltProviders double-mount MantineProvider, the palette <style> and ' +
386
389
  'ConnectivityProvider. Mount exactly one, at the app root.',
387
390
  )
388
391
  }
389
- return () => {
390
- mountedProviderCount--
391
- }
392
+ return () => void providerMounts.unmount()
392
393
  }, [])
393
394
  }
394
395
 
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Aside docking (`docs/DESIGN-CORE.md` § Layout, elevation, shapes) — the pure decision `ShellFrame`
3
+ * sizes `AppShell.Aside` from. An open aside pushes main only in `expanded` while main keeps
4
+ * `MAIN_MIN_WIDTH` beside the navbar; otherwise the region reserves just its rail and an open panel
5
+ * overlays main. Unclaimed, the region is zero-wide.
6
+ */
7
+ import { toEm } from '../tokens/size-classes'
8
+ import type { SizeClass } from './use-size-class'
9
+
10
+ /**
11
+ * Main keeps at least this much width, or an open aside overlays it instead of docking. Not
12
+ * derived from a token: it is the readable floor for a page body, independent of density.
13
+ */
14
+ const MAIN_MIN_WIDTH = 720
15
+
16
+ export type AsideDockingInput = {
17
+ claimed: boolean
18
+ folded: boolean
19
+ sizeClass: SizeClass
20
+ /** The `asideDockQuery` media query's result. */
21
+ roomToDock: boolean
22
+ asideWidth: number
23
+ asideRailWidth: number
24
+ }
25
+
26
+ export type AsideDocking = {
27
+ /** An open aside pushes main rather than overlaying it. */
28
+ docks: boolean
29
+ /** An open aside renders over main (reserving only its rail). */
30
+ overlay: boolean
31
+ /** The width `AppShell` reserves for the region. */
32
+ width: number
33
+ }
34
+
35
+ /** The `min-width` query that holds while navbar + open aside + `MAIN_MIN_WIDTH` fit side by side. */
36
+ export function asideDockQuery(input: { navbarWidth: number; asideWidth: number }): string {
37
+ return `(min-width: ${toEm(input.navbarWidth + input.asideWidth + MAIN_MIN_WIDTH)})`
38
+ }
39
+
40
+ /**
41
+ * The room-to-dock query's SSR/no-`matchMedia` fallback — an `'expanded'` `sizeClassHint` means the
42
+ * server already believes there is room, matching `useSizeClass`'s own hint-seeded guard
43
+ * (`use-size-class.ts`), so the first client paint doesn't fold a default-open aside for one commit.
44
+ */
45
+ export function roomToDockServerFallback(sizeClassHint: SizeClass | undefined): boolean {
46
+ return sizeClassHint === 'expanded'
47
+ }
48
+
49
+ export function resolveAsideDocking(input: AsideDockingInput): AsideDocking {
50
+ const docks = input.sizeClass === 'expanded' && input.roomToDock
51
+ const open = input.claimed && !input.folded
52
+ const reserved = open && docks ? input.asideWidth : input.asideRailWidth
53
+ return { docks, overlay: open && !docks, width: input.claimed ? reserved : 0 }
54
+ }
@@ -34,6 +34,7 @@ import { blockRowCount, projectMobileNav } from './mobile-nav-model'
34
34
  import { AppBreadcrumbs } from './app-breadcrumbs'
35
35
  import { PageBarBandOutlet, PageBarOutlet, PageBarProvider, usePageKebabClaimed } from './page-bar'
36
36
  import { AsideDocksContext, AsideOutlet, AsideProvider, useAsideRegion } from './page-aside'
37
+ import { asideDockQuery, resolveAsideDocking, roomToDockServerFallback } from './aside-docking'
37
38
  import { OverflowMenu, globalActionAsBarAction, globalActionMobile } from '../controls/actions'
38
39
  import type { GlobalAction } from '../controls/actions'
39
40
  import type { AccountMenuItem, BasaltAccountProps } from './account-types'
@@ -47,9 +48,7 @@ import type {
47
48
  } from './nav-types'
48
49
  import { CtlSlot, useBasaltSpacing } from '../theme'
49
50
  import { createPersistedStore } from '../state/persisted'
50
- import { toEm } from '../tokens/size-classes'
51
51
  import { SizeClassHintContext, useSizeClass } from './use-size-class'
52
- import type { SizeClass } from './use-size-class'
53
52
  import brandClasses from './app-brand.module.css'
54
53
  import headerClasses from './app-header.module.css'
55
54
  import mainClasses from './app-main.module.css'
@@ -303,25 +302,6 @@ function collapseStore(key: string): CollapseStore {
303
302
  return store
304
303
  }
305
304
 
306
- /**
307
- * Main keeps at least this much width, or an open aside overlays it instead of docking. Not
308
- * derived from a token: it is the readable floor for a page body, independent of density.
309
- */
310
- const MAIN_MIN_WIDTH = 720
311
-
312
- /**
313
- * The room-to-dock `min-width` query's SSR/no-`matchMedia` fallback — an `'expanded'`
314
- * `sizeClassHint` means the server already believes there is room, matching `useSizeClass`'s own
315
- * hint-seeded guard one level up (`use-size-class.ts`). Extracted as its own pure function (rather
316
- * than the inline `sizeClassHint === 'expanded'` it replaces) purely so `index.test.tsx` can pin it
317
- * without needing to render `BasaltShell` through a real SSR pass — `AppShell`'s + `PageAside`'s own
318
- * effects/refs/portals make that path unobservable through rendered markup, and the previous
319
- * hardcoded `false` fallback here shipped with no test at all for exactly that reason.
320
- */
321
- export function roomToDockServerFallback(sizeClassHint: SizeClass | undefined): boolean {
322
- return sizeClassHint === 'expanded'
323
- }
324
-
325
305
  /**
326
306
  * The shell's two page-level regions are providers, and both wrap the frame rather than living
327
307
  * inside it: `PageBarProvider` owns the header portal and the single-kebab claim, `AsideProvider`
@@ -395,25 +375,23 @@ function ShellFrame({
395
375
  const isCollapseControlled = collapsedProp !== undefined
396
376
  const uncontrolledCollapsed = collapseIsSet ? storedCollapsed : sizeClass === 'medium'
397
377
  const collapsed = isCollapseControlled ? collapsedProp : uncontrolledCollapsed
398
- // Aside docking (`docs/DESIGN-CORE.md` § Layout, elevation, shapes): an open aside pushes main only in
399
- // `expanded` while main keeps `MAIN_MIN_WIDTH` beside this navbar; otherwise the region reserves
400
- // just its rail and an open panel overlays main. Provided through `AsideDocksContext` in the SAME
401
- // render, so `PageAside` defaults its fold from the right value on its first pass.
378
+ // Aside docking (`aside-docking.ts`), provided through `AsideDocksContext` in the SAME render so
379
+ // `PageAside` defaults its fold from the right value on its first pass.
402
380
  const navbarWidth = collapsed ? step.appShellNavbarRailWidth : step.appShellNavbarWidth
403
- // The SSR/first-paint fallback mirrors `useSizeClass`'s own hint-seeded guard: an `expanded` hint
404
- // from `<BasaltProvider sizeClassHint>` means the server already believes there is room to dock, so
405
- // the first client paint agrees instead of defaulting to "cannot dock" and folding a default-open
406
- // aside for one commit.
407
381
  const sizeClassHint = useContext(SizeClassHintContext)
408
382
  const roomToDock = useMediaQuery(
409
- `(min-width: ${toEm(navbarWidth + step.appShellAsideWidth + MAIN_MIN_WIDTH)})`,
383
+ asideDockQuery({ navbarWidth, asideWidth: step.appShellAsideWidth }),
410
384
  roomToDockServerFallback(sizeClassHint),
411
385
  )
412
- const docks = sizeClass === 'expanded' && roomToDock
413
- const asideOpen = aside.claimed && !aside.folded
414
- const asideOverlay = asideOpen && !docks
415
- const asideReserved = asideOpen && docks ? step.appShellAsideWidth : step.appShellAsideRailWidth
416
- const asideWidth = aside.claimed ? asideReserved : 0
386
+ const docking = resolveAsideDocking({
387
+ claimed: aside.claimed,
388
+ folded: aside.folded,
389
+ sizeClass,
390
+ roomToDock,
391
+ asideWidth: step.appShellAsideWidth,
392
+ asideRailWidth: step.appShellAsideRailWidth,
393
+ })
394
+ const asideOverlay = docking.overlay
417
395
  // Keyboard escape hatch for the overlay form only — the docked form never blocks content, so
418
396
  // there is nothing to escape out of. `requestClose` is a stable callback from `AsideProvider`
419
397
  // (destructured, not read as `aside.requestClose`, so the effect deps name it directly rather
@@ -460,7 +438,7 @@ function ShellFrame({
460
438
  )
461
439
 
462
440
  return (
463
- <AsideDocksContext.Provider value={docks}>
441
+ <AsideDocksContext.Provider value={docking.docks}>
464
442
  <AppShell
465
443
  h="100dvh"
466
444
  // Mantine's DEFAULT layout, not `alt`: the header spans the full viewport width on top and
@@ -484,7 +462,7 @@ function ShellFrame({
484
462
  // prop for it on purpose; the ROUTE decides, the same way it decides its page bar.
485
463
  aside={{
486
464
  // An overlay reserves the rail only — `<AppShell.Aside>` below widens itself over main.
487
- width: asideWidth,
465
+ width: docking.width,
488
466
  breakpoint: 'sm',
489
467
  // Below `sm` there is no region at all — `PageAside` renders its content in the page
490
468
  // flow instead, one node, no responsive twin (law C9).
@@ -346,6 +346,30 @@ function useMeasuredHeightVar(active: boolean): (el: HTMLDivElement | null) => v
346
346
  return attach
347
347
  }
348
348
 
349
+ /**
350
+ * The page's control bar — row 1 (title/icon, `actions`, `sync`) and row 2 (`tabs`, `filters`,
351
+ * `filtersEnd`). Inside `BasaltShell` both rows portal into shell chrome; with no shell it renders
352
+ * ONE sticky bar in flow, `title` leading, and needs no `PageBarProvider`.
353
+ *
354
+ * @example
355
+ * // Shell-less (linewatch-shaped): the store is local, the bar is the page's first child.
356
+ * const filters = createLocalStore({ key: 'outages', fields: { q: field.string({ max: 60 }) } })
357
+ *
358
+ * function OutagesPage() {
359
+ * const [q] = filters.field.q.use()
360
+ * return (
361
+ * <>
362
+ * <PageBar
363
+ * title="Outages"
364
+ * actions={{ primary: { key: 'export', label: 'Export', onClick: exportCsv } }}
365
+ * filters={<FilterSet><SearchFilter field={filters.field.q} placeholder="Find a line" /></FilterSet>}
366
+ * classNames={{ root: classes.bleed }} // your gutter bleed — see `PageBarSlot`
367
+ * />
368
+ * <OutageTable query={q} />
369
+ * </>
370
+ * )
371
+ * }
372
+ */
349
373
  export function PageBar({
350
374
  title,
351
375
  icon,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `useBreakpoint` — DEPRECATED (on notice in `MIGRATING.md`, removed next minor): use
2
+ * `useBreakpoint` — DEPRECATED (on notice in `MIGRATING.md`, removed in 1.33.0): use
3
3
  * `useSizeClass` from `./use-size-class`. One media-query hook over the theme's own breakpoints,
4
4
  * built on the shared `useMediaQuery` (`common/use-media-query`) — SSR/hydration-safe.
5
5
  *
@@ -21,7 +21,7 @@ import { useMediaQuery } from '../common/use-media-query'
21
21
  export type BreakpointName = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
22
22
 
23
23
  /**
24
- * @deprecated Use `useSizeClass` — removed next minor (see `MIGRATING.md`). `theme.breakpoints` is
24
+ * @deprecated Use `useSizeClass` — removed in 1.33.0 (see `MIGRATING.md`). `theme.breakpoints` is
25
25
  * now derived: `sm` moved 48em → 52.5em and `md`/`lg`/`xl` all sit at 75em (`xs`/`sm` collapse,
26
26
  * `md`/`lg`/`xl` collapse).
27
27
  */
@@ -42,6 +42,7 @@
42
42
  * `.sheetBody` is the Modal body; it reaches the trailing button row `overlays.confirm`
43
43
  * renders (Mantine `Group`), stretching the buttons to full width at the `--vx-hit` floor.
44
44
  */
45
+ /* theme-allow raw-media-query — the bottom sheet IS the compact size class's overlay form */
45
46
  @media (max-width: 52.49375em) {
46
47
  .sheetInner {
47
48
  align-items: flex-end;
@@ -178,6 +178,8 @@ export function DeriveControls({ resetIcon }: DeriveControlsProps) {
178
178
  <Text size="xs" fw={500} mb={4}>
179
179
  Neutral family
180
180
  </Text>
181
+ {/* theme-allow control-outside-home — a theme-lab setting (a settings row, a declared
182
+ non-home), not a page filter: it edits the derive config, never page data. */}
181
183
  <SegmentedControl
182
184
  size="xs"
183
185
  fullWidth
@@ -52,6 +52,11 @@ export type { RadiusValues } from './palette'
52
52
  export { deriveSpacing } from './palette'
53
53
  export type { SpaceValues } from './palette'
54
54
 
55
+ // The two responsive tables — MIGRATING recipes and the raw-breakpoint guard messages send
56
+ // consumers here for them; `migrating-imports.test.ts` pins that every documented import resolves.
57
+ export { CONTAINER_CLASSES, resolveContainerClass, SIZE_CLASSES } from './size-classes'
58
+ export type { ContainerClass } from './size-classes'
59
+
55
60
  /** A per-theme color pair: a hue keeps its identity but shifts shade across schemes. */
56
61
  export type ColorPair = { light: string; dark: string }
57
62