basalt-ui 1.29.2 → 1.30.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.
- package/CHANGELOG.md +17 -0
- package/MIGRATING.md +901 -366
- package/README.md +23 -2
- package/agent/rules/basalt-controls.md +34 -34
- package/agent/skills/basalt-app/SKILL.md +4 -2
- package/agent/templates/query-client.ts.tpl +18 -4
- package/configs/lefthook.yml +51 -19
- package/configs/oxlint-plugin.js +210 -84
- package/configs/oxlint-plugin.test.ts +182 -5
- package/configs/oxlint.json +4 -4
- package/dist/charts/index.d.ts +1 -1
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/kinds/BandStrip.d.ts +5 -3
- package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.d.ts +4 -2
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.d.ts +4 -2
- package/dist/charts/kinds/Donut.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.d.ts +4 -2
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/Heatmap.d.ts +5 -2
- package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
- package/dist/charts/kinds/MirroredBars.d.ts +4 -2
- package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.d.ts +4 -2
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.d.ts +4 -2
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.d.ts +4 -2
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.d.ts +4 -1
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.js +2 -2
- package/dist/charts/primitives/ChartFrame.d.ts +25 -4
- package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.js +23 -16
- package/dist/charts/primitives/chart-frame-layout.d.ts +115 -6
- package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
- package/dist/charts/primitives/chart-frame-layout.js +46 -6
- package/dist/cli/check-theme.d.ts.map +1 -1
- package/dist/cli/check-theme.js +6 -0
- package/dist/cli/doctor.d.ts +10 -4
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +28 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +2 -1
- package/dist/cli/index.d.ts +12 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +29 -0
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +4 -15
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +8 -14
- package/dist/controls/actions.d.ts.map +1 -1
- package/dist/controls/actions.js +12 -2
- package/dist/dashboard/section.module.css +15 -7
- package/dist/dashboard/stat-card.d.ts.map +1 -1
- package/dist/dashboard/stat-card.js +7 -2
- package/dist/dashboard/stat-group.d.ts +8 -2
- package/dist/dashboard/stat-group.d.ts.map +1 -1
- package/dist/dashboard/stat-group.js +23 -15
- package/dist/dashboard/stat-group.module.css +85 -9
- package/dist/dashboard/stat-group.module.css.d.ts +1 -0
- package/dist/dashboard/widget-grid.d.ts.map +1 -1
- package/dist/dashboard/widget-grid.js +4 -5
- package/dist/dashboard/widget-grid.module.css +64 -21
- package/dist/dashboard/widget-grid.module.css.d.ts +1 -0
- package/dist/guard/index.d.ts +19 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +27 -4
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/shell/app-brand.module.css +12 -3
- package/dist/shell/app-header.module.css +49 -9
- package/dist/shell/app-main.module.css +24 -4
- package/dist/shell/app-mobile-nav.d.ts.map +1 -1
- package/dist/shell/app-mobile-nav.js +98 -51
- package/dist/shell/app-mobile-nav.module.css +99 -11
- package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
- package/dist/shell/app-sidebar.module.css +55 -2
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +3 -3
- package/dist/shell/mobile-nav-model.d.ts +26 -7
- package/dist/shell/mobile-nav-model.d.ts.map +1 -1
- package/dist/shell/mobile-nav-model.js +12 -3
- package/dist/shell/page-aside.module.css +8 -6
- package/dist/shell/page-bar.d.ts +13 -0
- package/dist/shell/page-bar.d.ts.map +1 -1
- package/dist/shell/page-bar.js +13 -3
- package/dist/shell/page-bar.module.css +20 -12
- package/dist/shell/page-title.module.css +3 -1
- package/dist/theme/use-basalt-spacing.d.ts.map +1 -1
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +51 -7
- package/dist/tokens/palette.d.ts +98 -17
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +110 -18
- package/dist/tokens.css +17 -5
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/charts/index.ts +1 -0
- package/src/charts/kinds/BandStrip.tsx +5 -3
- package/src/charts/kinds/Bars.tsx +4 -2
- package/src/charts/kinds/Donut.tsx +4 -2
- package/src/charts/kinds/DualPanel.tsx +4 -2
- package/src/charts/kinds/Heatmap.tsx +5 -2
- package/src/charts/kinds/MirroredBars.tsx +4 -2
- package/src/charts/kinds/MultiLine.tsx +4 -2
- package/src/charts/kinds/StackedArea.tsx +4 -2
- package/src/charts/kinds/ZonedLine.tsx +4 -2
- package/src/charts/primitives/CartesianChart.tsx +4 -1
- package/src/charts/primitives/ChartCard.tsx +12 -7
- package/src/charts/primitives/ChartFrame.tsx +51 -37
- package/src/charts/primitives/chart-frame-layout.ts +172 -13
- package/src/cli/check-theme.ts +13 -0
- package/src/cli/doctor.ts +49 -8
- package/src/cli/help.ts +5 -3
- package/src/cli/index.ts +49 -0
- package/src/cli/init.ts +6 -27
- package/src/cli/sync.ts +11 -22
- package/src/controls/actions.tsx +51 -0
- package/src/dashboard/section.module.css +15 -7
- package/src/dashboard/stat-card.tsx +7 -2
- package/src/dashboard/stat-group.module.css +85 -9
- package/src/dashboard/stat-group.module.css.d.ts +1 -0
- package/src/dashboard/stat-group.tsx +40 -20
- package/src/dashboard/widget-grid.module.css +64 -21
- package/src/dashboard/widget-grid.module.css.d.ts +1 -0
- package/src/dashboard/widget-grid.tsx +30 -12
- package/src/guard/index.ts +79 -11
- package/src/index.ts +9 -0
- package/src/shell/app-brand.module.css +12 -3
- package/src/shell/app-header.module.css +49 -9
- package/src/shell/app-main.module.css +24 -4
- package/src/shell/app-mobile-nav.module.css +99 -11
- package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
- package/src/shell/app-mobile-nav.tsx +225 -83
- package/src/shell/app-sidebar.module.css +55 -2
- package/src/shell/index.tsx +26 -6
- package/src/shell/mobile-nav-model.ts +37 -9
- package/src/shell/page-aside.module.css +8 -6
- package/src/shell/page-bar.module.css +20 -12
- package/src/shell/page-bar.tsx +39 -5
- package/src/shell/page-title.module.css +3 -1
- package/src/theme/use-basalt-spacing.ts +8 -6
- package/src/tokens/index.ts +90 -6
- package/src/tokens/palette.ts +111 -18
package/configs/oxlint-plugin.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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.
|
|
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
|
|
2961
|
-
'`PageBar.title
|
|
2962
|
-
'
|
|
2963
|
-
'
|
|
2964
|
-
'
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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(…)`.
|
|
3592
|
-
'
|
|
3593
|
-
'
|
|
3594
|
-
'
|
|
3595
|
-
'
|
|
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:
|
|
3626
|
-
*
|
|
3627
|
-
*
|
|
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:
|
|
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.
|
|
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
|
|
3811
|
-
'
|
|
3812
|
-
'
|
|
3813
|
-
'
|
|
3814
|
-
|
|
3815
|
-
'
|
|
3816
|
-
'
|
|
3817
|
-
'
|
|
3818
|
-
|
|
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
|
/**
|