basalt-ui 1.20.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/AGENTS.md +9 -5
  2. package/CHANGELOG.md +19 -0
  3. package/MIGRATING.md +147 -49
  4. package/README.md +42 -8
  5. package/agent/rules/basalt-app.md +41 -0
  6. package/agent/rules/basalt-charts.md +15 -12
  7. package/agent/rules/basalt-content.md +4 -0
  8. package/agent/rules/basalt-router.md +57 -5
  9. package/agent/rules/basalt-state.md +21 -3
  10. package/agent/rules/basalt-tokens.md +73 -16
  11. package/agent/skills/basalt-app/SKILL.md +4 -1
  12. package/agent/skills/basalt-charts/SKILL.md +5 -3
  13. package/agent/templates/CLAUDE-block.md.tpl +11 -5
  14. package/configs/lefthook.yml +48 -15
  15. package/configs/oxlint-plugin.js +181 -68
  16. package/configs/oxlint-plugin.test.ts +215 -9
  17. package/dist/charts/kinds/Bars.d.ts +1 -1
  18. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  19. package/dist/charts/kinds/Donut.d.ts +1 -1
  20. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  21. package/dist/charts/kinds/DualPanel.d.ts +1 -1
  22. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  23. package/dist/charts/kinds/Heatmap.d.ts +1 -1
  24. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  25. package/dist/charts/kinds/MultiLine.d.ts +1 -1
  26. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  27. package/dist/charts/kinds/StackedArea.d.ts +1 -1
  28. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  29. package/dist/charts/kinds/ZonedLine.d.ts +1 -1
  30. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  31. package/dist/cli/index.d.ts +48 -14
  32. package/dist/cli/index.d.ts.map +1 -1
  33. package/dist/cli/index.js +328 -66
  34. package/dist/cli/waiver-audit.test.d.ts +2 -0
  35. package/dist/cli/waiver-audit.test.d.ts.map +1 -0
  36. package/dist/guard/index.d.ts +47 -2
  37. package/dist/guard/index.d.ts.map +1 -1
  38. package/dist/guard/index.js +176 -31
  39. package/dist/router-tanstack/multi-search-param-store.d.ts +13 -0
  40. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  41. package/dist/router-tanstack/multi-search-param-store.js +4 -1
  42. package/dist/router-tanstack/search-param-store.d.ts +29 -5
  43. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  44. package/dist/router-tanstack/search-param-store.js +31 -3
  45. package/dist/router-tanstack/search-param-store.test.d.ts +2 -0
  46. package/dist/router-tanstack/search-param-store.test.d.ts.map +1 -0
  47. package/dist/shell/index.d.ts +12 -5
  48. package/dist/shell/index.d.ts.map +1 -1
  49. package/dist/shell/index.js +24 -6
  50. package/dist/tokens/index.d.ts +3 -1
  51. package/dist/tokens/index.d.ts.map +1 -1
  52. package/dist/tokens/index.js +5 -1
  53. package/dist/tokens/palette.d.ts +2 -2
  54. package/dist/tokens/palette.js +2 -2
  55. package/dist/tokens/text-scale.test.d.ts +2 -0
  56. package/dist/tokens/text-scale.test.d.ts.map +1 -0
  57. package/dist/tokens.css +4 -2
  58. package/dist/vite.d.ts +24 -2
  59. package/dist/vite.d.ts.map +1 -1
  60. package/dist/vite.js +54 -13
  61. package/llms.txt +2 -2
  62. package/package.json +1 -1
  63. package/src/charts/kinds/Bars.tsx +1 -1
  64. package/src/charts/kinds/Donut.tsx +1 -1
  65. package/src/charts/kinds/DualPanel.tsx +2 -2
  66. package/src/charts/kinds/Heatmap.tsx +1 -1
  67. package/src/charts/kinds/MultiLine.tsx +1 -1
  68. package/src/charts/kinds/StackedArea.tsx +1 -1
  69. package/src/charts/kinds/ZonedLine.tsx +1 -1
  70. package/src/cli/index.ts +607 -128
  71. package/src/cli/init-sync.test.ts +2 -0
  72. package/src/cli/template-ledger.test.ts +1 -1
  73. package/src/cli/toolchain-wiring.test.ts +162 -0
  74. package/src/cli/waiver-audit.test.ts +131 -0
  75. package/src/guard/check-source.test.ts +441 -1
  76. package/src/guard/index.ts +430 -60
  77. package/src/router-tanstack/multi-search-param-store.ts +16 -1
  78. package/src/router-tanstack/search-param-store.test.ts +135 -0
  79. package/src/router-tanstack/search-param-store.ts +90 -8
  80. package/src/shell/app-mobile-nav.tsx +1 -1
  81. package/src/shell/index.test.tsx +52 -0
  82. package/src/shell/index.tsx +64 -13
  83. package/src/tokens/index.ts +24 -1
  84. package/src/tokens/palette.ts +2 -2
  85. package/src/tokens/text-scale.test.ts +51 -0
  86. package/src/vite.test.ts +125 -6
  87. package/src/vite.ts +148 -20
@@ -14,10 +14,13 @@ import {
14
14
  checkSource,
15
15
  DEFAULT_GUARD_CONFIG,
16
16
  GENERATED_HEADER_LINE,
17
+ guardKindRemedy,
18
+ guardWaiverHint,
17
19
  GUARD_RULES,
18
20
  TOKENS_ONLY_DISABLED_KINDS,
21
+ unmatchedExemptPatterns,
19
22
  } from './index'
20
- import type { Finding, GuardKind } from './types'
23
+ import type { Finding, GuardConfig, GuardKind } from './types'
21
24
 
22
25
  const PATH = 'src/Dashboard.tsx'
23
26
  const CHART_PATH = 'src/charts/kinds/Foo.tsx'
@@ -1586,6 +1589,99 @@ describe('exemptRules', () => {
1586
1589
  expect(kinds(f)).toContain('inline-display')
1587
1590
  })
1588
1591
 
1592
+ // ── Relative paths and globs ────────────────────────────────────────────────
1593
+ //
1594
+ // Only the bare-segment shape existed, and it is the one nobody guesses. rollhook wrote the
1595
+ // obvious, correct `"public/site.webmanifest"`, matched nothing, got no diagnostic, and kept
1596
+ // reporting — while `exemptRules` had just become the ONLY waiver route for a whole file class.
1597
+
1598
+ const HEX_MANIFEST = '{ "theme_color": "#27272a" }'
1599
+ const exemptedAt = (relPath: string, patterns: string[]): GuardKind[] =>
1600
+ kinds(
1601
+ checkSource(HEX_MANIFEST, relPath, {
1602
+ ...DEFAULT_GUARD_CONFIG,
1603
+ exemptRules: { 'raw-hex': patterns },
1604
+ }),
1605
+ )
1606
+
1607
+ it('matches a real relative path to one file', () => {
1608
+ expect(exemptedAt('public/site.webmanifest', ['public/site.webmanifest'])).not.toContain(
1609
+ 'raw-hex',
1610
+ )
1611
+ })
1612
+
1613
+ it('matches a relative directory prefix', () => {
1614
+ expect(exemptedAt('public/icons/theme.json', ['public/icons'])).not.toContain('raw-hex')
1615
+ })
1616
+
1617
+ it("normalizes a leading './' and a trailing '/'", () => {
1618
+ expect(exemptedAt('public/site.webmanifest', ['./public/'])).not.toContain('raw-hex')
1619
+ })
1620
+
1621
+ it('matches a glob, with `*` stopping at a slash', () => {
1622
+ expect(exemptedAt('public/site.webmanifest', ['public/*.webmanifest'])).not.toContain('raw-hex')
1623
+ expect(exemptedAt('public/nested/site.webmanifest', ['public/*.webmanifest'])).toContain(
1624
+ 'raw-hex',
1625
+ )
1626
+ })
1627
+
1628
+ it('matches a `**` glob across slashes', () => {
1629
+ expect(exemptedAt('src/a/b/theme.json', ['src/**/theme.json'])).not.toContain('raw-hex')
1630
+ })
1631
+
1632
+ it('matches a slash-free glob against the BASENAME, like a bare segment does', () => {
1633
+ const f = checkSource(`.a { padding: 18px }`, 'src/features/Card.module.css', {
1634
+ ...DEFAULT_GUARD_CONFIG,
1635
+ exemptRules: { 'inline-spacing': ['*.module.css'] },
1636
+ })
1637
+ expect(kinds(f)).not.toContain('inline-spacing')
1638
+ })
1639
+
1640
+ it('still fires at a path the path/glob does not match', () => {
1641
+ expect(exemptedAt('public/other.webmanifest', ['public/site.webmanifest'])).toContain('raw-hex')
1642
+ })
1643
+
1644
+ // The other half of the fix. An exemption is a CLAIM, and a claim that resolves to nothing reads
1645
+ // as coverage in a config review while enforcing exactly as much as an empty object.
1646
+ describe('unmatchedExemptPatterns', () => {
1647
+ const scanned = ['src/App.tsx', 'public/site.webmanifest']
1648
+
1649
+ it('reports a pattern that matches no scanned file', () => {
1650
+ const cfg = { ...DEFAULT_GUARD_CONFIG, exemptRules: { 'raw-hex': ['public/manifest.json'] } }
1651
+ expect(unmatchedExemptPatterns(cfg, scanned)).toEqual([
1652
+ { kind: 'raw-hex', pattern: 'public/manifest.json', reason: 'no-match' },
1653
+ ])
1654
+ })
1655
+
1656
+ it('says nothing about a pattern that DOES match', () => {
1657
+ const cfg = {
1658
+ ...DEFAULT_GUARD_CONFIG,
1659
+ exemptRules: { 'raw-hex': ['public/site.webmanifest'] },
1660
+ }
1661
+ expect(unmatchedExemptPatterns(cfg, scanned)).toEqual([])
1662
+ })
1663
+
1664
+ it("reports a typo'd KIND — an exemption for a rule that does not exist", () => {
1665
+ const cfg = {
1666
+ ...DEFAULT_GUARD_CONFIG,
1667
+ exemptRules: { 'raw-hexx': ['src'] } as unknown as GuardConfig['exemptRules'],
1668
+ }
1669
+ expect(unmatchedExemptPatterns(cfg, scanned)).toEqual([
1670
+ { kind: 'raw-hexx', pattern: 'src', reason: 'unknown-kind' },
1671
+ ])
1672
+ })
1673
+
1674
+ it('reports the exact shape rollhook wrote before the path matcher existed', () => {
1675
+ const cfg = { ...DEFAULT_GUARD_CONFIG, exemptRules: { 'raw-hex': ['site.webmanifest'] } }
1676
+ expect(unmatchedExemptPatterns(cfg, ['public/site.webmanifest'])).toEqual([])
1677
+ expect(unmatchedExemptPatterns(cfg, ['src/App.tsx'])).toHaveLength(1)
1678
+ })
1679
+
1680
+ it('is silent on an empty config', () => {
1681
+ expect(unmatchedExemptPatterns(DEFAULT_GUARD_CONFIG, scanned)).toEqual([])
1682
+ })
1683
+ })
1684
+
1589
1685
  it('does NOT suppress a different kind on the same line/path', () => {
1590
1686
  const f = checkSource(`const c = '#ff0000'; ${TEXT}`, 'src/agent/x.tsx', {
1591
1687
  ...DEFAULT_GUARD_CONFIG,
@@ -1926,6 +2022,201 @@ describe('theme-allow scoping', () => {
1926
2022
  })
1927
2023
  })
1928
2024
 
2025
+ // ── 22b. theme-allow — the prefix rule and the two scopes, exhaustively ──────
2026
+ //
2027
+ // Three holes in one contract across two rounds, so this is a matrix rather than a case.
2028
+
2029
+ describe('theme-allow — annotation vs prose (the prefix rule)', () => {
2030
+ const HEX = `const c = '#ff0000'`
2031
+
2032
+ /** `comment` on its own line directly above a raw hex — the placement that waives the line below. */
2033
+ function above(comment: string): GuardKind[] {
2034
+ return kinds(find(`${comment}\n${HEX}`))
2035
+ }
2036
+
2037
+ // ── ACCEPTED — every shape a consumer actually writes ──────────────────────
2038
+
2039
+ it.each([
2040
+ '// theme-allow raw-hex — vendor brand',
2041
+ '//theme-allow raw-hex — no space after the marker',
2042
+ ' // theme-allow raw-hex — indented',
2043
+ '/* theme-allow raw-hex — a block comment */',
2044
+ '{/* theme-allow raw-hex — a JSX expression comment */}',
2045
+ '/**\n * theme-allow raw-hex — a docblock gutter line\n */',
2046
+ '// theme-allow',
2047
+ ])('%s is an annotation and waives the line below', (comment) => {
2048
+ expect([comment, above(comment).includes('raw-hex')]).toEqual([comment, false])
2049
+ })
2050
+
2051
+ it('a trailing annotation waives its own line', () => {
2052
+ expect(kinds(find(`${HEX} // theme-allow raw-hex — vendor brand`))).not.toContain('raw-hex')
2053
+ })
2054
+
2055
+ // The reason wrapping to a second line — or a docblock's own `*/` — used to absorb the whole
2056
+ // waiver, so the natural multi-line shape silently waived nothing. argo hit it three times in one
2057
+ // upgrade, each time on a comment that looked correct.
2058
+ it('a wrapped reason still reaches the code line under it', () => {
2059
+ const f = find(
2060
+ `// theme-allow raw-hex — the vendor brand hex, kept in sync with
2061
+ // the marketing site's own palette
2062
+ ${HEX}`,
2063
+ )
2064
+ expect(kinds(f)).not.toContain('raw-hex')
2065
+ })
2066
+
2067
+ it('a blank line ends the block — the waiver does not jump it', () => {
2068
+ expect(
2069
+ kinds(
2070
+ find(`// theme-allow raw-hex — about something else
2071
+
2072
+ ${HEX}`),
2073
+ ),
2074
+ ).toContain('raw-hex')
2075
+ })
2076
+
2077
+ it('a trailing annotation does NOT walk forward — it stays on its own line', () => {
2078
+ expect(kinds(find(`const ok = 1 // theme-allow raw-hex — about this line\n${HEX}`))).toContain(
2079
+ 'raw-hex',
2080
+ )
2081
+ })
2082
+
2083
+ // ── REJECTED — prose that MENTIONS the token ──────────────────────────────
2084
+
2085
+ // The linewatch defect, and the one that made this a false NEGATIVE: a file documenting its own
2086
+ // waivers disarmed itself. Every shape here is lifted from real consumer or basalt source.
2087
+ it.each([
2088
+ '// see the `theme-allow` contract for how exceptions are written',
2089
+ '/* Since 1.20.0 a `theme-allow` that names a rule declares the file */',
2090
+ '/**\n * A `theme-allow` with a reason declares the whole FILE wherever it is written.\n */',
2091
+ '// each chart carries a theme-allow saying why',
2092
+ '// documented at theme-allow raw-hex — this sentence is prose, not a waiver',
2093
+ ])('%s is prose and waives nothing', (comment) => {
2094
+ expect([comment, above(comment).includes('raw-hex')]).toEqual([comment, true])
2095
+ })
2096
+
2097
+ // A kind name written in prose used to consume no id, fall through to `rules: []`, and read as
2098
+ // the blanket form — so a sentence about the reporting rule switched every rule off.
2099
+ it('a comment starting with the longer word `theme-allow-unscoped` is not an annotation', () => {
2100
+ expect(above('// theme-allow-unscoped is what reports a bare waiver')).toContain('raw-hex')
2101
+ })
2102
+
2103
+ it('the token inside a string literal is code, not an annotation', () => {
2104
+ expect(kinds(find(`const doc = 'theme-allow raw-hex — x'\n${HEX}`))).toContain('raw-hex')
2105
+ })
2106
+
2107
+ // Prose is not an annotation for REPORTING either — otherwise every sentence documenting the
2108
+ // escape hatch becomes a theme-allow-unscoped finding (this package's own guard/types.ts).
2109
+ it('prose mentioning the token is not reported as an unscoped waiver', () => {
2110
+ expect(above('// see the `theme-allow` contract')).not.toContain('theme-allow-unscoped')
2111
+ })
2112
+ })
2113
+
2114
+ describe('theme-allow-file — whole-file scope', () => {
2115
+ const SRC = [`const a = '#ff0000'`, `const b = '#00ff00'`, `const c = '#0000ff'`].join('\n')
2116
+
2117
+ it('waives every line in the file, not just the one below it', () => {
2118
+ const f = find(`// theme-allow-file raw-hex — the vendor brand sheet\n${SRC}`)
2119
+ expect(kinds(f)).not.toContain('raw-hex')
2120
+ })
2121
+
2122
+ it('reaches BACKWARDS too — a declaration at the bottom covers the top', () => {
2123
+ const f = find(`${SRC}\n// theme-allow-file raw-hex — the vendor brand sheet`)
2124
+ expect(kinds(f)).not.toContain('raw-hex')
2125
+ })
2126
+
2127
+ it('waives only the kinds it names', () => {
2128
+ const f = find(
2129
+ `// theme-allow-file raw-hex — vendor brand\n${SRC}\n<Box style={{ display: 'flex' }} />`,
2130
+ )
2131
+ expect(kinds(f)).not.toContain('raw-hex')
2132
+ expect(kinds(f)).toContain('inline-display')
2133
+ })
2134
+
2135
+ // File scope is the widest waiver in the contract, so it is the one that has to name what it
2136
+ // waives. A bare `theme-allow-file` is `exemptRules` without the config review.
2137
+ it('a BARE theme-allow-file waives nothing, and is reported as unscoped', () => {
2138
+ const f = find(`// theme-allow-file — everything in here is bespoke\n${SRC}`)
2139
+ expect(kinds(f)).toContain('raw-hex')
2140
+ expect(kinds(f)).toContain('theme-allow-unscoped')
2141
+ })
2142
+
2143
+ it("a theme-allow-file naming a typo'd id waives nothing", () => {
2144
+ const f = find(`// theme-allow-file raw-hexx — vendor brand\n${SRC}`)
2145
+ expect(kinds(f)).toContain('raw-hex')
2146
+ expect(kinds(f)).toContain('theme-allow-unscoped')
2147
+ })
2148
+
2149
+ it('an id-and-reason theme-allow-file is accountable — no unscoped finding', () => {
2150
+ const f = find(`// theme-allow-file raw-hex — the vendor brand sheet\n${SRC}`)
2151
+ expect(kinds(f)).not.toContain('theme-allow-unscoped')
2152
+ })
2153
+
2154
+ // The complement, and the half 1.20.0 did not deliver: naming a rule AND giving a reason used to
2155
+ // promote a waiver to the whole file in the oxlint plugin, so whole-file was the only expressible
2156
+ // scope. The guard's plain `theme-allow` has always been line-scoped, and stays that way.
2157
+ it('a plain theme-allow with an id and a reason stays line-scoped', () => {
2158
+ const f = find(`// theme-allow raw-hex — vendor brand\n${SRC}`)
2159
+ expect(kinds(f).filter((k) => k === 'raw-hex')).toHaveLength(2)
2160
+ })
2161
+ })
2162
+
2163
+ /** A two-hex manifest with `extra` (an annotation member, or nothing) inserted at the top. */
2164
+ const MANIFEST = (extra: string) =>
2165
+ `{\n${extra} "theme_color": "#27272a",\n "background_color": "#27272a"\n}\n`
2166
+
2167
+ describe('theme-allow in JSON — the "basalt:theme-allow" member', () => {
2168
+ it('a webmanifest hex reports with no annotation — the 1.20.0 baseline', () => {
2169
+ const f = checkSource(MANIFEST(''), 'public/site.webmanifest', DEFAULT_GUARD_CONFIG)
2170
+ expect(kinds(f).filter((k) => k === 'raw-hex')).toHaveLength(2)
2171
+ })
2172
+
2173
+ // JSON has no comments, so from 1.20.0 the whole file class was permanently unwaivable and the
2174
+ // printed remedy prescribed a comment the file cannot carry. Both consumers that hit it fell back
2175
+ // to a blanket `exemptRules` entry — the shape the release was spent retiring.
2176
+ it('a "basalt:theme-allow-file" member declares the file', () => {
2177
+ const f = checkSource(
2178
+ MANIFEST(' "basalt:theme-allow-file": "raw-hex — a manifest cannot read a CSS variable",\n'),
2179
+ 'public/site.webmanifest',
2180
+ DEFAULT_GUARD_CONFIG,
2181
+ )
2182
+ expect(kinds(f)).not.toContain('raw-hex')
2183
+ })
2184
+
2185
+ it('a "basalt:theme-allow" member is line-scoped — its own line and the one below', () => {
2186
+ const f = checkSource(
2187
+ MANIFEST(' "basalt:theme-allow": "raw-hex — the theme color only",\n'),
2188
+ 'public/site.webmanifest',
2189
+ DEFAULT_GUARD_CONFIG,
2190
+ )
2191
+ expect(kinds(f).filter((k) => k === 'raw-hex')).toHaveLength(1)
2192
+ })
2193
+
2194
+ it('the member must name its ids — a bare one waives nothing', () => {
2195
+ const f = checkSource(
2196
+ MANIFEST(' "basalt:theme-allow-file": "everything in here is bespoke",\n'),
2197
+ 'public/site.webmanifest',
2198
+ DEFAULT_GUARD_CONFIG,
2199
+ )
2200
+ expect(kinds(f)).toContain('raw-hex')
2201
+ })
2202
+
2203
+ it('works in a plain .json too, not only a manifest', () => {
2204
+ const f = checkSource(
2205
+ MANIFEST(' "basalt:theme-allow-file": "raw-hex — vendor brand data",\n'),
2206
+ 'src/brand.json',
2207
+ DEFAULT_GUARD_CONFIG,
2208
+ )
2209
+ expect(kinds(f)).not.toContain('raw-hex')
2210
+ })
2211
+
2212
+ // The member is JSON-only: in TS that string is a value, and a value that could switch off a rule
2213
+ // is the hole `stripComments` exists to close.
2214
+ it('the same string in a TS file is code, not an annotation', () => {
2215
+ const f = find(`const cfg = { "basalt:theme-allow-file": "raw-hex — x" }\nconst c = '#ff0000'`)
2216
+ expect(kinds(f)).toContain('raw-hex')
2217
+ })
2218
+ })
2219
+
1929
2220
  // ── 23. surface-shadow-override ──────────────────────────────────────────────
1930
2221
 
1931
2222
  describe('surface-shadow-override', () => {
@@ -2293,3 +2584,152 @@ describe("profile: 'tokens-only'", () => {
2293
2584
  expect(kinds(checkSource(src, PATH, TOKENS_ONLY))).not.toContain('hidden-inline-style')
2294
2585
  })
2295
2586
  })
2587
+
2588
+ // ── 31. remedy + waiver hint — what a report actually PRINTS ─────────────────
2589
+
2590
+ describe('guardKindRemedy', () => {
2591
+ // `check-theme`'s `Fix:` epilogue read a hand-duplicated subset of these, and the subset was
2592
+ // missing every kind added at 1.20.0. All five of those ship `warn` under grace, so the findings
2593
+ // whose whole argument is "this looks correct and is not" arrived with no argument — only "add a
2594
+ // `theme-allow`", which reads as advice to waive. Sourcing it from the registry retires the
2595
+ // duplicate instead of extending it, and this test is what stops a new kind shipping without one.
2596
+ it('gives every kind a remedy, prefixed with the kind id', () => {
2597
+ for (const kind of Object.keys(GUARD_RULES) as GuardKind[]) {
2598
+ const remedy = guardKindRemedy(kind)
2599
+ expect([kind, remedy.startsWith(`${kind}: `)]).toEqual([kind, true])
2600
+ expect([kind, remedy.length > kind.length + 20]).toEqual([kind, true])
2601
+ }
2602
+ })
2603
+
2604
+ it('covers the five 1.20.0 kinds that the duplicated table did not', () => {
2605
+ for (const kind of [
2606
+ 'surface-shadow-override',
2607
+ 'css-raw-surface',
2608
+ 'inline-font-size',
2609
+ 'hidden-inline-style',
2610
+ 'theme-allow-unscoped',
2611
+ ] as GuardKind[]) {
2612
+ expect([kind, guardKindRemedy(kind)]).toEqual([kind, `${kind}: ${GUARD_RULES[kind].message}`])
2613
+ }
2614
+ })
2615
+
2616
+ // argo: the kind fires on a fixed dock replacing card depth, where the right answer is the
2617
+ // overlay tier — and `SHADOW_KEEPS_CARD_DEPTH` accepts any `--vx-shadow-*`, so the message was
2618
+ // narrower than the check and pointed away from the correct token.
2619
+ it('surface-shadow-override names the overlay tier, not only the card one', () => {
2620
+ expect(guardKindRemedy('surface-shadow-override')).toContain('--vx-shadow-overlay')
2621
+ })
2622
+ })
2623
+
2624
+ describe('guardWaiverHint', () => {
2625
+ // The `Fix:` closer prescribed a `theme-allow` COMMENT for every file class, including the one
2626
+ // 1.20.0 had just started scanning and which cannot hold a comment at all.
2627
+ it('never prescribes a comment to a file that cannot carry one', () => {
2628
+ for (const relPath of ['public/site.webmanifest', 'src/brand.json']) {
2629
+ expect([relPath, /theme-allow[^-]/.test(guardWaiverHint(relPath))]).toEqual([relPath, false])
2630
+ expect([relPath, guardWaiverHint(relPath)]).toEqual([
2631
+ relPath,
2632
+ expect.stringContaining('basalt:theme-allow-file'),
2633
+ ])
2634
+ }
2635
+ })
2636
+
2637
+ // rollhook's sharper point: a manifest hex can never be RIGHT, because a manifest cannot
2638
+ // reference a CSS variable — so the value is a hand-copy that drifts. Both consumers that hit it
2639
+ // found a dead colour. The remedy is to stop hand-writing the file.
2640
+ it('points a manifest at basaltAppPlugin first, and the annotation second', () => {
2641
+ const hint = guardWaiverHint('public/site.webmanifest')
2642
+ expect(hint).toContain('basaltAppPlugin')
2643
+ expect(hint.indexOf('basaltAppPlugin')).toBeLessThan(hint.indexOf('basalt:theme-allow-file'))
2644
+ })
2645
+
2646
+ it('prescribes the comment form everywhere a comment exists', () => {
2647
+ for (const relPath of ['src/App.tsx', 'src/app.css', 'index.html']) {
2648
+ expect([relPath, guardWaiverHint(relPath)]).toEqual([
2649
+ relPath,
2650
+ expect.stringContaining('theme-allow <rule-id>'),
2651
+ ])
2652
+ }
2653
+ })
2654
+
2655
+ it('names both scopes, so the narrow one is the one a reader reaches for first', () => {
2656
+ const hint = guardWaiverHint('src/App.tsx')
2657
+ expect(hint.indexOf('theme-allow <rule-id>')).toBeLessThan(hint.indexOf('theme-allow-file'))
2658
+ })
2659
+
2660
+ // Every hint has to name a mechanism that EXISTS — the defect it replaces was a hint that did
2661
+ // not. These two assert the guard actually honours what each hint prescribes.
2662
+ it('the JSON hint names a member the guard honours', () => {
2663
+ const f = checkSource(
2664
+ '{\n "basalt:theme-allow-file": "raw-hex — a manifest cannot read a CSS variable",\n "theme_color": "#27272a"\n}\n',
2665
+ 'public/site.webmanifest',
2666
+ DEFAULT_GUARD_CONFIG,
2667
+ )
2668
+ expect(kinds(f)).not.toContain('raw-hex')
2669
+ })
2670
+
2671
+ it('the comment hint names a form the guard honours', () => {
2672
+ expect(kinds(find(`// theme-allow raw-hex — vendor brand\nconst c = '#ff0000'`))).not.toContain(
2673
+ 'raw-hex',
2674
+ )
2675
+ expect(
2676
+ kinds(find(`// theme-allow-file raw-hex — vendor brand\nconst c = '#ff0000'`)),
2677
+ ).not.toContain('raw-hex')
2678
+ })
2679
+ })
2680
+
2681
+ // ── 32. inline-spacing — a property bag that never reaches CSS ───────────────
2682
+
2683
+ describe('inline-spacing — the style-object test', () => {
2684
+ const inlineSpacing = (src: string): GuardKind[] =>
2685
+ kinds(find(src)).filter((k) => k === 'inline-spacing')
2686
+
2687
+ // argo: `const FIT_BOUNDS_OPTIONS = { padding: 48, duration: 0 }` is a maplibre `fitBounds`
2688
+ // viewport inset measured in MAP pixels. There is no Mantine token that could express it, and
2689
+ // "use p/m/gap with xs..xl" is not a thing that can be done to it. The class recurs in any
2690
+ // consumer wrapping a map or a canvas library — basalt's own shell carries one (a floating-ui
2691
+ // `shift: { padding: 8 }`), which is how it stayed invisible.
2692
+ it('does NOT flag a unitless number in a plain options bag', () => {
2693
+ expect(inlineSpacing('const FIT_BOUNDS_OPTIONS = { padding: 48, duration: 0 }')).toEqual([])
2694
+ })
2695
+
2696
+ it('does NOT flag a unitless number in a call argument', () => {
2697
+ expect(inlineSpacing('map.fitBounds(bounds, { padding: 48 })')).toEqual([])
2698
+ })
2699
+
2700
+ it('does NOT flag a nested non-style bag (floating-ui middleware)', () => {
2701
+ expect(inlineSpacing('const M = { flip: false, shift: { padding: 8 } }')).toEqual([])
2702
+ })
2703
+
2704
+ // Everything the kind was built for still reports.
2705
+ it.each([
2706
+ '<Box style={{ padding: 16 }} />',
2707
+ '<Box\n style={{\n padding: 16,\n }}\n/>',
2708
+ '<Input styles={{ input: { paddingLeft: 12 } }} />',
2709
+ 'const wrapperStyle = { padding: 16 }',
2710
+ 'const s: CSSProperties = { marginTop: 12 }',
2711
+ ])('still flags %s', (src) => {
2712
+ expect([src, inlineSpacing(src)]).toEqual([src, ['inline-spacing']])
2713
+ })
2714
+
2715
+ // The hoisted form the name alone does not give away — resolved from the `style={…}` use site,
2716
+ // the same seam `hidden-inline-style` already walks.
2717
+ it('flags a hoisted object the file hands to a style prop', () => {
2718
+ expect(
2719
+ inlineSpacing('const s = { padding: 16 }\nexport const D = () => <div style={s} />'),
2720
+ ).toEqual(['inline-spacing'])
2721
+ })
2722
+
2723
+ // A UNIT is evidence the number is CSS wherever it was written, so it never consults the context.
2724
+ it.each([`const OPTS = { padding: '16px' }`, `const OPTS = { marginTop: '1.5rem' }`])(
2725
+ '%s carries a unit and is flagged regardless of context',
2726
+ (src) => {
2727
+ expect([src, inlineSpacing(src)]).toEqual([src, ['inline-spacing']])
2728
+ },
2729
+ )
2730
+
2731
+ it('CSS is untouched by the test — there is no object context to consult', () => {
2732
+ const f = checkSource('.a { padding: 18px }', 'src/Card.module.css', DEFAULT_GUARD_CONFIG)
2733
+ expect(kinds(f)).toContain('inline-spacing')
2734
+ })
2735
+ })