basalt-ui 1.34.1 → 1.35.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 (38) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/MIGRATING.md +17 -12
  3. package/dist/guard/index.d.ts.map +1 -1
  4. package/dist/guard/index.js +16 -6
  5. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  6. package/dist/shell/app-mobile-nav.js +19 -10
  7. package/dist/shell/app-sidebar-account.d.ts.map +1 -1
  8. package/dist/shell/app-sidebar-account.js +1 -0
  9. package/dist/shell/app-sidebar.d.ts.map +1 -1
  10. package/dist/shell/app-sidebar.js +11 -6
  11. package/dist/shell/app-sidebar.module.css +39 -0
  12. package/dist/shell/app-sidebar.module.css.d.ts +3 -0
  13. package/dist/shell/index.d.ts +12 -5
  14. package/dist/shell/index.d.ts.map +1 -1
  15. package/dist/shell/nav-types.d.ts +19 -3
  16. package/dist/shell/nav-types.d.ts.map +1 -1
  17. package/dist/shell/sidebar-block-model.d.ts +3 -3
  18. package/dist/shell/sidebar-block-model.d.ts.map +1 -1
  19. package/dist/shell/sidebar-block-model.js +6 -2
  20. package/dist/shell/sidebar-blocks.d.ts +2 -1
  21. package/dist/shell/sidebar-blocks.d.ts.map +1 -1
  22. package/dist/shell/sidebar-blocks.js +13 -5
  23. package/dist/surfaces.js +2 -2
  24. package/dist/tokens/index.js +1 -1
  25. package/llms.txt +2 -2
  26. package/package.json +1 -1
  27. package/src/guard/index.ts +34 -18
  28. package/src/shell/app-mobile-nav.tsx +34 -15
  29. package/src/shell/app-sidebar-account.tsx +3 -0
  30. package/src/shell/app-sidebar.module.css +39 -0
  31. package/src/shell/app-sidebar.module.css.d.ts +3 -0
  32. package/src/shell/app-sidebar.tsx +13 -6
  33. package/src/shell/index.tsx +12 -5
  34. package/src/shell/nav-types.ts +19 -3
  35. package/src/shell/sidebar-block-model.ts +13 -5
  36. package/src/shell/sidebar-blocks.tsx +30 -5
  37. package/src/surfaces.ts +2 -2
  38. package/src/tokens/index.ts +1 -1
@@ -1,5 +1,5 @@
1
1
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
- import { Collapse, NavLink, Progress, Text, UnstyledButton } from "@mantine/core";
2
+ import { Collapse, NavLink, Popover, Progress, Text, UnstyledButton } from "@mantine/core";
3
3
  import { useState } from "react";
4
4
  import { NavCountBadge } from "./nav-count-badge.js";
5
5
  import { createPersistedState } from "../state.js";
@@ -150,15 +150,23 @@ function ProgressBlock({ block }) {
150
150
  ] });
151
151
  return /* @__PURE__ */ jsx("div", { className: classes.block, "data-rail": sidebarBlockRail(block), children: block.onClick !== void 0 ? /* @__PURE__ */ jsx(UnstyledButton, { className: classes.blockProgress, onClick: block.onClick, children: inner }) : /* @__PURE__ */ jsx("div", { className: classes.blockProgress, children: inner }) });
152
152
  }
153
- function CustomBlock({ block }) {
154
- return /* @__PURE__ */ jsx("div", { className: classes.block, "data-rail": "hidden", children: block.node });
153
+ function CustomBlock({ block, collapsed }) {
154
+ const rail = sidebarBlockRail(block);
155
+ if (collapsed && rail === "icon") {
156
+ return /* @__PURE__ */ jsx("div", { className: classes.block, "data-rail": "icon", children: /* @__PURE__ */ jsxs(Popover, { position: "right-start", withArrow: false, offset: 4, withinPortal: true, zIndex: 400, children: [
157
+ /* @__PURE__ */ jsx(Popover.Target, { children: /* @__PURE__ */ jsx(UnstyledButton, { className: classes.blockRailTrigger, "aria-label": block.label, children: /* @__PURE__ */ jsx(IconSlot, { children: block.icon }) }) }),
158
+ /* @__PURE__ */ jsx(Popover.Dropdown, { p: "xs", className: classes.blockRailPopover, children: block.node })
159
+ ] }) });
160
+ }
161
+ return /* @__PURE__ */ jsx("div", { className: classes.block, "data-rail": rail, children: block.node });
155
162
  }
156
163
  function SidebarBlockView({
157
- block
164
+ block,
165
+ collapsed = false
158
166
  }) {
159
167
  if (block.kind === "list") return /* @__PURE__ */ jsx(ListBlock, { block });
160
168
  if (block.kind === "progress") return /* @__PURE__ */ jsx(ProgressBlock, { block });
161
- return /* @__PURE__ */ jsx(CustomBlock, { block });
169
+ return /* @__PURE__ */ jsx(CustomBlock, { block, collapsed });
162
170
  }
163
171
  export {
164
172
  IconChevron,
package/dist/surfaces.js CHANGED
@@ -357,8 +357,8 @@ const SURFACES = {
357
357
  layer: "mantine-coupled",
358
358
  rule: "controls",
359
359
  skill: ["basalt-design"],
360
- // The text lane of law C1 — the plugin's `control-outside-home` seen through a 12-line host-tag
361
- // window, since a regex scan has no ancestry. Same law, same wave, same promotion.
360
+ // The text lane of law C1 — the plugin's `control-outside-home` seen through a host-tag nesting
361
+ // count, since a regex scan has no AST. Same law, same wave, same promotion.
362
362
  guardKinds: ["raw-selection-control"],
363
363
  // The control tier itself (laws C1/C3/C5/C9). `hand-rolled-filter`, `control-size-literal` and
364
364
  // `responsive-twin` all ship `error`; the two C1 placement rules are the grace entries in
@@ -557,7 +557,7 @@ function warnConsumerVxName(name) {
557
557
  if (!BASALT_VX_FAMILIES.includes(family) || warnedVxNames.has(name)) return;
558
558
  warnedVxNames.add(name);
559
559
  console.warn(
560
- `[basalt] ${name} is inside basalt's own --vx-${family} family \u2014 name your series group something basalt does not own (groupTokens('app', \u2026)). Throws from 1.35.0.`
560
+ `[basalt] ${name} is inside basalt's own --vx-${family} family \u2014 name your series group something basalt does not own (groupTokens('app', \u2026)). Throws from 1.36.0.`
561
561
  );
562
562
  }
563
563
  function seriesTokens(map, prefix = "") {
package/llms.txt CHANGED
@@ -1,4 +1,4 @@
1
- # basalt-ui v1.34.1
1
+ # basalt-ui v1.35.0
2
2
  # Opinionated framework for Mantine-based React apps — theme, app shell, visx charts, toolchain presets
3
3
  #
4
4
  # basalt-ui is an opinionated framework for Mantine-based React apps.
@@ -19,7 +19,7 @@
19
19
  # ./tokens.css subpath. Guide: docs/FRAMEWORK-FREE.md in the repo.
20
20
 
21
21
  Package: basalt-ui
22
- Version: 1.34.1
22
+ Version: 1.35.0
23
23
 
24
24
  ## basalt-ui
25
25
  Import: import { ... } from 'basalt-ui'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "basalt-ui",
3
- "version": "1.34.1",
3
+ "version": "1.35.0",
4
4
  "description": "Opinionated framework for Mantine-based React apps — theme, app shell, visx charts, toolchain presets",
5
5
  "keywords": [
6
6
  "charts",
@@ -738,8 +738,8 @@ const PROSE_CONTEXT_TAG = /<(?:Prose|ArticleLayout|Modal|Drawer)(?![\w])/
738
738
  * does not need to: both mean "a raw control here is not homeless".
739
739
  *
740
740
  * The three subtree homes are the plugin's `BASALT_HOST_TAGS`, mirrored here so one law does not
741
- * read differently in the two lanes. The plugin gates them on the tag's basalt IMPORT; a 12-line
742
- * regex window has no import graph, so this lane matches by name and accepts the missed warn on a
741
+ * read differently in the two lanes. The plugin gates them on the tag's basalt IMPORT; a regex walk
742
+ * has no import graph, so this lane matches by name and accepts the missed warn on a
743
743
  * consumer's own `PanelRow` — the same direction every approximation in this scan already leans.
744
744
  *
745
745
  * The name set is the plugin's `CONTROL_HOST_TAGS` ∪ `BASALT_HOST_TAGS`; `oxlint-plugin.test.ts` ›
@@ -749,16 +749,31 @@ const CONTROL_HOST_TAG =
749
749
  /<(?:SettingsRow|FormRow|FormGroup|Modal|Drawer|Popover\.Dropdown|Menu\.Dropdown|Composer|FilterSet|PageAside|PanelRow)(?![\w])/
750
750
 
751
751
  /**
752
- * How far ABOVE a control the host-tag window reaches, in lines.
753
- *
754
- * The text lane has no ancestry, so "inside a SettingsRow" is approximated by "a SettingsRow opens
755
- * within the last N lines". 12 covers every row shape in the five consumer repos (label +
756
- * description + the `control={` line); past that the window would start swallowing the next row's
757
- * control, which is the direction that makes the kind silent rather than noisy. The plugin rule
758
- * (`basalt/control-outside-home`) is the one that answers this exactly — this kind exists for the
759
- * PreToolUse hook lane, which sees one file's TEXT and no AST at all.
752
+ * Open and close tags of the same host names, for the ancestry walk in {@link insideHostTag}. The
753
+ * opening alternative tolerates `=>` inside props (`onChange={(v) => …}`) like
754
+ * {@link RAW_SELECTION_CONTROL_TAG} does, and a `/>` ending marks a self-closing host, which opens
755
+ * nothing.
760
756
  */
761
- const CONTROL_HOST_WINDOW_LINES = 12
757
+ const CONTROL_HOST_TAG_SCAN = new RegExp(
758
+ `<(/?)(?:${/\(\?:([^)]*)\)/.exec(CONTROL_HOST_TAG.source)?.[1] ?? ''})(?![\\w])(?:=>|[^>])*?>`,
759
+ 'g',
760
+ )
761
+
762
+ /**
763
+ * Whether `offset` sits inside a host tag — one opened before it and not yet closed. The text lane
764
+ * has no AST, but the nesting of host tags in plain JSX is countable: +1 per opening tag, −1 per
765
+ * closing one, self-closing tags neither. That is what the plugin's `control-outside-home` answers
766
+ * by walking ancestors, so a control 40 lines under its `<SettingsRow>` is as homed here as there,
767
+ * and one after the row closed is not — the cases a fixed line window got wrong in both directions.
768
+ */
769
+ function insideHostTag(codeText: string, offset: number): boolean {
770
+ let depth = 0
771
+ for (const m of codeText.slice(0, offset).matchAll(CONTROL_HOST_TAG_SCAN)) {
772
+ if (m[1] === '/') depth = Math.max(0, depth - 1)
773
+ else if (!m[0].endsWith('/>')) depth += 1
774
+ }
775
+ return depth > 0
776
+ }
762
777
 
763
778
  /**
764
779
  * A file whose BASENAME declares it is an overlay's or a form's own body — the cross-file half of
@@ -768,7 +783,7 @@ const CONTROL_HOST_WINDOW_LINES = 12
768
783
  *
769
784
  * Law C1's cross-file case is advisory by declaration (`docs/CONTROLS-SPEC.md` §6, "Honest
770
785
  * coverage"), and this kind was paying for it: a `<Select>` inside `edit-session-modal.tsx` whose
771
- * `<Modal>` is rendered by the parent route is outside BOTH the 12-line host window and any
786
+ * `<Modal>` is rendered by the parent route is outside any
772
787
  * ancestry walk, because the host tag is not in the file at all. argo carried 9 of them.
773
788
  *
774
789
  * KEBAB and PascalCase both count, because a repo picks one file-naming law and the convention has
@@ -917,12 +932,14 @@ export type GraceEntry = { since: string; promote: string; why: string }
917
932
  export const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, GraceEntry>> = {
918
933
  'unknown-vx-token': {
919
934
  since: '1.34.0',
920
- promote: '1.35.0',
935
+ promote: '1.36.0',
921
936
  why:
922
937
  'New kind at 1.33.0 (consumer-loop r2 B3): obsidian lost its 44px touch floor to a removed ' +
923
938
  '`var(--vx-space-touch-target)` that nothing reported. WIDENED at 1.34.0 (image-gen r4: ' +
924
939
  '`var(--vx-surface-2)` rendered transparent, unreported) to every unemitted name inside a ' +
925
- 'basalt-owned family, so the grace restarts: warn through 1.34.x, error at 1.35.0.',
940
+ 'basalt-owned family, so the grace restarts: warn through 1.34.x, error at 1.35.0. EXTENDED one minor at 1.35.0: that ' +
941
+ 'minor ships the shell a11y fixes consumers are upgrading for, and the promotion also turns the ' +
942
+ 'seriesTokens warning into a throw — not a change to bundle into that bump. Error at 1.36.0.',
926
943
  },
927
944
  }
928
945
 
@@ -2232,7 +2249,7 @@ export const GUARD_RULES = {
2232
2249
  },
2233
2250
  'raw-selection-control': {
2234
2251
  kind: 'raw-selection-control',
2235
- pattern: RAW_SELECTION_CONTROL_TAG, // handled inline (full-text tag-scoped scan + host window)
2252
+ pattern: RAW_SELECTION_CONTROL_TAG, // handled inline (full-text tag-scoped scan + host ancestry)
2236
2253
  // JSX-tag-shaped (`<Select …>`) — never appears in CSS text.
2237
2254
  appliesTo: (relPath) => !relPath.endsWith('.css'),
2238
2255
  message:
@@ -2760,7 +2777,7 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
2760
2777
  }
2761
2778
  }
2762
2779
 
2763
- // raw-selection-control — full-text tag scan plus the host WINDOW (see CONTROL_HOST_WINDOW_LINES).
2780
+ // raw-selection-control — full-text tag scan plus the host ancestry walk (see insideHostTag).
2764
2781
  // Three file-level exemptions mirror the plugin rule's, so the two lanes agree on the same file:
2765
2782
  // a file that DEFINES a basalt control (and imports none — see CONTROL_OWNER_DEF) cannot be told
2766
2783
  // to use one, a file importing `@mantine/form` is a form — C1's third home, whose inputs are not
@@ -2774,8 +2791,7 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
2774
2791
  ) {
2775
2792
  for (const m of codeText.matchAll(RAW_SELECTION_CONTROL_TAG)) {
2776
2793
  const startLine = codeText.slice(0, m.index ?? 0).split('\n').length
2777
- const windowStart = Math.max(0, startLine - 1 - CONTROL_HOST_WINDOW_LINES)
2778
- if (CONTROL_HOST_TAG.test(codeLines.slice(windowStart, startLine).join('\n'))) continue
2794
+ if (insideHostTag(codeText, m.index ?? 0)) continue
2779
2795
  const endLine = startLine + (m[0].split('\n').length - 1)
2780
2796
  if (isAllowedInRange(startLine, endLine, 'raw-selection-control')) continue
2781
2797
  findings.push({
@@ -33,6 +33,7 @@ import type {
33
33
  SidebarBlock,
34
34
  SidebarBlockItem,
35
35
  SidebarItem,
36
+ SidebarCustomBlock,
36
37
  SidebarListBlock,
37
38
  SidebarProgressBlock,
38
39
  } from './nav-types'
@@ -127,6 +128,14 @@ type ActionRow = {
127
128
  onClick?: ((e: MouseEvent<HTMLElement>) => void) | undefined
128
129
  /** Mirrors `SettingsMenuItem.active` — a trailing check + `aria-current` in every row form. */
129
130
  active?: boolean
131
+ /** Mirrors `SettingsMenuItem.current` — `'page'` swaps the check for the nav active style. */
132
+ current?: 'selection' | 'page'
133
+ }
134
+
135
+ /** `aria-current` for an action row — `"page"` for a destination, `"true"` for a chosen value. */
136
+ function rowCurrent(row: ActionRow): 'page' | 'true' | undefined {
137
+ if (!row.active) return undefined
138
+ return row.current === 'page' ? 'page' : 'true'
130
139
  }
131
140
 
132
141
  /**
@@ -198,6 +207,7 @@ function settingsRows(items: SettingsMenuItem[] | undefined): ActionRow[] {
198
207
  icon: item.icon,
199
208
  onClick: item.onClick,
200
209
  ...(item.active !== undefined && { active: item.active }),
210
+ ...(item.current !== undefined && { current: item.current }),
201
211
  }))
202
212
  }
203
213
 
@@ -248,10 +258,10 @@ const menuActionRow = (row: ActionRow, sectionStart = false) => (
248
258
  key={row.key}
249
259
  className={sectionStart ? `${classes.menuItem} ${classes.menuSectionStart}` : classes.menuItem}
250
260
  leftSection={row.icon}
251
- rightSection={row.active ? <IconCheck /> : undefined}
261
+ rightSection={row.active && row.current !== 'page' ? <IconCheck /> : undefined}
252
262
  {...(row.danger ? { color: 'red' } : {})}
253
263
  onClick={(event: MouseEvent<HTMLElement>) => row.onClick?.(event)}
254
- aria-current={row.active ? 'true' : undefined}
264
+ aria-current={rowCurrent(row)}
255
265
  >
256
266
  {row.label}
257
267
  </Menu.Item>
@@ -353,12 +363,13 @@ export function MobileNav({
353
363
  * applies — the count that picked `menu` vs `sheet` and the rows that render must agree.
354
364
  */
355
365
  const moreBlocks = (blocks ?? []).filter(
356
- (block): block is SidebarListBlock | SidebarProgressBlock =>
357
- block.kind !== 'custom' &&
358
- sidebarBlockMobile(block) === 'more' &&
359
- (block.kind !== 'list' || block.items.length > 0),
366
+ (block): block is SidebarListBlock | SidebarProgressBlock | SidebarCustomBlock =>
367
+ sidebarBlockMobile(block) === 'more' && (block.kind !== 'list' || block.items.length > 0),
368
+ )
369
+ // The blocks that open a nested sheet — a list's rows, or a custom block's node.
370
+ const sheetBlocks = moreBlocks.filter(
371
+ (block): block is SidebarListBlock | SidebarCustomBlock => block.kind !== 'progress',
360
372
  )
361
- const listBlocks = moreBlocks.filter((block): block is SidebarListBlock => block.kind === 'list')
362
373
 
363
374
  /**
364
375
  * Whether the More surface's UNLABELLED tail (blocks + account + settings) opens its own band —
@@ -369,10 +380,10 @@ export function MobileNav({
369
380
  const tailRule = (slot: SurfaceSlot): boolean =>
370
381
  moreBlocks.length + extraRows.length > 0 && slotHasRows(slot)
371
382
 
372
- /** A list block raises its nested sheet; a progress block just fires its own handler. */
383
+ /** A list or custom block raises its nested sheet; a progress block just fires its own handler. */
373
384
  const activateBlock = (block: (typeof moreBlocks)[number]) => {
374
385
  close()
375
- if (block.kind === 'list') {
386
+ if (block.kind !== 'progress') {
376
387
  // ONE FRAME, deliberately. `close()` starts the Menu's `returnFocus` (it restores focus to
377
388
  // the More tab) while the nested Drawer traps focus on mount — in the same handler the two
378
389
  // race, and focus lands wherever the loser wrote last, which is how a tap on a block row
@@ -614,13 +625,14 @@ export function MobileNav({
614
625
  classNames={{ root: classes.row }}
615
626
  label={row.label}
616
627
  leftSection={row.icon}
617
- rightSection={row.active ? <IconCheck /> : undefined}
628
+ rightSection={row.active && row.current !== 'page' ? <IconCheck /> : undefined}
618
629
  {...(row.danger ? { color: 'red' } : {})}
619
630
  onClick={(event: MouseEvent<HTMLElement>) => {
620
631
  row.onClick?.(event)
621
632
  close()
622
633
  }}
623
- aria-current={row.active ? 'true' : undefined}
634
+ active={row.active === true && row.current === 'page'}
635
+ aria-current={rowCurrent(row)}
624
636
  />
625
637
  )
626
638
 
@@ -773,7 +785,7 @@ export function MobileNav({
773
785
  * exit transition would play against a blank sheet; a closed Mantine Drawer renders nothing, so N
774
786
  * instances cost N nothings.
775
787
  */
776
- const blockSheet = (block: SidebarListBlock) => (
788
+ const blockSheet = (block: SidebarListBlock | SidebarCustomBlock) => (
777
789
  <Drawer
778
790
  key={`block-${block.key}`}
779
791
  opened={openBlockKey === block.key}
@@ -799,7 +811,11 @@ export function MobileNav({
799
811
  }}
800
812
  >
801
813
  <ScrollArea.Autosize mah="62dvh" type="scroll" offsetScrollbars="present" scrollbarSize={8}>
802
- <Stack gap={1}>{block.items.map(blockItemRow)}</Stack>
814
+ {block.kind === 'list' ? (
815
+ <Stack gap={1}>{block.items.map(blockItemRow)}</Stack>
816
+ ) : (
817
+ block.node
818
+ )}
803
819
  </ScrollArea.Autosize>
804
820
  </Drawer>
805
821
  )
@@ -815,13 +831,16 @@ export function MobileNav({
815
831
  if (slot.kind === 'menu') return menuSlot(slot)
816
832
  return sheetSlot(slot)
817
833
  })}
818
- {listBlocks.map(blockSheet)}
834
+ {sheetBlocks.map(blockSheet)}
819
835
  </nav>
820
836
  )
821
837
  }
822
838
 
823
839
  /** `Awaiting action · 3` — the row's whole job is to say how much is behind it. */
824
- function blockRowLabel(block: SidebarListBlock | SidebarProgressBlock): string {
840
+ function blockRowLabel(
841
+ block: SidebarListBlock | SidebarProgressBlock | SidebarCustomBlock,
842
+ ): string {
843
+ if (block.kind === 'custom') return block.label ?? ''
825
844
  return block.kind === 'list'
826
845
  ? `${block.label} · ${block.count ?? block.items.length}`
827
846
  : `${block.label} · ${block.value} of ${block.total}`
@@ -101,6 +101,9 @@ export function SidebarAccount(props: BasaltAccountProps & BasaltProps) {
101
101
  return (
102
102
  <UnstyledButton
103
103
  className={cx(classes.accountRow, className)}
104
+ // The collapsed rail hides `.accountText` (display:none), which drops "Sign in" from the
105
+ // accessibility tree — the label keeps the button nameable with only the glyph showing.
106
+ aria-label="Sign in"
104
107
  onClick={actions?.onSignIn}
105
108
  {...(style !== undefined && { style })}
106
109
  >
@@ -249,6 +249,21 @@
249
249
  color: var(--vx-ink);
250
250
  }
251
251
 
252
+ /* A settings entry that IS the current page (`SettingsMenuItem.current: 'page'`) — the nav row's
253
+ * active treatment (theme/nav-link.module.css): accent fill, weight 600, accent icon. */
254
+ .footerBtn[aria-current='page'],
255
+ .settingsMenuItem[aria-current='page'] {
256
+ background-color: color-mix(in srgb, var(--vx-accent) 12%, transparent);
257
+ color: var(--vx-ink);
258
+ font-weight: 600;
259
+ }
260
+
261
+ .footerBtn[aria-current='page'] > svg,
262
+ .footerBtn[aria-current='page'] .footerIconSlot > svg,
263
+ .settingsMenuItem[aria-current='page'] [data-position='left'] {
264
+ color: var(--vx-accent);
265
+ }
266
+
252
267
  .footerText {
253
268
  flex: 1;
254
269
  text-align: left;
@@ -399,6 +414,25 @@
399
414
  color: var(--vx-ink);
400
415
  }
401
416
 
417
+ /* A custom block's rail trigger (`rail: 'icon'`) — a footer-row-sized icon target. */
418
+ .blockRailTrigger {
419
+ display: flex;
420
+ align-items: center;
421
+ justify-content: center;
422
+ padding: var(--vx-space-row-inset-y) var(--vx-space-row-inset-x);
423
+ border-radius: var(--vx-radius-ctrl);
424
+ color: var(--vx-faint);
425
+ }
426
+
427
+ .blockRailTrigger:hover {
428
+ background-color: color-mix(in srgb, var(--vx-ink) 6%, transparent);
429
+ color: var(--vx-ink);
430
+ }
431
+
432
+ .blockRailPopover {
433
+ min-width: 220px;
434
+ }
435
+
402
436
  .blockIcon {
403
437
  position: relative;
404
438
  display: flex;
@@ -717,6 +751,11 @@
717
751
  display: none;
718
752
  }
719
753
 
754
+ .root[data-collapsed] .block[data-rail='icon'] {
755
+ display: flex;
756
+ justify-content: center;
757
+ }
758
+
720
759
  .root[data-collapsed] .block[data-rail='dot'] .blockHeader {
721
760
  justify-content: center;
722
761
  padding: 0;
@@ -42,5 +42,8 @@ declare const classes: {
42
42
  readonly footerLinks: string
43
43
  readonly footerIconSlot: string
44
44
  readonly footerVersion: string
45
+ readonly settingsMenuItem: string
46
+ readonly blockRailTrigger: string
47
+ readonly blockRailPopover: string
45
48
  }
46
49
  export default classes
@@ -183,6 +183,12 @@ function hasActiveDescendant(item: SidebarItem): boolean {
183
183
  return item.children?.some((c) => hasActiveDescendant(c)) ?? false
184
184
  }
185
185
 
186
+ /** `aria-current` for a settings entry: `"page"` for a destination, `"true"` for a chosen value. */
187
+ function settingsCurrent(entry: SettingsMenuItem): 'page' | 'true' | undefined {
188
+ if (!entry.active) return undefined
189
+ return entry.current === 'page' ? 'page' : 'true'
190
+ }
191
+
186
192
  /**
187
193
  * Renders a nav link body without the Tooltip/Box wrapper.
188
194
  *
@@ -488,7 +494,7 @@ export function AppSidebar(props: AppSidebarProps) {
488
494
  className={classes.footerBtn}
489
495
  onClick={entry.onClick}
490
496
  aria-label={entry.label}
491
- aria-current={entry.active ? 'true' : undefined}
497
+ aria-current={settingsCurrent(entry)}
492
498
  >
493
499
  {/* A FIXED slot, so rows align on one icon column whether or not each ships an icon —
494
500
  and the gear fallback is functional, not decorative: in the collapsed rail the label
@@ -501,7 +507,7 @@ export function AppSidebar(props: AppSidebarProps) {
501
507
  </Text>
502
508
  {/* The rail ring rides the FIRST row only — one progress block is one mark. */}
503
509
  {index === 0 ? ringMark : null}
504
- {entry.active ? <IconCheck /> : null}
510
+ {entry.active && entry.current !== 'page' ? <IconCheck /> : null}
505
511
  </UnstyledButton>
506
512
  ))}
507
513
  {versionLabel !== undefined && (
@@ -526,10 +532,11 @@ export function AppSidebar(props: AppSidebarProps) {
526
532
  {settingsItems.map((entry) => (
527
533
  <Menu.Item
528
534
  key={entry.key}
535
+ className={classes.settingsMenuItem}
529
536
  leftSection={entry.icon}
530
- rightSection={entry.active ? <IconCheck /> : undefined}
537
+ rightSection={entry.active && entry.current !== 'page' ? <IconCheck /> : undefined}
531
538
  onClick={entry.onClick}
532
- aria-current={entry.active ? 'true' : undefined}
539
+ aria-current={settingsCurrent(entry)}
533
540
  >
534
541
  {entry.label}
535
542
  </Menu.Item>
@@ -576,7 +583,7 @@ export function AppSidebar(props: AppSidebarProps) {
576
583
  <NavSection key={section.label} section={section} collapsed={collapsed} />
577
584
  ))}
578
585
  {navBlocks.map((block) => (
579
- <SidebarBlockView key={block.key} block={block} />
586
+ <SidebarBlockView key={block.key} block={block} collapsed={collapsed} />
580
587
  ))}
581
588
  </Stack>
582
589
  </ScrollArea>
@@ -584,7 +591,7 @@ export function AppSidebar(props: AppSidebarProps) {
584
591
  {(bottomBlocks.length > 0 || settingsRow !== null || account !== undefined) && (
585
592
  <Stack gap={0} className={classes.footer}>
586
593
  {bottomBlocks.map((block) => (
587
- <SidebarBlockView key={block.key} block={block} />
594
+ <SidebarBlockView key={block.key} block={block} collapsed={collapsed} />
588
595
  ))}
589
596
  {settingsRow}
590
597
  {account !== undefined && (
@@ -130,10 +130,16 @@ export type SettingsMenuItem = {
130
130
  label: string
131
131
  icon?: ReactNode
132
132
  onClick?: (e: MouseEvent) => void
133
- /** This entry is the current selection (e.g. the active theme radio, the open dev tool) — a
134
- * trailing check glyph renders in every form (flat footer row, gear `Menu.Item`, the mobile
135
- * More-sheet row) and the row carries `aria-current="true"`. */
133
+ /** This entry is the current one (e.g. the active theme radio, the open dev tool, the open
134
+ * settings page) — how it shows is `current`. */
136
135
  active?: boolean
136
+ /**
137
+ * What `active` means. `'selection'` (default) is a chosen value: a trailing check glyph in every
138
+ * form (flat footer row, gear `Menu.Item`, the mobile More-sheet row) and `aria-current="true"`.
139
+ * `'page'` is a navigation destination: no check, the nav-item active style, and
140
+ * `aria-current="page"` — what a router-backed settings entry wants.
141
+ */
142
+ current?: 'selection' | 'page'
137
143
  }
138
144
 
139
145
  export type BasaltShellProps = BasaltProps & {
@@ -167,8 +173,9 @@ export type BasaltShellProps = BasaltProps & {
167
173
  * Because they are data, basalt owns the projections the two `ReactNode` slots could not express:
168
174
  * a `list` with a `count` becomes a dot on its icon in the collapsed rail, a `progress` block
169
175
  * becomes a ring on the settings row, and a block with `mobile: 'more'` becomes one More-sheet
170
- * row opening a nested sheet of its items. A `kind: 'custom'` block is desktop-only, exactly as
171
- * `sidebarNavExtra` was.
176
+ * row opening a nested sheet of its items. A `kind: 'custom'` block is desktop-only and hidden in the rail, exactly
177
+ * as `sidebarNavExtra` was, unless it opts in with `icon` + `label` (a rail icon opening it in a
178
+ * popover) and `mobile: 'more'` (a More row opening it in a sheet).
172
179
  */
173
180
  sidebarBlocks?: SidebarBlock[]
174
181
  /**
@@ -139,9 +139,10 @@ export type SidebarProgressBlock = {
139
139
  }
140
140
 
141
141
  /**
142
- * DESKTOP ONLY — arbitrary consumer content (a tree, a filter panel) that no set of rows can
143
- * express. Replaced `BasaltShellProps.sidebarNavExtra`; hidden on the collapsed rail and absent
144
- * from mobile for the same reason that prop was.
142
+ * Arbitrary consumer content (a tree, a filter panel) that no set of rows can express. Replaced
143
+ * `BasaltShellProps.sidebarNavExtra`. By default desktop-only and hidden on the collapsed rail,
144
+ * exactly as that prop was; `icon` + `label` opt it into the rail (as an icon opening the node in a
145
+ * popover) and `mobile: 'more'` into the More sheet.
145
146
  */
146
147
  export type SidebarCustomBlock = {
147
148
  kind: 'custom'
@@ -149,6 +150,21 @@ export type SidebarCustomBlock = {
149
150
  node: ReactNode
150
151
  /** @default 'nav' */
151
152
  placement?: 'nav' | 'bottom'
153
+ /** The rail icon. Needs `label` too: an icon-only trigger must be nameable. */
154
+ icon?: ReactNode
155
+ /** The rail icon's accessible name, the popover heading and the More row / sheet title. */
156
+ label?: string
157
+ /**
158
+ * Collapsed-rail projection: `'icon'` swaps the node for `icon`, which opens it in a popover.
159
+ * @default 'icon' when both `icon` and `label` are set, `'hidden'` otherwise
160
+ */
161
+ rail?: 'icon' | 'hidden'
162
+ /**
163
+ * Below `sm` there is no sidebar: `'more'` makes one More row, labelled `label`, that opens the
164
+ * node in a sheet. Needs `label`.
165
+ * @default 'hidden'
166
+ */
167
+ mobile?: 'more' | 'hidden'
152
168
  }
153
169
 
154
170
  /**
@@ -26,11 +26,17 @@ export function sidebarBlockPlacement(block: SidebarBlock): 'nav' | 'bottom' {
26
26
  /**
27
27
  * The collapsed-rail projection. A list block earns a dot only when it carries a `count` — a rail
28
28
  * icon with no number behind it is a dot that means nothing — and a progress block a ring on the
29
- * settings row. A `custom` block has no rail representation at all (arbitrary content in ~48px of
30
- * icon column), which is exactly what `sidebarNavExtra` did before it.
29
+ * settings row. A `custom` block has a rail form only when it opts in (see below), which is
30
+ * `sidebarNavExtra`'s old hidden-in-the-rail behaviour otherwise.
31
31
  */
32
- export function sidebarBlockRail(block: SidebarBlock): 'dot' | 'ring' | 'hidden' {
33
- if (block.kind === 'custom') return 'hidden'
32
+ export function sidebarBlockRail(block: SidebarBlock): 'dot' | 'ring' | 'icon' | 'hidden' {
33
+ // A `custom` block is arbitrary content with no icon-column form of its own, so it is hidden
34
+ // unless the consumer supplied the icon AND the name that make a rail trigger. Like a list's dot,
35
+ // that overrides an explicit `rail: 'icon'` without them.
36
+ if (block.kind === 'custom') {
37
+ if (block.icon === undefined || block.label === undefined) return 'hidden'
38
+ return block.rail ?? 'icon'
39
+ }
34
40
  if (block.kind === 'progress') return block.rail ?? 'ring'
35
41
  // A dot has to sit ON something. With no `icon` there is no rail node to badge, and with no
36
42
  // `count` there is no number behind the dot — either way the block leaves the rail rather than
@@ -47,7 +53,9 @@ export function sidebarBlockRail(block: SidebarBlock): 'dot' | 'ring' | 'hidden'
47
53
  * threshold is measured against.
48
54
  */
49
55
  export function sidebarBlockMobile(block: SidebarBlock): 'more' | 'hidden' {
50
- if (block.kind === 'custom') return 'hidden'
56
+ // A custom block's More row is named by `label`; without one there is nothing to name it.
57
+ if (block.kind === 'custom')
58
+ return block.mobile === 'more' && block.label !== undefined ? 'more' : 'hidden'
51
59
  if (block.kind === 'progress') return block.mobile ?? 'hidden'
52
60
  return block.mobile ?? 'more'
53
61
  }
@@ -12,7 +12,7 @@
12
12
  * module's class. A second CSS module would hash `.block*` into a different name than the rail
13
13
  * rules could ever select.
14
14
  */
15
- import { Collapse, NavLink, Progress, Text, UnstyledButton } from '@mantine/core'
15
+ import { Collapse, NavLink, Popover, Progress, Text, UnstyledButton } from '@mantine/core'
16
16
  import { useState } from 'react'
17
17
  import type { ReactNode } from 'react'
18
18
  import type {
@@ -280,10 +280,33 @@ function ProgressBlock({ block }: { block: SidebarProgressBlock }) {
280
280
  )
281
281
  }
282
282
 
283
- /** A `kind: 'custom'` block — consumer content, hidden on the rail exactly as `navExtra` was. */
284
- function CustomBlock({ block }: { block: SidebarCustomBlock }) {
283
+ /**
284
+ * A `kind: 'custom'` block — consumer content. Without `icon` + `label` it is hidden on the rail
285
+ * exactly as `navExtra` was (CSS only, the node stays mounted). With them the rail swaps the node
286
+ * for its icon, which opens the node in a popover: that swap is JS-gated on `collapsed`, because
287
+ * the trigger is a different element and nothing in the node's own subtree could express it. The
288
+ * popover mounts only while open, so the inline node is never duplicated.
289
+ */
290
+ function CustomBlock({ block, collapsed }: { block: SidebarCustomBlock; collapsed: boolean }) {
291
+ const rail = sidebarBlockRail(block)
292
+ if (collapsed && rail === 'icon') {
293
+ return (
294
+ <div className={classes.block} data-rail="icon">
295
+ <Popover position="right-start" withArrow={false} offset={4} withinPortal zIndex={400}>
296
+ <Popover.Target>
297
+ <UnstyledButton className={classes.blockRailTrigger} aria-label={block.label}>
298
+ <IconSlot>{block.icon}</IconSlot>
299
+ </UnstyledButton>
300
+ </Popover.Target>
301
+ <Popover.Dropdown p="xs" className={classes.blockRailPopover}>
302
+ {block.node}
303
+ </Popover.Dropdown>
304
+ </Popover>
305
+ </div>
306
+ )
307
+ }
285
308
  return (
286
- <div className={classes.block} data-rail="hidden">
309
+ <div className={classes.block} data-rail={rail}>
287
310
  {block.node}
288
311
  </div>
289
312
  )
@@ -292,10 +315,12 @@ function CustomBlock({ block }: { block: SidebarCustomBlock }) {
292
315
  /** One block, whichever kind it is. `AppSidebar` places it; this decides nothing but the paint. */
293
316
  export function SidebarBlockView({
294
317
  block,
318
+ collapsed = false,
295
319
  }: {
296
320
  block: SidebarListBlock | SidebarProgressBlock | SidebarCustomBlock
321
+ collapsed?: boolean
297
322
  }) {
298
323
  if (block.kind === 'list') return <ListBlock block={block} />
299
324
  if (block.kind === 'progress') return <ProgressBlock block={block} />
300
- return <CustomBlock block={block} />
325
+ return <CustomBlock block={block} collapsed={collapsed} />
301
326
  }
package/src/surfaces.ts CHANGED
@@ -564,8 +564,8 @@ export const SURFACES = {
564
564
  layer: 'mantine-coupled',
565
565
  rule: 'controls',
566
566
  skill: ['basalt-design'],
567
- // The text lane of law C1 — the plugin's `control-outside-home` seen through a 12-line host-tag
568
- // window, since a regex scan has no ancestry. Same law, same wave, same promotion.
567
+ // The text lane of law C1 — the plugin's `control-outside-home` seen through a host-tag nesting
568
+ // count, since a regex scan has no AST. Same law, same wave, same promotion.
569
569
  guardKinds: ['raw-selection-control'],
570
570
  // The control tier itself (laws C1/C3/C5/C9). `hand-rolled-filter`, `control-size-literal` and
571
571
  // `responsive-twin` all ship `error`; the two C1 placement rules are the grace entries in
@@ -824,7 +824,7 @@ function warnConsumerVxName(name: string): void {
824
824
  if (!BASALT_VX_FAMILIES.includes(family) || warnedVxNames.has(name)) return
825
825
  warnedVxNames.add(name)
826
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.`,
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.36.0.`,
828
828
  )
829
829
  }
830
830