basalt-ui 1.29.1 → 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 (119) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/MIGRATING.md +701 -369
  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/query-client.d.ts +20 -2
  54. package/dist/query-client.d.ts.map +1 -1
  55. package/dist/shell/app-brand.module.css +12 -3
  56. package/dist/shell/app-header.module.css +49 -9
  57. package/dist/shell/app-main.module.css +24 -4
  58. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  59. package/dist/shell/app-mobile-nav.js +98 -51
  60. package/dist/shell/app-mobile-nav.module.css +99 -11
  61. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  62. package/dist/shell/app-sidebar.module.css +55 -2
  63. package/dist/shell/index.d.ts.map +1 -1
  64. package/dist/shell/index.js +3 -3
  65. package/dist/shell/mobile-nav-model.d.ts +26 -7
  66. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  67. package/dist/shell/mobile-nav-model.js +12 -3
  68. package/dist/shell/page-aside.module.css +8 -6
  69. package/dist/shell/page-bar.d.ts +13 -0
  70. package/dist/shell/page-bar.d.ts.map +1 -1
  71. package/dist/shell/page-bar.js +13 -3
  72. package/dist/shell/page-bar.module.css +20 -12
  73. package/dist/shell/page-title.module.css +3 -1
  74. package/dist/theme/use-basalt-spacing.d.ts.map +1 -1
  75. package/dist/tokens/index.d.ts.map +1 -1
  76. package/dist/tokens/index.js +51 -7
  77. package/dist/tokens/palette.d.ts +98 -17
  78. package/dist/tokens/palette.d.ts.map +1 -1
  79. package/dist/tokens/palette.js +110 -18
  80. package/dist/tokens.css +17 -5
  81. package/llms.txt +2 -2
  82. package/package.json +1 -1
  83. package/src/charts/primitives/ChartCard.tsx +12 -7
  84. package/src/charts/primitives/ChartFrame.tsx +30 -15
  85. package/src/charts/primitives/chart-frame-layout.ts +86 -0
  86. package/src/cli/check-theme.ts +13 -0
  87. package/src/cli/doctor.ts +49 -8
  88. package/src/cli/help.ts +5 -3
  89. package/src/cli/index.ts +49 -0
  90. package/src/cli/init.ts +6 -27
  91. package/src/cli/sync.ts +11 -22
  92. package/src/controls/actions.tsx +51 -0
  93. package/src/dashboard/section.module.css +15 -7
  94. package/src/dashboard/stat-card.tsx +7 -2
  95. package/src/dashboard/stat-group.module.css +85 -9
  96. package/src/dashboard/stat-group.module.css.d.ts +1 -0
  97. package/src/dashboard/stat-group.tsx +40 -20
  98. package/src/dashboard/widget-grid.module.css +64 -21
  99. package/src/dashboard/widget-grid.module.css.d.ts +1 -0
  100. package/src/dashboard/widget-grid.tsx +30 -12
  101. package/src/guard/index.ts +79 -11
  102. package/src/index.ts +9 -0
  103. package/src/query-client.ts +17 -5
  104. package/src/shell/app-brand.module.css +12 -3
  105. package/src/shell/app-header.module.css +49 -9
  106. package/src/shell/app-main.module.css +24 -4
  107. package/src/shell/app-mobile-nav.module.css +99 -11
  108. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  109. package/src/shell/app-mobile-nav.tsx +225 -83
  110. package/src/shell/app-sidebar.module.css +55 -2
  111. package/src/shell/index.tsx +26 -6
  112. package/src/shell/mobile-nav-model.ts +37 -9
  113. package/src/shell/page-aside.module.css +8 -6
  114. package/src/shell/page-bar.module.css +20 -12
  115. package/src/shell/page-bar.tsx +39 -5
  116. package/src/shell/page-title.module.css +3 -1
  117. package/src/theme/use-basalt-spacing.ts +8 -6
  118. package/src/tokens/index.ts +90 -6
  119. package/src/tokens/palette.ts +111 -18
@@ -4,12 +4,18 @@
4
4
  * packs tighter than a chart card and the law starts at TWO on a phone.
5
5
  *
6
6
  * **`cols` is the DESKTOP count** and the only number a consumer states; the law is
7
- * `base 2 → sm min(cols, 3) → lg cols`. Five is the ceiling — past that the cells are narrower than
7
+ * `base 2 → sm min(cols, 3), except 4 → 2 → lg cols`. Five is the ceiling — past that the cells are narrower than
8
8
  * a formatted currency value and the row is a table.
9
9
  *
10
10
  * **`lg` enters the package here and in `WidgetGrid`, nowhere else.** `sm` stays the only
11
11
  * breakpoint a consumer writes (`docs/CONTROLS-SPEC.md` §2).
12
12
  *
13
+ * **The column law keys on the CONTAINER, not the viewport.** `StatGroup` renders its own
14
+ * `container-type: inline-size` box around the grid, so the counts follow the width the row
15
+ * ACTUALLY has — a `PageAside` claiming 300px, a collapsed sidebar giving 208px back, a consumer's
16
+ * split pane — rather than the viewport width, which changes for none of those. See
17
+ * `stat-group.module.css`'s header for the measured defect and the pre-2023 fallback.
18
+ *
13
19
  * `divided` swaps the column gap for a hairline rail between cells — the bare-metric-row look, for
14
20
  * KPIs that are one reading split several ways rather than several independent cards. It draws the
15
21
  * rule on the GROUP, so the cells stay whatever they are; on `StatCard`s (which carry their own
@@ -33,7 +39,7 @@ import classes from './stat-group.module.css'
33
39
  export type StatGroupCols = 2 | 3 | 4 | 5
34
40
 
35
41
  export type StatGroupProps = BasaltProps & {
36
- /** The DESKTOP column count (`lg` and up). Default `4`. `sm` gets `min(cols, 3)`, a phone gets 2.
42
+ /** The DESKTOP column count (`lg` and up). Default `4`. `sm` gets `min(cols, 3)` — except `4`, which gets 2 rather than an orphan row. A phone gets 2.
37
43
  * Never a responsive object — the law is basalt's, see the module docblock. */
38
44
  cols?: StatGroupCols
39
45
  /** Swap the column gap for a hairline rail between cells. Default `false`. */
@@ -49,26 +55,40 @@ export function StatGroup({
49
55
  className,
50
56
  style,
51
57
  }: StatGroupProps) {
52
- const smCols = Math.min(cols, 3)
58
+ // `4 -> 2`, not `4 -> 3`. `min(cols, 3)` is the general rule and it is right for 2, 3 and 5, but
59
+ // FOUR is the default and the overwhelmingly common count, and three columns leave a four-KPI row
60
+ // as 3 + 1 — one orphan cell against two thirds of empty track. MEASURED on the playground
61
+ // dashboard with a `PageAside` claimed (group 916px): three cells of ~273px plus an orphan, where
62
+ // two of ~450px read as one balanced block. A KPI row is a set of peers; a ragged last row says
63
+ // one of them is different, which is the one thing it must not say.
64
+ const smCols = cols === 4 ? 2 : Math.min(cols, 3)
53
65
 
54
66
  return (
55
- <div
56
- className={cx(classes.root, className)}
57
- data-cols={cols}
58
- data-sm-cols={smCols}
59
- // An ATTRIBUTE, not a second class: the rail's rules key off the resolved column count
60
- // (`[data-cols]`/`[data-sm-cols]`) already, so one selector shape carries the whole law —
61
- // same idiom `stat-card.module.css` uses for `[data-placement]`.
62
- {...(divided && { 'data-divided': 'true' })}
63
- style={
64
- {
65
- '--basalt-stat-group-cols-sm': smCols,
66
- '--basalt-stat-group-cols-lg': cols,
67
- ...style,
68
- } as CSSProperties
69
- }
70
- >
71
- {children}
67
+ // Two boxes, and the outer one is not decoration: a size container is a container for its
68
+ // DESCENDANTS, never for itself, so the grid whose tracks the query decides has to sit inside
69
+ // the box being measured. `className`/`style` land on the OUTER box because that is the root
70
+ // element a consumer positions (`BasaltProps`) — and because measuring the box the consumer
71
+ // sized is the whole point. `container-type: inline-size` contains the inline axis only, so the
72
+ // wrapper's block size still comes from the grid: no percentage height resolves differently,
73
+ // and there is no paint containment, so nothing inside is clipped.
74
+ <div className={cx(classes.container, className)} style={style}>
75
+ <div
76
+ className={classes.root}
77
+ data-cols={cols}
78
+ data-sm-cols={smCols}
79
+ // An ATTRIBUTE, not a second class: the rail's rules key off the resolved column count
80
+ // (`[data-cols]`/`[data-sm-cols]`) already, so one selector shape carries the whole law —
81
+ // same idiom `stat-card.module.css` uses for `[data-placement]`.
82
+ {...(divided && { 'data-divided': 'true' })}
83
+ style={
84
+ {
85
+ '--basalt-stat-group-cols-sm': smCols,
86
+ '--basalt-stat-group-cols-lg': cols,
87
+ } as CSSProperties
88
+ }
89
+ >
90
+ {children}
91
+ </div>
72
92
  </div>
73
93
  )
74
94
  }
@@ -1,15 +1,34 @@
1
1
  /*
2
2
  * WidgetGrid's column law, in CSS (law C9 — a responsive swap belongs to the control, never to a JS
3
- * media query). The two literals below are Mantine's own `--mantine-breakpoint-sm` (48em) and
4
- * `--mantine-breakpoint-lg` (75em); a `@media` condition is resolved before custom properties
5
- * exist, so the number has to be written — same standing constraint `content/article-layout.
6
- * module.css` documents.
3
+ * media query).
7
4
  *
8
- * The COUNTS are custom properties instead, set once per instance by the component: three
9
- * hand-written `repeat()` blocks would have to enumerate every `cols` value, and the whole point of
10
- * this primitive is that a consumer states one desktop number and basalt owns the rest.
5
+ * **The law keys on the CONTAINER, not the viewport**, for the reason `stat-group.module.css`'s
6
+ * header states in full: the shell moves the available width without moving the viewport (a
7
+ * `PageAside` claiming 300px, the sidebar collapsing to its 48px rail, a consumer's split layout),
8
+ * and `@media` is blind to all of it. `.container` — rendered by `WidgetGrid` around the grid —
9
+ * is what makes the queries answerable and is always the box the grid actually fills; a query with
10
+ * no container resolves to false, so leaning on the shell to establish one would have pinned every
11
+ * non-shell consumer to a single column.
12
+ *
13
+ * `.item`'s span law is in the same blocks for the same reason: `grid-column: span <n>` must be
14
+ * clamped against the count the ROOT is actually rendering, and the root's count now follows the
15
+ * container. A span resolved against the viewport while the tracks follow the container is exactly
16
+ * the phantom-track defect `WidgetGrid.Item`'s context clamp exists to prevent.
17
+ *
18
+ * The literals: px in `@container`, Mantine's own `em` in the `@supports not` fallback — same
19
+ * boundary (768px / 1200px at the 16px initial font size), but `em` inside a `@container`
20
+ * condition resolves against the CONTAINER's font-size, so px is the only spelling that cannot
21
+ * drift. A condition cannot read a custom property either way, so the numbers are written; the
22
+ * COUNTS are custom properties, set once per instance by the component — three hand-written
23
+ * `repeat()` blocks would have to enumerate every `cols` value, and the whole point of this
24
+ * primitive is that a consumer states one desktop number and basalt owns the rest.
11
25
  */
12
26
 
27
+ .container {
28
+ container-type: inline-size;
29
+ container-name: basalt-widget-grid;
30
+ }
31
+
13
32
  .root {
14
33
  display: grid;
15
34
  /* Below `sm` a widget column is always 1 — a dashboard card holds a chart, and two of them on a
@@ -18,18 +37,6 @@
18
37
  gap: var(--vx-space-stack-md);
19
38
  }
20
39
 
21
- @media (min-width: 48em) {
22
- .root {
23
- grid-template-columns: repeat(var(--basalt-widget-grid-cols-sm), minmax(0, 1fr));
24
- }
25
- }
26
-
27
- @media (min-width: 75em) {
28
- .root {
29
- grid-template-columns: repeat(var(--basalt-widget-grid-cols-lg), minmax(0, 1fr));
30
- }
31
- }
32
-
33
40
  /*
34
41
  * `min-width: 0` on every cell: a grid track's default `min-width: auto` is its content's intrinsic
35
42
  * width, so one wide `<table>` or one long unbroken value blows the whole row out instead of
@@ -40,14 +47,50 @@
40
47
  min-width: 0;
41
48
  }
42
49
 
43
- @media (min-width: 48em) {
50
+ @container basalt-widget-grid (min-width: 768px) {
51
+ .root {
52
+ grid-template-columns: repeat(var(--basalt-widget-grid-cols-sm), minmax(0, 1fr));
53
+ }
54
+
44
55
  .item {
45
56
  grid-column: span var(--basalt-widget-grid-span-sm);
46
57
  }
47
58
  }
48
59
 
49
- @media (min-width: 75em) {
60
+ @container basalt-widget-grid (min-width: 1200px) {
61
+ .root {
62
+ grid-template-columns: repeat(var(--basalt-widget-grid-cols-lg), minmax(0, 1fr));
63
+ }
64
+
50
65
  .item {
51
66
  grid-column: span var(--basalt-widget-grid-span-lg);
52
67
  }
53
68
  }
69
+
70
+ /*
71
+ * The pre-container-query fallback — Safari < 16, Chrome < 106, Firefox < 110. Verbatim the law
72
+ * this file shipped before, keyed on the viewport, and reachable ONLY where `@container` is not: a
73
+ * browser that cannot parse `container-type` cannot parse the blocks above either, so the two sets
74
+ * are never live at once.
75
+ */
76
+ @supports not (container-type: inline-size) {
77
+ @media (min-width: 48em) {
78
+ .root {
79
+ grid-template-columns: repeat(var(--basalt-widget-grid-cols-sm), minmax(0, 1fr));
80
+ }
81
+
82
+ .item {
83
+ grid-column: span var(--basalt-widget-grid-span-sm);
84
+ }
85
+ }
86
+
87
+ @media (min-width: 75em) {
88
+ .root {
89
+ grid-template-columns: repeat(var(--basalt-widget-grid-cols-lg), minmax(0, 1fr));
90
+ }
91
+
92
+ .item {
93
+ grid-column: span var(--basalt-widget-grid-span-lg);
94
+ }
95
+ }
96
+ }
@@ -3,6 +3,7 @@
3
3
  * signature — so dot access stays verbatim under the package's strict flags.
4
4
  */
5
5
  declare const classes: {
6
+ readonly container: string
6
7
  readonly root: string
7
8
  readonly item: string
8
9
  }
@@ -14,6 +14,14 @@
14
14
  * deliberately NOT forwarded — a responsive object on the public prop is exactly the seam that let
15
15
  * five call sites disagree.
16
16
  *
17
+ * **The law keys on the CONTAINER, not the viewport.** `WidgetGrid` renders its own
18
+ * `container-type: inline-size` box around the grid, so the counts follow the width the grid
19
+ * ACTUALLY has. The shell moves that width without moving the viewport — a `PageAside` claiming its
20
+ * 300px, the sidebar collapsing 256px → its 48px rail, a consumer's split pane — and a `@media`
21
+ * query sees none of it: it kept three chart columns in a 956px content box and every chart in the
22
+ * row went narrower than `VX.phoneChartWidth`, which is the exact condition the `lg` rung exists to
23
+ * avoid. See `widget-grid.module.css`'s header for the fallback.
24
+ *
17
25
  * **This is the sanctioned place `lg` enters the package.** `sm` is still the only breakpoint a
18
26
  * consumer writes (`docs/CONTROLS-SPEC.md` §2); `WidgetGrid` and `StatGroup` own `lg` internally so
19
27
  * nobody else has to.
@@ -77,18 +85,28 @@ export function WidgetGrid({ cols = 2, children, className, style }: WidgetGridP
77
85
 
78
86
  return (
79
87
  <WidgetGridColsContext.Provider value={resolved}>
80
- <div
81
- className={cx(classes.root, className)}
82
- data-cols={cols}
83
- style={
84
- {
85
- '--basalt-widget-grid-cols-sm': resolved.sm,
86
- '--basalt-widget-grid-cols-lg': resolved.lg,
87
- ...style,
88
- } as CSSProperties
89
- }
90
- >
91
- {children}
88
+ {/*
89
+ Two boxes, and the outer one is not decoration: a size container is a container for its
90
+ DESCENDANTS, never for itself, so the grid whose tracks the query decides has to sit inside
91
+ the box being measured. `className`/`style` land on the OUTER box because that is the root
92
+ element a consumer positions (`BasaltProps`) — and because measuring the box the consumer
93
+ sized is the whole point. `container-type: inline-size` contains the inline axis only, so
94
+ the wrapper's block size still comes from the grid: percentage heights resolve exactly as
95
+ before, and there is no paint containment, so nothing inside is clipped.
96
+ */}
97
+ <div className={cx(classes.container, className)} style={style}>
98
+ <div
99
+ className={classes.root}
100
+ data-cols={cols}
101
+ style={
102
+ {
103
+ '--basalt-widget-grid-cols-sm': resolved.sm,
104
+ '--basalt-widget-grid-cols-lg': resolved.lg,
105
+ } as CSSProperties
106
+ }
107
+ >
108
+ {children}
109
+ </div>
92
110
  </div>
93
111
  </WidgetGridColsContext.Provider>
94
112
  )
@@ -517,6 +517,33 @@ const PAGE_TITLE_TAG = /<Title(?![\w.])(?:=>|[^>])*?>/g
517
517
  /** `order={1}` / `order={2}` — the two orders that name a PAGE rather than a section. */
518
518
  const PAGE_TITLE_ORDER_PROP = /\border\s*=\s*\{\s*([12])\s*\}/
519
519
 
520
+ /**
521
+ * The text-lane twin of the plugin's `hasStaticTitleText`: does the body between `<Title …>` and
522
+ * `</Title>` spell a NAME out, or render a value?
523
+ *
524
+ * The law is "one NAME per page", and a name is written in words — so a `<Title order={1}>{
525
+ * knot.title}</Title>` on a nav-less detail route, and a card's hero value
526
+ * `<Title order={2}>{RANK_LABEL[rank]}</Title>`, are data rather than a second page name and stop
527
+ * reporting in BOTH lanes. Kept in step with the plugin deliberately: the two share one id, so one
528
+ * `theme-allow` covers both and they must not disagree about what they cover.
529
+ *
530
+ * Text-lane approximation, and the direction of its error is the safe one: everything outside the
531
+ * outermost `{…}` runs is stripped of braces' contents and what remains has to be blank. A
532
+ * LAUNDERED literal (`{'Users'}`, a substitution-free template) is read as the text it is, so the
533
+ * escape is the expression, not the braces.
534
+ */
535
+ const PAGE_TITLE_BRACED_RUN = /\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g
536
+ const PAGE_TITLE_LAUNDERED_LITERAL = /^\s*['"`][^'"`]*['"`]\s*$/
537
+
538
+ function titleBodyNamesThePage(body: string): boolean {
539
+ let outsideBraces = body
540
+ for (const braced of body.match(PAGE_TITLE_BRACED_RUN) ?? []) {
541
+ if (PAGE_TITLE_LAUNDERED_LITERAL.test(braced.slice(1, -1))) return true
542
+ outsideBraces = outsideBraces.replace(braced, '')
543
+ }
544
+ return outsideBraces.replace(/<[^>]*>/g, '').trim() !== ''
545
+ }
546
+
520
547
  /**
521
548
  * A raw Mantine selection control's opening tag — the text-lane twin of the oxlint plugin's
522
549
  * RAW_FILTER_TAGS. `Chip.Group` carries a dot, so the name class allows one.
@@ -713,19 +740,22 @@ export type GraceEntry = { since: string; promote: string; why: string }
713
740
  export const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, GraceEntry>> = {
714
741
  'raw-selection-control': {
715
742
  since: '1.26.0',
716
- promote: '1.30.0',
743
+ promote: '1.31.0',
717
744
  why:
718
745
  'new in the wave-6 control guards (docs/CONTROLS-SPEC.md §6, law C1). The text lane cannot ' +
719
746
  'see ancestry, so "no home" is approximated by a 12-line host-tag window — the loosest ' +
720
747
  'reading in the guard, and the reason this one lands warn rather than error even though its ' +
721
- 'law is settled. It moved from 1.27.0 to 1.30.0 with its AST twin, `basalt/control-outside-' +
722
- 'home`, whose PLUGIN_RULE_GRACE entry carries the measurement: the wave-7 run left 9 warns ' +
723
- 'in argo, all of them a control in a modal/form module whose `<Modal>` is rendered by the ' +
724
- 'PARENT — law C1 cross-file, which neither lane can see. `isOverlayConventionFile` (this ' +
725
- 'file, and the same regex in the plugin) exempts that declared naming convention in both ' +
726
- 'lanes. Re-dated 2026-08-28 — the argo wave-7 migration has not run; the PascalCase overlay ' +
727
- 'convention (this change) is expected to clear most of the 9. 1.30.0 is when the remainder ' +
728
- 'is re-measured. One law, two lanes, one promotion.',
748
+ 'law is settled. ONE LAW, TWO LANES, ONE PROMOTION: it moves with its AST twin, ' +
749
+ '`basalt/control-outside-home`, whose PLUGIN_RULE_GRACE entry carries the full argument and ' +
750
+ 'the measurement, and it must never be promoted or extended alone — a consumer waiving one ' +
751
+ 'spelling and failing the other is the exact confusion `WAIVER_ID_ALIASES` exists to close. ' +
752
+ 'RE-MEASURED 2026-09-09 across the whole fleet on 1.29.2: 29 AST-lane incumbents in five ' +
753
+ "repos, plus three of basalt's own (data-table, notifications/center, theme-lab). Extended " +
754
+ 'to 1.31.0 because 1.30.0 is the FIRST release in which either lane names a home a ' +
755
+ 'provider-only consumer can reach — every home both messages listed was a shell slot, and ' +
756
+ 'the shell-less `PageBar` sentence ships in this minor. Promoting in the same minor that ' +
757
+ 'makes a rule satisfiable gives nobody a window to act. 1.31.0 is when the 29 are ' +
758
+ 're-measured against that number.',
729
759
  },
730
760
  }
731
761
 
@@ -940,6 +970,27 @@ export const PLUGIN_RULE_IDS: ReadonlySet<string> = new Set([
940
970
  'query-dual-import',
941
971
  ])
942
972
 
973
+ /**
974
+ * ONE LAW, TWO RULE IDS — the ids that waive each other, in BOTH directions.
975
+ *
976
+ * `raw-selection-control` (this lane) and `basalt/control-outside-home` (the oxlint plugin) are the
977
+ * same law read by two engines, and this lane's own message already says so. They keep separate ids
978
+ * because the two engines disagree often enough that a consumer needs to be able to name which one
979
+ * they are waiving — but a consumer waiving the LAW should not have to know that, and before this
980
+ * map they did: `theme-allow control-outside-home` parsed here (the plugin's ids are all known,
981
+ * see {@link PLUGIN_RULE_IDS}) and then covered nothing, so the only working annotation was
982
+ * `theme-allow raw-selection-control control-outside-home` — two ids, undocumented, one silent
983
+ * debug cycle per consumer. `in-body-page-title` needs no entry: it is one id in both lanes.
984
+ *
985
+ * Mirrored VERBATIM in `configs/oxlint-plugin.js` (which cannot import from this package — see its
986
+ * header); `check-source.test.ts` asserts the two agree. An alias only ever WIDENS what an
987
+ * annotation covers, so a waiver can never become stricter by being spelled the other way.
988
+ */
989
+ export const WAIVER_ID_ALIASES: Readonly<Record<string, readonly string[]>> = {
990
+ 'control-outside-home': ['raw-selection-control'],
991
+ 'raw-selection-control': ['control-outside-home'],
992
+ }
993
+
943
994
  /** The shortest string accepted as a written reason — enough to exclude a stray separator. */
944
995
  const MIN_ALLOW_REASON_LENGTH = 4
945
996
 
@@ -1346,6 +1397,7 @@ function collectAllowAnnotations(
1346
1397
  */
1347
1398
  function annotationCovers(annotation: AllowAnnotation, kind: GuardKind): boolean {
1348
1399
  if (annotation.rules.includes(kind)) return true
1400
+ if ((WAIVER_ID_ALIASES[kind] ?? []).some((alias) => annotation.rules.includes(alias))) return true
1349
1401
  if (annotation.scope === 'file') return false
1350
1402
  return annotation.rules.length === 0 && annotation.unknownRules.length === 0
1351
1403
  }
@@ -1998,7 +2050,7 @@ export const GUARD_RULES = {
1998
2050
  // JSX-tag-shaped (`<Title order={1}>`) — never appears in CSS text.
1999
2051
  appliesTo: (relPath) => !relPath.endsWith('.css'),
2000
2052
  message:
2001
- 'In-body page title — a page is named ONCE, by the breadcrumb (staticData.title) or by PageBar.title in a shell-less app, and every section/card/table title is a WidgetHeader (docs/CONTROLS-SPEC.md law C8). An <Title order={1|2}> in the body is a second name for the same page, and it drifts. Prose / ArticleLayout / an overlay and anything under a content/ path are document headings and never report. Same law the oxlint plugin enforces as basalt/in-body-page-title — one id, two lanes, so one theme-allow covers both.',
2053
+ 'In-body page title — a page is named ONCE: by the breadcrumb (staticData.title), by PageBar.title, or by <PageTitle> on a shell-less surface (a route error, an auth gate, a standalone screen). Every section/card/table title is a WidgetHeader (docs/CONTROLS-SPEC.md law C8). An <Title order={1|2}> spelling a name out in the body is a second name for the same page, and it drifts. A Title rendering a VALUE — {doc.title}, {RANK_LABEL[rank]} — never reports: that is data, not a name. Prose / ArticleLayout / an overlay and anything under a content/ path never report either. Same law the oxlint plugin enforces as basalt/in-body-page-title — one id, two lanes, so one theme-allow covers both.',
2002
2054
  },
2003
2055
  'raw-selection-control': {
2004
2056
  kind: 'raw-selection-control',
@@ -2006,7 +2058,7 @@ export const GUARD_RULES = {
2006
2058
  // JSX-tag-shaped (`<Select …>`) — never appears in CSS text.
2007
2059
  appliesTo: (relPath) => !relPath.endsWith('.css'),
2008
2060
  message:
2009
- 'Raw selection control with no home — a filter or tab belongs in a PageBar / Section / WidgetHeader slot and takes a `field` (a FieldHandle), so it owns the URL write and the localStorage mirror instead of carrying value/onChange (docs/CONTROLS-SPEC.md laws C1–C3). Use the bound control from basalt-ui/controls. A settings row, an overlay and an @mantine/form file are the declared non-homes and never report. This is the TEXT lane of basalt/control-outside-home, which answers the same question against the real AST — where the two disagree, the plugin is right.',
2061
+ 'Raw selection control with no home — a filter or tab belongs in a PageBar / Section / WidgetHeader slot and takes a `field` (a FieldHandle), so it owns the URL write and the localStorage mirror instead of carrying value/onChange (docs/CONTROLS-SPEC.md laws C1–C3). Use the bound control from basalt-ui/controls. A settings row, an overlay and an @mantine/form file are the declared non-homes and never report. No shell? PageBar needs no BasaltShell: outside one it renders in flow, sticky, with its own title — so its slots are a reachable home for a provider-only app. This is the TEXT lane of basalt/control-outside-home, which answers the same question against the real AST — where the two disagree, the plugin is right.',
2010
2062
  },
2011
2063
  'inline-font-size': {
2012
2064
  kind: 'inline-font-size',
@@ -2125,6 +2177,12 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
2125
2177
  for (const annotation of fileScoped) {
2126
2178
  for (const rule of annotation.rules) {
2127
2179
  if (Object.hasOwn(GUARD_RULES, rule)) fileWaived.add(rule as GuardKind)
2180
+ // The plugin-id spelling of a shared law reaches file scope too — a `theme-allow-file
2181
+ // control-outside-home — <why>` names a rule this lane knows under another id, and honouring
2182
+ // it on one lane only is the same silent half-application the line scope had.
2183
+ for (const alias of WAIVER_ID_ALIASES[rule] ?? []) {
2184
+ if (Object.hasOwn(GUARD_RULES, alias)) fileWaived.add(alias as GuardKind)
2185
+ }
2128
2186
  }
2129
2187
  }
2130
2188
 
@@ -2432,6 +2490,16 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
2432
2490
  for (const m of codeText.matchAll(PAGE_TITLE_TAG)) {
2433
2491
  const order = PAGE_TITLE_ORDER_PROP.exec(m[0])
2434
2492
  if (order === null) continue
2493
+ // A self-closing `<Title order={1} />` renders no name at all, so it never reports — and the
2494
+ // test has to be explicit rather than fall out of the body scan, which would otherwise run to
2495
+ // the NEXT `</Title>` in the file and judge unrelated code. The plugin agrees by construction
2496
+ // (an element with no children has no static text), and one id must not mean two verdicts.
2497
+ if (m[0].endsWith('/>')) continue
2498
+ const bodyStart = (m.index ?? 0) + m[0].length
2499
+ const closeAt = codeText.indexOf('</Title>', bodyStart)
2500
+ // No closing tag at all: no body to judge, so nothing names the page. See below for what a
2501
+ // body has to hold before this kind reports.
2502
+ if (closeAt === -1 || !titleBodyNamesThePage(codeText.slice(bodyStart, closeAt))) continue
2435
2503
  const startLine = codeText.slice(0, m.index ?? 0).split('\n').length
2436
2504
  const endLine = startLine + (m[0].split('\n').length - 1)
2437
2505
  if (isAllowedInRange(startLine, endLine, 'in-body-page-title')) continue
package/src/index.ts CHANGED
@@ -37,6 +37,15 @@ export { createBasaltTheme, baseTheme, cssVariablesResolver } from './theme'
37
37
  export type { BasaltFontsConfig, CreateBasaltThemeOptions } from './theme'
38
38
  export { CTL_THEME, CtlSlot } from './theme'
39
39
  export type { CtlSlotProps } from './theme'
40
+ /**
41
+ * The ACTIVE resolved spacing off the running theme, not the frozen level-0 `SPACE`/`SPACE_STEP`
42
+ * snapshot on `basalt-ui/tokens`. Public because a consumer laying out ITS OWN chrome beside the
43
+ * shell's (a sticky sub-header under the app header, a fixed rail beside the navbar) has to hit the
44
+ * same rhythm the shell resolved, and every one of those numbers is JS-consumed — an AppShell
45
+ * dimension or a `<Menu width={…}>` — so there is no `--vx-*` var to read it from instead. See
46
+ * `theme/use-basalt-spacing.ts` for the level-0 fallback contract.
47
+ */
48
+ export { useBasaltSpacing } from './theme'
40
49
  export { ThemeToggle, type ThemeToggleProps } from './theme/theme-toggle'
41
50
  /**
42
51
  * The action vocabulary every home's `actions` slot takes (`docs/CONTROLS-SPEC.md` §2.1). The
@@ -63,6 +63,16 @@ export function createBasaltQueryClient(config?: QueryClientConfig): QueryClient
63
63
  * `unwrap` reference — so `api.threads.get().then(unwrap)` silently widened. A single generic
64
64
  * signature has exactly one type to read regardless of how the reference is used.
65
65
  *
66
+ * **The conditional infers the raw `data` type and strips `null`/`undefined` from it**, rather
67
+ * than inferring the already-narrowed `Envelope<TData>`'s `TData` directly — Eden Treaty's
68
+ * response type is a UNION of envelope shapes (`{ data: T; error: null } | { data: null; error:
69
+ * EdenFetchError }`), and inferring through `Envelope<infer TData>` reads `TData` off whichever
70
+ * union member the conditional happens to match, landing on `T | null` at every Eden call site. A
71
+ * conditional type distributes over a union input when the checked type is a bare type parameter,
72
+ * so inferring `D` from `{ data: infer D; error: unknown }` and wrapping the result in
73
+ * `NonNullable` runs the check per union member and unions the results back together —
74
+ * `NonNullable<T> | NonNullable<null>` collapses to `T`.
75
+ *
66
76
  * @example
67
77
  * import { unwrap } from 'basalt-ui'
68
78
  *
@@ -86,12 +96,14 @@ export function unwrap<R extends Envelope | Promise<Envelope>>(response: R): Unw
86
96
  type Envelope<TData = unknown> = { data: TData | null | undefined; error: unknown }
87
97
 
88
98
  /** `unwrap`'s conditional return: a `Promise<TData>` when `R` is a promised envelope, `TData` when
89
- * it is an already-resolved one — inferred off the SAME `R` the overload used to split on. */
99
+ * it is an already-resolved one — inferred off the SAME `R` the overload used to split on, and
100
+ * distributing over a union `R` (see the JSDoc above) so an Eden Treaty response's null branch
101
+ * never leaks into the resolved type. */
90
102
  type UnwrapResult<R> =
91
- R extends Promise<Envelope<infer TData>>
92
- ? Promise<TData>
93
- : R extends Envelope<infer TData>
94
- ? TData
103
+ R extends Promise<{ data: infer TData; error: unknown }>
104
+ ? Promise<NonNullable<TData>>
105
+ : R extends { data: infer TData; error: unknown }
106
+ ? NonNullable<TData>
95
107
  : never
96
108
 
97
109
  function unwrapEnvelope<TData>(envelope: Envelope<TData>): TData {
@@ -10,7 +10,11 @@
10
10
  * state and lands its trailing edge exactly on the sidebar|main seam. The negative
11
11
  * `margin-inline-start` cancels `AppShell.Header`'s own `px` inset so the zone starts at the
12
12
  * viewport edge and re-applies that inset as padding — which is what puts the brand lead on the
13
- * same x as the sidebar rows below it.
13
+ * same x as the sidebar's nav ICON column below it. The arithmetic, now that the page gutter is
14
+ * `SPACE.appShellInset`: the zone re-applies `--app-shell-padding` = 20, and a nav icon sits at
15
+ * 10px of `.root` frame inset plus 10px of NavLink inset (`rowInsetX`) = 20. One vertical line
16
+ * across the seam — a claim this comment used to make while the gutter was 13, and that the
17
+ * 13 -> 20 move made true.
14
18
  */
15
19
  .zone {
16
20
  flex: 0 0 var(--app-shell-navbar-offset, 0rem);
@@ -58,11 +62,16 @@
58
62
  }
59
63
 
60
64
  /* Workspace switcher (`brand.menu`) — the brand name plus a chevron, as a row-shaped trigger. The
61
- * chevron is faint until hover so the brand still reads as the brand, not as a control. */
65
+ * chevron is faint until hover so the brand still reads as the brand, not as a control.
66
+ *
67
+ * `control-gap`, not the `sidebar-section-label-gap` this used to borrow: it is a HORIZONTAL
68
+ * name-to-glyph gap inside one control, and it has nothing to do with a sidebar label's vertical
69
+ * proximity pair — which is exactly why it moved when that token was retuned for an unrelated
70
+ * reason. Same 6px, correct by meaning. */
62
71
  .brandButton {
63
72
  display: flex;
64
73
  align-items: center;
65
- gap: var(--vx-space-sidebar-section-label-gap);
74
+ gap: var(--vx-space-control-gap);
66
75
  min-width: 0;
67
76
  padding: 0;
68
77
  color: var(--vx-faint);
@@ -1,4 +1,4 @@
1
- /* App-shell top bar layout. ONE 48px row on every viewport, spanning the FULL viewport width —
1
+ /* App-shell top bar layout. ONE 44px row on every viewport, spanning the FULL viewport width —
2
2
  * [brand | collapse] [breadcrumb] ········ [page bar row 1] | [global actions].
3
3
  *
4
4
  * The leading zone is `app-brand.module.css`'s `.zone`: exactly `--app-shell-navbar-offset` wide,
@@ -36,17 +36,48 @@
36
36
  * overflows towards its START, so the sync button painted 30px INTO the page title. Measured in
37
37
  * Chrome: the title spanned 119.7–197.1 and the sync button 158.3–188.3.
38
38
  *
39
- * So the lead is now the elastic side (`flex: 1 1 auto`, `min-width: 0`, each crumb truncating via
40
- * its own `truncate` prop) and the control side is rigid. A row of buttons that genuinely does not
41
- * fit is not a shrinking problem — it is the OVERFLOW FOLD's problem, and basalt already owns that
42
- * (law C7: below `sm` row 1 is the primary plus ONE kebab).
39
+ * So the lead is now the elastic side (`flex: 1 1 auto`, each crumb truncating via its own
40
+ * `truncate` prop) and the control side is rigid. A row of buttons that genuinely does not fit is
41
+ * not a shrinking problem — it is the OVERFLOW FOLD's problem, and basalt already owns that (law
42
+ * C7: below `sm` row 1 is the primary plus ONE kebab).
43
+ *
44
+ * `min-width` is a FLOOR, not the `0` it used to be: elastic still, but never elastic to nothing.
45
+ * With `0` the breadcrumb was the row's entire shock absorber and it absorbed itself out of
46
+ * existence — and then the row overflowed anyway, because `.pageBar` and `.global` below are both
47
+ * rigid. Measured on /dashboard at exactly 768px (the `sm` breakpoint, where the navbar appears):
48
+ * 768 − 256 navbar = 512px of header left, against ~554px of row 1 + ~102px of globals + ~48px of
49
+ * gaps ≈ 704px. ~192px over, with the page title already at zero — so the fold bought nothing and
50
+ * cost the reader the one word saying where they are.
51
+ *
52
+ * 96px, because the current crumb is `VX.text.lg` (16px) at `font-stretch: 88%`, i.e. ~7px of
53
+ * average advance: 96px is ~13 characters ("Study progress") before the crumb's own `truncate`
54
+ * starts eliding, and a short title ("Dashboard", "Settings") fits with room to spare. Below that
55
+ * the crumb is an ellipsis with a letter in front of it, which is the state this floor exists to
56
+ * forbid.
57
+ *
58
+ * It reads a TOKEN (`SPACE_STEP.appShellHeaderLeadMin`) rather than the raw `96px` it shipped as,
59
+ * because the derivation above is entirely in units that move with density — the crumb's type size
60
+ * and its average advance — so a frozen floor would buy ~13 characters at level 0 and fewer at
61
+ * every level up. The rem fallback is the level-0 number, for a stylesheet loaded before the
62
+ * provider's.
63
+ *
64
+ * Unscoped by width on purpose — it costs nothing on a phone. At 390px the row is the breadcrumb
65
+ * plus ~66px of mobile bar, ~66px of globals and ~26px of gaps inside a 374px content box, so the
66
+ * floor is never the binding constraint there.
67
+ *
68
+ * The floor is NOT free above `sm`: on its own it adds its own 96px to that ~192px overflow. The
69
+ * `md` label fold in `controls/actions.tsx` (`BarEntry`, same 62em tier) is what pays for it —
70
+ * dropping up to `DESKTOP_SECONDARY_MAX` (3) labelled secondaries to `ctl`-tier icon buttons returns
71
+ * roughly 180-240px on that same row. The two are one change; neither is correct alone, and
72
+ * NEITHER is the real answer, which is measuring the row (a ResizeObserver fold) rather than
73
+ * declaring breakpoints for it. This pair is the cheap, declared interim until that exists.
43
74
  */
44
75
  .lead {
45
76
  display: flex;
46
77
  align-items: center;
47
78
  gap: var(--mantine-spacing-sm);
48
79
  flex: 1 1 auto;
49
- min-width: 0;
80
+ min-width: var(--vx-space-app-shell-header-lead-min, 96px);
50
81
  }
51
82
 
52
83
  /* No `overflow: hidden` here or on `.lead` above: Mantine's focus ring is `outline: 2px solid;
@@ -81,7 +112,8 @@
81
112
  }
82
113
 
83
114
  /*
84
- * The shell globals sit at the SAME 30px tier as everything else in the row, and they get there the
115
+ * The shell globals sit at the SAME 32px `ctl` tier as everything else in the row, and they get there
116
+ * the
85
117
  * same way a home's slot does — `HeaderGlobalActions` wraps this region in a `CtlSlot`, so a
86
118
  * consumer's bare `<ActionIcon>` in `globalActions` defaults to `size="ctl"` with no prop at the call
87
119
  * site (law C5). They rendered at Mantine's `md` 28px before, which is 2px under the page group
@@ -98,9 +130,17 @@
98
130
  flex-shrink: 0;
99
131
  }
100
132
 
133
+ /* Glyph BOX, 1.125rem — the SAME decoration-class fixed size `page-bar.module.css` sets on row 1's
134
+ * own glyphs, and deliberately NOT a `--vx-space-*` token (an icon is not a gap; the shipped icon
135
+ * set draws on a 24px grid and the box is a fraction of the type size, not of the spacing scale).
136
+ * Do not re-litigate these in a token sweep.
137
+ * 1.125rem rather than the old 1rem for the same reason the page bar's grew — `controlHeightCtl`
138
+ * went 30 -> 32, and a glyph held at 16px inside a 32px control reads EMPTIER, not bigger. The two
139
+ * rows are ONE control family (`HeaderGlobalActions` and `PageBar` row 1 sit in the same 44px
140
+ * band), so they had to stop disagreeing by 2px on the same glyph. */
101
141
  .global :global(.mantine-ActionIcon-root) > svg {
102
- width: 1rem;
103
- height: 1rem;
142
+ width: 1.125rem;
143
+ height: 1.125rem;
104
144
  }
105
145
 
106
146
  /*
@@ -89,7 +89,23 @@
89
89
  margin-bottom: calc(var(--app-shell-footer-offset, 0rem) + env(safe-area-inset-bottom, 0px));
90
90
  margin-inline-start: var(--app-shell-navbar-offset, 0rem);
91
91
  margin-inline-end: var(--app-shell-aside-offset, 0rem);
92
- padding: var(--app-shell-padding);
92
+ /*
93
+ * The scrollport's own inset, SPLIT by axis — a gap under a divider is not a gutter beside a
94
+ * seam.
95
+ *
96
+ * Inline stays on `--app-shell-padding`, the one Mantine var this module still reads for a
97
+ * value rather than an offset, because Mantine owns the responsive switch (`shell/index.tsx`
98
+ * hands `AppShell` the `appShellInset`/`appShellInsetMobile` pair) and the header, the band and
99
+ * Main all have to land on the SAME number — re-deriving it here would fork it. It is an
100
+ * ALIGNMENT constraint: 20 at `sm`+ puts the page's first column on the sidebar nav icon
101
+ * column's x across the seam (`app-brand.module.css` states that arithmetic).
102
+ *
103
+ * Block is `stack-md` and is only breathing room under the band's single `--vx-divider` line —
104
+ * nothing aligns to it, so it has no business tracking a gutter that grew for an alignment
105
+ * reason. Holding it at 12 keeps the total chrome above the first card at ~97px (44 header + 40
106
+ * band + 1 seam + 12) — DOWN from 100 — even though the inline gutter grew 13 -> 20.
107
+ */
108
+ padding: var(--vx-space-stack-md) var(--app-shell-padding);
93
109
  /* Without this a wide flex/grid child (a bare table, a nowrap control row) sizes Main to ITSELF. */
94
110
  min-width: 0;
95
111
  overflow-y: auto;
@@ -107,9 +123,13 @@
107
123
  }
108
124
 
109
125
  /* Installed PWA (C5 consolidation, seeded from argo's `styles/native.css`): clear the landscape
110
- * notch on the side Main touches. `max()` is a FLOOR over `.main`'s own `padding` shorthand above
111
- * — same mechanism as `app-sidebar.module.css`'s left counterpart. The bottom inset already rides
112
- * `margin-bottom` above (`env(safe-area-inset-bottom)`); the PWA's top inset needs no rule here
126
+ * notch on the side Main touches. `max()` is a FLOOR over the INLINE value of `.main`'s `padding`
127
+ * shorthand above — same mechanism as `app-sidebar.module.css`'s left counterpart, and it still
128
+ * floors correctly now that the shorthand is two axes and the inline one is responsive: this rule
129
+ * reads the same `--app-shell-padding`, so the floor is against whichever of 20/8 is live, and on
130
+ * the phone half (8) it is doing MORE work than before, not less. It overrides only
131
+ * `padding-right`; the block padding keeps coming from the shorthand. The bottom inset already
132
+ * rides `margin-bottom` above (`env(safe-area-inset-bottom)`); the PWA's top inset needs no rule here
113
133
  * (the status bar uses the opaque `default` style, per argo's own seed comment). */
114
134
  @media (display-mode: standalone) {
115
135
  .main {