basalt-ui 1.32.1 → 1.34.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 (185) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +65 -0
  3. package/MIGRATING.md +218 -25
  4. package/README.md +3 -0
  5. package/agent/rules/basalt-state.md +2 -2
  6. package/agent/rules/basalt-tokens.md +1 -1
  7. package/agent/skills/basalt-app/SKILL.md +5 -3
  8. package/configs/oxlint-basalt.json +33 -0
  9. package/configs/oxlint-plugin.js +89 -67
  10. package/configs/oxlint-plugin.test.ts +224 -19
  11. package/configs/oxlint.json +2 -30
  12. package/dist/agent/coalesce.d.ts.map +1 -1
  13. package/dist/agent/coalesce.js +1 -2
  14. package/dist/agent/merge.d.ts.map +1 -1
  15. package/dist/agent/merge.js +1 -2
  16. package/dist/agent-chat/relative-time.d.ts +9 -0
  17. package/dist/agent-chat/relative-time.d.ts.map +1 -1
  18. package/dist/agent-chat/relative-time.js +1 -2
  19. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  20. package/dist/agent-chat/thread-message.js +1 -2
  21. package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
  22. package/dist/charts/kinds/BandStrip.js +3 -3
  23. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  24. package/dist/charts/kinds/Bars.js +1 -2
  25. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  26. package/dist/charts/kinds/StackedArea.js +1 -2
  27. package/dist/charts/primitives/legend-disclosure.d.ts.map +1 -1
  28. package/dist/cli/audit-allows.d.ts +1 -1
  29. package/dist/cli/audit-allows.d.ts.map +1 -1
  30. package/dist/cli/audit-allows.js +4 -4
  31. package/dist/cli/check-theme.d.ts.map +1 -1
  32. package/dist/cli/check-theme.js +38 -38
  33. package/dist/cli/config.d.ts +197 -0
  34. package/dist/cli/config.d.ts.map +1 -0
  35. package/dist/cli/config.js +20 -0
  36. package/dist/cli/doctor.d.ts.map +1 -1
  37. package/dist/cli/doctor.js +4 -6
  38. package/dist/cli/guard-hook.d.ts.map +1 -1
  39. package/dist/cli/guard-hook.js +7 -41
  40. package/dist/cli/index.d.ts +31 -257
  41. package/dist/cli/index.d.ts.map +1 -1
  42. package/dist/cli/index.js +64 -37
  43. package/dist/cli/sync.d.ts.map +1 -1
  44. package/dist/cli/sync.js +7 -16
  45. package/dist/commands/define-commands.d.ts.map +1 -1
  46. package/dist/commands/define-commands.js +2 -2
  47. package/dist/commands/define-overlays.d.ts.map +1 -1
  48. package/dist/commands/define-overlays.js +1 -2
  49. package/dist/common/index.d.ts +0 -1
  50. package/dist/common/index.d.ts.map +1 -1
  51. package/dist/common/index.js +0 -2
  52. package/dist/common/validate.d.ts +2 -2
  53. package/dist/common/validate.d.ts.map +1 -1
  54. package/dist/common/validate.js +2 -3
  55. package/dist/common/vx-families.d.ts +7 -0
  56. package/dist/common/vx-families.d.ts.map +1 -0
  57. package/dist/common/vx-families.js +32 -0
  58. package/dist/common/vx-family.d.ts +11 -0
  59. package/dist/common/vx-family.d.ts.map +1 -0
  60. package/dist/common/vx-family.js +12 -0
  61. package/dist/content/article-card.d.ts.map +1 -1
  62. package/dist/content/article-card.js +4 -1
  63. package/dist/controls/enum-filter.d.ts.map +1 -1
  64. package/dist/controls/enum-filter.js +3 -13
  65. package/dist/controls/filter-pill.d.ts +8 -0
  66. package/dist/controls/filter-pill.d.ts.map +1 -1
  67. package/dist/controls/filter-pill.js +15 -0
  68. package/dist/controls/multi-select-filter.d.ts.map +1 -1
  69. package/dist/controls/multi-select-filter.js +3 -13
  70. package/dist/controls/number-filter.d.ts +3 -0
  71. package/dist/controls/number-filter.d.ts.map +1 -1
  72. package/dist/controls/number-filter.js +12 -4
  73. package/dist/controls/range-filter.d.ts.map +1 -1
  74. package/dist/controls/range-filter.js +1 -2
  75. package/dist/data/data-table.d.ts.map +1 -1
  76. package/dist/data/data-table.js +2 -3
  77. package/dist/guard/index.d.ts +17 -3
  78. package/dist/guard/index.d.ts.map +1 -1
  79. package/dist/guard/index.js +62 -23
  80. package/dist/guard/types.d.ts +3 -3
  81. package/dist/guard/types.d.ts.map +1 -1
  82. package/dist/guard/vx-names.d.ts +3 -0
  83. package/dist/guard/vx-names.d.ts.map +1 -0
  84. package/dist/guard/vx-names.js +257 -0
  85. package/dist/index.d.ts +1 -1
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +0 -2
  88. package/dist/notifications/mount-guard.d.ts.map +1 -1
  89. package/dist/notifications/mount-guard.js +1 -2
  90. package/dist/provider/index.d.ts.map +1 -1
  91. package/dist/provider/index.js +2 -3
  92. package/dist/provider/lab-theme.d.ts +5 -4
  93. package/dist/provider/lab-theme.d.ts.map +1 -1
  94. package/dist/provider/lab-theme.js +1 -3
  95. package/dist/router-tanstack/search-store.d.ts +18 -0
  96. package/dist/router-tanstack/search-store.d.ts.map +1 -1
  97. package/dist/router-tanstack/search-store.js +33 -1
  98. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  99. package/dist/shell/app-mobile-nav.js +2 -1
  100. package/dist/shell/app-mobile-nav.module.css +12 -0
  101. package/dist/shell/app-sidebar.d.ts.map +1 -1
  102. package/dist/shell/app-sidebar.js +32 -36
  103. package/dist/shell/app-sidebar.module.css +6 -0
  104. package/dist/shell/index.d.ts +0 -1
  105. package/dist/shell/index.d.ts.map +1 -1
  106. package/dist/shell/index.js +0 -2
  107. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  108. package/dist/shell/mobile-nav-model.js +3 -4
  109. package/dist/shell/nav-glyph.d.ts +14 -0
  110. package/dist/shell/nav-glyph.d.ts.map +1 -0
  111. package/dist/shell/nav-glyph.js +21 -0
  112. package/dist/shell/nav-types.d.ts +1 -0
  113. package/dist/shell/nav-types.d.ts.map +1 -1
  114. package/dist/shell/page-bar.d.ts.map +1 -1
  115. package/dist/shell/page-bar.js +1 -2
  116. package/dist/shell/sidebar-search.d.ts.map +1 -1
  117. package/dist/shell/sidebar-search.js +1 -3
  118. package/dist/surfaces.d.ts +3 -3
  119. package/dist/surfaces.d.ts.map +1 -1
  120. package/dist/surfaces.js +4 -2
  121. package/dist/tokens/index.d.ts +1 -1
  122. package/dist/tokens/index.d.ts.map +1 -1
  123. package/dist/tokens/index.js +24 -2
  124. package/dist/tokens/size-classes.d.ts +25 -0
  125. package/dist/tokens/size-classes.d.ts.map +1 -1
  126. package/dist/tokens/size-classes.js +14 -0
  127. package/llms.txt +4 -4
  128. package/package.json +1 -1
  129. package/src/agent/coalesce.ts +1 -2
  130. package/src/agent/merge.ts +1 -2
  131. package/src/agent-chat/relative-time.ts +1 -2
  132. package/src/agent-chat/thread-message.tsx +2 -3
  133. package/src/charts/kinds/BandStrip.tsx +7 -3
  134. package/src/charts/kinds/Bars.tsx +1 -2
  135. package/src/charts/kinds/StackedArea.tsx +4 -2
  136. package/src/charts/primitives/legend-disclosure.tsx +1 -0
  137. package/src/cli/audit-allows.ts +10 -9
  138. package/src/cli/check-theme.ts +61 -39
  139. package/src/cli/config.ts +209 -0
  140. package/src/cli/doctor.ts +5 -7
  141. package/src/cli/guard-hook.ts +9 -42
  142. package/src/cli/index.ts +101 -294
  143. package/src/cli/sync.ts +7 -16
  144. package/src/commands/define-commands.ts +2 -2
  145. package/src/commands/define-overlays.ts +1 -2
  146. package/src/common/index.ts +0 -1
  147. package/src/common/validate.ts +4 -5
  148. package/src/common/vx-families.ts +36 -0
  149. package/src/common/vx-family.ts +22 -0
  150. package/src/content/article-card.tsx +1 -0
  151. package/src/controls/enum-filter.tsx +3 -13
  152. package/src/controls/filter-pill.tsx +20 -0
  153. package/src/controls/multi-select-filter.tsx +3 -13
  154. package/src/controls/number-filter.tsx +24 -3
  155. package/src/controls/range-filter.tsx +1 -2
  156. package/src/data/data-table.tsx +2 -3
  157. package/src/guard/index.ts +134 -47
  158. package/src/guard/types.ts +3 -2
  159. package/src/guard/vx-names.ts +257 -0
  160. package/src/index.ts +0 -2
  161. package/src/notifications/mount-guard.ts +1 -2
  162. package/src/provider/index.tsx +2 -3
  163. package/src/provider/lab-theme.ts +28 -21
  164. package/src/router-tanstack/search-store.ts +73 -1
  165. package/src/shell/app-mobile-nav.module.css +12 -0
  166. package/src/shell/app-mobile-nav.tsx +8 -1
  167. package/src/shell/app-sidebar.module.css +6 -0
  168. package/src/shell/app-sidebar.tsx +52 -50
  169. package/src/shell/index.tsx +0 -1
  170. package/src/shell/mobile-nav-model.ts +3 -5
  171. package/src/shell/nav-glyph.tsx +38 -0
  172. package/src/shell/nav-types.ts +1 -0
  173. package/src/shell/page-bar.tsx +1 -4
  174. package/src/shell/sidebar-search.tsx +1 -5
  175. package/src/surfaces.ts +4 -2
  176. package/src/tokens/index.ts +34 -2
  177. package/src/tokens/size-classes.ts +27 -0
  178. package/dist/common/is-dev.d.ts +0 -3
  179. package/dist/common/is-dev.d.ts.map +0 -1
  180. package/dist/common/is-dev.js +0 -6
  181. package/dist/shell/use-breakpoint.d.ts +0 -10
  182. package/dist/shell/use-breakpoint.d.ts.map +0 -1
  183. package/dist/shell/use-breakpoint.js +0 -11
  184. package/src/common/is-dev.ts +0 -4
  185. package/src/shell/use-breakpoint.ts +0 -37
@@ -39,6 +39,7 @@ import { assertRequiredProps } from '../common/validate'
39
39
  import type { BrandConfig, SettingsMenuItem } from './index'
40
40
  import type { SidebarBlock, SidebarItem, SidebarSection } from './nav-types'
41
41
  import { NavCountBadge } from './nav-count-badge'
42
+ import { iconOrInitial } from './nav-glyph'
42
43
  import { SidebarAccount } from './app-sidebar-account'
43
44
  import type { AccountMenuItem, BasaltAccountProps } from './account-types'
44
45
  import { SidebarSearch } from './sidebar-search'
@@ -190,12 +191,24 @@ function hasActiveDescendant(item: SidebarItem): boolean {
190
191
  * element. That is what replaced the old render callback — a callback had to re-derive basalt's
191
192
  * own styling to look right, and every consumer got it subtly wrong.
192
193
  */
193
- function NavLinkBody({ item, active }: { item: SidebarItem; active: boolean }) {
194
+ function NavLinkBody({
195
+ item,
196
+ active,
197
+ collapsed = false,
198
+ }: {
199
+ item: SidebarItem
200
+ active: boolean
201
+ collapsed?: boolean
202
+ }) {
194
203
  const Anchor = item.Anchor
195
204
  const shared = {
196
205
  classNames: { root: classes.link },
197
206
  label: item.label,
198
- leftSection: item.icon,
207
+ leftSection: iconOrInitial(item.icon, item.label),
208
+ // Only the collapsed rail hides the label (CSS), leaving the icon alone — name the link there.
209
+ // Expanded, the visible text IS the name and carries the count badge; an `aria-label` would
210
+ // override both and drop the count ("Inbox 5" → "Inbox").
211
+ ...(collapsed && { 'aria-label': item.label }),
199
212
  rightSection: item.badge ?? (item.count ? <NavCountBadge count={item.count} /> : undefined),
200
213
  active,
201
214
  // Basalt is the one that stamps `aria-current`, not the theme and not a router `<Link>`'s own
@@ -215,6 +228,32 @@ function NavLinkBody({ item, active }: { item: SidebarItem; active: boolean }) {
215
228
  return <NavLink component="a" {...(item.href !== undefined && { href: item.href })} {...shared} />
216
229
  }
217
230
 
231
+ /**
232
+ * One child row, shared by the inline child list and the hover popover. Never in the rail (the
233
+ * inline list is hidden there and the popover shows labels), so it carries no `aria-label`.
234
+ */
235
+ function ChildRow({ child }: { child: SidebarItem }) {
236
+ if (!child.disabled) {
237
+ return (
238
+ <Box>
239
+ <NavLinkBody item={child} active={Boolean(child.active)} />
240
+ </Box>
241
+ )
242
+ }
243
+ return (
244
+ <Tooltip label="Coming soon" position="right" withArrow>
245
+ <Box>
246
+ <NavLink
247
+ classNames={{ root: classes.link }}
248
+ label={child.label}
249
+ leftSection={iconOrInitial(child.icon, child.label)}
250
+ data-disabled
251
+ />
252
+ </Box>
253
+ </Tooltip>
254
+ )
255
+ }
256
+
218
257
  /**
219
258
  * Group label — a micro-label (docs/DESIGN-SPEC.md §3: mono, uppercase, tracked, faint). Typography
220
259
  * lives entirely in `.sectionLabel` (app-sidebar.module.css), not Mantine `Text` props, since the
@@ -304,7 +343,8 @@ function NavItemRow({ item, collapsed }: { item: SidebarItem; collapsed: boolean
304
343
  <NavLink
305
344
  classNames={{ root: classes.link }}
306
345
  label={item.label}
307
- leftSection={item.icon}
346
+ leftSection={iconOrInitial(item.icon, item.label)}
347
+ {...(collapsed && { 'aria-label': item.label })}
308
348
  data-disabled
309
349
  />
310
350
  </Box>
@@ -316,7 +356,7 @@ function NavItemRow({ item, collapsed }: { item: SidebarItem; collapsed: boolean
316
356
  return (
317
357
  <Tooltip key={item.key} label={item.label} position="right" withArrow disabled={!collapsed}>
318
358
  <Box className={classes.navItem}>
319
- <NavLinkBody item={item} active={active} />
359
+ <NavLinkBody item={item} active={active} collapsed={collapsed} />
320
360
  </Box>
321
361
  </Tooltip>
322
362
  )
@@ -331,32 +371,13 @@ function NavItemRow({ item, collapsed }: { item: SidebarItem; collapsed: boolean
331
371
  <Box key={item.key}>
332
372
  <Tooltip label={item.label} position="right" withArrow disabled={!collapsed}>
333
373
  <Box className={classes.navItem}>
334
- <NavLinkBody item={item} active={active} />
374
+ <NavLinkBody item={item} active={active} collapsed={collapsed} />
335
375
  </Box>
336
376
  </Tooltip>
337
377
  <Stack gap={0} className={classes.childList}>
338
- {item.children!.map((child) => {
339
- if (child.disabled) {
340
- return (
341
- <Tooltip key={child.key} label="Coming soon" position="right" withArrow>
342
- <Box>
343
- <NavLink
344
- classNames={{ root: classes.link }}
345
- label={child.label}
346
- leftSection={child.icon}
347
- data-disabled
348
- />
349
- </Box>
350
- </Tooltip>
351
- )
352
- }
353
- const childActive = Boolean(child.active)
354
- return (
355
- <Box key={child.key}>
356
- <NavLinkBody item={child} active={childActive} />
357
- </Box>
358
- )
359
- })}
378
+ {item.children!.map((child) => (
379
+ <ChildRow key={child.key} child={child} />
380
+ ))}
360
381
  </Stack>
361
382
  </Box>
362
383
  )
@@ -383,7 +404,7 @@ function NavItemRow({ item, collapsed }: { item: SidebarItem; collapsed: boolean
383
404
  onFocus={scheduleOpen}
384
405
  onBlur={scheduleClose}
385
406
  >
386
- <NavLinkBody item={item} active={active} />
407
+ <NavLinkBody item={item} active={active} collapsed={collapsed} />
387
408
  </Box>
388
409
  </Tooltip>
389
410
  </Popover.Target>
@@ -397,28 +418,9 @@ function NavItemRow({ item, collapsed }: { item: SidebarItem; collapsed: boolean
397
418
  onBlur={scheduleClose}
398
419
  >
399
420
  <Stack gap={0}>
400
- {item.children!.map((child) => {
401
- if (child.disabled) {
402
- return (
403
- <Tooltip key={child.key} label="Coming soon" position="right" withArrow>
404
- <Box>
405
- <NavLink
406
- classNames={{ root: classes.link }}
407
- label={child.label}
408
- leftSection={child.icon}
409
- data-disabled
410
- />
411
- </Box>
412
- </Tooltip>
413
- )
414
- }
415
- const childActive = Boolean(child.active)
416
- return (
417
- <Box key={child.key}>
418
- <NavLinkBody item={child} active={childActive} />
419
- </Box>
420
- )
421
- })}
421
+ {item.children!.map((child) => (
422
+ <ChildRow key={child.key} child={child} />
423
+ ))}
422
424
  </Stack>
423
425
  </Popover.Dropdown>
424
426
 
@@ -84,7 +84,6 @@ export {
84
84
  export { AppBreadcrumbs, type AppBreadcrumbsProps } from './app-breadcrumbs'
85
85
  export { PageBar, type PageBarProps, type PageBarSlot } from './page-bar'
86
86
  export { PageAside, type PageAsideProps, type PageAsideSlot } from './page-aside'
87
- export { useBreakpoint, type BreakpointName } from './use-breakpoint'
88
87
  export { useSizeClass, type SizeClass } from './use-size-class'
89
88
  export { PageTitle, type PageTitleProps, type PageTitleSlot } from './page-title'
90
89
 
@@ -87,8 +87,6 @@ export function blockRowCount(blocks: readonly SidebarBlock[] | undefined): numb
87
87
  }).length
88
88
  }
89
89
 
90
- const DEV = process.env['NODE_ENV'] !== 'production'
91
-
92
90
  /**
93
91
  * §2.3 rule 1. A section's `mobile: false` wins over every item inside it; otherwise the item's own
94
92
  * field decides, with the shorthand booleans normalized and `undefined` meaning "reachable, but not
@@ -351,7 +349,7 @@ function buildCandidates(
351
349
  /** Rule 11 — `mobile: 'tab'` on a disabled destination is a configuration mistake, not a slot. */
352
350
  const tabbable = (item: SidebarItem): boolean => {
353
351
  if (!item.disabled) return true
354
- if (DEV) {
352
+ if (process.env.NODE_ENV !== 'production') {
355
353
  console.warn(
356
354
  `[basalt] MobileNav: "${item.key}" asks for a bar slot but is disabled — a disabled destination never becomes a tab. It still renders, disabled, in the More surface.`,
357
355
  )
@@ -373,7 +371,7 @@ function buildCandidates(
373
371
  candidates.push(sectionCandidate(section, menuMax))
374
372
  continue
375
373
  }
376
- if (DEV) {
374
+ if (process.env.NODE_ENV !== 'production') {
377
375
  console.warn(
378
376
  `[basalt] MobileNav: mobileNav.tabs names "${id}", which is neither a destination key nor a visible section label — ignoring it.`,
379
377
  )
@@ -464,7 +462,7 @@ export function projectMobileNav(
464
462
  const limit = needsMore ? maxTabs - 1 : maxTabs
465
463
  const kept = candidates.slice(0, limit)
466
464
  const dropped = candidates.slice(limit)
467
- if (DEV && dropped.length > 0) {
465
+ if (process.env.NODE_ENV !== 'production' && dropped.length > 0) {
468
466
  // Rule 13 wants the NAMES, not a tally: "2 candidates dropped" tells a consumer their bar is
469
467
  // wrong, while the keys tell them which `mobile: 'tab'` to move or which section to reorder.
470
468
  console.warn(
@@ -0,0 +1,38 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ /** `Intl.Segmenter` is in every supported runtime; the code-point split is the belt-and-braces fallback. */
4
+ const segmenter =
5
+ typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
6
+ ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
7
+ : undefined
8
+
9
+ /** The first grapheme of a label — the fallback body for an item or action shipping no icon. */
10
+ export function initial(label: string): string {
11
+ const first = segmenter
12
+ ? segmenter.segment(label)[Symbol.iterator]().next().value?.segment
13
+ : [...label][0]
14
+ return first?.toUpperCase() ?? '?'
15
+ }
16
+
17
+ /** False for everything React renders as nothing — `cond && <Icon />` yields `false`, not `null`. */
18
+ export function hasIcon(icon: ReactNode): boolean {
19
+ return icon !== null && icon !== undefined && typeof icon !== 'boolean' && icon !== ''
20
+ }
21
+
22
+ /**
23
+ * The label's first grapheme as a decorative glyph. The collapsed rail and the landscape tab bar
24
+ * show the icon ALONE, so an absent icon would render a nameless, empty target. `aria-hidden`: the
25
+ * accessible name comes from the label, never the glyph.
26
+ */
27
+ export function initialGlyph(label: string): ReactNode {
28
+ return (
29
+ <span aria-hidden data-nav-glyph>
30
+ {initial(label)}
31
+ </span>
32
+ )
33
+ }
34
+
35
+ /** A nav item's icon, or its label's first-letter glyph when it ships none. */
36
+ export function iconOrInitial(icon: ReactNode, label: string): ReactNode {
37
+ return hasIcon(icon) ? icon : initialGlyph(label)
38
+ }
@@ -31,6 +31,7 @@ export type SidebarItem = {
31
31
  short?: string
32
32
  /** Mobile placement. `true` ≡ `'tab'`, `false` ≡ `'hidden'`. @default 'more' */
33
33
  mobile?: boolean | NavMobilePlacement
34
+ /** The collapsed rail and landscape tab bar show the icon alone; `null` falls back to the label's first letter. */
34
35
  icon: ReactNode
35
36
  /**
36
37
  * The router seam. basalt renders every pixel of chrome (desktop row, 56px slot, 44px sheet
@@ -46,9 +46,6 @@ import asideClasses from './page-aside.module.css'
46
46
  /** The custom property row 2's measured height is published on. */
47
47
  export const PAGE_BAR_HEIGHT_VAR = '--basalt-page-bar-h'
48
48
 
49
- /** Dev-only diagnostics, constant-folded out of a production bundle — see {@link useAsidePanelSlot}. */
50
- const DEV = process.env['NODE_ENV'] !== 'production'
51
-
52
49
  /**
53
50
  * What a `PageAside` publishes when it projects itself into row 2 below `sm`
54
51
  * (`docs/ASIDE-SPEC.md` §0 "Desktop and mobile are one declaration"). Metadata only — the aside's
@@ -236,7 +233,7 @@ export function useAsidePanelSlot(): {
236
233
  setSettled(true)
237
234
  }, [])
238
235
  useEffect(() => {
239
- if (!DEV || !settled || !inShell || panelHost) return
236
+ if (process.env.NODE_ENV === 'production' || !settled || !inShell || panelHost) return
240
237
  console.warn(
241
238
  "[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.",
242
239
  )
@@ -13,6 +13,7 @@ import { OverflowMenu } from '../controls/actions'
13
13
  import type { BarAction } from '../controls/actions'
14
14
  import { cx } from '../common/props'
15
15
  import type { BasaltProps } from '../common/props'
16
+ import { initial } from './nav-glyph'
16
17
  import classes from './sidebar-search.module.css'
17
18
 
18
19
  /**
@@ -55,11 +56,6 @@ function IconSearch() {
55
56
  )
56
57
  }
57
58
 
58
- /** The first grapheme of a label — the fallback body for an action shipping no icon. */
59
- function initial(label: string): string {
60
- return [...label][0]?.toUpperCase() ?? '?'
61
- }
62
-
63
59
  /**
64
60
  * One trailing action as an icon-only button. `size="icon"` is EXPLICIT here, not inherited: the
65
61
  * search row is chrome, not a control home, so no `CtlSlot` wraps it and nothing else would resolve
package/src/surfaces.ts CHANGED
@@ -299,7 +299,7 @@ export const SURFACES = {
299
299
  "no second cssVariablesResolver — don't hand-build createTheme or re-add the resolver basalt already installs",
300
300
  ],
301
301
  description:
302
- 'BasaltProvider, createBasaltTheme, BasaltShell + sidebar/mobile-nav/breadcrumbs, PageBar, PageAside, useSizeClass (useBreakpoint on notice), NavCountBadge, ThemeToggle, ThreadWorkspace + thread-chat components, WidgetHeader, dashboard composites (DeltaBadge, StatCard with threshold tone, EmptyState, QueryState/LoadingState/ErrorState, SettingsSection/SettingsRow/DangerZone), ConnectivityProvider/useConnectivity/ConnectivityIndicator (auto-mounted, C1: absorbed from the dropped ./connectivity), createBasaltQueryClient/unwrap/BasaltQueryDevtools (C1: absorbed from the dropped ./query), BasaltDevDock (C5: lazy Router/Query devtools + theme lab in one dock)',
302
+ 'BasaltProvider, createBasaltTheme, BasaltShell + sidebar/mobile-nav/breadcrumbs, PageBar, PageAside, useSizeClass, NavCountBadge, ThemeToggle, ThreadWorkspace + thread-chat components, WidgetHeader, dashboard composites (DeltaBadge, StatCard with threshold tone, EmptyState, QueryState/LoadingState/ErrorState, SettingsSection/SettingsRow/DangerZone), ConnectivityProvider/useConnectivity/ConnectivityIndicator (auto-mounted, C1: absorbed from the dropped ./connectivity), createBasaltQueryClient/unwrap/BasaltQueryDevtools (C1: absorbed from the dropped ./query), BasaltDevDock (C5: lazy Router/Query devtools + theme lab in one dock)',
303
303
  optionalPeers: [
304
304
  'react-markdown',
305
305
  'remark-gfm',
@@ -355,6 +355,8 @@ export const SURFACES = {
355
355
  'inline-font-size',
356
356
  'css-raw-surface',
357
357
  'surface-shadow-override',
358
+ // A `var(--vx-*)` naming a token basalt no longer (or never) emits.
359
+ 'unknown-vx-token',
358
360
  // Not a token kind — the kind that judges the ANNOTATION rather than the code. It lands here
359
361
  // because `agent/rules/basalt-tokens.md` is where the `theme-allow` grammar is documented
360
362
  // (said once, on purpose), so this is the header that must name its enforcement.
@@ -660,7 +662,7 @@ export const SURFACES = {
660
662
  kind: 'tooling',
661
663
  layer: 'non-js-asset',
662
664
  description:
663
- 'Raw toolchain presets for consumer extends: oxlint.json, oxfmt.json, tsconfig.*.json, lefthook.yml, check.yml',
665
+ 'Raw toolchain presets for consumer extends: oxlint.json (or the guards-only oxlint-basalt.json), oxfmt.json, tsconfig.*.json, lefthook.yml, check.yml',
664
666
  forbiddenImports: [],
665
667
  },
666
668
  './llms.txt': {
@@ -22,6 +22,8 @@ import {
22
22
  import type { PaletteData, RadiusValues, SpaceValues } from './palette'
23
23
  import { SIZE_CLASSES, sizeClassMaxEm } from './size-classes'
24
24
  import { HIT_COARSE, HIT_FINE } from '../common/hit-floor'
25
+ import { BASALT_VX_FAMILIES } from '../common/vx-families'
26
+ import { vxFamilyOf } from '../common/vx-family'
25
27
 
26
28
  // The raw hue families + pair-picker — the building blocks a consumer's series module composes
27
29
  // (`hrv: p(BP.blue)`). The doctrine sends every consumer here, so they are public surface, not
@@ -54,7 +56,13 @@ export type { SpaceValues } from './palette'
54
56
 
55
57
  // The two responsive tables — MIGRATING recipes and the raw-breakpoint guard messages send
56
58
  // consumers here for them; `migrating-imports.test.ts` pins that every documented import resolves.
57
- export { CONTAINER_CLASSES, resolveContainerClass, SIZE_CLASSES } from './size-classes'
59
+ export {
60
+ CONTAINER_CLASSES,
61
+ CONTAINER_GRID_BREAKPOINTS,
62
+ CONTAINER_KEYS,
63
+ resolveContainerClass,
64
+ SIZE_CLASSES,
65
+ } from './size-classes'
58
66
  export type { ContainerClass } from './size-classes'
59
67
 
60
68
  /** A per-theme color pair: a hue keeps its identity but shifts shade across schemes. */
@@ -800,6 +808,26 @@ function frameworkDerived(data: PaletteData, only: 'core' | 'all', legacyAliases
800
808
  return legacyAliases ? `${canonical}\n${frameworkDerivedAliases(data)}` : canonical
801
809
  }
802
810
 
811
+ const warnedVxNames = new Set<string>()
812
+
813
+ /**
814
+ * Warn, once per name, on a consumer series/group name inside a basalt-owned `--vx-*` family
815
+ * (`surface`, `space`, `ink`, …): `check-theme`'s `unknown-vx-token` judges every name there against
816
+ * what basalt emits, which is sound only while no consumer REF points there. A WARNING for the
817
+ * minor that kind is graced in (1.34.x), dev builds only; `grace.test.ts` pins the message's
818
+ * version to the ledger's `promote` and requires a throw once the kind promotes.
819
+ * The families are the guard's own generated `BASALT_VX_FAMILIES`. Checked on the refs, not in
820
+ * `buildPaletteCss`, which runs on every first paint.
821
+ */
822
+ function warnConsumerVxName(name: string): void {
823
+ const family = vxFamilyOf(name)
824
+ if (!BASALT_VX_FAMILIES.includes(family) || warnedVxNames.has(name)) return
825
+ warnedVxNames.add(name)
826
+ console.warn(
827
+ `[basalt] ${name} is inside basalt's own --vx-${family} family — name your series group something basalt does not own (groupTokens('app', …)). Throws from 1.35.0.`,
828
+ )
829
+ }
830
+
803
831
  /**
804
832
  * Wrap a map of `{ name: ColorPair }` into `var(--vx-<prefix><name>)` token refs — the runtime
805
833
  * companion to `buildPaletteCss`. Lets a consumer define its own series and read stable refs.
@@ -812,7 +840,11 @@ export function seriesTokens<const T extends SeriesMap>(
812
840
  prefix = '',
813
841
  ): { [K in keyof T]: string } {
814
842
  const out = {} as { [K in keyof T]: string }
815
- for (const key of Object.keys(map) as (keyof T)[]) out[key] = `var(--vx-${prefix}${String(key)})`
843
+ for (const key of Object.keys(map) as (keyof T)[]) {
844
+ // Literal read at the branch, so a production bundle drops the check (common/dev-gate.test.ts).
845
+ if (process.env.NODE_ENV !== 'production') warnConsumerVxName(`--vx-${prefix}${String(key)}`)
846
+ out[key] = `var(--vx-${prefix}${String(key)})`
847
+ }
816
848
  return out
817
849
  }
818
850
 
@@ -14,6 +14,33 @@ export const CONTAINER_CLASSES = { micro: 0, compact: 240, regular: 480, wide: 8
14
14
 
15
15
  export type ContainerClass = keyof typeof CONTAINER_CLASSES
16
16
 
17
+ /**
18
+ * `CONTAINER_CLASSES` as the CSS lengths `<SimpleGrid type="container">` keys take — Mantine writes
19
+ * each key verbatim into `@container simple-grid (min-width: <key>)`, so a theme name (`sm`) is a
20
+ * dead query and the key has to be a px length: `cols={{ base: 1, [CONTAINER_KEYS.regular]: 2 }}`.
21
+ * `micro` has no key — `base` is that class. `basalt/raw-breakpoint` judges theme names only, so a
22
+ * computed key is green.
23
+ */
24
+ export const CONTAINER_KEYS = {
25
+ compact: `${CONTAINER_CLASSES.compact}px`,
26
+ regular: `${CONTAINER_CLASSES.regular}px`,
27
+ wide: `${CONTAINER_CLASSES.wide}px`,
28
+ } as const
29
+
30
+ /**
31
+ * `CONTAINER_CLASSES` as the five-key map `<Grid type="container" breakpoints={…}>` takes (Mantine
32
+ * types it `Record<MantineSize, string>`), so `Grid.Col` keys resolve onto the grid's own width:
33
+ * `xs` opens compact, `sm` regular, `md`/`lg`/`xl` wide. `basalt/raw-breakpoint` trusts this import
34
+ * by name (from `basalt-ui/tokens`) — any other imported map is unreadable to it and flagged.
35
+ */
36
+ export const CONTAINER_GRID_BREAKPOINTS = {
37
+ xs: CONTAINER_KEYS.compact,
38
+ sm: CONTAINER_KEYS.regular,
39
+ md: CONTAINER_KEYS.wide,
40
+ lg: CONTAINER_KEYS.wide,
41
+ xl: CONTAINER_KEYS.wide,
42
+ } as const
43
+
17
44
  /** The class a MEASURED inline size falls in (`px` is a min-width boundary, so 240 is `compact`). */
18
45
  export function resolveContainerClass(px: number): ContainerClass {
19
46
  if (px >= CONTAINER_CLASSES.wide) return 'wide'
@@ -1,3 +0,0 @@
1
- /** Dev-mode gate. Bundlers constant-fold `process.env.NODE_ENV` so the branch disappears in production builds. */
2
- export declare function isDev(): boolean;
3
- //# sourceMappingURL=is-dev.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"is-dev.d.ts","sourceRoot":"","sources":["../../src/common/is-dev.ts"],"names":[],"mappings":"AAAA,kHAAkH;AAClH,wBAAgB,KAAK,IAAI,OAAO,CAE/B"}
@@ -1,6 +0,0 @@
1
- function isDev() {
2
- return process.env["NODE_ENV"] !== "production";
3
- }
4
- export {
5
- isDev
6
- };
@@ -1,10 +0,0 @@
1
- export type BreakpointName = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
2
- /**
3
- * @deprecated Use `useSizeClass` — removed in 1.33.0 (see `MIGRATING.md`). `theme.breakpoints` is
4
- * now derived: `sm` moved 48em → 52.5em and `md`/`lg`/`xl` all sit at 75em (`xs`/`sm` collapse,
5
- * `md`/`lg`/`xl` collapse).
6
- */
7
- export declare function useBreakpoint(name: BreakpointName, edge?: 'min' | 'max', options?: {
8
- fallback?: boolean;
9
- }): boolean;
10
- //# sourceMappingURL=use-breakpoint.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"use-breakpoint.d.ts","sourceRoot":"","sources":["../../src/shell/use-breakpoint.ts"],"names":[],"mappings":"AAoBA,MAAM,MAAM,cAAc,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAA;AAE7D;;;;GAIG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,cAAc,EACpB,IAAI,GAAE,KAAK,GAAG,KAAa,EAC3B,OAAO,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAE,GAC/B,OAAO,CAKT"}
@@ -1,11 +0,0 @@
1
- import { useMantineTheme } from "@mantine/core";
2
- import { useMediaQuery } from "../common/use-media-query.js";
3
- function useBreakpoint(name, edge = "min", options) {
4
- const theme = useMantineTheme();
5
- const bp = theme.breakpoints[name];
6
- const fallback = options?.fallback ?? edge === "max";
7
- return useMediaQuery(`(${edge}-width: ${bp})`, fallback);
8
- }
9
- export {
10
- useBreakpoint
11
- };
@@ -1,4 +0,0 @@
1
- /** Dev-mode gate. Bundlers constant-fold `process.env.NODE_ENV` so the branch disappears in production builds. */
2
- export function isDev(): boolean {
3
- return process.env['NODE_ENV'] !== 'production'
4
- }
@@ -1,37 +0,0 @@
1
- /**
2
- * `useBreakpoint` — DEPRECATED (on notice in `MIGRATING.md`, removed in 1.33.0): use
3
- * `useSizeClass` from `./use-size-class`. One media-query hook over the theme's own breakpoints,
4
- * built on the shared `useMediaQuery` (`common/use-media-query`) — SSR/hydration-safe.
5
- *
6
- * `edge` defaults to `'min'` (`"at least this wide"`); pass `'max'` for `"narrower than"`. The
7
- * `fallback` (what the server, and a shim with no `matchMedia`, sees) defaults to the answer that
8
- * assumes the NARROW side of the query — `false` for `min`, `true` for `max` — since a component
9
- * library cannot know a consumer's real device mix; `options.fallback` overrides it.
10
- *
11
- * @example
12
- * import { useBreakpoint } from 'basalt-ui'
13
- *
14
- * const isDesktop = useBreakpoint('sm') // >= theme.breakpoints.sm
15
- * const isNarrow = !useBreakpoint('sm') // < theme.breakpoints.sm
16
- * const isWide = useBreakpoint('lg') // >= theme.breakpoints.lg
17
- */
18
- import { useMantineTheme } from '@mantine/core'
19
- import { useMediaQuery } from '../common/use-media-query'
20
-
21
- export type BreakpointName = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
22
-
23
- /**
24
- * @deprecated Use `useSizeClass` — removed in 1.33.0 (see `MIGRATING.md`). `theme.breakpoints` is
25
- * now derived: `sm` moved 48em → 52.5em and `md`/`lg`/`xl` all sit at 75em (`xs`/`sm` collapse,
26
- * `md`/`lg`/`xl` collapse).
27
- */
28
- export function useBreakpoint(
29
- name: BreakpointName,
30
- edge: 'min' | 'max' = 'min',
31
- options?: { fallback?: boolean },
32
- ): boolean {
33
- const theme = useMantineTheme()
34
- const bp = theme.breakpoints[name]
35
- const fallback = options?.fallback ?? edge === 'max'
36
- return useMediaQuery(`(${edge}-width: ${bp})`, fallback)
37
- }