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
@@ -180,6 +180,27 @@ export const KNOWN_RULE_IDS = new Set([
180
180
  ...RETIRED_RULE_IDS,
181
181
  ])
182
182
 
183
+ /**
184
+ * ONE LAW, TWO RULE IDS — the ids that waive each other, in BOTH directions.
185
+ *
186
+ * `basalt/control-outside-home` (this plugin, AST) and `raw-selection-control` (`src/guard`'s text
187
+ * lane) are the same law read by two engines, and the guard's own message says so. They could not
188
+ * share one id the way `in-body-page-title` does, because the two lanes disagree often enough that
189
+ * a consumer needs to be able to name which one they are waiving — but a consumer waiving the law
190
+ * should not have to know that, and before this map they did: `theme-allow control-outside-home`
191
+ * silenced oxlint and left check-theme reporting, so the only working annotation was
192
+ * `theme-allow raw-selection-control control-outside-home` — two ids, undocumented, discovered one
193
+ * debug cycle per consumer.
194
+ *
195
+ * Mirrored VERBATIM by `src/guard`'s copy (this file must not import from the package — see the
196
+ * header); `check-source.test.ts` asserts the two agree. An alias only ever WIDENS what an
197
+ * annotation covers, so a waiver can never become stricter by being spelled the other way.
198
+ */
199
+ export const WAIVER_ID_ALIASES = {
200
+ 'control-outside-home': ['raw-selection-control'],
201
+ 'raw-selection-control': ['control-outside-home'],
202
+ }
203
+
183
204
  const ALLOW_RULE_TOKEN = /^(?:basalt\/)?([a-z][a-z0-9-]*)(?=$|[\s,:—–])/
184
205
  const ALLOW_REASON_SEPARATOR = /^(?:—|–|-{1,2}|:)\s*/
185
206
  /** Shortest string accepted as a written reason — enough to exclude a stray separator. */
@@ -277,6 +298,7 @@ function parseThemeAllows(commentValue) {
277
298
  */
278
299
  function allowCovers(allow, ruleId) {
279
300
  if (allow.rules.includes(ruleId)) return true
301
+ if ((WAIVER_ID_ALIASES[ruleId] ?? []).some((alias) => allow.rules.includes(alias))) return true
280
302
  if (allow.scope === 'file') return false
281
303
  return allow.rules.length === 0 && allow.unknownRules.length === 0
282
304
  }
@@ -2233,7 +2255,9 @@ function collectBasaltImportMap(node, into, ownTree) {
2233
2255
  * caller resolves it against {@link collectBasaltImports}'s set afterwards.
2234
2256
  */
2235
2257
  function slotOwnerBinding(attr) {
2236
- if (attr === null || attr === undefined || attr.type !== 'JSXAttribute') return undefined
2258
+ if (attr === null || attr === undefined) return undefined
2259
+ if (attr.type === 'Property') return spreadSlotOwnerBinding(attr)
2260
+ if (attr.type !== 'JSXAttribute') return undefined
2237
2261
  const name = attr.name?.name
2238
2262
  if (typeof name !== 'string' || !SLOT_ATTRS.has(name)) return undefined
2239
2263
  const owner = attr.parent
@@ -2242,6 +2266,57 @@ function slotOwnerBinding(attr) {
2242
2266
  return jsxRootName(owner.name)
2243
2267
  }
2244
2268
 
2269
+ /**
2270
+ * Everything that may sit between a slot Property and the `{...}` spreading it. A conditional slot
2271
+ * is written as one of `{...obj}`, `{...(cond && { tabs })}` or `{...(cond ? { tabs } : {})}`, and
2272
+ * nothing else reaches a spread without changing what the property MEANS.
2273
+ */
2274
+ const SPREAD_WRAPPER_TYPES = new Set([
2275
+ 'ObjectExpression',
2276
+ 'LogicalExpression',
2277
+ 'ConditionalExpression',
2278
+ ])
2279
+
2280
+ /**
2281
+ * {@link slotOwnerBinding} for a slot written as an OBJECT SPREAD — `<Section {...(cond && { tabs:
2282
+ * <ViewTabs/> })}>` is the same home as `<Section tabs={<ViewTabs/>}>` and used to be invisible,
2283
+ * because every control rule resolved homes off JSXAttribute nodes alone.
2284
+ *
2285
+ * That is not an exotic shape: under `exactOptionalPropertyTypes` a consumer CANNOT write
2286
+ * `tabs={cond ? <X/> : undefined}` at all — `undefined` is not assignable to an optional prop — so
2287
+ * the spread IS the idiom, and linewatch (a strict house-style repo) writes it three times in one
2288
+ * element. `bound-control-outside-home` therefore reported a false positive on every strict
2289
+ * consumer's conditional slot, waivable only by comment, on a rule scheduled to go `error`.
2290
+ *
2291
+ * The walk out is deliberately narrow ({@link SPREAD_WRAPPER_TYPES}): the property has to reach a
2292
+ * `JSXSpreadAttribute` through object/`&&`/`?:` nodes only. A property inside a `.map()`, a call
2293
+ * argument or a hoisted `const` is NOT claimed — that value is not written into this element, and
2294
+ * treating it as a home would exempt controls that really have none.
2295
+ *
2296
+ * A computed key (`{ [k]: <ViewTabs/> }`) is not a slot: the name is not knowable statically, and
2297
+ * guessing one direction or the other is exactly the "looks correct and is not" class the control
2298
+ * rules exist to remove.
2299
+ */
2300
+ function spreadSlotOwnerBinding(property) {
2301
+ if (property.computed === true) return undefined
2302
+ const name = property.key?.name ?? property.key?.value
2303
+ if (typeof name !== 'string' || !SLOT_ATTRS.has(name)) return undefined
2304
+ let current = property.parent
2305
+ for (let depth = 0; current !== null && current !== undefined; depth++) {
2306
+ if (depth > ANCESTRY_MAX_DEPTH) return undefined
2307
+ if (current.type === 'JSXSpreadAttribute') break
2308
+ if (!SPREAD_WRAPPER_TYPES.has(current.type)) return undefined
2309
+ current = current.parent
2310
+ }
2311
+ if (current === null || current === undefined || current.type !== 'JSXSpreadAttribute') {
2312
+ return undefined
2313
+ }
2314
+ const owner = current.parent
2315
+ if (owner === null || owner === undefined || owner.type !== 'JSXOpeningElement') return undefined
2316
+ if (!SLOT_OWNER_TAGS.has(jsxTagName(owner.name) ?? '')) return undefined
2317
+ return jsxRootName(owner.name)
2318
+ }
2319
+
2245
2320
  /** The home slot attribute that ENCLOSES `node`, or undefined. Stops at the attribute, never the element. */
2246
2321
  function enclosingSlotAttribute(node) {
2247
2322
  let current = node.parent
@@ -2277,6 +2352,8 @@ function hostedInsideSlot(node) {
2277
2352
  return true
2278
2353
  }
2279
2354
  if (current.type === 'JSXAttribute' && SLOT_ATTRS.has(current.name?.name ?? '')) return false
2355
+ // The object-spread spelling of the same boundary — see {@link spreadSlotOwnerBinding}.
2356
+ if (slotOwnerBinding(current) !== undefined) return false
2280
2357
  current = current.parent
2281
2358
  }
2282
2359
  return false
@@ -2398,7 +2475,11 @@ function createSlotContext(context) {
2398
2475
  noteImport(node) {
2399
2476
  collectBasaltImportMap(node, homes, ownTree)
2400
2477
  },
2401
- /** Feed every JSXAttribute here — a slot attribute contributes its value's identifiers. */
2478
+ /**
2479
+ * Feed every JSXAttribute AND every ObjectExpression Property here — a slot attribute, in
2480
+ * either spelling, contributes its value's identifiers. A Property's `value` is the expression
2481
+ * directly (no JSX container to unwrap), which `unwrapExpressionContainer` passes through.
2482
+ */
2402
2483
  note(attr) {
2403
2484
  const owner = slotOwnerBinding(attr)
2404
2485
  if (owner === undefined) return
@@ -2560,6 +2641,11 @@ const handRolledFilter = {
2560
2641
  collectMantineImports(node, mantineImports)
2561
2642
  slots.noteImport(node)
2562
2643
  },
2644
+ // The object-spread spelling of a slot — see {@link spreadSlotOwnerBinding}. `slots.note`
2645
+ // accepts either node shape; feeding both is what keeps ONE home model across the lanes.
2646
+ Property(node) {
2647
+ slots.note(node)
2648
+ },
2563
2649
  JSXAttribute(node) {
2564
2650
  slots.note(node)
2565
2651
  },
@@ -2589,12 +2675,24 @@ const handRolledFilter = {
2589
2675
 
2590
2676
  // ── Rule 17 — control-outside-home ──────────────────────────────────────────────────────────────
2591
2677
 
2678
+ /**
2679
+ * The one fact that decides a SHELL-LESS app, and the one neither control-home message used to
2680
+ * state. `PageBar` does not require `BasaltShell`: without a shell it renders both rows in flow,
2681
+ * sticky at the top of the document, with `title` + `icon` leading row 1 (see
2682
+ * `src/shell/page-bar.tsx`'s own header). So a provider-only consumer — image-gen mounts
2683
+ * `BasaltProvider` and a hand-rolled Tauri window header, no shell — has a reachable home, and the
2684
+ * rule is satisfiable there. Both messages read the same sentence so the two lanes cannot drift.
2685
+ */
2686
+ const SHELL_LESS_HOME_HINT =
2687
+ 'No shell? `PageBar` needs no `BasaltShell`: outside one it renders in flow, sticky, with its ' +
2688
+ 'own `title` — so its slots are a reachable home for a provider-only app.'
2689
+
2592
2690
  const CONTROL_OUTSIDE_HOME_MESSAGE =
2593
2691
  'Raw Mantine selection control with no home — a filter, tab or action belongs in exactly one of ' +
2594
2692
  'the three homes (a PageBar / Section / WidgetHeader slot, or a form row), and a home is entered ' +
2595
2693
  'through a slot prop (law C1). A settings row, a form row (FormRow/FormGroup), an overlay ' +
2596
2694
  '(Modal/Drawer/Popover/Menu) and a form (@mantine/form) are the declared non-homes and never ' +
2597
- 'report. (basalt/control-outside-home)'
2695
+ `report. ${SHELL_LESS_HOME_HINT} (basalt/control-outside-home)`
2598
2696
 
2599
2697
  /**
2600
2698
  * The cross-file half of C1, and the one rule here that is openly a HEURISTIC: "this control has
@@ -2608,7 +2706,7 @@ const CONTROL_OUTSIDE_HOME_MESSAGE =
2608
2706
  * `@mantine/form` (a form is the third home and its inputs are not filters), and the owner
2609
2707
  * exemption — a file DEFINING a basalt control cannot be told to use one.
2610
2708
  */
2611
- // Ships: warn (grace → 1.30.0)
2709
+ // Ships: warn (grace → 1.31.0)
2612
2710
  const controlOutsideHome = {
2613
2711
  meta: {
2614
2712
  type: 'suggestion',
@@ -2632,6 +2730,11 @@ const controlOutsideHome = {
2632
2730
  owner.noteImport(node)
2633
2731
  if ((node.source?.value ?? '') === '@mantine/form') importsMantineForm = true
2634
2732
  },
2733
+ // The object-spread spelling of a slot — see {@link spreadSlotOwnerBinding}. `slots.note`
2734
+ // accepts either node shape; feeding both is what keeps ONE home model across the lanes.
2735
+ Property(node) {
2736
+ slots.note(node)
2737
+ },
2635
2738
  JSXAttribute(node) {
2636
2739
  slots.note(node)
2637
2740
  },
@@ -2760,6 +2863,11 @@ const controlSizeLiteral = {
2760
2863
  collectMantineImports(node, mantineImports)
2761
2864
  slots.noteImport(node)
2762
2865
  },
2866
+ // The object-spread spelling of a slot — see {@link spreadSlotOwnerBinding}. `slots.note`
2867
+ // accepts either node shape; feeding both is what keeps ONE home model across the lanes.
2868
+ Property(node) {
2869
+ slots.note(node)
2870
+ },
2763
2871
  JSXAttribute(node) {
2764
2872
  slots.note(node)
2765
2873
  const name = node.name?.name
@@ -2905,6 +3013,11 @@ const pageBarBudget = {
2905
3013
  if (actions.value.elements.length <= SECTION_ACTION_BUDGET) return
2906
3014
  findings.push({ node: actions.attr, owner, message: PAGE_BAR_BUDGET_MESSAGES.section })
2907
3015
  },
3016
+ // The object-spread spelling of a slot — see {@link spreadSlotOwnerBinding}. `slots.note`
3017
+ // accepts either node shape; feeding both is what keeps ONE home model across the lanes.
3018
+ Property(node) {
3019
+ slots.note(node)
3020
+ },
2908
3021
  JSXAttribute(node) {
2909
3022
  slots.note(node)
2910
3023
  if (node.name?.name !== 'variant') return
@@ -2957,11 +3070,55 @@ const pageBarBudget = {
2957
3070
  const PAGE_TITLE_HOST_TAGS = new Set(['Prose', 'ArticleLayout', 'Modal', 'Drawer'])
2958
3071
 
2959
3072
  const IN_BODY_PAGE_TITLE_MESSAGE =
2960
- 'In-body page title — the page is named ONCE, by the breadcrumb (`staticData.title`) or by ' +
2961
- '`PageBar.title` in a shell-less app, and every section/card/table title is a `WidgetHeader` ' +
2962
- '(law C8). An `<Title order={1|2}>` in the body is a second, drifting name for the same page. ' +
2963
- 'Prose / ArticleLayout / an overlay and anything under a `content/` path are document headings ' +
2964
- 'and never report. (basalt/in-body-page-title)'
3073
+ 'In-body page title — the page is named ONCE: by the breadcrumb (`staticData.title`), by ' +
3074
+ '`PageBar.title`, or by `<PageTitle>` on a shell-less surface (a route error, an auth gate, a ' +
3075
+ 'standalone screen — it renders the page name at the right step with no shell above it). Every ' +
3076
+ 'section/card/table title is a `WidgetHeader` (law C8). An `<Title order={1|2}>` spelling a name ' +
3077
+ 'out in the body is a second, drifting name for the same page. A Title rendering a VALUE — ' +
3078
+ '`{doc.title}`, `{RANK_LABEL[rank]}` — never reports: that is data, not a name. Prose / ' +
3079
+ 'ArticleLayout / an overlay and anything under a `content/` path never report either. ' +
3080
+ '(basalt/in-body-page-title)'
3081
+
3082
+ /**
3083
+ * Does this `<Title>` spell a name out, or render a value?
3084
+ *
3085
+ * The narrowing that made C8 satisfiable. The law is "one NAME per page", and a name is written in
3086
+ * words — so the rule now needs static text before it reports. Two shapes were unanswerable without
3087
+ * this, and between them they were 24 of rb's 27 lint errors across 12 files:
3088
+ *
3089
+ * 1. **A nav-less DETAIL route.** On `/knots/$slug` the breadcrumb names the PARENT list ("Learn /
3090
+ * Courses") and the document's own title has to live in the body. `PageTitle` does not help (it
3091
+ * is the shell-less surface primitive) and `PageBar.title` is a `string`. The law was
3092
+ * unsatisfiable and every consumer was going to paper it with the same waiver — which is the
3093
+ * rule losing, not the consumer.
3094
+ * 2. **A card's hero VALUE.** `<Title order={2}>{RANK_LABEL[game.rank]}</Title>` is a number at the
3095
+ * display type step. There is no non-restyling fix: `order={3}` is a different step, `Text`
3096
+ * drops the type scale, and `component="p"` does not help because the rule keys on the tag name
3097
+ * and the `order` prop.
3098
+ *
3099
+ * Both are `{expression}` children, and by construction an expression is not a static page name.
3100
+ * The false negative this accepts is narrow and named: `<Title order={1}>{t('page.users')}</Title>`
3101
+ * is a page name and no longer reports. A LAUNDERED literal still does — `{'Users'}` and a
3102
+ * substitution-free template are read as the text they are, so the escape is the expression, not
3103
+ * the braces.
3104
+ */
3105
+ function hasStaticTitleText(node, depth = 0) {
3106
+ if (node === null || node === undefined || depth > 4) return false
3107
+ for (const child of node.children ?? []) {
3108
+ if (child.type === 'JSXText' && child.value.trim() !== '') return true
3109
+ if (child.type === 'JSXElement' || child.type === 'JSXFragment') {
3110
+ if (hasStaticTitleText(child, depth + 1)) return true
3111
+ continue
3112
+ }
3113
+ if (child.type !== 'JSXExpressionContainer') continue
3114
+ const expr = child.expression
3115
+ if (typeof expr?.value === 'string' && expr.value.trim() !== '') return true
3116
+ if (expr?.type === 'TemplateLiteral' && (expr.expressions?.length ?? 0) === 0) {
3117
+ if ((expr.quasis ?? []).some((q) => (q.value?.cooked ?? '').trim() !== '')) return true
3118
+ }
3119
+ }
3120
+ return false
3121
+ }
2965
3122
 
2966
3123
  /** The `order` prop as a number literal, or undefined. */
2967
3124
  function titleOrderOf(node) {
@@ -2994,6 +3151,8 @@ const inBodyPageTitle = {
2994
3151
  const order = titleOrderOf(node)
2995
3152
  if (order !== 1 && order !== 2) return
2996
3153
  if (hasAncestorTag(node, PAGE_TITLE_HOST_TAGS)) return
3154
+ // A Title with no static text renders a value, not a name — see hasStaticTitleText.
3155
+ if (!hasStaticTitleText(node.parent)) return
2997
3156
  if (hasThemeAllow(context, node, 'in-body-page-title')) return
2998
3157
  context.report({ node, message: IN_BODY_PAGE_TITLE_MESSAGE })
2999
3158
  },
@@ -3212,7 +3371,8 @@ const BOUND_CONTROL_OUTSIDE_HOME_MESSAGE =
3212
3371
  '— where the panel surface renders it as a row instead (law C1, docs/ASIDE-SPEC.md §3). ' +
3213
3372
  'SliderControl is not policed: it renders its own PanelRow and has no pill form. An overlay ' +
3214
3373
  '(Modal/Drawer/Popover/Menu), a settings row and a form row (FormRow/FormGroup) are the ' +
3215
- 'declared non-homes and never report. (basalt/bound-control-outside-home)'
3374
+ `declared non-homes and never report. ${SHELL_LESS_HOME_HINT} ` +
3375
+ '(basalt/bound-control-outside-home)'
3216
3376
 
3217
3377
  /**
3218
3378
  * A BOUND basalt control that is in no home at all — ledger G5 (`docs/ASIDE-SPEC.md` §2), the half
@@ -3235,7 +3395,7 @@ const BOUND_CONTROL_OUTSIDE_HOME_MESSAGE =
3235
3395
  * 12-line window has neither. A text heuristic here would report a consumer's own `SelectFilter` in
3236
3396
  * its own `filters` slot — signal the plugin already has, noise the guard would add.
3237
3397
  */
3238
- // Ships: warn (grace → 1.30.0)
3398
+ // Ships: error
3239
3399
  const boundControlOutsideHome = {
3240
3400
  meta: {
3241
3401
  type: 'suggestion',
@@ -3258,6 +3418,11 @@ const boundControlOutsideHome = {
3258
3418
  slots.noteImport(node)
3259
3419
  owner.noteImport(node)
3260
3420
  },
3421
+ // The object-spread spelling of a slot — see {@link spreadSlotOwnerBinding}. `slots.note`
3422
+ // accepts either node shape; feeding both is what keeps ONE home model across the lanes.
3423
+ Property(node) {
3424
+ slots.note(node)
3425
+ },
3261
3426
  JSXAttribute(node) {
3262
3427
  slots.note(node)
3263
3428
  },
@@ -3319,7 +3484,7 @@ const PROVIDER_ABOVE_ROUTER_MESSAGE =
3319
3484
  * have — so this catches the one-file case and the law stays advisory for the rest. That is why it
3320
3485
  * lands at `warn`.
3321
3486
  */
3322
- // Ships: warn (grace → 1.30.0)
3487
+ // Ships: error
3323
3488
  const providerAboveRouter = {
3324
3489
  meta: {
3325
3490
  type: 'suggestion',
@@ -3403,7 +3568,7 @@ const UNWRAP_CALL = /\bunwrap\s*\(/
3403
3568
  * Scoped to files importing `basalt-ui/query` literally, for the reason {@link queryDualImport}
3404
3569
  * gives: without that gate it is an opinion about `fetch`, which basalt does not have.
3405
3570
  */
3406
- // Ships: warn (grace → 1.30.0)
3571
+ // Ships: error
3407
3572
  const queryFnUnwrap = {
3408
3573
  meta: {
3409
3574
  type: 'suggestion',
@@ -3588,12 +3753,11 @@ const FORMS_FIELD_KEY_MESSAGES = {
3588
3753
  'spread compiles unchanged and silently stops resetting. ' +
3589
3754
  '(basalt/forms-field-key)',
3590
3755
  deprecatedAlias:
3591
- 'A spread `field(…)`. That is the @deprecated 1.27 alias and it still bundles `key` INTO the ' +
3592
- 'returned object, so it still logs React 19\'s `A props object containing a "key" prop is ' +
3593
- 'being spread into JSX` on every render — kept byte-identical on purpose, because a ' +
3594
- 'deprecation is a schedule and not a silent behaviour change. Migrate to ' +
3595
- '`key={fieldKey(form, path)} {...inputProps(form, path)}` (packages/basalt-ui/MIGRATING.md ' +
3596
- 'carries the row); the alias itself is removed in 1.29.0. ' +
3756
+ 'A spread `field(…)`. `field` NO LONGER EXISTS — it was the @deprecated 1.27 alias and 1.29.0 ' +
3757
+ 'removed it, so this import does not resolve and this file does not compile. Rewrite it as ' +
3758
+ '`key={fieldKey(form, path)} {...inputProps(form, path)}` (two calls, never one object: a ' +
3759
+ 'spread `key` is a React 19 warning). Not autofixed: the honest edit rewrites the import as ' +
3760
+ 'well as the call site. packages/basalt-ui/MIGRATING.md carries the row. ' +
3597
3761
  '(basalt/forms-field-key)',
3598
3762
  }
3599
3763
 
@@ -3622,16 +3786,19 @@ function hasKeyAttribute(opening) {
3622
3786
  * `basalt-ui/forms` import when it is not already there, so `--fix` produces working code rather
3623
3787
  * than an unresolved identifier.
3624
3788
  * - `{...field(…)}` — the pre-migration call site. Reported ALWAYS, key or no key, and deliberately
3625
- * NOT autofixed: `field` still returns the bundle, so inserting a second `key` would be wrong,
3626
- * and the honest remedy is the rename `basalt/deprecated-export` already nudges. This arm is a
3627
- * pointer at the migration, one lane over from that rule's import-level nudge.
3789
+ * NOT autofixed: the honest edit rewrites the IMPORT as well as the call site, and a fixer that
3790
+ * only touched the spread would leave an unresolved `field` behind. This arm is the whole reason
3791
+ * the rule still knows the name — 1.29.0 deleted `field`, and `basalt/deprecated-export`'s table
3792
+ * went empty with it, so a consumer skipping 1.28.x reaches 1.29.x with a hard compile error and
3793
+ * nothing but tsc to explain it. The message says that in so many words; it used to describe the
3794
+ * alias as still shipping and still bundling `key`, which read as "nothing is broken yet".
3628
3795
  *
3629
3796
  * Provenance is the `basalt-ui/forms` specifier (plus a relative import inside basalt's own `src/`,
3630
3797
  * or the dogfood surface goes silent — the same gate `collectBasaltImports` uses), read through the
3631
3798
  * local→imported map so `import { inputProps as f }` is covered. `inputProps` is an ordinary enough
3632
3799
  * name that firing on an unimported one would be an opinion about a consumer's own helper.
3633
3800
  */
3634
- // Ships: warn (grace → 1.30.0)
3801
+ // Ships: error
3635
3802
  const formsFieldKey = {
3636
3803
  meta: {
3637
3804
  type: 'suggestion',
@@ -3801,74 +3968,33 @@ const noImportMetaEnv = {
3801
3968
  export const PLUGIN_RULE_GRACE = {
3802
3969
  'control-outside-home': {
3803
3970
  since: '1.26.0',
3804
- promote: '1.30.0',
3971
+ promote: '1.31.0',
3805
3972
  why:
3806
3973
  'the wave-6 control guards (docs/CONTROLS-SPEC.md §6). The one openly HEURISTIC rule of the ' +
3807
3974
  'set — "this control has no home" is a claim about layout intent, so its false-positive load ' +
3808
3975
  'is carried by four exemptions (overlay/settings-row ancestor, the overlay FILENAME ' +
3809
3976
  'convention below, @mantine/form, owner definition) rather than by certainty. The other five ' +
3810
- 'wave-6 rules promoted at 1.27.0; this one did not, and the reason is measured rather than ' +
3811
- 'cautious: the wave-7 run found 9 remaining warns in argo, every one of them a control in a ' +
3812
- 'modal/form module whose `<Modal>` is rendered by the PARENT — the cross-file case law C1 is ' +
3813
- 'explicitly advisory about, which no ancestry walk inside one file can ever see. ' +
3814
- '{@link isOverlayConventionFile} exempts that declared naming convention, in BOTH the kebab ' +
3815
- '(`edit-session-modal.tsx`) and the PascalCase (`CbbiPanel.tsx`) dialect. Re-dated ' +
3816
- '2026-08-28 — the argo wave-7 migration has not run; the PascalCase overlay convention (this ' +
3817
- 'change) is expected to clear most of the 9. 1.30.0 is when the remainder is re-measured. A ' +
3818
- "file outside the convention that is still an overlay's own body declares it with " +
3977
+ 'wave-6 rules promoted at 1.27.0; this one has not, across three re-datings. ' +
3978
+ 'RE-MEASURED 2026-09-09, every consumer freshly migrated to 1.29.2 and basalt built from ' +
3979
+ 'master: 29 incumbents across five repos — image-gen 10, rb 8, image-share 6, the playground ' +
3980
+ '3, argo 2 — and zero in linewatch, meteo and obsidian. The 1.28.0 prediction held: ' +
3981
+ "{@link isOverlayConventionFile} took argo's 9 cross-file overlay warns down to 2, which is " +
3982
+ 'the part of the remainder that was ever going to fall to an exemption. ' +
3983
+ 'Extended to 1.31.0, and the argument is NOT the count — it is that 1.30.0 is the first ' +
3984
+ 'release in which this rule names an answer the 29 can reach. Every home it listed was a ' +
3985
+ 'shell slot (PageBar / Section / WidgetHeader), so a consumer that mounts `BasaltProvider` ' +
3986
+ 'and no `BasaltShell` had no reachable home at all: image-gen is a Tauri window with a ' +
3987
+ 'hand-rolled header and carries 10 of the 29 by itself, and `bound-control-outside-home` ' +
3988
+ 'closed the `ViewTabs` escape it would otherwise have taken. 1.30.0 adds the missing ' +
3989
+ 'sentence to both messages ({@link SHELL_LESS_HOME_HINT} — a `PageBar` outside a shell ' +
3990
+ 'renders in flow, sticky, with its own title, which is what `src/shell/page-bar.tsx` ' +
3991
+ 'actually does). Promoting a rule to `error` in the same minor that first makes it ' +
3992
+ 'satisfiable hands a consumer zero minors to act on the answer, which is exactly the failure ' +
3993
+ 'the grace mechanism exists to prevent. 1.31.0 is when the 29 have been through the new home ' +
3994
+ 'and the remainder is measured against that number. A file outside the convention that is ' +
3995
+ "still an overlay's own body declares it with " +
3819
3996
  '`theme-allow-file control-outside-home — overlay`.',
3820
3997
  },
3821
- 'bound-control-outside-home': {
3822
- since: '1.28.0',
3823
- promote: '1.30.0',
3824
- why:
3825
- 'ASIDE-SPEC G5 — a bound control in a section body rendered as a stray pill and nothing saw ' +
3826
- 'it. `control-outside-home` matches raw Mantine tags only, so the whole basalt half of law ' +
3827
- 'C1 was unguarded: `<SelectFilter field={…}/>` written into a `Section` body or a bare page ' +
3828
- 'stack passed every lane. A NEW id rather than a widening (C16 — a level is per-id), landing ' +
3829
- 'warn because it inherits the sibling rule\'s heuristic: "this control has no home" is a ' +
3830
- 'claim about layout intent, and the ancestry it reads now spans four subtree homes ' +
3831
- '(FilterSet / PageAside / PanelRow / the overlay hosts) that only shipped in 1.27.0. 1.30.0 ' +
3832
- 'is when the incumbent count across argo, linewatch, image-share, rb, image-gen and the ' +
3833
- 'playground is re-measured.',
3834
- },
3835
- 'provider-above-router': {
3836
- since: '1.28.0',
3837
- promote: '1.30.0',
3838
- why:
3839
- 'F5 — one of the three basalt-mantine.md laws that shipped under a `not guarded: —` banner. ' +
3840
- 'It reads STATIC JSX ancestry inside one file, which is a genuine fraction of the law: the ' +
3841
- 'usual real shape composes the two providers across modules, where no oxlint rule can see ' +
3842
- 'the nesting at all. Landing it at `error` would state a certainty the implementation does ' +
3843
- 'not have. 1.30.0 is when the incumbent count across argo, linewatch, image-share, rb, ' +
3844
- 'image-gen and the playground is measured — every one of them mounts a router, so the ' +
3845
- 'false-positive question has a real sample.',
3846
- },
3847
- 'forms-field-key': {
3848
- since: '1.28.0',
3849
- promote: '1.30.0',
3850
- why:
3851
- "the 1.28.0 forms minor took `key` out of `inputProps`' return, and the OLD call site " +
3852
- "(`<TextInput {...inputProps(form, 'x')} />`) survives that change with no compile error, " +
3853
- 'no throw and no console line — it just stops resetting. This rule is the only thing that ' +
3854
- 'reports it, which is an argument for `error`, not `warn`; it lands at `warn` anyway ' +
3855
- 'because it fires on code every consumer is CURRENTLY shipping, and the grace minor is what ' +
3856
- 'turns "your build is broken by the upgrade" into "your editor lists the eleven lines". ' +
3857
- 'The `field(` arm is the same law read from the pre-migration end, and that alias is removed ' +
3858
- 'in 1.29.0 anyway. 1.30.0 is when argo, linewatch, image-share, rb, image-gen and the ' +
3859
- 'playground have all been through the autofix and the remainder is measured.',
3860
- },
3861
- 'query-fn-unwrap': {
3862
- since: '1.28.0',
3863
- promote: '1.30.0',
3864
- why:
3865
- 'F5 — the second unguarded ./query law. Openly a TEXT heuristic inside a syntactic range: ' +
3866
- '`fetch(` present in the queryFn span and `unwrap(` absent. It cannot see a `queryFn: ' +
3867
- 'fetchThing` reference, an Eden/Treaty call, or an unwrap one frame out, and it counts an ' +
3868
- '`unwrap(` applied to anything. That is a deliberate floor rather than a first draft, and a ' +
3869
- 'rule whose stated reach is this narrow has no business failing a build. 1.30.0 is when the ' +
3870
- 'measured false-positive load decides whether it promotes or stays advisory.',
3871
- },
3872
3998
  }
3873
3999
 
3874
4000
  /**