basalt-ui 1.32.0 → 1.33.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 (145) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/MIGRATING.md +228 -20
  3. package/agent/rules/basalt-mantine.md +2 -2
  4. package/agent/rules/basalt-state.md +2 -2
  5. package/agent/rules/basalt-tokens.md +1 -1
  6. package/configs/oxlint-plugin.js +160 -89
  7. package/configs/oxlint-plugin.test.ts +273 -10
  8. package/configs/oxlint.json +2 -2
  9. package/dist/agent/coalesce.d.ts.map +1 -1
  10. package/dist/agent/coalesce.js +1 -2
  11. package/dist/agent/merge.d.ts.map +1 -1
  12. package/dist/agent/merge.js +1 -2
  13. package/dist/agent-chat/relative-time.d.ts +9 -0
  14. package/dist/agent-chat/relative-time.d.ts.map +1 -1
  15. package/dist/agent-chat/relative-time.js +1 -2
  16. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  17. package/dist/agent-chat/thread-message.js +1 -2
  18. package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
  19. package/dist/charts/kinds/BandStrip.js +3 -3
  20. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  21. package/dist/charts/kinds/Bars.js +1 -2
  22. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  23. package/dist/charts/kinds/StackedArea.js +1 -2
  24. package/dist/charts/primitives/legend-disclosure.d.ts.map +1 -1
  25. package/dist/cli/audit-allows.d.ts +1 -1
  26. package/dist/cli/audit-allows.d.ts.map +1 -1
  27. package/dist/cli/audit-allows.js +4 -4
  28. package/dist/cli/check-theme.d.ts.map +1 -1
  29. package/dist/cli/check-theme.js +1 -2
  30. package/dist/cli/config.d.ts +197 -0
  31. package/dist/cli/config.d.ts.map +1 -0
  32. package/dist/cli/config.js +20 -0
  33. package/dist/cli/doctor.js +1 -1
  34. package/dist/cli/guard-hook.d.ts.map +1 -1
  35. package/dist/cli/guard-hook.js +2 -8
  36. package/dist/cli/index.d.ts +2 -196
  37. package/dist/cli/index.d.ts.map +1 -1
  38. package/dist/cli/index.js +1 -18
  39. package/dist/commands/define-commands.d.ts.map +1 -1
  40. package/dist/commands/define-commands.js +2 -2
  41. package/dist/commands/define-overlays.d.ts.map +1 -1
  42. package/dist/commands/define-overlays.js +1 -2
  43. package/dist/common/index.d.ts +0 -1
  44. package/dist/common/index.d.ts.map +1 -1
  45. package/dist/common/index.js +0 -2
  46. package/dist/common/validate.d.ts +2 -2
  47. package/dist/common/validate.d.ts.map +1 -1
  48. package/dist/common/validate.js +2 -3
  49. package/dist/content/article-card.d.ts.map +1 -1
  50. package/dist/content/article-card.js +4 -1
  51. package/dist/controls/enum-filter.d.ts.map +1 -1
  52. package/dist/controls/enum-filter.js +3 -13
  53. package/dist/controls/filter-pill.d.ts +8 -0
  54. package/dist/controls/filter-pill.d.ts.map +1 -1
  55. package/dist/controls/filter-pill.js +15 -0
  56. package/dist/controls/multi-select-filter.d.ts.map +1 -1
  57. package/dist/controls/multi-select-filter.js +3 -13
  58. package/dist/controls/number-filter.d.ts +3 -0
  59. package/dist/controls/number-filter.d.ts.map +1 -1
  60. package/dist/controls/number-filter.js +12 -4
  61. package/dist/controls/range-filter.d.ts.map +1 -1
  62. package/dist/controls/range-filter.js +1 -2
  63. package/dist/data/data-table.d.ts +9 -2
  64. package/dist/data/data-table.d.ts.map +1 -1
  65. package/dist/data/data-table.js +2 -3
  66. package/dist/forms/form-layout.d.ts +6 -1
  67. package/dist/forms/form-layout.d.ts.map +1 -1
  68. package/dist/forms/form-layout.module.css +3 -0
  69. package/dist/guard/index.d.ts +18 -4
  70. package/dist/guard/index.d.ts.map +1 -1
  71. package/dist/guard/index.js +67 -31
  72. package/dist/guard/types.d.ts +3 -3
  73. package/dist/guard/types.d.ts.map +1 -1
  74. package/dist/guard/vx-names.d.ts +3 -0
  75. package/dist/guard/vx-names.d.ts.map +1 -0
  76. package/dist/guard/vx-names.js +257 -0
  77. package/dist/notifications/mount-guard.d.ts.map +1 -1
  78. package/dist/notifications/mount-guard.js +1 -2
  79. package/dist/provider/index.d.ts.map +1 -1
  80. package/dist/provider/index.js +2 -3
  81. package/dist/provider/lab-theme.d.ts +5 -4
  82. package/dist/provider/lab-theme.d.ts.map +1 -1
  83. package/dist/provider/lab-theme.js +1 -3
  84. package/dist/router-tanstack/search-store.d.ts +18 -0
  85. package/dist/router-tanstack/search-store.d.ts.map +1 -1
  86. package/dist/router-tanstack/search-store.js +33 -1
  87. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  88. package/dist/shell/mobile-nav-model.js +3 -4
  89. package/dist/shell/page-bar.d.ts +24 -0
  90. package/dist/shell/page-bar.d.ts.map +1 -1
  91. package/dist/shell/page-bar.js +1 -2
  92. package/dist/surfaces.d.ts +1 -1
  93. package/dist/surfaces.d.ts.map +1 -1
  94. package/dist/surfaces.js +2 -0
  95. package/dist/tokens/index.d.ts +2 -0
  96. package/dist/tokens/index.d.ts.map +1 -1
  97. package/dist/tokens/index.js +10 -0
  98. package/dist/tokens/size-classes.d.ts +13 -0
  99. package/dist/tokens/size-classes.d.ts.map +1 -1
  100. package/dist/tokens/size-classes.js +8 -0
  101. package/llms.txt +2 -2
  102. package/package.json +1 -1
  103. package/src/agent/coalesce.ts +1 -2
  104. package/src/agent/merge.ts +1 -2
  105. package/src/agent-chat/relative-time.ts +1 -2
  106. package/src/agent-chat/thread-message.tsx +2 -3
  107. package/src/charts/kinds/BandStrip.tsx +7 -3
  108. package/src/charts/kinds/Bars.tsx +1 -2
  109. package/src/charts/kinds/StackedArea.tsx +4 -2
  110. package/src/charts/primitives/legend-disclosure.tsx +1 -0
  111. package/src/cli/audit-allows.ts +10 -9
  112. package/src/cli/check-theme.ts +1 -2
  113. package/src/cli/config.ts +209 -0
  114. package/src/cli/doctor.ts +2 -2
  115. package/src/cli/guard-hook.ts +2 -8
  116. package/src/cli/index.ts +2 -206
  117. package/src/commands/define-commands.ts +2 -2
  118. package/src/commands/define-overlays.ts +1 -2
  119. package/src/common/index.ts +0 -1
  120. package/src/common/validate.ts +4 -5
  121. package/src/content/article-card.tsx +1 -0
  122. package/src/controls/enum-filter.tsx +3 -13
  123. package/src/controls/filter-pill.tsx +20 -0
  124. package/src/controls/multi-select-filter.tsx +3 -13
  125. package/src/controls/number-filter.tsx +24 -3
  126. package/src/controls/range-filter.tsx +1 -2
  127. package/src/data/data-table.tsx +11 -5
  128. package/src/forms/form-layout.module.css +3 -0
  129. package/src/forms/form-layout.tsx +6 -1
  130. package/src/guard/index.ts +151 -62
  131. package/src/guard/types.ts +3 -2
  132. package/src/guard/vx-names.ts +257 -0
  133. package/src/notifications/mount-guard.ts +1 -2
  134. package/src/provider/index.tsx +2 -3
  135. package/src/provider/lab-theme.ts +28 -21
  136. package/src/router-tanstack/search-store.ts +73 -1
  137. package/src/shell/mobile-nav-model.ts +3 -5
  138. package/src/shell/page-bar.tsx +25 -4
  139. package/src/surfaces.ts +2 -0
  140. package/src/tokens/index.ts +10 -0
  141. package/src/tokens/size-classes.ts +14 -0
  142. package/dist/common/is-dev.d.ts +0 -3
  143. package/dist/common/is-dev.d.ts.map +0 -1
  144. package/dist/common/is-dev.js +0 -6
  145. package/src/common/is-dev.ts +0 -4
@@ -177,6 +177,7 @@ export const KNOWN_RULE_IDS = new Set([
177
177
  'in-body-page-title',
178
178
  'raw-selection-control',
179
179
  'raw-media-query',
180
+ 'unknown-vx-token',
180
181
  // Retired guard kinds — no longer in GUARD_RULES, but a `theme-allow` naming one must still
181
182
  // parse as a real (dead) waiver rather than an unknown-id typo. See RETIRED_RULE_IDS above.
182
183
  ...RETIRED_RULE_IDS,
@@ -390,8 +391,12 @@ function hasFileDeclaration(context, ruleId) {
390
391
  )
391
392
  }
392
393
 
393
- /** Test/spec files — design guidance does not apply to a fixture. Mirrors `src/cli`'s own SKIP. */
394
- const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|[\\/])__tests__[\\/]/
394
+ /**
395
+ * Test/spec files — design guidance does not apply to a fixture. Mirrors `src/cli`'s own SKIP, plus
396
+ * a `test/`/`tests/` directory (1.32.1): a test-setup polyfill (`tests/setup/dom.ts` stubbing
397
+ * `window.matchMedia`) is neither a `.test` file nor under `__tests__`, and needed a waiver.
398
+ */
399
+ const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|[\\/])(?:__tests__|tests?)[\\/]/
395
400
 
396
401
  function isTestFile(context) {
397
402
  return TEST_FILE.test(getFilename(context))
@@ -2698,9 +2703,8 @@ const CONTROL_OUTSIDE_HOME_MESSAGE =
2698
2703
 
2699
2704
  /**
2700
2705
  * The cross-file half of C1, and the one rule here that is openly a HEURISTIC: "this control has
2701
- * no home" is a claim about layout intent, which no AST can see. It ships `warn` for that reason
2702
- * and stays warn until the playground and the five consumer repos run it with ≤3 waivers
2703
- * (docs/CONTROLS-SPEC.md §9 wave 7).
2706
+ * no home" is a claim about layout intent, which no AST can see. That is why it sat in grace from
2707
+ * 1.26.0 to 1.33.0, promoting only once basalt's tree and nine consumers measured at zero.
2704
2708
  *
2705
2709
  * Four exemptions carry the false-positive load: an overlay/settings-row/form-row ancestor
2706
2710
  * ({@link CONTROL_HOST_TAGS}, `FormRow`/`FormGroup` included), an {@link isOverlayConventionFile}
@@ -2708,7 +2712,7 @@ const CONTROL_OUTSIDE_HOME_MESSAGE =
2708
2712
  * `@mantine/form` (a form is the third home and its inputs are not filters), and the owner
2709
2713
  * exemption — a file DEFINING a basalt control cannot be told to use one.
2710
2714
  */
2711
- // Ships: warn (grace → 1.33.0)
2715
+ // Ships: error
2712
2716
  const controlOutsideHome = {
2713
2717
  meta: {
2714
2718
  type: 'suggestion',
@@ -3972,26 +3976,57 @@ function isResponsiveObjectValue(node, namedOnly = false) {
3972
3976
  return !namedOnly || names.some((name) => name !== 'base')
3973
3977
  }
3974
3978
 
3979
+ /** The `const` initializers {@link reachableObjects} can resolve an Identifier to. */
3980
+ const RESOLVABLE_INIT_TYPES = new Set([
3981
+ 'ObjectExpression',
3982
+ 'ConditionalExpression',
3983
+ 'LogicalExpression',
3984
+ 'Identifier',
3985
+ ])
3986
+
3975
3987
  /**
3976
- * Does this prop value CONTAIN a responsive object — itself, nested one level down in a property
3977
- * (`footer={{ height: { base: 60, sm: 0 } }}`, Mantine's own AppShell idiom), or behind a same-file
3978
- * `const` binding at either place (`const h = { base: 60, sm: 0 }`, then `footer={{ height: h }}`)?
3979
- * `consts` is the same-file name→object map, so an Identifier resolves identically wherever it
3980
- * appears — the top-level attribute value and a nested property value alike. Matching only a nested
3981
- * `ObjectExpression` left the Identifier-in-a-property shape trivially bypassable.
3988
+ * Every object literal a value can evaluate to: an Identifier resolves through `consts` (the
3989
+ * same-file name→init map) FIRST, then a conditional contributes both branches and a logical both
3990
+ * sides — in any nesting, so `const span = wide ? { base: 12, md: 4 } : 12` then `span={span}` is
3991
+ * the same shape as writing the conditional inline (rb's hero Grid was a false negative until
3992
+ * 1.32.1). `seen` guards an Identifier cycle (`const a = b`, `const b = a`) and a shared object.
3982
3993
  */
3983
- function containsResponsiveObjectValue(node, consts, namedOnly = false, seen = new Set()) {
3984
- const resolved =
3985
- node !== null && node !== undefined && node.type === 'Identifier' ? consts.get(node.name) : node
3986
- if (resolved === null || resolved === undefined || resolved.type !== 'ObjectExpression')
3987
- return false
3988
- if (seen.has(resolved)) return false
3994
+ function reachableObjects(node, consts, seen = new Set()) {
3995
+ const resolved = node?.type === 'Identifier' ? consts.get(node.name) : node
3996
+ if (resolved === null || resolved === undefined || seen.has(resolved)) return []
3989
3997
  seen.add(resolved)
3990
- if (isResponsiveObjectValue(resolved, namedOnly)) return true
3991
- return resolved.properties.some(
3992
- (prop) =>
3993
- prop.type === 'Property' &&
3994
- containsResponsiveObjectValue(prop.value, consts, namedOnly, seen),
3998
+ switch (resolved.type) {
3999
+ case 'ObjectExpression':
4000
+ return [resolved]
4001
+ case 'Identifier':
4002
+ return reachableObjects(resolved, consts, seen)
4003
+ case 'ConditionalExpression':
4004
+ return [resolved.consequent, resolved.alternate].flatMap((branch) =>
4005
+ reachableObjects(branch, consts, seen),
4006
+ )
4007
+ case 'LogicalExpression':
4008
+ return [resolved.left, resolved.right].flatMap((side) => reachableObjects(side, consts, seen))
4009
+ default:
4010
+ return []
4011
+ }
4012
+ }
4013
+
4014
+ /**
4015
+ * Does this prop value CONTAIN a responsive object — any object it can evaluate to
4016
+ * ({@link reachableObjects}), or one nested one level down in a property of one
4017
+ * (`footer={{ height: { base: 60, sm: 0 } }}`, Mantine's own AppShell idiom), the property value
4018
+ * resolving the same way. Matching only a nested `ObjectExpression` left the Identifier-in-a-
4019
+ * property shape trivially bypassable.
4020
+ */
4021
+ function containsResponsiveObjectValue(node, consts, namedOnly = false, seen = new Set()) {
4022
+ return reachableObjects(node, consts, seen).some(
4023
+ (object) =>
4024
+ isResponsiveObjectValue(object, namedOnly) ||
4025
+ object.properties.some(
4026
+ (prop) =>
4027
+ prop.type === 'Property' &&
4028
+ containsResponsiveObjectValue(prop.value, consts, namedOnly, seen),
4029
+ ),
3995
4030
  )
3996
4031
  }
3997
4032
 
@@ -4031,8 +4066,9 @@ const CONTAINER_ROUTED_PROPS = {
4031
4066
  * `type` has to be the string literal `"container"`; a dynamic value is not a decision this rule
4032
4067
  * can read. `Grid.Col` takes its grid's mode by walking to the nearest enclosing `Grid` element.
4033
4068
  * `consts` is the rule's same-file name→object map, so `breakpoints={map}` resolves like any value.
4069
+ * The one IMPORTED map trusted is basalt's own `CONTAINER_GRID_BREAKPOINTS` ({@link isShippedGridMap}).
4034
4070
  */
4035
- function containerGridMode(opening, mantineImports, consts) {
4071
+ function containerGridMode(opening, mantineImports, consts, sourceCode) {
4036
4072
  let tag = resolveMantineTag(opening.name, mantineImports)
4037
4073
  let grid = opening
4038
4074
  if (tag === 'Grid.Col') {
@@ -4061,12 +4097,69 @@ function containerGridMode(opening, mantineImports, consts) {
4061
4097
  const breakpoints = attr('breakpoints')
4062
4098
  if (breakpoints === undefined) return 'fallback'
4063
4099
  let map = attrValue(breakpoints)
4100
+ if (isShippedGridMap(sourceCode, map)) return 'container'
4064
4101
  if (map?.type === 'Identifier') map = consts.get(map.name)
4065
4102
  const nonEmpty =
4066
4103
  map?.type === 'ObjectExpression' && map.properties.some((prop) => prop.type === 'Property')
4067
4104
  return nonEmpty ? 'container' : 'fallback'
4068
4105
  }
4069
4106
 
4107
+ /**
4108
+ * Whether `node` reads basalt's own `CONTAINER_GRID_BREAKPOINTS` — its value is known, so it is
4109
+ * the one imported `breakpoints` map trusted. Resolved through the scope manager to the BINDING the
4110
+ * identifier reads, never by local name: a parameter or inner `const` shadowing the import is not
4111
+ * the import. The binding has to be an `ImportSpecifier` naming it from `basalt-ui/tokens` (any
4112
+ * alias, not type-only), a `.CONTAINER_GRID_BREAKPOINTS` member of that module's namespace import,
4113
+ * or a `const` whose initializer resolves the same way. Without a scope manager nothing is trusted
4114
+ * — reported, the conservative answer.
4115
+ */
4116
+ function isShippedGridMap(sourceCode, node, seen = new Set()) {
4117
+ if (node?.type === 'MemberExpression') return isShippedGridMapMember(sourceCode, node)
4118
+ if (node?.type !== 'Identifier' || seen.has(node)) return false
4119
+ seen.add(node)
4120
+ const def = soleDefinition(sourceCode, node)
4121
+ if (def?.type === 'ImportBinding') {
4122
+ const spec = def.node
4123
+ const decl = def.parent
4124
+ if (decl?.type !== 'ImportDeclaration' || decl.importKind === 'type') return false
4125
+ if (decl.source?.value !== 'basalt-ui/tokens') return false
4126
+ if (spec.type !== 'ImportSpecifier' || spec.importKind === 'type') return false
4127
+ const imported = spec.imported
4128
+ const importedName = imported.type === 'Identifier' ? imported.name : imported.value
4129
+ return importedName === 'CONTAINER_GRID_BREAKPOINTS'
4130
+ }
4131
+ if (def?.type === 'Variable' && def.parent?.kind === 'const')
4132
+ return isShippedGridMap(sourceCode, def.node.init, seen)
4133
+ return false
4134
+ }
4135
+
4136
+ /** `tokens.CONTAINER_GRID_BREAKPOINTS` (or `tokens['CONTAINER_GRID_BREAKPOINTS']`) on `import * as tokens from 'basalt-ui/tokens'`. */
4137
+ function isShippedGridMapMember(sourceCode, node) {
4138
+ const property = node.property
4139
+ const name = node.computed
4140
+ ? isStringLiteral(property) && property.value
4141
+ : property?.type === 'Identifier' && property.name
4142
+ if (name !== 'CONTAINER_GRID_BREAKPOINTS' || node.object?.type !== 'Identifier') return false
4143
+ const def = soleDefinition(sourceCode, node.object)
4144
+ return (
4145
+ def?.type === 'ImportBinding' &&
4146
+ def.node.type === 'ImportNamespaceSpecifier' &&
4147
+ def.parent?.importKind !== 'type' &&
4148
+ def.parent?.source?.value === 'basalt-ui/tokens'
4149
+ )
4150
+ }
4151
+
4152
+ /** The one definition of the variable `identifier` reads, or `undefined` (unresolved, or more than one). */
4153
+ function soleDefinition(sourceCode, identifier) {
4154
+ if (typeof sourceCode?.getScope !== 'function') return undefined
4155
+ for (let scope = sourceCode.getScope(identifier); scope; scope = scope.upper) {
4156
+ const variable = scope.set.get(identifier.name)
4157
+ if (variable === undefined) continue
4158
+ return variable.defs.length === 1 ? variable.defs[0] : undefined
4159
+ }
4160
+ return undefined
4161
+ }
4162
+
4070
4163
  /**
4071
4164
  * The shell components that are `visibleFrom`/`hiddenFrom`'s legitimate home — the size-class
4072
4165
  * axis's one JSX escape hatch, alongside overlays. Matched by DECLARATION name, the same
@@ -4099,7 +4192,7 @@ const RAW_BREAKPOINT_EXEMPT_FILE = /(?:^|[\\/])(?:use-media-query\.ts|ChartToolt
4099
4192
  export const RAW_BREAKPOINT_RECIPE =
4100
4193
  'A page’s own layout keys on its container: <SimpleGrid type="container" cols={{ base: 1, ' +
4101
4194
  "'480px': 2, '800px': 3 }}> (px keys = CONTAINER_CLASSES, basalt-ui/tokens), or <Grid " +
4102
- 'type="container" breakpoints={…}> mapping all five xs–xl onto those px widths, with Grid.Col ' +
4195
+ 'type="container" breakpoints={CONTAINER_GRID_BREAKPOINTS}> (basalt-ui/tokens), with Grid.Col ' +
4103
4196
  'span={{ base: 12, sm: 6 }} keyed through that map; a Flex direction swap becomes that SimpleGrid (equal ' +
4104
4197
  'columns), a wrapping Group, or a CSS-module @container basalt-grid rule. useSizeClass() is for ' +
4105
4198
  'shell chrome only. Recipes: basalt-ui MIGRATING.md § basalt/raw-breakpoint.'
@@ -4108,6 +4201,33 @@ const RAW_BREAKPOINT_RESPONSIVE_PROP_MESSAGE =
4108
4201
  'Responsive-object prop keyed on theme breakpoints — those are viewport widths. ' +
4109
4202
  `${RAW_BREAKPOINT_RECIPE} (basalt/raw-breakpoint)`
4110
4203
 
4204
+ /** rb r2 #5: a `(hover: none)` read was flagged with advice that only covered widths. */
4205
+ const RAW_BREAKPOINT_POINTER_HINT =
4206
+ 'A hover/pointer capability read belongs in CSS: @media (pointer: coarse) in a module (the ' +
4207
+ 'query --vx-hit keys on) — (hover: none) is not a sanctioned query.'
4208
+
4209
+ const RAW_BREAKPOINT_BASE_ONLY_MESSAGE =
4210
+ 'Responsive-object prop with only a base key — there is no breakpoint in it at all, so drop the ' +
4211
+ 'object and write the value: cols={{ base: 3 }} → cols={3}. (basalt/raw-breakpoint)'
4212
+
4213
+ /**
4214
+ * Is every responsive object this value can evaluate to ({@link reachableObjects} — the same
4215
+ * unwrap detection uses) one carrying ONLY `base`? That is no breakpoint decision, and the viewport
4216
+ * recipe is the wrong advice for it — the fix is the bare value. Message-only: the shape still
4217
+ * reports, as it always has.
4218
+ */
4219
+ function isBaseOnlyResponsiveObject(node, consts) {
4220
+ const responsive = reachableObjects(node, consts).filter((object) =>
4221
+ isResponsiveObjectValue(object),
4222
+ )
4223
+ return (
4224
+ responsive.length > 0 &&
4225
+ responsive.every((object) =>
4226
+ object.properties.every((prop) => propertyKeyName(prop) === 'base'),
4227
+ )
4228
+ )
4229
+ }
4230
+
4111
4231
  const RAW_BREAKPOINT_CONTAINER_KEY_MESSAGE =
4112
4232
  'Theme breakpoint name under type="container" — Mantine does not resolve it there: ' +
4113
4233
  "SimpleGrid uses each key verbatim as a (min-width: …) length, so 'sm' is a dead query, " +
@@ -4121,11 +4241,12 @@ const RAW_BREAKPOINT_VISIBLE_HIDDEN_MESSAGE =
4121
4241
  const RAW_BREAKPOINT_HOOK_MESSAGE =
4122
4242
  "useMediaQuery/useMatches/useViewportSize — a raw viewport read outside the framework's " +
4123
4243
  'one seam. Shell chrome reads useSizeClass(); a page swapping its own layout (a table for a card ' +
4124
- `list, a column count) is a container decision. ${RAW_BREAKPOINT_RECIPE} (basalt/raw-breakpoint)`
4244
+ `list, a column count) is a container decision. ${RAW_BREAKPOINT_POINTER_HINT} ` +
4245
+ `${RAW_BREAKPOINT_RECIPE} (basalt/raw-breakpoint)`
4125
4246
 
4126
4247
  const RAW_BREAKPOINT_GLOBAL_MESSAGE =
4127
4248
  'window.matchMedia/window.innerWidth read directly — the viewport read useSizeClass() owns ' +
4128
- `for shell chrome. ${RAW_BREAKPOINT_RECIPE} (basalt/raw-breakpoint)`
4249
+ `for shell chrome. ${RAW_BREAKPOINT_POINTER_HINT} ${RAW_BREAKPOINT_RECIPE} (basalt/raw-breakpoint)`
4129
4250
 
4130
4251
  /** The three viewport hooks the law names, whatever module they are imported from. */
4131
4252
  const RAW_BREAKPOINT_HOOK_NAMES = new Set(['useMediaQuery', 'useMatches', 'useViewportSize'])
@@ -4141,7 +4262,7 @@ const RAW_BREAKPOINT_HOOK_NAMES = new Set(['useMediaQuery', 'useMatches', 'useVi
4141
4262
  * declares a shell home (or a control's own C9 swap) is exempt for all four shapes uniformly — the
4142
4263
  * shell's own responsive chrome is the sanctioned use, whatever shape it takes.
4143
4264
  */
4144
- // Ships: warn (grace → 1.33.0)
4265
+ // Ships: error
4145
4266
  const rawBreakpoint = {
4146
4267
  meta: {
4147
4268
  type: 'suggestion',
@@ -4168,12 +4289,14 @@ const rawBreakpoint = {
4168
4289
  let definesShellHome = false
4169
4290
  const visibleHiddenCandidates = []
4170
4291
  const responsiveObjectCandidates = []
4171
- // Same-file `const NAME = { base, sm }` bindings, resolved when a JSX attribute or a nested
4172
- // property passes the Identifier — no scope manager here, so this is a flat name→object map.
4292
+ // Same-file `const NAME = { base, sm }` bindings (or a conditional/logical/alias init, see
4293
+ // RESOLVABLE_INIT_TYPES), resolved when a JSX attribute or a nested property passes the
4294
+ // Identifier — no scope manager here, so this is a flat name→init map.
4173
4295
  // A name declared more than once in the file is ambiguous (its two bindings cannot be told
4174
4296
  // apart), so it is dropped from the map entirely rather than guessed at last-wins.
4175
4297
  const responsiveObjectConsts = new Map()
4176
4298
  const seenDeclaratorNames = new Set()
4299
+ const sourceCode = context.sourceCode ?? context.getSourceCode?.()
4177
4300
 
4178
4301
  const noteShellHomeOwner = (name) => {
4179
4302
  if (typeof name === 'string' && SHELL_HOME_NAMES.has(name)) definesShellHome = true
@@ -4217,7 +4340,7 @@ const rawBreakpoint = {
4217
4340
  return
4218
4341
  }
4219
4342
  seenDeclaratorNames.add(name)
4220
- if (node.init?.type === 'ObjectExpression') responsiveObjectConsts.set(name, node.init)
4343
+ if (RESOLVABLE_INIT_TYPES.has(node.init?.type)) responsiveObjectConsts.set(name, node.init)
4221
4344
  },
4222
4345
  JSXAttribute(node) {
4223
4346
  const name = node.name?.name
@@ -4258,16 +4381,16 @@ const rawBreakpoint = {
4258
4381
  ? CONTAINER_ROUTED_PROPS[tag]
4259
4382
  : undefined
4260
4383
  const mode = routed?.has(node.name.name)
4261
- ? containerGridMode(owner, mantineImports, responsiveObjectConsts)
4384
+ ? containerGridMode(owner, mantineImports, responsiveObjectConsts, sourceCode)
4262
4385
  : undefined
4263
4386
  if (mode === 'container') continue
4264
4387
  if (!containsResponsiveObjectValue(value, responsiveObjectConsts, mode !== undefined))
4265
4388
  continue
4266
4389
  if (hasThemeAllow(context, node, 'raw-breakpoint')) continue
4267
- const message =
4268
- mode === undefined
4269
- ? RAW_BREAKPOINT_RESPONSIVE_PROP_MESSAGE
4270
- : RAW_BREAKPOINT_CONTAINER_KEY_MESSAGE
4390
+ let message = RAW_BREAKPOINT_RESPONSIVE_PROP_MESSAGE
4391
+ if (mode !== undefined) message = RAW_BREAKPOINT_CONTAINER_KEY_MESSAGE
4392
+ else if (isBaseOnlyResponsiveObject(value, responsiveObjectConsts))
4393
+ message = RAW_BREAKPOINT_BASE_ONLY_MESSAGE
4271
4394
  context.report({ node, message })
4272
4395
  }
4273
4396
  for (const node of visibleHiddenCandidates) {
@@ -4309,59 +4432,7 @@ const rawBreakpoint = {
4309
4432
  *
4310
4433
  * @type {Record<string, GraceEntry>}
4311
4434
  */
4312
- export const PLUGIN_RULE_GRACE = {
4313
- 'control-outside-home': {
4314
- since: '1.26.0',
4315
- promote: '1.33.0',
4316
- why:
4317
- "Extended 1.32.0 -> 1.33.0 at the 1.32.0 release, the last extension: basalt's own tree is at 0 (consumer-loop r1 dogfood) and argo is at 2, but RE-MEASURED 2026-09-30 the fleet carries ~33 (image-gen 11, rb 7, image-share 6, weatherorb 5, email-gateway 4, argo 2). The 1.32.0 fleet upgrade clears them while they warn, together with raw-breakpoint, so 1.33.0 promotes all four ids against a measured-clean fleet. " +
4318
- 'Extended 1.31.0 -> 1.32.0 at the 1.31.0 release: the basalt tree still carries incumbents (data-table, notifications center, theme-lab) and the argo sweep has not run. ' +
4319
- 'the wave-6 control guards (docs/CONTROLS-SPEC.md §6). The one openly HEURISTIC rule of the ' +
4320
- 'set — "this control has no home" is a claim about layout intent, so its false-positive load ' +
4321
- 'is carried by four exemptions (overlay/settings-row ancestor, the overlay FILENAME ' +
4322
- 'convention below, @mantine/form, owner definition) rather than by certainty. The other five ' +
4323
- 'wave-6 rules promoted at 1.27.0; this one has not, across three re-datings. ' +
4324
- 'RE-MEASURED 2026-09-09, every consumer freshly migrated to 1.29.2 and basalt built from ' +
4325
- 'master: 29 incumbents across five repos — image-gen 10, rb 8, image-share 6, the playground ' +
4326
- '3, argo 2 — and zero in linewatch, meteo and obsidian. The 1.28.0 prediction held: ' +
4327
- "{@link isOverlayConventionFile} took argo's 9 cross-file overlay warns down to 2, which is " +
4328
- 'the part of the remainder that was ever going to fall to an exemption. ' +
4329
- 'Extended to 1.31.0, and the argument is NOT the count — it is that 1.30.0 is the first ' +
4330
- 'release in which this rule names an answer the 29 can reach. Every home it listed was a ' +
4331
- 'shell slot (PageBar / Section / WidgetHeader), so a consumer that mounts `BasaltProvider` ' +
4332
- 'and no `BasaltShell` had no reachable home at all: image-gen is a Tauri window with a ' +
4333
- 'hand-rolled header and carries 10 of the 29 by itself, and `bound-control-outside-home` ' +
4334
- 'closed the `ViewTabs` escape it would otherwise have taken. 1.30.0 adds the missing ' +
4335
- 'sentence to both messages ({@link SHELL_LESS_HOME_HINT} — a `PageBar` outside a shell ' +
4336
- 'renders in flow, sticky, with its own title, which is what `src/shell/page-bar.tsx` ' +
4337
- 'actually does). Promoting a rule to `error` in the same minor that first makes it ' +
4338
- 'satisfiable hands a consumer zero minors to act on the answer, which is exactly the failure ' +
4339
- 'the grace mechanism exists to prevent. 1.31.0 is when the 29 have been through the new home ' +
4340
- 'and the remainder is measured against that number. A file outside the convention that is ' +
4341
- "still an overlay's own body declares it with " +
4342
- '`theme-allow-file control-outside-home — overlay`.',
4343
- },
4344
- 'raw-breakpoint': {
4345
- since: '1.31.0',
4346
- promote: '1.33.0',
4347
- why:
4348
- 'New in the wave-11 responsive/touch guards. Catches four independent shapes at once — a ' +
4349
- 'responsive-object Mantine prop, visibleFrom/hiddenFrom outside the three shell homes, the ' +
4350
- 'three raw viewport hooks, and window.matchMedia/innerWidth — none of which basalt policed ' +
4351
- 'before this wave, so every consumer on an earlier minor has a green build with all four ' +
4352
- "shapes already in it. basalt-ui/content's own article-card.tsx (a `SimpleGrid cols={{ base: " +
4353
- "1, sm: 2, lg }}`) was one incumbent measured in basalt's own tree at ship time — a real " +
4354
- 'number, not zero, so it shipped warn rather than error. ' +
4355
- 'Extended 1.32.0 -> 1.33.0: 1.31.0 shipped the rule naming "a container query" but no ' +
4356
- 'primitive, and MIGRATING never mentioned it (argo: 42 warns, no documented target). 1.32.0 ' +
4357
- 'is the first minor with a reachable, documented answer (Mantine type="container" grids keyed ' +
4358
- 'on CONTAINER_CLASSES, exempted here), and promoting in the minor that first makes a rule ' +
4359
- 'satisfiable hands a consumer zero minors to act on it — the control-outside-home ' +
4360
- "extension's reasoning. 1.33.0 is when the incumbents are expected to have moved to a " +
4361
- 'container grid or a theme-allow with a stated reason. Promotes together with its CSS twin, ' +
4362
- 'the `raw-media-query` guard kind.',
4363
- },
4364
- }
4435
+ export const PLUGIN_RULE_GRACE = {}
4365
4436
 
4366
4437
  /**
4367
4438
  * Plugin rules that stay `warn` in the shipped preset PERMANENTLY — outside the C16 version gate,