basalt-ui 1.29.2 → 1.30.0

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 (116) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/MIGRATING.md +693 -368
  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/primitives/ChartCard.d.ts.map +1 -1
  12. package/dist/charts/primitives/ChartCard.js +2 -2
  13. package/dist/charts/primitives/ChartFrame.d.ts +23 -3
  14. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  15. package/dist/charts/primitives/ChartFrame.js +4 -4
  16. package/dist/charts/primitives/chart-frame-layout.d.ts +55 -0
  17. package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
  18. package/dist/charts/primitives/chart-frame-layout.js +16 -0
  19. package/dist/cli/check-theme.d.ts.map +1 -1
  20. package/dist/cli/check-theme.js +6 -0
  21. package/dist/cli/doctor.d.ts +10 -4
  22. package/dist/cli/doctor.d.ts.map +1 -1
  23. package/dist/cli/doctor.js +28 -1
  24. package/dist/cli/help.d.ts.map +1 -1
  25. package/dist/cli/help.js +2 -1
  26. package/dist/cli/index.d.ts +12 -0
  27. package/dist/cli/index.d.ts.map +1 -1
  28. package/dist/cli/index.js +29 -0
  29. package/dist/cli/init.d.ts.map +1 -1
  30. package/dist/cli/init.js +4 -15
  31. package/dist/cli/sync.d.ts.map +1 -1
  32. package/dist/cli/sync.js +8 -14
  33. package/dist/controls/actions.d.ts.map +1 -1
  34. package/dist/controls/actions.js +12 -2
  35. package/dist/dashboard/section.module.css +15 -7
  36. package/dist/dashboard/stat-card.d.ts.map +1 -1
  37. package/dist/dashboard/stat-card.js +7 -2
  38. package/dist/dashboard/stat-group.d.ts +8 -2
  39. package/dist/dashboard/stat-group.d.ts.map +1 -1
  40. package/dist/dashboard/stat-group.js +23 -15
  41. package/dist/dashboard/stat-group.module.css +85 -9
  42. package/dist/dashboard/stat-group.module.css.d.ts +1 -0
  43. package/dist/dashboard/widget-grid.d.ts.map +1 -1
  44. package/dist/dashboard/widget-grid.js +4 -5
  45. package/dist/dashboard/widget-grid.module.css +64 -21
  46. package/dist/dashboard/widget-grid.module.css.d.ts +1 -0
  47. package/dist/guard/index.d.ts +19 -2
  48. package/dist/guard/index.d.ts.map +1 -1
  49. package/dist/guard/index.js +27 -4
  50. package/dist/index.d.ts +9 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +2 -0
  53. package/dist/shell/app-brand.module.css +12 -3
  54. package/dist/shell/app-header.module.css +49 -9
  55. package/dist/shell/app-main.module.css +24 -4
  56. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  57. package/dist/shell/app-mobile-nav.js +98 -51
  58. package/dist/shell/app-mobile-nav.module.css +99 -11
  59. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  60. package/dist/shell/app-sidebar.module.css +55 -2
  61. package/dist/shell/index.d.ts.map +1 -1
  62. package/dist/shell/index.js +3 -3
  63. package/dist/shell/mobile-nav-model.d.ts +26 -7
  64. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  65. package/dist/shell/mobile-nav-model.js +12 -3
  66. package/dist/shell/page-aside.module.css +8 -6
  67. package/dist/shell/page-bar.d.ts +13 -0
  68. package/dist/shell/page-bar.d.ts.map +1 -1
  69. package/dist/shell/page-bar.js +13 -3
  70. package/dist/shell/page-bar.module.css +20 -12
  71. package/dist/shell/page-title.module.css +3 -1
  72. package/dist/theme/use-basalt-spacing.d.ts.map +1 -1
  73. package/dist/tokens/index.d.ts.map +1 -1
  74. package/dist/tokens/index.js +51 -7
  75. package/dist/tokens/palette.d.ts +98 -17
  76. package/dist/tokens/palette.d.ts.map +1 -1
  77. package/dist/tokens/palette.js +110 -18
  78. package/dist/tokens.css +17 -5
  79. package/llms.txt +2 -2
  80. package/package.json +1 -1
  81. package/src/charts/primitives/ChartCard.tsx +12 -7
  82. package/src/charts/primitives/ChartFrame.tsx +30 -15
  83. package/src/charts/primitives/chart-frame-layout.ts +86 -0
  84. package/src/cli/check-theme.ts +13 -0
  85. package/src/cli/doctor.ts +49 -8
  86. package/src/cli/help.ts +5 -3
  87. package/src/cli/index.ts +49 -0
  88. package/src/cli/init.ts +6 -27
  89. package/src/cli/sync.ts +11 -22
  90. package/src/controls/actions.tsx +51 -0
  91. package/src/dashboard/section.module.css +15 -7
  92. package/src/dashboard/stat-card.tsx +7 -2
  93. package/src/dashboard/stat-group.module.css +85 -9
  94. package/src/dashboard/stat-group.module.css.d.ts +1 -0
  95. package/src/dashboard/stat-group.tsx +40 -20
  96. package/src/dashboard/widget-grid.module.css +64 -21
  97. package/src/dashboard/widget-grid.module.css.d.ts +1 -0
  98. package/src/dashboard/widget-grid.tsx +30 -12
  99. package/src/guard/index.ts +79 -11
  100. package/src/index.ts +9 -0
  101. package/src/shell/app-brand.module.css +12 -3
  102. package/src/shell/app-header.module.css +49 -9
  103. package/src/shell/app-main.module.css +24 -4
  104. package/src/shell/app-mobile-nav.module.css +99 -11
  105. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  106. package/src/shell/app-mobile-nav.tsx +225 -83
  107. package/src/shell/app-sidebar.module.css +55 -2
  108. package/src/shell/index.tsx +26 -6
  109. package/src/shell/mobile-nav-model.ts +37 -9
  110. package/src/shell/page-aside.module.css +8 -6
  111. package/src/shell/page-bar.module.css +20 -12
  112. package/src/shell/page-bar.tsx +39 -5
  113. package/src/shell/page-title.module.css +3 -1
  114. package/src/theme/use-basalt-spacing.ts +8 -6
  115. package/src/tokens/index.ts +90 -6
  116. package/src/tokens/palette.ts +111 -18
@@ -142,12 +142,50 @@
142
142
 
143
143
  /* Re-apply the bled inset INSIDE the viewport, so rows keep their exact 10px/10px column while the
144
144
  * bar floats clear of them in the gutter. Static padding, not Mantine's `offsetScrollbars`: the
145
- * latter pads only while overflowing, which reflows the rows the moment a section expands. */
145
+ * latter pads only while overflowing, which reflows the rows the moment a section expands.
146
+ *
147
+ * `padding-block-end` is the scroll END-STOP: with only the inline inset, an overflowing column
148
+ * cut its last row flush against the viewport edge, which reads as a label whose list is CLIPPED
149
+ * rather than as something that scrolls. One `region-gap` of air below the last row is the same
150
+ * number the search slot opens at the column's other end.
151
+ *
152
+ * The end-stop is necessary and NOT sufficient, and the mask below is the other half. MEASURED at
153
+ * 1512x945 with the playground's nav: `.navScroll` is `flex: 1; min-height: 0` between the search
154
+ * slot and a `flex-shrink: 0` footer, so any nav taller than the leftover column overflows — and
155
+ * the end-stop only pads the BOTTOM OF THE CONTENT, which the reader sees exactly once, at the
156
+ * scroll end. Everywhere else the viewport's own edge cuts whatever row happens to cross it, and
157
+ * `.footer`'s progress block starts one `region-gap` under that cut. Two hard edges 12px apart:
158
+ * the column reads BROKEN rather than scrolled. Nothing else says otherwise, because the
159
+ * ScrollArea is `type="hover"` — at rest there is no bar at all.
160
+ *
161
+ * So: a scroll fade on the viewport's bottom edge, and deliberately NOT the other candidate, a
162
+ * `--vx-divider` seam on `.footer`. A rule there would answer a question nobody asked — the footer
163
+ * boundary is already carried by its own `padding-top` and the account row's inset (see `.footer`
164
+ * below) — and it would answer the real one WRONG: a hairline drawn across a row that is severed
165
+ * mid-glyph makes the severing crisper, not legible. A fade is the only one of the two that says
166
+ * "there is more, and it moves". It also keeps this module out of `theme/divider-law.test.ts`'s
167
+ * inventory, which is the correct outcome and not a dodge: that ledger exists so every hairline is
168
+ * a line someone chose, and this one would have been chosen to stand in for a scrollbar.
169
+ *
170
+ * The ramp is one `region-gap` tall, which is what pairs it with the end-stop instead of fighting
171
+ * it: at the scroll END those 12px hold the end-stop's padding — empty — so the last row lands
172
+ * fully opaque, and a column that does not overflow fades nothing but its own trailing air. Only
173
+ * mid-scroll, where a real row crosses the edge, does the fade have anything to dim.
174
+ *
175
+ * `black`/`transparent` here are alpha, not color: a mask reads only the alpha channel, so these
176
+ * carry no design meaning and deliberately are NOT `--vx-*` tokens. Do not route them through the
177
+ * palette in a token sweep. */
146
178
  .navViewport {
147
179
  padding-inline-end: var(--vx-space-row-inset-x);
180
+ padding-block-end: var(--vx-space-sidebar-region-gap);
181
+ mask-image: linear-gradient(
182
+ to bottom,
183
+ black calc(100% - var(--vx-space-sidebar-region-gap)),
184
+ transparent
185
+ );
148
186
  }
149
187
 
150
- /* Sections separate by SPACING only — 15px between sections, nothing above the first (the brand
188
+ /* Sections separate by SPACING only — 16px between sections, nothing above the first (the brand
151
189
  * band's centred `min-height` already opens the region's air, and the optional search slot's own
152
190
  * `padding-block` opens more when it renders). */
153
191
  .nav > div + div {
@@ -156,9 +194,24 @@
156
194
 
157
195
  /*
158
196
  * Footer region — no border; the account row's own top padding supplies the separation.
197
+ *
198
+ * The region owns its own air on both axes, because the `Stack` wrapping it is `gap={0}`
199
+ * (`app-sidebar.tsx`) — the footer's rhythm is single-sourced HERE, so the whole answer is
200
+ * readable in one place rather than split between a prop and a stylesheet. Without it a
201
+ * bottom-placed block (a `kind: 'progress'` block defaults to `'bottom'`) stacked FLUSH on the
202
+ * settings and account rows and on the scroll region above, which is the "cramped" footer.
203
+ *
204
+ * `region-gap` (12) above the cluster, `stack-sm` (8) between its parts — deliberately tighter
205
+ * than the nav's `section-gap` (16): the footer is ONE pinned cluster whose rows belong together,
206
+ * not a list of peer sections.
159
207
  */
160
208
  .footer {
161
209
  flex-shrink: 0;
210
+ padding-top: var(--vx-space-sidebar-region-gap);
211
+ }
212
+
213
+ .footer > * + * {
214
+ margin-top: var(--vx-space-stack-sm);
162
215
  }
163
216
 
164
217
  /*
@@ -291,10 +291,6 @@ function collapseStore(key: string): () => readonly [boolean, (next: boolean) =>
291
291
  return store
292
292
  }
293
293
 
294
- // The shared inset for the shell padding and the header's inline padding, so the breadcrumb's
295
- // left edge and the global actions' right edge land on the card column's edges.
296
- const SHELL_INSET = 'sm' as const
297
-
298
294
  /**
299
295
  * The shell's two page-level regions are providers, and both wrap the frame rather than living
300
296
  * inside it: `PageBarProvider` owns the header portal and the single-kebab claim, `AsideProvider`
@@ -334,6 +330,30 @@ function ShellFrame({
334
330
  // (controls, the search trigger/avatar, nav labels) already track density, so a fixed literal
335
331
  // container squeezes progressively worse as density rises.
336
332
  const { step } = useBasaltSpacing()
333
+ // THE PAGE GUTTER, responsive — the shared inset for `AppShell`'s own padding and the header's
334
+ // inline padding, so the breadcrumb's left edge and the global actions' right edge land on the
335
+ // card column's edges. It used to be the literal `'sm'` spacing key, which is 13px at EVERY
336
+ // width: 13 on a 360px phone and 13 on a 2560px monitor, with no token behind it. Now it is the
337
+ // `appShellInset` / `appShellInsetMobile` pair (see their entries in `tokens/palette.ts`).
338
+ //
339
+ // On a 390px phone that is 8 a side instead of 13 — 10px more line width, and 10px less vertical
340
+ // chrome, since `padding` is Main's vertical inset too. From `sm` up it is 20, which is the x the
341
+ // sidebar's nav icon column already sits at, so the brand mark, the nav icons and the page's
342
+ // first column all start on one vertical line.
343
+ //
344
+ // VERIFIED against the installed Mantine 9.3 source, because both props take the object by a
345
+ // DIFFERENT road and both had to be checked:
346
+ // - `AppShell padding`: `AppShellMediaStyles/assign-padding-variables.mjs` puts `base` in the
347
+ // `:root` rule and every other key in `get-variables.mjs`'s MIN-width block
348
+ // (`(min-width: <breakpoint>)`), so the pair is mobile-first and needs no max-width twin.
349
+ // - `AppShell.Header px`: a Box style prop, `Box/style-props/parse-style-props.mjs` — same
350
+ // `{ base, … }` shape, same `(min-width: theme.breakpoints.sm)` query, emitted on a generated
351
+ // class. Both resolve `sm` from `theme.breakpoints`, so the two gutters step at one width.
352
+ // A NUMBER on either road is CONVERTED by Mantine's `rem()` — 8 becomes
353
+ // `calc(0.5rem * var(--mantine-scale))` — rather than looked up, which is exactly why these are
354
+ // numbers: a spacing KEY resolves to `var(--mantine-spacing-sm)` and hands the gutter back to
355
+ // Mantine's own scale, which is where the 13px came from.
356
+ const inset = { base: step.appShellInsetMobile, sm: step.appShellInset }
337
357
  const aside = useAsideRegion()
338
358
  const [storedCollapsed, setStoredCollapsed] = collapseStore(storageKey)()
339
359
  // Controlled/uncontrolled seam (item 19): an explicit `collapsed` prop overrides the internal
@@ -410,7 +430,7 @@ function ShellFrame({
410
430
  // A plain number, NOT a `calc(... + env(safe-area-inset-bottom))` string: Mantine's own
411
431
  // `.footer` rule already adds the inset to both the height and the padding (§2.7).
412
432
  footer={{ height: { base: step.mobileNavBarHeight, sm: 0 } }}
413
- padding={SHELL_INSET}
433
+ padding={inset}
414
434
  // `mainClasses.shell` is not decoration — see its rule for why the root has to establish a
415
435
  // block formatting context now that Main is offset with margins.
416
436
  className={cx(mainClasses.shell, className)}
@@ -419,7 +439,7 @@ function ShellFrame({
419
439
  {/* Region seams (docs/DESIGN-SPEC.md §5, §8 #12): Mantine's `[data-with-border]` painted in
420
440
  * `--vx-divider` by the theme's `AppShell.extend({ vars })`. No shell module draws a region
421
441
  * edge; never opt a section back out of its border here. */}
422
- <AppShell.Header px={SHELL_INSET}>
442
+ <AppShell.Header px={inset}>
423
443
  <div className={headerClasses.bar}>
424
444
  {/* The header's LEADING ZONE — exactly `--app-shell-navbar-offset` wide, so its trailing
425
445
  * edge lands on the sidebar|main seam and the breadcrumb after it starts on Main's own
@@ -9,9 +9,23 @@
9
9
  * The law it implements is the interaction model, not a heuristic: a slot is a DESTINATION, so a
10
10
  * tap navigates with no overlay to dismiss. An overlay exists only where a slot genuinely holds
11
11
  * more than one destination, and its surface is INFERRED from how many rows it holds rather than
12
- * configured — six rows is the arithmetic ceiling for a menu that pops out of a 56px bar without
13
- * ever rendering below the fold (6 x 44px + 8px padding = 272px against 415px of headroom on the
14
- * smallest supported viewport), so past that the surface becomes a bottom sheet.
12
+ * configured — up to `menuMax` rows it is a content-sized menu popping out of the tab, past that a
13
+ * bottom sheet.
14
+ *
15
+ * WHAT KEEPS THE MENU ABOVE THE FOLD IS CSS, NOT THIS CONSTANT. `.menuDropdown` declares
16
+ * `max-height: min(72dvh, 560px)` + `overflow-y: auto` + `overscroll-behavior: contain`, so the
17
+ * dropdown is bounded at ANY row count and scrolls inside itself past it. Measured at 390x844 the
18
+ * headroom above the bar is 772px (bar top 788, less the 8px menu offset and the 8px shift
19
+ * padding), so the 560px cap uses 73% of it; on the smallest supported viewport (320x568) the
20
+ * headroom is ~496px and 72dvh is 409px — the cap is binding on every supported phone, and the row
21
+ * count never is. The old arithmetic here ("6 x 44px + 8px padding against 415px of headroom") was
22
+ * stale in almost every number: rows are 40px (`--vx-space-mobile-nav-row-height`), the dropdown's
23
+ * own padding is `2 * --vx-space-stack-xs` = 8px, and the headroom figures above are the measured
24
+ * ones.
25
+ *
26
+ * `menuMax` therefore survives for the case a menu genuinely should not hold: a 30-destination nav
27
+ * still deserves a sheet, where a title, a close button and 62dvh of scroll read better than a
28
+ * tall popover hanging off one tab.
15
29
  */
16
30
  import type {
17
31
  MobileNavConfig,
@@ -29,11 +43,16 @@ import { sidebarBlockMobile } from './sidebar-block-model'
29
43
  export const MOBILE_MAX_TABS_DEFAULT = 5
30
44
 
31
45
  /**
32
- * Rows a slot may hold before it becomes a sheet instead of a menu. Arithmetic, not taste — see
33
- * the module doc: six rows is the last count a bottom-anchored, non-flipping menu fits above the
34
- * bar on the smallest supported viewport.
46
+ * Rows a slot may hold before it becomes a sheet instead of a menu.
47
+ *
48
+ * It was 6 on the theory that the count was what kept the menu above the fold. It is not — the
49
+ * dropdown's own `max-height` is (module doc), at every row count. What 6 actually did was flip
50
+ * the playground's ~11-row More slot into a full Drawer, which is the "big door on mobile" the
51
+ * owner reported. 12 keeps every realistic More surface a compact popover and leaves the sheet
52
+ * for the nav that has genuinely outgrown one. `mobileNav.menuMax` remains the consumer opt-out
53
+ * in both directions.
35
54
  */
36
- export const MOBILE_MENU_MAX_DEFAULT = 6
55
+ export const MOBILE_MENU_MAX_DEFAULT = 12
37
56
 
38
57
  /** The overflow slot's reserved key. A slot like any other — it just exists only when needed. */
39
58
  export const MOBILE_MORE_KEY = '__more'
@@ -285,7 +304,13 @@ function sectionCandidate(section: SidebarSection, menuMax: number): Candidate {
285
304
  // The section's own identity wins when it declares one — the consumer configured the
286
305
  // SLOT — and falls back to the single destination's icon when it does not.
287
306
  icon: mobile?.icon ?? section.icon ?? only.icon,
288
- active: hasActiveDestination(only),
307
+ // `isActiveDestination`, not the rollup — this collapse produced a LINK, and a link slot
308
+ // covers exactly the one key it navigates to, exactly as `itemCandidate` does. The two
309
+ // predicates agree TODAY (this branch needs `rows === 1`, `countRows` counts descendants,
310
+ // so `only` is childless whenever it is taken) — which is the point: activeness now reads
311
+ // the slot's own `covered` set instead of a non-local fact about what `rows === 1`
312
+ // implies. Widen the collapse and the rollup would light a tab whose tap lands elsewhere.
313
+ active: isActiveDestination(only),
289
314
  item: only,
290
315
  }
291
316
  }
@@ -474,7 +499,10 @@ export function projectMobileNav(
474
499
  label: only.label,
475
500
  short: only.short ?? only.label,
476
501
  icon: only.icon,
477
- active: hasActiveDestination(only),
502
+ // Same as `sectionCandidate`'s collapse, for the same reason and with the same caveat: a
503
+ // LINK slot reads only the destination its tap reaches, and `rows === 1` makes the two
504
+ // predicates agree today — state the law locally rather than inherit it.
505
+ active: isActiveDestination(only),
478
506
  item: only,
479
507
  })
480
508
  return { slots }
@@ -49,12 +49,13 @@
49
49
  }
50
50
 
51
51
  /* The portalled form's band matches the SHELL-OWNED page-bar band exactly, whenever one is
52
- * present (`--basalt-page-bar-h`, `docs/DESIGN-SPEC.md` §5) — not a fixed 48px. `PageBar`
52
+ * present (`--basalt-page-bar-h`, `docs/DESIGN-SPEC.md` §5) — not a fixed header height. `PageBar`
53
53
  * publishes that var on `documentElement` from a ResizeObserver on the band's own content
54
54
  * (`shell/page-bar.tsx`), so it inherits down to every descendant, this header included, with no
55
55
  * extra wiring. Two seams, one line: MEASURED on `/cbbi` 1440x900, the band's outer edge (its own
56
56
  * `border-bottom`, `app-main.module.css` `.band:not(:empty)`) used to sit at y87 while this header
57
- * (unconditionally `var(--app-shell-header-height)`, 48px) sat at y96 — 9px apart.
57
+ * (unconditionally `var(--app-shell-header-height)`, 48px at the time — the token is 44 since the
58
+ * desktop-shell pass) sat at y96 — 9px apart.
58
59
  *
59
60
  * The `+ 1px` is not decorative. `--basalt-page-bar-h` measures the band's CONTENT box only (the
60
61
  * portalled row-2 wrapper `page-bar.tsx` attaches its ResizeObserver to); the band's own
@@ -63,9 +64,10 @@
63
64
  * under the page's global `box-sizing: border-box` reset — so its OWN `border-bottom` is absorbed
64
65
  * INSIDE whatever height is declared instead of adding on top. Feeding the raw var straight in
65
66
  * therefore undershoots by exactly the band's border: `calc(38px + 1px)` = 39px, matching the
66
- * band's true painted height, while the no-band fallback nets out unchanged (`calc((48px - 1px) +
67
- * 1px)` = 48px) because the same `+ 1px` also wraps that branch. Without a `PageBar` there is no
68
- * band seam to align to, so the header keeps closing the ordinary 48px top belt
67
+ * band's true painted height, while the no-band fallback nets out unchanged (`calc((44px - 1px) +
68
+ * 1px)` = 44px, reading today's `appShellHeaderHeight`) because the same `+ 1px` also wraps that
69
+ * branch. Without a `PageBar` there is no band seam to align to, so the header keeps closing the
70
+ * ordinary 44px top belt
69
71
  * (`docs/DESIGN-SPEC.md` §5) the header and sidebar-brand bands also close.
70
72
  */
71
73
  .panel[data-basalt-page-aside='shell'] .header {
@@ -81,7 +83,7 @@
81
83
  }
82
84
 
83
85
  /* A real title (docs/DESIGN-SPEC.md §5): mirrors `widget-header.module.css` `.heading` and the
84
- * breadcrumb's current-page treatment — the three 48px bands each carry one 550-weight head-font
86
+ * breadcrumb's current-page treatment — the three 44px bands each carry one 550-weight head-font
85
87
  * name. */
86
88
  .title {
87
89
  font-family: var(--basalt-font-head);
@@ -96,10 +96,15 @@
96
96
  margin-inline-end: var(--vx-space-control-gap);
97
97
  }
98
98
 
99
+ /* Glyph BOX, 1.125rem — a decoration-class fixed size, deliberately NOT a `--vx-space-*` token
100
+ * (an icon is not a gap; the shipped icon set draws on a 24px grid and the box is a fraction of
101
+ * the type size, not of the spacing scale). Do not re-litigate these in a token sweep.
102
+ * 1.125rem rather than the old 1rem because `controlHeightCtl` grew 30 -> 32: a glyph held at
103
+ * 16px inside a 32px control reads EMPTIER, not bigger. */
99
104
  .row1 :global(.mantine-Button-section) > svg,
100
105
  .row1 :global(.mantine-ActionIcon-root) > svg {
101
- width: 1rem;
102
- height: 1rem;
106
+ width: 1.125rem;
107
+ height: 1.125rem;
103
108
  }
104
109
 
105
110
  /* Actions and sync are right-aligned; the lead (or the breadcrumb, in a shell) holds the left. */
@@ -114,21 +119,24 @@
114
119
  }
115
120
 
116
121
  /*
117
- * Row 2's rhythm is 8px · pills · 8px, NOT a 40px reserved row.
122
+ * Row 2's rhythm is 8px · pills · 8px, NOT a reserved row-height box.
118
123
  *
119
- * `min-height: var(--vx-space-page-bar-row-height)` (40px) left a 30px pill floating inside a 40px
120
- * box with the 5px above and below it belonging to nothing — and because the row sat in a `Stack`
121
- * that already spaced it, the visible gap between the header and the pills read as ~19px while the
122
- * gap from the pills to the first card read as ~19px too. Symmetric padding on the row itself is
123
- * one declaration owning both halves, so the sticky bar's published height covers exactly the band
124
- * it paints — including below `sm`, where `.row2` becomes a COLUMN of `.tabs` then `.pills` and
125
- * `padding-block` still owns both edges of the taller band.
124
+ * `min-height: var(--vx-space-page-bar-row-height)` was the defect, and the arithmetic is the whole
125
+ * reason it is gone. Measured when that token was 40 and `ctl` was 30: a 30px pill floated inside a
126
+ * 40px box with the 5px above and below it belonging to nothing — and because the row sat in a
127
+ * `Stack` that already spaced it, the visible gap between the header and the pills read as ~19px
128
+ * while the gap from the pills to the first card read as ~19px too. The desktop-shell pass has
129
+ * since moved both numbers (row height 40 -> 36, `ctl` 30 -> 32), which narrows that unowned band
130
+ * to 2px a side but does not close it: a HEIGHT token still hands its spare pixels to nobody.
131
+ * Symmetric padding on the row itself is one declaration owning both halves, so the sticky bar's
132
+ * published height covers exactly the band it paints — including below `sm`, where `.row2` becomes
133
+ * a COLUMN of `.tabs` then `.pills` and `padding-block` still owns both edges of the taller band.
126
134
  *
127
135
  * `stackXs` and not `stackSm`: the row sits inside the shell's own bordered band
128
136
  * (`app-main.module.css`'s `.band`), so it no longer has to carry the visual separation in
129
- * whitespace — 4px above and below a 30px `ctl` pill still clears it (38px total) and buys back 8px
137
+ * whitespace — 4px above and below a 32px `ctl` pill still clears it (40px total) and buys back 8px
130
138
  * of content on every page.
131
- * Row 1's 40px `--vx-space-page-bar-row-height` is untouched: it is a HEIGHT token, not padding.
139
+ * Row 1's 36px `--vx-space-page-bar-row-height` is untouched: it is a HEIGHT token, not padding.
132
140
  */
133
141
  .row2 {
134
142
  display: flex;
@@ -41,6 +41,9 @@ import asideClasses from './page-aside.module.css'
41
41
  /** The custom property row 2's measured height is published on. */
42
42
  export const PAGE_BAR_HEIGHT_VAR = '--basalt-page-bar-h'
43
43
 
44
+ /** Dev-only diagnostics, constant-folded out of a production bundle — see {@link useAsidePanelSlot}. */
45
+ const DEV = process.env['NODE_ENV'] !== 'production'
46
+
44
47
  /**
45
48
  * What a `PageAside` publishes when it projects itself into row 2 below `sm`
46
49
  * (`docs/ASIDE-SPEC.md` §0 "Desktop and mobile are one declaration"). Metadata only — the aside's
@@ -203,13 +206,36 @@ export function usePageKebabClaimed(): boolean {
203
206
  * Internal — the seam `PageAside` projects itself through below `sm` (`docs/ASIDE-SPEC.md` §0).
204
207
  * `host` says whether there is a row 2 to hang the pill off at all; `claim` publishes the aside's
205
208
  * title and glyph; `target` is the node inside the opened sheet its children portal into.
209
+ *
210
+ * It also carries THE ONE BRANCH NOBODY PREDICTS, as a dev-only warning. `PageBar` only claims the
211
+ * panel host when it renders a row 2 (`tabs`, `filters` or a non-empty `filtersEnd`), so a page
212
+ * whose bar has actions ALONE gets `host: false` — and below `sm` that aside silently stops being
213
+ * reachable: no pill, no sheet, just the in-flow form at the bottom of a scrolled page. The
214
+ * behaviour is deliberate (there is genuinely nowhere to hang a trigger) and pinned by
215
+ * `page-aside.test.tsx`, so this warns rather than changes it. Nothing about the page LOOKS wrong
216
+ * on the desktop the author is building on, which is exactly why it needs saying out loud.
217
+ *
218
+ * `settled` is why the warning is not a one-shot mount effect: `panelHost` is published by a LAYOUT
219
+ * effect inside `PageBar`, so on the first pass it is false on every page, including the ones that
220
+ * do host. Deferring by one commit — the same deferral `PageAside` runs for the same reason — is
221
+ * what keeps this from firing on every aside in the library.
206
222
  */
207
223
  export function useAsidePanelSlot(): {
208
224
  host: boolean
209
225
  claim: (panel: AsidePanelClaim) => () => void
210
226
  target: HTMLElement | null
211
227
  } {
212
- const { panelHost, claimPanel, panelTarget } = useContext(PageBarContext)
228
+ const { panelHost, claimPanel, panelTarget, inShell } = useContext(PageBarContext)
229
+ const [settled, setSettled] = useState(false)
230
+ useIsomorphicLayoutEffect(() => {
231
+ setSettled(true)
232
+ }, [])
233
+ useEffect(() => {
234
+ if (!DEV || !settled || !inShell || panelHost) return
235
+ console.warn(
236
+ "[basalt] PageAside: this page's PageBar renders no row 2 (no `tabs`, no `filters`, no `filtersEnd`), so below `sm` there is no pill to open the aside — it renders in flow at the bottom of the page instead. Give the bar a row 2, or expect the in-flow form on a phone.",
237
+ )
238
+ }, [settled, inShell, panelHost])
213
239
  return { host: panelHost, claim: claimPanel, target: panelTarget }
214
240
  }
215
241
 
@@ -419,10 +445,18 @@ export function PageBar({
419
445
  )}
420
446
  {panel !== null && (
421
447
  <FilterPill
422
- // One word, no count: unlike `Filters (n)` an aside has no census to count (its
423
- // children are not a `FilterSet`), and a number nobody can derive is worse than none.
424
- label="Panel"
425
- ariaLabel={panel.title}
448
+ // THE ASIDE'S OWN TITLE, not the word "Panel" — the title names the CONTENT, never
449
+ // the region (`docs/ASIDE-SPEC.md` §0), which is the rule the desktop header already
450
+ // obeys by painting `panel.title` at the top of the region. A phone user reading
451
+ // "Panel" for an aside called "Inspector" or "Session detail" is being told which
452
+ // BOX it is, which they can see, instead of what is in it, which they cannot. The
453
+ // separate `ariaLabel` went with it: it existed only to put the real title back for a
454
+ // screen reader, so once the visible label IS the title the two would say the same
455
+ // thing twice.
456
+ //
457
+ // Still no count: unlike `Filters (n)` an aside has no census to count (its children
458
+ // are not a `FilterSet`), and a number nobody can derive is worse than none.
459
+ label={panel.title}
426
460
  icon={panel.icon}
427
461
  className={classes.panelPill}
428
462
  hideGlyph
@@ -5,10 +5,12 @@
5
5
  * and every named spot reads identically regardless of which one a given surface uses.
6
6
  */
7
7
 
8
+ /* Title over subtitle is the tightest pair the 4px vertical rhythm has a step for — `stack-xs`,
9
+ * not the literal 4px it duplicated. */
8
10
  .root {
9
11
  display: flex;
10
12
  flex-direction: column;
11
- gap: 4px;
13
+ gap: var(--vx-space-stack-xs);
12
14
  }
13
15
 
14
16
  .titleRow {
@@ -22,12 +22,14 @@
22
22
  * as the AppShell dimensions above (see `sidebarAccountMenuWidth`'s doc in `../tokens/palette`
23
23
  * for why a fixed dropdown width needs to track density at all).
24
24
  *
25
- * Deliberately INTERNAL for now — exported from this module (so `../shell` can reach it) but NOT
26
- * re-exported by the root `.` barrel (`src/index.ts`), so it is not part of the published package
27
- * surface (same convention `scaleSpace`'s doc comment in `../tokens/palette` uses for the same
28
- * reason). These three `../shell` components are its only consumers today; promote it to the root
29
- * barrel — with a surface entry in this package's `CLAUDE.md` — if/when a consumer outside the
30
- * framework's own components needs the ACTIVE resolved spacing.
25
+ * PUBLIC since the desktop-shell spacing pass — re-exported by the root `.` barrel
26
+ * (`src/index.ts`), on exactly the trigger this doc used to name as the condition for promoting it:
27
+ * a consumer outside the framework's own components needs the ACTIVE resolved spacing. Laying its
28
+ * own chrome out beside the shell's — a sticky sub-header under the app header, a fixed rail beside
29
+ * the navbar — means matching numbers that are ALL JS-consumed (an AppShell dimension, a
30
+ * `<Menu width={…}>`), so unlike a CSS inset there is no `--vx-*` var to read them from instead;
31
+ * without this hook the consumer's only option was to hardcode the level-0 constants and silently
32
+ * desync at any other density.
31
33
  */
32
34
  import { useMantineTheme } from '@mantine/core'
33
35
  import { DEFAULT_SPACE_VALUES } from '../tokens/palette'
@@ -556,8 +556,9 @@ function spaceDecls(space: SpaceValues): string[] {
556
556
  decl('space-stack-xl', `${space.anchors.stackXl}px`),
557
557
  // The CSS-module spacing sweep's one-offs (docs/STATUS.md) — same single-source reasoning,
558
558
  // grouped to match `SPACE_STEP_BASE`'s own grouping in tokens/palette.ts.
559
- // ONE value at every viewport since 1.26.0 — the AppShell header is a single 48px row (law
560
- // C14), so the `-mobile` override of the old Decision-3 responsive pair is gone.
559
+ // ONE value at every viewport since 1.26.0 — the AppShell header is a single
560
+ // `appShellHeaderHeight` row (law C14), so the `-mobile` override of the old Decision-3
561
+ // responsive pair is gone.
561
562
  decl('space-sticky-header-clearance', `${space.step.stickyHeaderClearance}px`),
562
563
  decl('space-nav-icon-gap', `${space.step.navIconGap}px`),
563
564
  decl('space-sidebar-region-gap', `${space.step.sidebarRegionGap}px`),
@@ -634,6 +635,24 @@ function spaceDecls(space: SpaceValues): string[] {
634
635
  decl('space-sidebar-child-list-indent', `${space.step.sidebarChildListIndent}px`),
635
636
  decl('space-sidebar-child-row-inset-y', `${space.step.sidebarChildRowInsetY}px`),
636
637
  decl('space-sidebar-child-row-indent', `${space.step.sidebarChildRowIndent}px`),
638
+ // The page gutter — the ONE `appShell*` dimension that IS emitted, against the JS-number-only
639
+ // rule its five neighbours follow (see the note at the bottom of this list). It earns the
640
+ // exception because it is the most visible spacing number the framework ships — every page's
641
+ // left edge — and it was, until now, the one number the `--vx-*` layer did not expose at all, so
642
+ // a consumer retuning its identity through `createBasaltTheme` could not reach it and had to
643
+ // reach past the token layer into `AppShell.Main`'s own styles. Emitted in PLAIN PX, like every
644
+ // other inset/gap in this list and unlike the `pxRem` heights above it: the rem-plus-
645
+ // `var(--mantine-scale)` reconstruction exists so a CONTROL's box tracks the root font size and
646
+ // Mantine's global scale knob; a page gutter is a layout margin against a viewport, not a box
647
+ // sized to text, so scaling it with the font would make a large-type user's content column
648
+ // narrower rather than more legible.
649
+ decl('space-app-shell-inset', `${space.step.appShellInset}px`),
650
+ decl('space-app-shell-inset-mobile', `${space.step.appShellInsetMobile}px`),
651
+ // The header's breadcrumb floor — emitted for the same reason as the gutter pair above it and
652
+ // not for the JS-number reason its `appShell*` neighbours below are withheld for: this one has
653
+ // a real CSS consumer (`shell/app-header.module.css`'s `.lead`), which is the whole test this
654
+ // list applies. Plain px, like every other layout dimension here.
655
+ decl('space-app-shell-header-lead-min', `${space.step.appShellHeaderLeadMin}px`),
637
656
  decl('space-mobile-nav-tab-gap', `${space.step.mobileNavTabGap}px`),
638
657
  decl('space-mobile-nav-icon-size', `${space.step.mobileNavIconSize}px`),
639
658
  decl('space-mobile-nav-tab-inset-y', `${space.step.mobileNavTabInsetY}px`),
@@ -651,6 +670,11 @@ function spaceDecls(space: SpaceValues): string[] {
651
670
  decl('space-badge-inset-y', `${space.step.badgeInsetY}px`),
652
671
  decl('space-badge-inset-x', `${space.step.badgeInsetX}px`),
653
672
  decl('space-stat-card-gap', `${space.step.statCardGap}px`),
673
+ // The card/section inset ladder — the three values `spaceMobileDecls` below re-points at `< sm`
674
+ // (see its doc for where the law lives and why it lives there).
675
+ decl('space-card-inset-y', `${space.step.cardInsetY}px`),
676
+ decl('space-card-inset-x', `${space.step.cardInsetX}px`),
677
+ decl('space-section-gap', `${space.step.sectionGap}px`),
654
678
  decl('space-virtual-row-inset-y', `${space.step.virtualRowInsetY}px`),
655
679
  decl('space-virtual-row-inset-x', `${space.step.virtualRowInsetX}px`),
656
680
  // Controls tier row/gap heights (`docs/CONTROLS-SPEC.md` §5) — the current commit's consumers
@@ -666,7 +690,8 @@ function spaceDecls(space: SpaceValues): string[] {
666
690
  // `chartDotR`/`progressBarSize`/`timelineBullet` are DELIBERATELY absent — see this function's
667
691
  // doc for why (JS-number-only consumers, zero `var()` reads). `appShellHeaderHeight`/
668
692
  // `appShellNavbarWidth`/`appShellNavbarRailWidth`/`appShellAsideWidth`/`appShellAsideRailWidth`
669
- // join that list —
693
+ // join that list — but NOT `appShellInset`/`appShellInsetMobile`/`appShellHeaderLeadMin`, which
694
+ // are emitted above and carry their own reason for the exception —
670
695
  // `shell/index.tsx` reads all five as JS numbers via `useBasaltSpacing()` (Mantine's `AppShell`
671
696
  // `header`/`navbar`/`aside` props take numbers, not `var()` strings), so a `--vx-space-app-shell-*`
672
697
  // decl here would have had zero consumers, framework-wide, the same dead-weight shape this function's
@@ -912,6 +937,54 @@ const indent = (block: string): string =>
912
937
  .map((line) => (line === '' ? line : ` ${line}`))
913
938
  .join('\n')
914
939
 
940
+ /**
941
+ * The `< sm` breakpoint, written the way a stylesheet has to write it — the SAME literal every
942
+ * basalt CSS module spells (`shell/app-header.module.css`, `dashboard/section.module.css`, …), and
943
+ * for the same reason `BREAKPOINTS` above exists: a `@media` condition is resolved before custom
944
+ * properties exist, so the number cannot be a `var()`. Held here as a constant rather than inlined
945
+ * so the emitter and the modules can be grepped as one set.
946
+ */
947
+ const MOBILE_MEDIA = '@media (max-width: 47.99375em)'
948
+
949
+ /**
950
+ * The MOBILE INSET LADDER, and this function is its ONE home — framework-wide, not per module.
951
+ *
952
+ * The problem it answers: the desktop-shell pass took the page gutter from 20 to 8 below `sm`
953
+ * (`appShellInsetMobile`), and the card ladder INSIDE that gutter never moved — card insets stayed
954
+ * `xs`/`sm` and a `Section`'s gaps stayed `stackMd` at every width — so a 390px phone still read
955
+ * "content spacing too big" after the gutter fix. Two rungs close it: the card's horizontal inset
956
+ * steps `sm` -> `xs`, and the Section's gap steps `stackMd` -> `stackSm`.
957
+ *
958
+ * Why it lives HERE and not in the three CSS files that consume it: two of those three sites are
959
+ * INLINE styles in TSX (`dashboard/stat-card.tsx`, `charts/primitives/ChartCard.tsx`), and an
960
+ * inline style cannot carry a media query at all. The only surface that reaches all three is a
961
+ * custom property whose VALUE moves at the breakpoint, and the only stylesheet guaranteed present
962
+ * wherever those components render is this one — `BasaltProvider` injects `buildPaletteCss()`, and
963
+ * a component's own CSS module is loaded only if a bundler kept that module. So the law is one
964
+ * `@media` block emitted beside the base declarations it overrides, and every consuming site is a
965
+ * plain `var()` read with no breakpoint knowledge. A media query copied into three files (or into
966
+ * one component stylesheet reaching for `:global(:root)`) was the alternative, and it fails the
967
+ * same way twice: three copies drift, and one copy in a tree-shakeable module is a law that
968
+ * sometimes does not ship.
969
+ *
970
+ * This does NOT make the token layer responsive in general. It stays what `BREAKPOINTS`'s doc says
971
+ * it is — the layer that owns the NUMBER a media query has to write — and this is the one law whose
972
+ * consuming sites cannot write that query themselves. A dimension whose consumer is a CSS module
973
+ * keeps making the choice in that module (`appShellInset`/`appShellInsetMobile` still do).
974
+ */
975
+ function spaceMobileDecls(space: SpaceValues): string[] {
976
+ return [
977
+ decl('space-card-inset-x', `${space.scale.xs}px`),
978
+ decl('space-section-gap', `${space.anchors.stackSm}px`),
979
+ ]
980
+ }
981
+
982
+ /** {@link spaceMobileDecls} as the `@media` block that overrides the base `:root` declarations —
983
+ * same specificity, later in source order, so it wins below `sm` and nowhere else. */
984
+ function mobileLadderBlock(space: SpaceValues): string {
985
+ return `${MOBILE_MEDIA} {\n :root {\n${indent(spaceMobileDecls(space).join('\n'))}\n }\n}`
986
+ }
987
+
915
988
  /**
916
989
  * Emit the `--vx-*` stylesheet: a `:root` block of theme-independent scalars, then the
917
990
  * dark (default) and light primitive blocks under `html[data-mantine-color-scheme='…']`.
@@ -942,8 +1015,9 @@ export function buildPaletteCss(
942
1015
  ): string {
943
1016
  const groups = opts.groups ?? {}
944
1017
  const legacyAliases = opts.legacyAliases ?? true
1018
+ const only = opts.only ?? 'all'
945
1019
  const extraDerived = (opts.derived ?? []).map((d) => ` ${d}`).join('\n')
946
- const derivedBlock = frameworkDerived(data, opts.only ?? 'all', legacyAliases)
1020
+ const derivedBlock = frameworkDerived(data, only, legacyAliases)
947
1021
  const derived = extraDerived ? `${derivedBlock}\n${extraDerived}` : derivedBlock
948
1022
  const side = (s: Side): string => {
949
1023
  const extra = Object.entries(groups)
@@ -952,7 +1026,14 @@ export function buildPaletteCss(
952
1026
  const framework = frameworkPrimitives(s, data, legacyAliases)
953
1027
  return extra ? `${framework}\n${extra}` : framework
954
1028
  }
955
- const rootBlock = `:root {\n${derived}\n}`
1029
+ // The scalars, then the ONE `@media` block that re-points two of them below `sm` (see
1030
+ // `spaceMobileDecls`). Dropped under `only: 'core'` for the same reason the declarations it
1031
+ // overrides are: both vars are component-named one-offs, not `SPACE` anchors, so a core-only
1032
+ // consumer never receives the base pair the block exists to override.
1033
+ const rootBlock =
1034
+ only === 'core'
1035
+ ? `:root {\n${derived}\n}`
1036
+ : `:root {\n${derived}\n}\n${mobileLadderBlock(DEFAULT_SPACE_VALUES)}`
956
1037
 
957
1038
  // The legacy shape — kept verbatim, and only reachable when the caller asked for nothing.
958
1039
  if (opts.scheme === undefined && opts.defaultScheme === undefined && !opts.mediaFallback) {
@@ -1073,5 +1154,8 @@ export function buildRadiusCss(radius: RadiusValues | undefined): string {
1073
1154
  */
1074
1155
  export function buildDensityCss(space: SpaceValues | undefined): string {
1075
1156
  if (!space) return ''
1076
- return `:root {\n${spaceDecls(space).join('\n')}\n}`
1157
+ // The `< sm` ladder rides along, at the retuned level — without it a phone at `density: 2` would
1158
+ // take the level-0 mobile inset from the static stylesheet and the level-2 base from this one,
1159
+ // which is the only way the two rungs of one ladder could ever come from different levels.
1160
+ return `:root {\n${spaceDecls(space).join('\n')}\n}\n${mobileLadderBlock(space)}`
1077
1161
  }