basalt-ui 1.26.0 → 1.27.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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +8 -0
- package/MIGRATING.md +299 -33
- package/agent/rules/basalt-controls.md +17 -12
- package/agent/rules/basalt-state.md +5 -0
- package/configs/oxlint-plugin.js +251 -66
- package/configs/oxlint-plugin.test.ts +243 -0
- package/configs/oxlint.json +5 -5
- package/dist/controls/enum-filter.d.ts +11 -2
- package/dist/controls/enum-filter.d.ts.map +1 -1
- package/dist/controls/enum-filter.js +4 -2
- package/dist/controls/filter-context.d.ts +7 -2
- package/dist/controls/filter-context.d.ts.map +1 -1
- package/dist/controls/index.d.ts +1 -0
- package/dist/controls/index.d.ts.map +1 -1
- package/dist/controls/index.js +2 -0
- package/dist/controls/multi-select-filter.js +2 -2
- package/dist/controls/number-filter.d.ts +26 -0
- package/dist/controls/number-filter.d.ts.map +1 -0
- package/dist/controls/number-filter.js +143 -0
- package/dist/controls/range-filter.d.ts +19 -5
- package/dist/controls/range-filter.d.ts.map +1 -1
- package/dist/controls/range-filter.js +22 -7
- package/dist/controls/range-filter.type-guard.test.d.ts +2 -0
- package/dist/controls/range-filter.type-guard.test.d.ts.map +1 -0
- package/dist/controls/search-filter.js +1 -1
- package/dist/controls/toggle-filter.js +1 -1
- package/dist/dashboard/index.d.ts +1 -1
- package/dist/dashboard/index.d.ts.map +1 -1
- package/dist/dashboard/index.js +3 -1
- package/dist/dashboard/stat-card.d.ts +52 -1
- package/dist/dashboard/stat-card.d.ts.map +1 -1
- package/dist/dashboard/stat-card.js +42 -15
- package/dist/dashboard/stat-card.module.css +71 -0
- package/dist/dashboard/stat-card.module.css.d.ts +4 -0
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +8 -0
- package/dist/guard/grace.test.d.ts.map +1 -1
- package/dist/guard/index.d.ts +1 -1
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +6 -10
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/router-tanstack/field.d.ts +1 -1
- package/dist/router-tanstack/field.d.ts.map +1 -1
- package/dist/router-tanstack/index.d.ts +1 -1
- package/dist/router-tanstack/index.d.ts.map +1 -1
- package/dist/router-tanstack/search-store.d.ts +4 -1
- package/dist/router-tanstack/search-store.d.ts.map +1 -1
- package/dist/router-tanstack/search-store.js +91 -7
- package/dist/shell/page-bar.d.ts +1 -1
- package/dist/state/fields.d.ts +209 -36
- package/dist/state/fields.d.ts.map +1 -1
- package/dist/state/fields.js +68 -32
- package/dist/state/persisted.d.ts +12 -0
- package/dist/state/persisted.d.ts.map +1 -1
- package/dist/state/persisted.js +36 -18
- package/dist/state/persisted.test.d.ts +2 -0
- package/dist/state/persisted.test.d.ts.map +1 -0
- package/dist/state.d.ts +1 -1
- package/dist/state.d.ts.map +1 -1
- package/dist/surfaces.d.ts +1 -1
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +4 -3
- package/dist/widget-header/widget-header.d.ts +31 -1
- package/dist/widget-header/widget-header.d.ts.map +1 -1
- package/dist/widget-header/widget-header.js +13 -1
- package/dist/widget-header/widget-header.module.css +15 -0
- package/dist/widget-header/widget-header.module.css.d.ts +1 -0
- package/llms.txt +3 -3
- package/package.json +1 -1
- package/src/controls/controls.router.test.tsx +416 -0
- package/src/controls/enum-filter.tsx +14 -3
- package/src/controls/filter-context.tsx +7 -2
- package/src/controls/index.ts +1 -0
- package/src/controls/multi-select-filter.tsx +2 -2
- package/src/controls/number-filter.tsx +291 -0
- package/src/controls/props-contract.test.tsx +31 -0
- package/src/controls/range-filter.tsx +64 -11
- package/src/controls/range-filter.type-guard.test.tsx +122 -0
- package/src/controls/search-filter.tsx +1 -1
- package/src/controls/toggle-filter.tsx +1 -1
- package/src/dashboard/index.ts +6 -1
- package/src/dashboard/stat-card.module.css +71 -0
- package/src/dashboard/stat-card.module.css.d.ts +4 -0
- package/src/dashboard/stat-card.test.tsx +231 -0
- package/src/dashboard/stat-card.tsx +81 -0
- package/src/data/data-table.tsx +8 -0
- package/src/guard/check-source.test.ts +35 -2
- package/src/guard/grace.test.ts +14 -5
- package/src/guard/index.ts +30 -17
- package/src/index.ts +5 -0
- package/src/router-tanstack/field.ts +4 -0
- package/src/router-tanstack/index.ts +4 -0
- package/src/router-tanstack/search-store.router.test.tsx +232 -11
- package/src/router-tanstack/search-store.test.ts +160 -0
- package/src/router-tanstack/search-store.ts +177 -11
- package/src/shell/page-bar.tsx +1 -1
- package/src/state/fields.test.tsx +254 -0
- package/src/state/fields.ts +328 -70
- package/src/state/persisted.test.ts +91 -0
- package/src/state/persisted.ts +76 -26
- package/src/state.ts +6 -0
- package/src/surfaces.ts +4 -3
- package/src/widget-header/widget-header.module.css +15 -0
- package/src/widget-header/widget-header.module.css.d.ts +1 -0
- package/src/widget-header/widget-header.tsx +46 -1
package/configs/oxlint-plugin.js
CHANGED
|
@@ -1588,6 +1588,65 @@ const SHADOW_ALIASES = {
|
|
|
1588
1588
|
JoinedButtons: 'ControlGroup',
|
|
1589
1589
|
}
|
|
1590
1590
|
|
|
1591
|
+
/**
|
|
1592
|
+
* The basalt export NAMES a file imports, each mapped to the LOCAL bindings it arrived under —
|
|
1593
|
+
* `StatCard → {StatCard}` for `import { StatCard }`, `StatCard → {BaseStatCard}` for
|
|
1594
|
+
* `import { StatCard as BaseStatCard }`.
|
|
1595
|
+
*
|
|
1596
|
+
* The counterpart of {@link collectBasaltImports}, keyed the other way round: that one collects
|
|
1597
|
+
* LOCAL names (is this `<Section>` basalt's?), this one is keyed by the CANONICAL export (does this
|
|
1598
|
+
* file compose basalt's `StatCard` at all?). An alias is precisely the case that separates them —
|
|
1599
|
+
* a wrapper importing `StatCard as BaseStatCard` composes `StatCard`, and a set of local names
|
|
1600
|
+
* would answer `BaseStatCard`. The local half is carried alongside because an import alone is not
|
|
1601
|
+
* composition: the caller has to check the binding is REFERENCED (see
|
|
1602
|
+
* {@link isValueReferencePosition}).
|
|
1603
|
+
*
|
|
1604
|
+
* TYPE-ONLY specifiers are skipped, declaration-level and per-specifier, exactly as
|
|
1605
|
+
* `agent-no-raw-usechat` skips them: `import type { StatCard }` is erased at compile time, so a fork
|
|
1606
|
+
* copying the original's props through `ComponentProps<typeof StatCard>` composes nothing at all.
|
|
1607
|
+
*
|
|
1608
|
+
* Only the package name and its subpaths; no relative half, because `shadow-basalt-export` already
|
|
1609
|
+
* returns early inside basalt's own source, where a relative import would be the dogfood lane.
|
|
1610
|
+
*/
|
|
1611
|
+
function collectBasaltImportedNames(node, into) {
|
|
1612
|
+
const source = node.source?.value
|
|
1613
|
+
if (typeof source !== 'string' || !BASALT_IMPORT_SOURCE.test(source)) return
|
|
1614
|
+
if (node.importKind === 'type') return
|
|
1615
|
+
for (const spec of node.specifiers ?? []) {
|
|
1616
|
+
if (spec.importKind === 'type') continue
|
|
1617
|
+
const imported = spec.imported?.name ?? spec.imported?.value
|
|
1618
|
+
const local = spec.local?.name
|
|
1619
|
+
if (typeof imported !== 'string' || typeof local !== 'string') continue
|
|
1620
|
+
const locals = into.get(imported)
|
|
1621
|
+
if (locals === undefined) into.set(imported, new Set([local]))
|
|
1622
|
+
else locals.add(local)
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
/** The positions an `Identifier`/`JSXIdentifier` occupies WITHOUT referencing a value. */
|
|
1627
|
+
const NON_VALUE_IDENTIFIER_PARENTS = new Set([
|
|
1628
|
+
// The binding's own declaration, not a use of it.
|
|
1629
|
+
'ImportSpecifier',
|
|
1630
|
+
'ImportDefaultSpecifier',
|
|
1631
|
+
'ImportNamespaceSpecifier',
|
|
1632
|
+
// A JSX attribute's NAME (`value={…}`), not its value.
|
|
1633
|
+
'JSXAttribute',
|
|
1634
|
+
])
|
|
1635
|
+
|
|
1636
|
+
/**
|
|
1637
|
+
* Does this identifier REFERENCE a value, rather than declare or type-position it?
|
|
1638
|
+
*
|
|
1639
|
+
* The type half matters as much as the import half: `typeof StatCard` parses as a `TSTypeQuery`
|
|
1640
|
+
* around a plain `Identifier`, so a value import used only in a type is indistinguishable from a
|
|
1641
|
+
* rendered one without this test — and that shape (a fork copying the original's props) is the
|
|
1642
|
+
* whole reason the composition check exists.
|
|
1643
|
+
*/
|
|
1644
|
+
function isValueReferencePosition(node) {
|
|
1645
|
+
const parentType = node.parent?.type
|
|
1646
|
+
if (typeof parentType !== 'string') return false
|
|
1647
|
+
return !parentType.startsWith('TS') && !NON_VALUE_IDENTIFIER_PARENTS.has(parentType)
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1591
1650
|
function shadowAliasMessage(name, canonical) {
|
|
1592
1651
|
return (
|
|
1593
1652
|
`Local component '${name}' is a renamed '${canonical}' — basalt ships that composite, and a ` +
|
|
@@ -1636,30 +1695,86 @@ const shadowBasaltExport = {
|
|
|
1636
1695
|
// obsidian's React-free `obsidian-vault-core` took the report and had to waive it.
|
|
1637
1696
|
if (filename.length === 0 || !isBasaltScopedFile(filename)) return {}
|
|
1638
1697
|
|
|
1639
|
-
|
|
1698
|
+
// Candidates are BUFFERED and judged at `Program:exit`, the same shape the chart and control
|
|
1699
|
+
// rules use: the alias exemption below needs the file's whole import list AND every reference in
|
|
1700
|
+
// the file, and a declaration hoisted above its imports would otherwise be judged against an
|
|
1701
|
+
// empty one.
|
|
1702
|
+
/** Canonical basalt export → the local bindings it was imported under (value imports only). */
|
|
1703
|
+
const basaltImportedNames = new Map()
|
|
1704
|
+
/** Every local name the file REFERENCES as a value — the composition half of the exemption. */
|
|
1705
|
+
const referencedLocals = new Set()
|
|
1706
|
+
const candidates = []
|
|
1707
|
+
|
|
1708
|
+
/** Does the file compose basalt's `canonical` — imported as a value AND referenced? */
|
|
1709
|
+
const composes = (canonical) => {
|
|
1710
|
+
const locals = basaltImportedNames.get(canonical)
|
|
1711
|
+
if (locals === undefined) return false
|
|
1712
|
+
for (const local of locals) {
|
|
1713
|
+
if (referencedLocals.has(local)) return true
|
|
1714
|
+
}
|
|
1715
|
+
return false
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
const noteReference = (node) => {
|
|
1719
|
+
if (typeof node.name !== 'string' || !isValueReferencePosition(node)) return
|
|
1720
|
+
referencedLocals.add(node.name)
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
const note = (node, name) => {
|
|
1640
1724
|
if (typeof name !== 'string') return
|
|
1641
1725
|
const alias = Object.hasOwn(SHADOW_ALIASES, name) ? SHADOW_ALIASES[name] : undefined
|
|
1642
1726
|
if (!BASALT_EXPORTS.has(name) && alias === undefined) return
|
|
1643
|
-
|
|
1644
|
-
const message =
|
|
1645
|
-
alias === undefined ? shadowBasaltExportMessage(name) : shadowAliasMessage(name, alias)
|
|
1646
|
-
context.report({ node, message })
|
|
1727
|
+
candidates.push({ node, name, alias })
|
|
1647
1728
|
}
|
|
1648
1729
|
|
|
1649
1730
|
return {
|
|
1731
|
+
ImportDeclaration(node) {
|
|
1732
|
+
collectBasaltImportedNames(node, basaltImportedNames)
|
|
1733
|
+
},
|
|
1734
|
+
// Both halves are needed and neither subsumes the other: a JSX tag is a `JSXIdentifier`
|
|
1735
|
+
// (`<Base/>`), while every other use of the same binding is an `Identifier`
|
|
1736
|
+
// (`component={Base}`, `createElement(Base)`, `memo(Base)`).
|
|
1737
|
+
Identifier(node) {
|
|
1738
|
+
noteReference(node)
|
|
1739
|
+
},
|
|
1740
|
+
JSXIdentifier(node) {
|
|
1741
|
+
noteReference(node)
|
|
1742
|
+
},
|
|
1650
1743
|
FunctionDeclaration(node) {
|
|
1651
|
-
|
|
1744
|
+
note(node, node.id?.name)
|
|
1652
1745
|
},
|
|
1653
1746
|
VariableDeclarator(node) {
|
|
1654
1747
|
if (!isComponentInit(node.init)) return
|
|
1655
|
-
|
|
1748
|
+
note(node, node.id?.name)
|
|
1656
1749
|
},
|
|
1657
1750
|
// A class only counts when it EXTENDS something — a legacy class component. basalt ships no
|
|
1658
1751
|
// class components, so a standalone `class X {}` sharing a name with one of its exports is a
|
|
1659
1752
|
// collision between two unrelated things, not a fork.
|
|
1660
1753
|
ClassDeclaration(node) {
|
|
1661
1754
|
if (node.superClass === null || node.superClass === undefined) return
|
|
1662
|
-
|
|
1755
|
+
note(node, node.id?.name)
|
|
1756
|
+
},
|
|
1757
|
+
'Program:exit'() {
|
|
1758
|
+
for (const { node, name, alias } of candidates) {
|
|
1759
|
+
// A file that COMPOSES the export it renames is a wrapper, not a fork, and the advice
|
|
1760
|
+
// ("import StatCard instead of re-rolling it") is already followed — argo's three hero
|
|
1761
|
+
// cards each imported `StatCard` and wrapped it, and each took a warn for it.
|
|
1762
|
+
//
|
|
1763
|
+
// COMPOSES, not "imports": an import the file never references is not composition, and the
|
|
1764
|
+
// two shapes that reach here silently are the ones a fork actually writes — a type-only
|
|
1765
|
+
// `import type { StatCard }` feeding `ComponentProps<typeof StatCard>` (props copied,
|
|
1766
|
+
// nothing rendered) and a dead import left behind after the body was re-rolled. Both used
|
|
1767
|
+
// to exempt the whole file.
|
|
1768
|
+
//
|
|
1769
|
+
// The collision half is deliberately NOT exempted either way: a local `StatCard` beside an
|
|
1770
|
+
// `import { StatCard as Base }` is a fork that kept the name AND kept a piece of the
|
|
1771
|
+
// original, which is the shape this rule most wants to see.
|
|
1772
|
+
if (alias !== undefined && composes(alias)) continue
|
|
1773
|
+
if (hasThemeAllow(context, node, 'shadow-basalt-export')) continue
|
|
1774
|
+
const message =
|
|
1775
|
+
alias === undefined ? shadowBasaltExportMessage(name) : shadowAliasMessage(name, alias)
|
|
1776
|
+
context.report({ node, message })
|
|
1777
|
+
}
|
|
1663
1778
|
},
|
|
1664
1779
|
}
|
|
1665
1780
|
},
|
|
@@ -1803,6 +1918,7 @@ const BOUND_TAGS = new Set([
|
|
|
1803
1918
|
'CompareFilter',
|
|
1804
1919
|
'SelectFilter',
|
|
1805
1920
|
'MultiSelectFilter',
|
|
1921
|
+
'NumberFilter',
|
|
1806
1922
|
'SearchFilter',
|
|
1807
1923
|
'ToggleFilter',
|
|
1808
1924
|
'ViewTabs',
|
|
@@ -1844,6 +1960,29 @@ const CONTROL_HOST_TAGS = new Set([
|
|
|
1844
1960
|
'Composer',
|
|
1845
1961
|
])
|
|
1846
1962
|
|
|
1963
|
+
/**
|
|
1964
|
+
* A file whose NAME declares it is an overlay's or a form's own body — the cross-file half of
|
|
1965
|
+
* {@link CONTROL_HOST_TAGS}, and the one exemption here that is a CONVENTION rather than a fact
|
|
1966
|
+
* about the AST.
|
|
1967
|
+
*
|
|
1968
|
+
* It exists because law C1's cross-file case is explicitly advisory (`docs/CONTROLS-SPEC.md` §6,
|
|
1969
|
+
* "Honest coverage") and `control-outside-home` was paying for it in false positives: argo's
|
|
1970
|
+
* wave-7 run left 9 warns, all of them a `Select`/`SegmentedControl` inside a `*-modal.tsx` or
|
|
1971
|
+
* `*-form.tsx` whose `<Modal>` is rendered by the PARENT route. Nothing in the flagged file can see
|
|
1972
|
+
* the overlay, so no ancestry walk — however deep — will ever reach it, and the alternative is 9
|
|
1973
|
+
* `theme-allow-file` comments that say what the filename already says.
|
|
1974
|
+
*
|
|
1975
|
+
* The trade is stated plainly: this exempts a whole file on a naming convention, so a `Select` that
|
|
1976
|
+
* genuinely belongs in a page bar goes unreported if it is written in `filters-panel.tsx`. That is
|
|
1977
|
+
* the same bargain `@mantine/form` already buys and a strictly smaller one than promoting the rule
|
|
1978
|
+
* with 9 known false positives. `-panel.tsx` is the loosest member and the one to reconsider first
|
|
1979
|
+
* if the set ever needs narrowing.
|
|
1980
|
+
*
|
|
1981
|
+
* Basename only — a `modal/` DIRECTORY is not the convention, because a directory of modals holds
|
|
1982
|
+
* the page pieces around them too.
|
|
1983
|
+
*/
|
|
1984
|
+
const OVERLAY_CONVENTION_FILE = /(?:^|\/)[^/]*-(?:modal|drawer|popover|panel|form)\.[jt]sx$/
|
|
1985
|
+
|
|
1847
1986
|
/** How far an ancestry walk climbs before giving up — a JSX tree this deep is not a slot value. */
|
|
1848
1987
|
const ANCESTRY_MAX_DEPTH = 60
|
|
1849
1988
|
|
|
@@ -1945,6 +2084,27 @@ function collectBasaltImports(node, into, ownTree) {
|
|
|
1945
2084
|
}
|
|
1946
2085
|
}
|
|
1947
2086
|
|
|
2087
|
+
/**
|
|
2088
|
+
* {@link collectBasaltImports} as a local→EXPORTED map — the same provenance test, keyed so a caller
|
|
2089
|
+
* can ask WHICH home it found rather than only whether it found one.
|
|
2090
|
+
*
|
|
2091
|
+
* `control-size-literal` needs the distinction: `ChartCard` is the one slot owner whose slot cannot
|
|
2092
|
+
* mount the tier theme, and `import { ChartCard as Card }` is a normal thing to write, so a
|
|
2093
|
+
* membership Set (which answers `Card`) could not express the exemption at all.
|
|
2094
|
+
*/
|
|
2095
|
+
function collectBasaltImportMap(node, into, ownTree) {
|
|
2096
|
+
const source = node.source?.value
|
|
2097
|
+
if (typeof source !== 'string') return
|
|
2098
|
+
if (!BASALT_IMPORT_SOURCE.test(source) && !(ownTree && RELATIVE_IMPORT_SOURCE.test(source))) {
|
|
2099
|
+
return
|
|
2100
|
+
}
|
|
2101
|
+
for (const spec of node.specifiers ?? []) {
|
|
2102
|
+
const local = spec.local?.name
|
|
2103
|
+
if (typeof local !== 'string') continue
|
|
2104
|
+
into.set(local, spec.imported?.name ?? spec.imported?.value ?? local)
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
|
|
1948
2108
|
/**
|
|
1949
2109
|
* The BINDING a home's slot prop hangs off — `PageBar` for `<PageBar filters={…}>` — or undefined
|
|
1950
2110
|
* when `attr` is not a slot-shaped prop on a home-shaped tag.
|
|
@@ -2067,14 +2227,22 @@ function collectSlotValueIdentifiers(node, into, depth = 0) {
|
|
|
2067
2227
|
* complete at exit.
|
|
2068
2228
|
*/
|
|
2069
2229
|
function createSlotContext(context) {
|
|
2070
|
-
|
|
2230
|
+
/** Local binding → the basalt export it was imported under. Membership IS the home test. */
|
|
2231
|
+
const homes = new Map()
|
|
2071
2232
|
const ownTree = isBasaltOwnSource(getFilename(context))
|
|
2072
|
-
/**
|
|
2233
|
+
/**
|
|
2234
|
+
* Hoisted identifier → EVERY owner BINDING whose slot it was handed to.
|
|
2235
|
+
*
|
|
2236
|
+
* A Set, not one binding: `const acts = <Button size="xs"/>` handed to both `<Section actions>` and
|
|
2237
|
+
* `<ChartCard actions>` is inside two slots at once, and a last-writer-wins map made the verdict
|
|
2238
|
+
* depend on which attribute came later in the file — the same code went silent or reported
|
|
2239
|
+
* depending on JSX order.
|
|
2240
|
+
*/
|
|
2073
2241
|
const slotBoundIdentifiers = new Map()
|
|
2074
2242
|
return {
|
|
2075
2243
|
/** Feed every ImportDeclaration here — this is where the owner provenance comes from. */
|
|
2076
2244
|
noteImport(node) {
|
|
2077
|
-
|
|
2245
|
+
collectBasaltImportMap(node, homes, ownTree)
|
|
2078
2246
|
},
|
|
2079
2247
|
/** Feed every JSXAttribute here — a slot attribute contributes its value's identifiers. */
|
|
2080
2248
|
note(attr) {
|
|
@@ -2082,7 +2250,11 @@ function createSlotContext(context) {
|
|
|
2082
2250
|
if (owner === undefined) return
|
|
2083
2251
|
const identifiers = new Set()
|
|
2084
2252
|
collectSlotValueIdentifiers(unwrapExpressionContainer(attr.value), identifiers)
|
|
2085
|
-
for (const id of identifiers)
|
|
2253
|
+
for (const id of identifiers) {
|
|
2254
|
+
const owners = slotBoundIdentifiers.get(id)
|
|
2255
|
+
if (owners === undefined) slotBoundIdentifiers.set(id, new Set([owner]))
|
|
2256
|
+
else owners.add(owner)
|
|
2257
|
+
}
|
|
2086
2258
|
},
|
|
2087
2259
|
/** Snapshot `node`'s ancestry while it is walkable — the owner NAME, resolved later. */
|
|
2088
2260
|
capture(node) {
|
|
@@ -2096,13 +2268,38 @@ function createSlotContext(context) {
|
|
|
2096
2268
|
resolve(captured) {
|
|
2097
2269
|
if (captured.directOwner !== undefined) return homes.has(captured.directOwner)
|
|
2098
2270
|
if (captured.declaratorName === undefined) return false
|
|
2099
|
-
const
|
|
2100
|
-
|
|
2271
|
+
const hoistedOwners = slotBoundIdentifiers.get(captured.declaratorName)
|
|
2272
|
+
if (hoistedOwners === undefined) return false
|
|
2273
|
+
// ANY basalt owner puts the binding in a home: a node handed to one basalt slot and one
|
|
2274
|
+
// consumer component still renders inside the basalt slot.
|
|
2275
|
+
for (const owner of hoistedOwners) {
|
|
2276
|
+
if (homes.has(owner)) return true
|
|
2277
|
+
}
|
|
2278
|
+
return false
|
|
2101
2279
|
},
|
|
2102
2280
|
/** Does `name` resolve to a basalt home component in this file? */
|
|
2103
2281
|
isHome(name) {
|
|
2104
2282
|
return name !== undefined && homes.has(name)
|
|
2105
2283
|
},
|
|
2284
|
+
/**
|
|
2285
|
+
* EVERY basalt EXPORT whose slot a captured node sits in — `['ChartCard']` for
|
|
2286
|
+
* `<ChartCard actions={…}>` and for `<Card actions={…}>` under an alias, `[]` when the node is in
|
|
2287
|
+
* no basalt slot. Resolves through the same hoisted-binding hop `resolve` does.
|
|
2288
|
+
*
|
|
2289
|
+
* A LIST, because one hoisted binding can be handed to several slots, and an exemption keyed on
|
|
2290
|
+
* the owner is only sound when it holds for all of them — a `size="xs"` shared between
|
|
2291
|
+
* `<ChartCard actions>` and `<Section actions>` really does render inside the Section's tiered
|
|
2292
|
+
* slot. Non-basalt owners are dropped: they are not homes, so they carry no tier either way.
|
|
2293
|
+
*/
|
|
2294
|
+
ownerExports(captured) {
|
|
2295
|
+
const bindings =
|
|
2296
|
+
captured.directOwner !== undefined
|
|
2297
|
+
? [captured.directOwner]
|
|
2298
|
+
: captured.declaratorName === undefined
|
|
2299
|
+
? []
|
|
2300
|
+
: [...(slotBoundIdentifiers.get(captured.declaratorName) ?? [])]
|
|
2301
|
+
return bindings.filter((binding) => homes.has(binding)).map((binding) => homes.get(binding))
|
|
2302
|
+
},
|
|
2106
2303
|
}
|
|
2107
2304
|
}
|
|
2108
2305
|
|
|
@@ -2205,9 +2402,10 @@ const CONTROL_OUTSIDE_HOME_MESSAGE =
|
|
|
2205
2402
|
* and stays warn until the playground and the five consumer repos run it with ≤3 waivers
|
|
2206
2403
|
* (docs/CONTROLS-SPEC.md §9 wave 7).
|
|
2207
2404
|
*
|
|
2208
|
-
*
|
|
2209
|
-
*
|
|
2210
|
-
*
|
|
2405
|
+
* Four exemptions carry the false-positive load: an overlay/settings-row ancestor, an
|
|
2406
|
+
* {@link OVERLAY_CONVENTION_FILE} basename (the CROSS-FILE case — the `<Modal>` lives in the
|
|
2407
|
+
* parent), a file that imports `@mantine/form` (a form is the third home and its inputs are not
|
|
2408
|
+
* filters), and the owner exemption — a file DEFINING a basalt control cannot be told to use one.
|
|
2211
2409
|
*/
|
|
2212
2410
|
const controlOutsideHome = {
|
|
2213
2411
|
meta: {
|
|
@@ -2217,6 +2415,7 @@ const controlOutsideHome = {
|
|
|
2217
2415
|
},
|
|
2218
2416
|
create(context) {
|
|
2219
2417
|
if (isTestFile(context)) return {}
|
|
2418
|
+
if (OVERLAY_CONVENTION_FILE.test(getFilename(context))) return {}
|
|
2220
2419
|
const slots = createSlotContext(context)
|
|
2221
2420
|
const owner = createControlOwnerProbe()
|
|
2222
2421
|
const mantineImports = new Map()
|
|
@@ -2319,6 +2518,24 @@ export const CTL_THEME_TAGS = new Set([
|
|
|
2319
2518
|
* {@link CTL_THEME_TAGS} member, and skipped under an overlay ({@link hostedInsideSlot}) — see
|
|
2320
2519
|
* `CTL_THEME_TAGS` for what firing on everything in the slot cost.
|
|
2321
2520
|
*/
|
|
2521
|
+
/**
|
|
2522
|
+
* The home whose slot cannot mount the tier theme, so the CONTROL there has to state its own size.
|
|
2523
|
+
*
|
|
2524
|
+
* `ChartCard` lives inside the Mantine-free chart layer (`src/charts/primitives/`), which means it
|
|
2525
|
+
* cannot render a `MantineThemeProvider` at all — it writes `data-basalt-tier="widget"` by hand and
|
|
2526
|
+
* nothing else (`../CLAUDE.md`, "A home sizes its own SLOT"; `docs/CONTROLS-SPEC.md` §5). So this
|
|
2527
|
+
* rule's whole message — "the HOME sets the tier, drop the prop" — is FALSE in exactly one slot: a
|
|
2528
|
+
* `Switch` in `ChartCard.actions` with no `size` renders at Mantine's default, not at `ctl`.
|
|
2529
|
+
*
|
|
2530
|
+
* It has to be an exemption rather than a waiver because the rule fires on the `size` ATTRIBUTE and
|
|
2531
|
+
* cannot tell a correct `size="ctl"` from the `size="xs"` it exists to catch. The playground carried
|
|
2532
|
+
* exactly one waiver for this and it was the honest kind — the rule was wrong, not the code.
|
|
2533
|
+
*
|
|
2534
|
+
* `hand-rolled-filter` is deliberately NOT exempted here: a raw `Select` in `ChartCard.actions` is
|
|
2535
|
+
* still a filter that should take a `field`, and the tier is not what makes that true.
|
|
2536
|
+
*/
|
|
2537
|
+
const TIERLESS_SLOT_OWNERS = new Set(['ChartCard'])
|
|
2538
|
+
|
|
2322
2539
|
const controlSizeLiteral = {
|
|
2323
2540
|
meta: {
|
|
2324
2541
|
type: 'suggestion',
|
|
@@ -2358,6 +2575,12 @@ const controlSizeLiteral = {
|
|
|
2358
2575
|
CTL_THEME_TAGS.has(resolveMantineTag(name, mantineImports) ?? '')
|
|
2359
2576
|
if (!tiered || hosted) continue
|
|
2360
2577
|
if (!slots.resolve(captured)) continue
|
|
2578
|
+
// EVERY home the node was handed to has to be tierless. One hoisted `const acts =
|
|
2579
|
+
// <Button size="xs"/>` given to both `<ChartCard actions>` and `<Section actions>` renders
|
|
2580
|
+
// in a tiered slot too, and the prop is redundant there — so the exemption does not apply.
|
|
2581
|
+
const owners = slots.ownerExports(captured)
|
|
2582
|
+
if (owners.length > 0 && owners.every((owner) => TIERLESS_SLOT_OWNERS.has(owner)))
|
|
2583
|
+
continue
|
|
2361
2584
|
if (hasThemeAllow(context, node, 'control-size-literal')) continue
|
|
2362
2585
|
context.report({ node, message: CONTROL_SIZE_LITERAL_MESSAGE })
|
|
2363
2586
|
}
|
|
@@ -2789,58 +3012,20 @@ const useSearchFromLiteral = {
|
|
|
2789
3012
|
export const PLUGIN_RULE_GRACE = {
|
|
2790
3013
|
'control-outside-home': {
|
|
2791
3014
|
since: '1.26.0',
|
|
2792
|
-
promote: '1.
|
|
3015
|
+
promote: '1.28.0',
|
|
2793
3016
|
why:
|
|
2794
3017
|
'the wave-6 control guards (docs/CONTROLS-SPEC.md §6). The one openly HEURISTIC rule of the ' +
|
|
2795
3018
|
'set — "this control has no home" is a claim about layout intent, so its false-positive load ' +
|
|
2796
|
-
'is carried by
|
|
2797
|
-
'definition) rather than by certainty.
|
|
2798
|
-
'
|
|
2799
|
-
'
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
'
|
|
2806
|
-
'minor ago — every `size="xs"` written into a bar before the tier existed — so it takes the ' +
|
|
2807
|
-
'grace minor the doctrine exists for. Every wave ships in ONE minor (1.26.0), so the whole ' +
|
|
2808
|
-
'set promotes together in 1.27.0 — one runway, not one per wave.',
|
|
2809
|
-
},
|
|
2810
|
-
'in-body-page-title': {
|
|
2811
|
-
since: '1.26.0',
|
|
2812
|
-
promote: '1.27.0',
|
|
2813
|
-
why:
|
|
2814
|
-
'the wave-6 control guards (docs/CONTROLS-SPEC.md §6, law C8). Its text-level twin is the ' +
|
|
2815
|
-
'guard kind of the SAME id, which carries the same dates in GRACE_PERIOD_KINDS — one law, ' +
|
|
2816
|
-
'two lanes, one promotion. Warn because an in-body h1/h2 is a judgement about which name ' +
|
|
2817
|
-
'the page already has, and the fix is a route/breadcrumb change rather than a prop edit.',
|
|
2818
|
-
},
|
|
2819
|
-
'responsive-twin': {
|
|
2820
|
-
since: '1.26.0',
|
|
2821
|
-
promote: '1.27.0',
|
|
2822
|
-
why:
|
|
2823
|
-
'the wave-6 control guards (docs/CONTROLS-SPEC.md §6, law C9 — linewatch shipped three ' +
|
|
2824
|
-
'doubled controls). Warn for one minor because the fix is a real edit (delete one mount, ' +
|
|
2825
|
-
'move the swap into the control), not a prop removal, and the pair test is a heuristic over ' +
|
|
2826
|
-
'sibling structure.',
|
|
2827
|
-
},
|
|
2828
|
-
'search-literal-link': {
|
|
2829
|
-
since: '1.26.0',
|
|
2830
|
-
promote: '1.27.0',
|
|
2831
|
-
why:
|
|
2832
|
-
'the wave-6 control guards (docs/CONTROLS-SPEC.md §6, law C10 — argo nav.tsx:132 pinned the ' +
|
|
2833
|
-
'fallback on every click, which is why its reader had zero call sites). Warn for one minor ' +
|
|
2834
|
-
'because the fix needs a store to exist at the call site, which is a wave-2 dependency for ' +
|
|
2835
|
-
'consumers still on the old enum-only pair.',
|
|
2836
|
-
},
|
|
2837
|
-
'use-search-from-literal': {
|
|
2838
|
-
since: '1.26.0',
|
|
2839
|
-
promote: '1.27.0',
|
|
2840
|
-
why:
|
|
2841
|
-
'the wave-6 control guards (docs/CONTROLS-SPEC.md §6, law C10). Same runway as ' +
|
|
2842
|
-
'search-literal-link and for the same reason: the remedy is `createSearchStore` + a prop, ' +
|
|
2843
|
-
'so a consumer needs the wave-2 store before the finding is actionable.',
|
|
3019
|
+
'is carried by four exemptions (overlay/settings-row ancestor, the overlay FILENAME ' +
|
|
3020
|
+
'convention below, @mantine/form, owner definition) rather than by certainty. The other five ' +
|
|
3021
|
+
'wave-6 rules promoted at 1.27.0; this one did not, and the reason is measured rather than ' +
|
|
3022
|
+
'cautious: the wave-7 run found 9 remaining warns in argo, every one of them a control in a ' +
|
|
3023
|
+
'modal/form module whose `<Modal>` is rendered by the PARENT — the cross-file case law C1 is ' +
|
|
3024
|
+
'explicitly advisory about, which no ancestry walk inside one file can ever see. ' +
|
|
3025
|
+
'{@link OVERLAY_CONVENTION_FILE} now exempts the declared naming convention for exactly ' +
|
|
3026
|
+
'that shape; 1.28.0 is when the remainder is re-measured. A file outside the convention ' +
|
|
3027
|
+
"that is still an overlay's own body declares it with " +
|
|
3028
|
+
'`theme-allow-file control-outside-home — overlay`.',
|
|
2844
3029
|
},
|
|
2845
3030
|
}
|
|
2846
3031
|
|