basalt-ui 1.31.0 → 1.32.1

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 (102) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/MIGRATING.md +212 -20
  3. package/agent/rules/basalt-mantine.md +4 -4
  4. package/configs/oxlint-plugin.js +240 -59
  5. package/configs/oxlint-plugin.test.ts +329 -1
  6. package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
  7. package/dist/charts/kinds/BandStrip.js +18 -66
  8. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  9. package/dist/charts/kinds/DualPanel.js +5 -26
  10. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
  11. package/dist/charts/kinds/MirroredBars.js +10 -68
  12. package/dist/charts/kinds/band-chrome.d.ts +31 -0
  13. package/dist/charts/kinds/band-chrome.d.ts.map +1 -0
  14. package/dist/charts/kinds/band-chrome.js +71 -0
  15. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  16. package/dist/charts/primitives/CartesianChart.js +49 -76
  17. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  18. package/dist/charts/primitives/ChartCard.js +5 -23
  19. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  20. package/dist/charts/primitives/ChartFrame.js +23 -40
  21. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  22. package/dist/charts/primitives/ChartLegend.js +5 -133
  23. package/dist/charts/primitives/ChartTooltip.d.ts +5 -0
  24. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  25. package/dist/charts/primitives/ChartTooltip.js +16 -1
  26. package/dist/charts/primitives/HoverOverlay.d.ts +24 -9
  27. package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
  28. package/dist/charts/primitives/HoverOverlay.js +17 -1
  29. package/dist/charts/primitives/chart-card-context.d.ts +17 -13
  30. package/dist/charts/primitives/chart-card-context.d.ts.map +1 -1
  31. package/dist/charts/primitives/chart-card-context.js +10 -5
  32. package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
  33. package/dist/charts/primitives/chart-frame-layout.js +25 -31
  34. package/dist/charts/primitives/legend-disclosure.d.ts +27 -0
  35. package/dist/charts/primitives/legend-disclosure.d.ts.map +1 -0
  36. package/dist/charts/primitives/legend-disclosure.js +153 -0
  37. package/dist/controls/controls.module.css +5 -0
  38. package/dist/controls/sync-button.module.css +1 -0
  39. package/dist/dashboard/stat-group.module.css +19 -64
  40. package/dist/dashboard/widget-grid.module.css +11 -38
  41. package/dist/data/data-table-state.d.ts +83 -0
  42. package/dist/data/data-table-state.d.ts.map +1 -0
  43. package/dist/data/data-table-state.js +139 -0
  44. package/dist/data/data-table.d.ts +40 -2
  45. package/dist/data/data-table.d.ts.map +1 -1
  46. package/dist/data/data-table.js +179 -277
  47. package/dist/forms/form-layout.d.ts +6 -1
  48. package/dist/forms/form-layout.d.ts.map +1 -1
  49. package/dist/forms/form-layout.module.css +6 -13
  50. package/dist/guard/index.d.ts +2 -2
  51. package/dist/guard/index.d.ts.map +1 -1
  52. package/dist/guard/index.js +10 -14
  53. package/dist/notifications/center.d.ts.map +1 -1
  54. package/dist/provider/index.d.ts.map +1 -1
  55. package/dist/provider/index.js +12 -10
  56. package/dist/shell/aside-docking.d.ts +31 -0
  57. package/dist/shell/aside-docking.d.ts.map +1 -0
  58. package/dist/shell/aside-docking.js +19 -0
  59. package/dist/shell/index.d.ts +0 -11
  60. package/dist/shell/index.d.ts.map +1 -1
  61. package/dist/shell/index.js +13 -14
  62. package/dist/shell/page-bar.d.ts +24 -0
  63. package/dist/shell/page-bar.d.ts.map +1 -1
  64. package/dist/shell/use-breakpoint.d.ts +1 -1
  65. package/dist/theme/floating.module.css +1 -0
  66. package/dist/theme-lab/derive-controls.d.ts.map +1 -1
  67. package/dist/tokens/index.d.ts +2 -0
  68. package/dist/tokens/index.d.ts.map +1 -1
  69. package/dist/tokens/index.js +4 -0
  70. package/llms.txt +2 -2
  71. package/package.json +2 -1
  72. package/src/charts/kinds/BandStrip.tsx +18 -64
  73. package/src/charts/kinds/DualPanel.tsx +4 -24
  74. package/src/charts/kinds/MirroredBars.tsx +12 -66
  75. package/src/charts/kinds/band-chrome.tsx +91 -0
  76. package/src/charts/primitives/CartesianChart.tsx +79 -122
  77. package/src/charts/primitives/ChartCard.tsx +5 -27
  78. package/src/charts/primitives/ChartFrame.tsx +37 -76
  79. package/src/charts/primitives/ChartLegend.tsx +7 -154
  80. package/src/charts/primitives/ChartTooltip.tsx +16 -0
  81. package/src/charts/primitives/HoverOverlay.tsx +52 -21
  82. package/src/charts/primitives/chart-card-context.ts +25 -16
  83. package/src/charts/primitives/chart-frame-layout.ts +44 -56
  84. package/src/charts/primitives/legend-disclosure.tsx +177 -0
  85. package/src/controls/controls.module.css +5 -0
  86. package/src/controls/sync-button.module.css +1 -0
  87. package/src/dashboard/stat-group.module.css +19 -64
  88. package/src/dashboard/widget-grid.module.css +11 -38
  89. package/src/data/data-table-state.ts +218 -0
  90. package/src/data/data-table.tsx +134 -218
  91. package/src/forms/form-layout.module.css +6 -13
  92. package/src/forms/form-layout.tsx +6 -1
  93. package/src/guard/index.ts +38 -34
  94. package/src/notifications/center.tsx +3 -0
  95. package/src/provider/index.tsx +18 -17
  96. package/src/shell/aside-docking.ts +54 -0
  97. package/src/shell/index.tsx +15 -37
  98. package/src/shell/page-bar.tsx +24 -0
  99. package/src/shell/use-breakpoint.ts +2 -2
  100. package/src/theme/floating.module.css +1 -0
  101. package/src/theme-lab/derive-controls.tsx +2 -0
  102. package/src/tokens/index.ts +5 -0
@@ -390,8 +390,12 @@ function hasFileDeclaration(context, ruleId) {
390
390
  )
391
391
  }
392
392
 
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__[\\/]/
393
+ /**
394
+ * Test/spec files — design guidance does not apply to a fixture. Mirrors `src/cli`'s own SKIP, plus
395
+ * a `test/`/`tests/` directory (1.32.1): a test-setup polyfill (`tests/setup/dom.ts` stubbing
396
+ * `window.matchMedia`) is neither a `.test` file nor under `__tests__`, and needed a waiver.
397
+ */
398
+ const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|[\\/])(?:__tests__|tests?)[\\/]/
395
399
 
396
400
  function isTestFile(context) {
397
401
  return TEST_FILE.test(getFilename(context))
@@ -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.32.0)
2715
+ // Ships: warn (grace → 1.33.0)
2712
2716
  const controlOutsideHome = {
2713
2717
  meta: {
2714
2718
  type: 'suggestion',
@@ -3656,8 +3660,9 @@ export const DEPRECATED_EXPORTS = [
3656
3660
  {
3657
3661
  subpath: 'basalt-ui',
3658
3662
  name: 'useBreakpoint',
3659
- replacement: 'useSizeClass (compact/medium/expanded) or @container for a container concern',
3660
- removeIn: '1.31.0',
3663
+ replacement:
3664
+ 'useSizeClass() for shell chrome; a page’s own layout swap is a container decision — a type="container" SimpleGrid/Grid or an @container rule',
3665
+ removeIn: '1.33.0',
3661
3666
  fix: false,
3662
3667
  },
3663
3668
  ]
@@ -3959,37 +3964,144 @@ const RESPONSIVE_OBJECT_KEYS = new Set(['base', 'xs', 'sm', 'md', 'lg', 'xl'])
3959
3964
  * object that merely happens to have a `sm` key among other, unrelated ones is not this shape, and
3960
3965
  * an empty object is not a breakpoint decision at all.
3961
3966
  */
3962
- function isResponsiveObjectValue(node) {
3967
+ function isResponsiveObjectValue(node, namedOnly = false) {
3963
3968
  if (node === null || node === undefined || node.type !== 'ObjectExpression') return false
3964
3969
  if (node.properties.length === 0) return false
3965
- return node.properties.every((prop) => {
3966
- if (prop.type !== 'Property') return false
3967
- const name = propertyKeyName(prop)
3968
- return typeof name === 'string' && RESPONSIVE_OBJECT_KEYS.has(name)
3969
- })
3970
+ const names = node.properties.map((prop) =>
3971
+ prop.type === 'Property' ? propertyKeyName(prop) : undefined,
3972
+ )
3973
+ if (!names.every((name) => typeof name === 'string' && RESPONSIVE_OBJECT_KEYS.has(name)))
3974
+ return false
3975
+ // Under `type="container"` a lone `base` is the unconditional value, not a breakpoint decision.
3976
+ return !namedOnly || names.some((name) => name !== 'base')
3970
3977
  }
3971
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
+
3972
3987
  /**
3973
- * Does this prop value CONTAIN a responsive object — itself, nested one level down in a property
3974
- * (`footer={{ height: { base: 60, sm: 0 } }}`, Mantine's own AppShell idiom), or behind a same-file
3975
- * `const` binding at either place (`const h = { base: 60, sm: 0 }`, then `footer={{ height: h }}`)?
3976
- * `consts` is the same-file name→object map, so an Identifier resolves identically wherever it
3977
- * appears — the top-level attribute value and a nested property value alike. Matching only a nested
3978
- * `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.
3979
3993
  */
3980
- function containsResponsiveObjectValue(node, consts, seen = new Set()) {
3981
- const resolved =
3982
- node !== null && node !== undefined && node.type === 'Identifier' ? consts.get(node.name) : node
3983
- if (resolved === null || resolved === undefined || resolved.type !== 'ObjectExpression')
3984
- return false
3985
- 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 []
3986
3997
  seen.add(resolved)
3987
- if (isResponsiveObjectValue(resolved)) return true
3988
- return resolved.properties.some(
3989
- (prop) => prop.type === 'Property' && containsResponsiveObjectValue(prop.value, consts, 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
+ ),
3990
4030
  )
3991
4031
  }
3992
4032
 
4033
+ /**
4034
+ * The props each Mantine grid routes through `@container` queries under `type="container"` —
4035
+ * confirmed in `@mantine/core` 9.3 source: `SimpleGridContainerVariables` reads `cols`/`spacing`/
4036
+ * `verticalSpacing`, `GridVariables` reads `gap`/`rowGap`/`columnGap`, `GridColVariables` reads
4037
+ * `span`/`offset`/`order`/`align`. Every OTHER responsive prop on the same element (`p`, `mt`,
4038
+ * `mih`, …) is a Box style prop and still compiles to viewport `@media`, so the container exemption
4039
+ * stops at this set. `Grid`'s own `breakpoints` is the container scale itself, not a viewport key.
4040
+ */
4041
+ const CONTAINER_ROUTED_PROPS = {
4042
+ SimpleGrid: new Set(['cols', 'spacing', 'verticalSpacing']),
4043
+ Grid: new Set(['gap', 'rowGap', 'columnGap', 'breakpoints']),
4044
+ 'Grid.Col': new Set(['span', 'offset', 'order', 'align']),
4045
+ }
4046
+
4047
+ /**
4048
+ * How Mantine 9 resolves a responsive object on this element's grid, read from `@mantine/core`
4049
+ * 9.3 source — the sanctioned answer to this rule, so it has to be exempt exactly where it works:
4050
+ *
4051
+ * - `'container'` — a `<Grid type="container" breakpoints={…}>` (or a `Grid.Col` inside one) whose
4052
+ * map statically resolves to a non-empty object. Keys resolve through that map into
4053
+ * `@container mantine-grid` queries, so an `sm` key is whatever width the map gives it: exempt.
4054
+ * Mantine types the map as `Record<MantineSize, string>`, so the recipe's keys ARE `xs`–`xl` —
4055
+ * exempting by key name instead of by owner would flag the sanctioned answer itself.
4056
+ * - `'simple-grid'` — `<SimpleGrid type="container">`. `SimpleGridContainerVariables` writes each
4057
+ * key VERBATIM as `@container simple-grid (min-width: <key>)` and never consults
4058
+ * `theme.breakpoints`: a `'480px'` key works (and is not a responsive-object key, so it never
4059
+ * reaches here), while `sm` emits `(min-width: sm)` — invalid CSS, a dead query. Flagged.
4060
+ * - `'fallback'` — `<Grid type="container">` whose `breakpoints` is absent, `undefined`, `{}` or not
4061
+ * statically resolvable (or a `Grid.Col` in one). `Grid` takes its container branch only on a
4062
+ * truthy map, and an empty one emits no query at all — so none of these is the container law,
4063
+ * and an unreadable map is reported rather than trusted. Flagged.
4064
+ * - `undefined` — not a container grid; the ordinary viewport law applies.
4065
+ *
4066
+ * `type` has to be the string literal `"container"`; a dynamic value is not a decision this rule
4067
+ * can read. `Grid.Col` takes its grid's mode by walking to the nearest enclosing `Grid` element.
4068
+ * `consts` is the rule's same-file name→object map, so `breakpoints={map}` resolves like any value.
4069
+ */
4070
+ function containerGridMode(opening, mantineImports, consts) {
4071
+ let tag = resolveMantineTag(opening.name, mantineImports)
4072
+ let grid = opening
4073
+ if (tag === 'Grid.Col') {
4074
+ grid = undefined
4075
+ let node = opening.parent?.parent
4076
+ for (let depth = 0; node !== null && node !== undefined; depth++, node = node.parent) {
4077
+ if (depth > ANCESTRY_MAX_DEPTH || node.type === 'Program') break
4078
+ if (node.type !== 'JSXElement') continue
4079
+ if (resolveMantineTag(node.openingElement.name, mantineImports) !== 'Grid') continue
4080
+ grid = node.openingElement
4081
+ tag = 'Grid'
4082
+ break
4083
+ }
4084
+ }
4085
+ if (grid === undefined || (tag !== 'Grid' && tag !== 'SimpleGrid')) return undefined
4086
+ const attr = (name) =>
4087
+ grid.attributes.find((a) => a.type === 'JSXAttribute' && a.name?.name === name)
4088
+ const type = attr('type')
4089
+ if (
4090
+ type === undefined ||
4091
+ !isStringLiteral(attrValue(type)) ||
4092
+ attrValue(type).value !== 'container'
4093
+ )
4094
+ return undefined
4095
+ if (tag === 'SimpleGrid') return 'simple-grid'
4096
+ const breakpoints = attr('breakpoints')
4097
+ if (breakpoints === undefined) return 'fallback'
4098
+ let map = attrValue(breakpoints)
4099
+ if (map?.type === 'Identifier') map = consts.get(map.name)
4100
+ const nonEmpty =
4101
+ map?.type === 'ObjectExpression' && map.properties.some((prop) => prop.type === 'Property')
4102
+ return nonEmpty ? 'container' : 'fallback'
4103
+ }
4104
+
3993
4105
  /**
3994
4106
  * The shell components that are `visibleFrom`/`hiddenFrom`'s legitimate home — the size-class
3995
4107
  * axis's one JSX escape hatch, alongside overlays. Matched by DECLARATION name, the same
@@ -4011,27 +4123,72 @@ const SHELL_HOME_NAMES = new Set(['BasaltShell', 'AppSidebar', 'MobileNav', 'App
4011
4123
  */
4012
4124
  const RAW_BREAKPOINT_EXEMPT_FILE = /(?:^|[\\/])(?:use-media-query\.ts|ChartTooltip\.tsx)$/
4013
4125
 
4126
+ /**
4127
+ * The one recipe every raw-breakpoint message points at. A page's own layout keys on its CONTAINER
4128
+ * (`CONTAINER_CLASSES`, `basalt-ui/tokens`: compact 240 / regular 480 / wide 800), never on the
4129
+ * viewport — `useSizeClass()` is the shell's seam, not a page's. Mantine 9's own `type="container"`
4130
+ * grids are the primitive: both self-wrap in an `inline-size` container, so they need no ancestor.
4131
+ * The px literals are `CONTAINER_CLASSES` written out (the plugin cannot import TS source);
4132
+ * `oxlint-plugin.test.ts` asserts every one against it, and the MIGRATING recipe too.
4133
+ */
4134
+ export const RAW_BREAKPOINT_RECIPE =
4135
+ 'A page’s own layout keys on its container: <SimpleGrid type="container" cols={{ base: 1, ' +
4136
+ "'480px': 2, '800px': 3 }}> (px keys = CONTAINER_CLASSES, basalt-ui/tokens), or <Grid " +
4137
+ 'type="container" breakpoints={…}> mapping all five xs–xl onto those px widths, with Grid.Col ' +
4138
+ 'span={{ base: 12, sm: 6 }} keyed through that map; a Flex direction swap becomes that SimpleGrid (equal ' +
4139
+ 'columns), a wrapping Group, or a CSS-module @container basalt-grid rule. useSizeClass() is for ' +
4140
+ 'shell chrome only. Recipes: basalt-ui MIGRATING.md § basalt/raw-breakpoint.'
4141
+
4014
4142
  const RAW_BREAKPOINT_RESPONSIVE_PROP_MESSAGE =
4015
- 'Responsive-object style prop on a Mantine component — the framework owns the three responsive ' +
4016
- 'axes (size class for shell chrome, container class for a component’s own width, pointer tier), ' +
4017
- 'never a per-component `{ base, sm, … }`. Reach for a container query for this component’s own ' +
4018
- 'width, or useSizeClass() for shell-only chrome. (basalt/raw-breakpoint)'
4143
+ 'Responsive-object prop keyed on theme breakpoints — those are viewport widths. ' +
4144
+ `${RAW_BREAKPOINT_RECIPE} (basalt/raw-breakpoint)`
4145
+
4146
+ /** rb r2 #5: a `(hover: none)` read was flagged with advice that only covered widths. */
4147
+ const RAW_BREAKPOINT_POINTER_HINT =
4148
+ 'A hover/pointer capability read belongs in CSS: @media (pointer: coarse) in a module (the ' +
4149
+ 'query --vx-hit keys on) — (hover: none) is not a sanctioned query.'
4150
+
4151
+ const RAW_BREAKPOINT_BASE_ONLY_MESSAGE =
4152
+ 'Responsive-object prop with only a base key — there is no breakpoint in it at all, so drop the ' +
4153
+ 'object and write the value: cols={{ base: 3 }} → cols={3}. (basalt/raw-breakpoint)'
4154
+
4155
+ /**
4156
+ * Is every responsive object this value can evaluate to ({@link reachableObjects} — the same
4157
+ * unwrap detection uses) one carrying ONLY `base`? That is no breakpoint decision, and the viewport
4158
+ * recipe is the wrong advice for it — the fix is the bare value. Message-only: the shape still
4159
+ * reports, as it always has.
4160
+ */
4161
+ function isBaseOnlyResponsiveObject(node, consts) {
4162
+ const responsive = reachableObjects(node, consts).filter((object) =>
4163
+ isResponsiveObjectValue(object),
4164
+ )
4165
+ return (
4166
+ responsive.length > 0 &&
4167
+ responsive.every((object) =>
4168
+ object.properties.every((prop) => propertyKeyName(prop) === 'base'),
4169
+ )
4170
+ )
4171
+ }
4172
+
4173
+ const RAW_BREAKPOINT_CONTAINER_KEY_MESSAGE =
4174
+ 'Theme breakpoint name under type="container" — Mantine does not resolve it there: ' +
4175
+ "SimpleGrid uses each key verbatim as a (min-width: …) length, so 'sm' is a dead query, " +
4176
+ 'and Grid only goes container with a breakpoints map (without one it silently falls back to ' +
4177
+ `viewport media queries). ${RAW_BREAKPOINT_RECIPE} (basalt/raw-breakpoint)`
4019
4178
 
4020
4179
  const RAW_BREAKPOINT_VISIBLE_HIDDEN_MESSAGE =
4021
- "visibleFrom/hiddenFrom outside a shell home or a control's own C9 swap — this pair is the " +
4022
- "size-class axis's one JSX escape hatch. Anywhere else it is a hand-rolled viewport breakpoint " +
4023
- "the framework cannot see or move. Use a container query for a component's own width instead. " +
4024
- '(basalt/raw-breakpoint)'
4180
+ "visibleFrom/hiddenFrom outside a shell home or a control's own C9 swap — a viewport " +
4181
+ `breakpoint the framework cannot see or move. ${RAW_BREAKPOINT_RECIPE} (basalt/raw-breakpoint)`
4025
4182
 
4026
4183
  const RAW_BREAKPOINT_HOOK_MESSAGE =
4027
- "useMediaQuery/useMatches/useViewportSize — a raw viewport read outside the framework's one seam " +
4028
- '(useSizeClass(), shell-only). A component deciding its own layout from a viewport read instead of ' +
4029
- 'a container query is exactly the law this rule exists to catch. (basalt/raw-breakpoint)'
4184
+ "useMediaQuery/useMatches/useViewportSize — a raw viewport read outside the framework's " +
4185
+ 'one seam. Shell chrome reads useSizeClass(); a page swapping its own layout (a table for a card ' +
4186
+ `list, a column count) is a container decision. ${RAW_BREAKPOINT_POINTER_HINT} ` +
4187
+ `${RAW_BREAKPOINT_RECIPE} (basalt/raw-breakpoint)`
4030
4188
 
4031
4189
  const RAW_BREAKPOINT_GLOBAL_MESSAGE =
4032
- 'window.matchMedia/window.innerWidth read directly — the same viewport read useSizeClass() already ' +
4033
- "owns, shell-only. Route a component's own layout decision through a container query instead. " +
4034
- '(basalt/raw-breakpoint)'
4190
+ 'window.matchMedia/window.innerWidth read directly — the viewport read useSizeClass() owns ' +
4191
+ `for shell chrome. ${RAW_BREAKPOINT_POINTER_HINT} ${RAW_BREAKPOINT_RECIPE} (basalt/raw-breakpoint)`
4035
4192
 
4036
4193
  /** The three viewport hooks the law names, whatever module they are imported from. */
4037
4194
  const RAW_BREAKPOINT_HOOK_NAMES = new Set(['useMediaQuery', 'useMatches', 'useViewportSize'])
@@ -4047,7 +4204,7 @@ const RAW_BREAKPOINT_HOOK_NAMES = new Set(['useMediaQuery', 'useMatches', 'useVi
4047
4204
  * declares a shell home (or a control's own C9 swap) is exempt for all four shapes uniformly — the
4048
4205
  * shell's own responsive chrome is the sanctioned use, whatever shape it takes.
4049
4206
  */
4050
- // Ships: warn (grace → 1.32.0)
4207
+ // Ships: warn (grace → 1.33.0)
4051
4208
  const rawBreakpoint = {
4052
4209
  meta: {
4053
4210
  type: 'suggestion',
@@ -4056,7 +4213,8 @@ const rawBreakpoint = {
4056
4213
  'Disallow a hand-rolled viewport breakpoint — a responsive-object Mantine prop, ' +
4057
4214
  'visibleFrom/hiddenFrom outside a shell home, useMediaQuery/useMatches/useViewportSize, or ' +
4058
4215
  'a raw window.matchMedia/innerWidth read. All four shapes are exempt inside a file that ' +
4059
- 'declares a shell-home component (or a control implementing its own C9 swap).',
4216
+ 'declares a shell-home component (or a control implementing its own C9 swap); a ' +
4217
+ 'responsive object on a Mantine type="container" grid is exempt where Mantine resolves it.',
4060
4218
  },
4061
4219
  schema: [],
4062
4220
  },
@@ -4073,8 +4231,9 @@ const rawBreakpoint = {
4073
4231
  let definesShellHome = false
4074
4232
  const visibleHiddenCandidates = []
4075
4233
  const responsiveObjectCandidates = []
4076
- // Same-file `const NAME = { base, sm }` bindings, resolved when a JSX attribute or a nested
4077
- // property passes the Identifier — no scope manager here, so this is a flat name→object map.
4234
+ // Same-file `const NAME = { base, sm }` bindings (or a conditional/logical/alias init, see
4235
+ // RESOLVABLE_INIT_TYPES), resolved when a JSX attribute or a nested property passes the
4236
+ // Identifier — no scope manager here, so this is a flat name→init map.
4078
4237
  // A name declared more than once in the file is ambiguous (its two bindings cannot be told
4079
4238
  // apart), so it is dropped from the map entirely rather than guessed at last-wins.
4080
4239
  const responsiveObjectConsts = new Map()
@@ -4122,7 +4281,7 @@ const rawBreakpoint = {
4122
4281
  return
4123
4282
  }
4124
4283
  seenDeclaratorNames.add(name)
4125
- if (node.init?.type === 'ObjectExpression') responsiveObjectConsts.set(name, node.init)
4284
+ if (RESOLVABLE_INIT_TYPES.has(node.init?.type)) responsiveObjectConsts.set(name, node.init)
4126
4285
  },
4127
4286
  JSXAttribute(node) {
4128
4287
  const name = node.name?.name
@@ -4153,12 +4312,27 @@ const rawBreakpoint = {
4153
4312
  if (definesShellHome || controlOwner.isOwner()) return
4154
4313
  for (const node of responsiveObjectCandidates) {
4155
4314
  const value = unwrapExpressionContainer(node.value)
4156
- if (!containsResponsiveObjectValue(value, responsiveObjectConsts)) continue
4157
4315
  const owner = node.parent
4158
4316
  if (owner === null || owner === undefined || owner.type !== 'JSXOpeningElement') continue
4159
- if (resolveMantineTag(owner.name, mantineImports) === undefined) continue
4317
+ const tag = resolveMantineTag(owner.name, mantineImports)
4318
+ if (tag === undefined) continue
4319
+ // The container law reaches only the props Mantine routes through the grid's container
4320
+ // queries; a `p={{ base, lg }}` on the same element is still a viewport @media.
4321
+ const routed = Object.hasOwn(CONTAINER_ROUTED_PROPS, tag)
4322
+ ? CONTAINER_ROUTED_PROPS[tag]
4323
+ : undefined
4324
+ const mode = routed?.has(node.name.name)
4325
+ ? containerGridMode(owner, mantineImports, responsiveObjectConsts)
4326
+ : undefined
4327
+ if (mode === 'container') continue
4328
+ if (!containsResponsiveObjectValue(value, responsiveObjectConsts, mode !== undefined))
4329
+ continue
4160
4330
  if (hasThemeAllow(context, node, 'raw-breakpoint')) continue
4161
- context.report({ node, message: RAW_BREAKPOINT_RESPONSIVE_PROP_MESSAGE })
4331
+ let message = RAW_BREAKPOINT_RESPONSIVE_PROP_MESSAGE
4332
+ if (mode !== undefined) message = RAW_BREAKPOINT_CONTAINER_KEY_MESSAGE
4333
+ else if (isBaseOnlyResponsiveObject(value, responsiveObjectConsts))
4334
+ message = RAW_BREAKPOINT_BASE_ONLY_MESSAGE
4335
+ context.report({ node, message })
4162
4336
  }
4163
4337
  for (const node of visibleHiddenCandidates) {
4164
4338
  if (hasThemeAllow(context, node, 'raw-breakpoint')) continue
@@ -4202,8 +4376,9 @@ const rawBreakpoint = {
4202
4376
  export const PLUGIN_RULE_GRACE = {
4203
4377
  'control-outside-home': {
4204
4378
  since: '1.26.0',
4205
- promote: '1.32.0',
4379
+ promote: '1.33.0',
4206
4380
  why:
4381
+ "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. " +
4207
4382
  '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. ' +
4208
4383
  'the wave-6 control guards (docs/CONTROLS-SPEC.md §6). The one openly HEURISTIC rule of the ' +
4209
4384
  'set — "this control has no home" is a claim about layout intent, so its false-positive load ' +
@@ -4232,17 +4407,23 @@ export const PLUGIN_RULE_GRACE = {
4232
4407
  },
4233
4408
  'raw-breakpoint': {
4234
4409
  since: '1.31.0',
4235
- promote: '1.32.0',
4410
+ promote: '1.33.0',
4236
4411
  why:
4237
- 'new in the wave-11 responsive/touch guards. Catches four ' +
4238
- 'independent shapes at once — a responsive-object Mantine prop, visibleFrom/hiddenFrom outside ' +
4239
- 'the three shell homes, the three raw viewport hooks, and window.matchMedia/innerWidth — none ' +
4240
- 'of which basalt policed before this wave, so every consumer on an earlier minor has a green ' +
4241
- "build with all four shapes already in it. basalt-ui/content's own article-card.tsx (a " +
4242
- "`SimpleGrid cols={{ base: 1, sm: 2, lg }}`) is one incumbent measured in basalt's own tree at " +
4243
- 'ship time — a real number, not zero, so this ships warn rather than error from the start. ' +
4244
- '1.32.0 is when that incumbent and any consumer-side ones are expected to have moved to a ' +
4245
- 'container query or a theme-allow with a stated reason.',
4412
+ 'New in the wave-11 responsive/touch guards. Catches four independent shapes at once — a ' +
4413
+ 'responsive-object Mantine prop, visibleFrom/hiddenFrom outside the three shell homes, the ' +
4414
+ 'three raw viewport hooks, and window.matchMedia/innerWidth — none of which basalt policed ' +
4415
+ 'before this wave, so every consumer on an earlier minor has a green build with all four ' +
4416
+ "shapes already in it. basalt-ui/content's own article-card.tsx (a `SimpleGrid cols={{ base: " +
4417
+ "1, sm: 2, lg }}`) was one incumbent measured in basalt's own tree at ship time — a real " +
4418
+ 'number, not zero, so it shipped warn rather than error. ' +
4419
+ 'Extended 1.32.0 -> 1.33.0: 1.31.0 shipped the rule naming "a container query" but no ' +
4420
+ 'primitive, and MIGRATING never mentioned it (argo: 42 warns, no documented target). 1.32.0 ' +
4421
+ 'is the first minor with a reachable, documented answer (Mantine type="container" grids keyed ' +
4422
+ 'on CONTAINER_CLASSES, exempted here), and promoting in the minor that first makes a rule ' +
4423
+ 'satisfiable hands a consumer zero minors to act on it — the control-outside-home ' +
4424
+ "extension's reasoning. 1.33.0 is when the incumbents are expected to have moved to a " +
4425
+ 'container grid or a theme-allow with a stated reason. Promotes together with its CSS twin, ' +
4426
+ 'the `raw-media-query` guard kind.',
4246
4427
  },
4247
4428
  }
4248
4429