basalt-ui 1.8.0 → 1.9.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 (72) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/agent/rules/basalt-charts.md +74 -5
  3. package/dist/charts/hooks/useHoverSync.d.ts +18 -2
  4. package/dist/charts/hooks/useHoverSync.d.ts.map +1 -1
  5. package/dist/charts/hooks/useHoverSync.js +12 -7
  6. package/dist/charts/index.d.ts +1 -0
  7. package/dist/charts/index.d.ts.map +1 -1
  8. package/dist/charts/index.js +6 -0
  9. package/dist/charts/kinds/Bars.d.ts +2 -0
  10. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  11. package/dist/charts/kinds/Bars.js +3 -1
  12. package/dist/charts/kinds/Donut.d.ts +2 -0
  13. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  14. package/dist/charts/kinds/Donut.js +8 -4
  15. package/dist/charts/kinds/DualPanel.d.ts +2 -0
  16. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  17. package/dist/charts/kinds/DualPanel.js +2 -1
  18. package/dist/charts/kinds/Heatmap.d.ts +8 -0
  19. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  20. package/dist/charts/kinds/Heatmap.js +7 -3
  21. package/dist/charts/kinds/MultiLine.d.ts +2 -0
  22. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  23. package/dist/charts/kinds/MultiLine.js +2 -1
  24. package/dist/charts/kinds/StackedArea.d.ts +2 -0
  25. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  26. package/dist/charts/kinds/StackedArea.js +2 -1
  27. package/dist/charts/kinds/ZonedLine.d.ts +2 -0
  28. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  29. package/dist/charts/kinds/ZonedLine.js +2 -1
  30. package/dist/charts/primitives/Axes.d.ts +6 -1
  31. package/dist/charts/primitives/Axes.d.ts.map +1 -1
  32. package/dist/charts/primitives/Axes.js +3 -2
  33. package/dist/charts/primitives/ChartFrame.d.ts +8 -1
  34. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  35. package/dist/charts/primitives/ChartFrame.js +8 -4
  36. package/dist/charts/primitives/ChartPending.d.ts +34 -0
  37. package/dist/charts/primitives/ChartPending.d.ts.map +1 -0
  38. package/dist/charts/primitives/ChartPending.js +23 -0
  39. package/dist/charts/primitives/ChartTooltip.d.ts +5 -2
  40. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  41. package/dist/charts/primitives/ChartTooltip.js +6 -1
  42. package/dist/charts/primitives/HoverOverlay.d.ts +5 -4
  43. package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
  44. package/dist/charts/primitives/HoverOverlay.js +4 -2
  45. package/dist/cli/index.d.ts +22 -1
  46. package/dist/cli/index.d.ts.map +1 -1
  47. package/dist/cli/index.js +86 -5
  48. package/dist/dashboard/stat-card.d.ts.map +1 -1
  49. package/dist/guard/index.d.ts.map +1 -1
  50. package/dist/guard/index.js +13 -5
  51. package/llms.txt +2 -2
  52. package/package.json +3 -5
  53. package/src/charts/hooks/useHoverSync.ts +43 -7
  54. package/src/charts/index.ts +6 -0
  55. package/src/charts/kinds/Bars.tsx +4 -0
  56. package/src/charts/kinds/Donut.tsx +10 -4
  57. package/src/charts/kinds/DualPanel.tsx +5 -1
  58. package/src/charts/kinds/Heatmap.tsx +19 -2
  59. package/src/charts/kinds/MultiLine.tsx +4 -1
  60. package/src/charts/kinds/StackedArea.tsx +4 -1
  61. package/src/charts/kinds/ZonedLine.tsx +4 -1
  62. package/src/charts/primitives/Axes.tsx +7 -1
  63. package/src/charts/primitives/ChartFrame.test.tsx +64 -0
  64. package/src/charts/primitives/ChartFrame.tsx +17 -4
  65. package/src/charts/primitives/ChartPending.tsx +59 -0
  66. package/src/charts/primitives/ChartTooltip.tsx +15 -3
  67. package/src/charts/primitives/HoverOverlay.tsx +12 -6
  68. package/src/cli/index.ts +154 -5
  69. package/src/cli/placement-engine.test.ts +104 -0
  70. package/src/dashboard/stat-card.tsx +12 -0
  71. package/src/guard/check-source.test.ts +20 -0
  72. package/src/guard/index.ts +20 -9
package/src/cli/index.ts CHANGED
@@ -675,8 +675,105 @@ function resolvePeerFlags(cwd: string, flags: ScaffoldFlags): PeerFlags {
675
675
  }
676
676
  }
677
677
 
678
+ /**
679
+ * Where the seed engine is running relative to the consumer's git repo — decides which
680
+ * repo-root-shaped seeds are safe to write. `init`/`sync` compute it once per run via
681
+ * `resolvePlacement` and print the matching skip notices themselves; `managedFiles` only reads it.
682
+ */
683
+ type PlacementFlags = {
684
+ /** True when the package directory IS the detected repo root, or no `.git` exists anywhere above
685
+ * it (a consumer not using git keeps today's behaviour exactly). False means the package lives
686
+ * in a subdirectory of the repo (a monorepo's `web/`, say) — lefthook and GitHub Actions both
687
+ * only read config at the repo root, so seeding either one there is worse than not seeding at
688
+ * all. */
689
+ isPackageRepoRoot: boolean
690
+ /** Absolute path of the detected repo root. Meaningful only when `isPackageRepoRoot` is false —
691
+ * otherwise it equals the package dir and is never read. */
692
+ repoRoot: string
693
+ /** Absolute path of a `query-client.ts` already present elsewhere under the package's `src/` (the
694
+ * consumer relocated it — e.g. to `src/lib/query-client.ts`), or null. The seed's OWN
695
+ * destination is deliberately excluded here — that exact-path case is handled by the ordinary
696
+ * skip-if-exists logic every seed file already goes through, unchanged. */
697
+ relocatedQueryClient: string | null
698
+ }
699
+
700
+ /** The default placement — today's single-package-at-repo-root behaviour, unconditionally. */
701
+ const ROOT_PLACEMENT: PlacementFlags = {
702
+ isPackageRepoRoot: true,
703
+ repoRoot: '',
704
+ relocatedQueryClient: null,
705
+ }
706
+
707
+ /**
708
+ * Walks up from `dir` looking for a `.git` entry — ordinarily a directory, but a git worktree or
709
+ * submodule points it at a FILE instead, and both still mean "this directory is the repo root".
710
+ * Stops at the filesystem root. Returns `dir` itself when no `.git` is found anywhere, so a
711
+ * consumer not using git falls back to today's behaviour exactly.
712
+ */
713
+ function findRepoRoot(dir: string): string {
714
+ let current = dir
715
+ for (;;) {
716
+ if (existsSync(resolve(current, '.git'))) return current
717
+ const parent = dirname(current)
718
+ if (parent === current) return dir
719
+ current = parent
720
+ }
721
+ }
722
+
723
+ /**
724
+ * First path under `dir` (recursive) whose basename is `filename`, or null. Skips dependency/build
725
+ * dirs, mirroring `walkSourceFiles`'s scoping.
726
+ */
727
+ function findFileNamed(dir: string, filename: string): string | null {
728
+ let entries: string[]
729
+ try {
730
+ entries = readdirSync(dir)
731
+ } catch {
732
+ return null
733
+ }
734
+ for (const name of entries) {
735
+ if (name === 'node_modules' || name === '.git' || name === 'dist') continue
736
+ const abs = resolve(dir, name)
737
+ let isDir: boolean
738
+ try {
739
+ isDir = statSync(abs).isDirectory()
740
+ } catch {
741
+ continue
742
+ }
743
+ if (isDir) {
744
+ const found = findFileNamed(abs, filename)
745
+ if (found !== null) return found
746
+ } else if (name === filename) {
747
+ return abs
748
+ }
749
+ }
750
+ return null
751
+ }
752
+
753
+ /**
754
+ * Absolute path of a `query-client.ts` living elsewhere under the package's `src/`, or null. The
755
+ * seed's own destination is excluded — reseeding on top of a RELOCATED client (the failure this
756
+ * exists to prevent) is a different case from the seed's own path already being occupied, which the
757
+ * ordinary skip-if-exists logic handles unchanged.
758
+ */
759
+ function findRelocatedQueryClient(cwd: string): string | null {
760
+ const seedAbs = resolve(cwd, 'src/query-client.ts')
761
+ const found = findFileNamed(resolve(cwd, 'src'), 'query-client.ts')
762
+ return found !== null && found !== seedAbs ? found : null
763
+ }
764
+
765
+ /** Resolve a run's placement flags once, from the consumer cwd. */
766
+ function resolvePlacement(cwd: string): PlacementFlags {
767
+ const repoRoot = findRepoRoot(cwd)
768
+ return {
769
+ isPackageRepoRoot: repoRoot === cwd,
770
+ repoRoot,
771
+ relocatedQueryClient: findRelocatedQueryClient(cwd),
772
+ }
773
+ }
774
+
678
775
  /** The full managed-file manifest. Stable, declarative — the single source of truth for init/sync. */
679
- function managedFiles(peers: PeerFlags): ManagedFile[] {
776
+ function managedFiles(peers: PeerFlags, placement: PlacementFlags = ROOT_PLACEMENT): ManagedFile[] {
680
777
  // ── managed: exactly what Claude reads ──────────────────────────────────────
681
778
  const rules: ManagedFile[] = RULE_NAMES.map((name) => ({
682
779
  dest: `.claude/rules/basalt-${name}.md`,
@@ -804,10 +901,16 @@ function managedFiles(peers: PeerFlags): ManagedFile[] {
804
901
  // type) — both are documented optional peers, so seeding either scaffold without its peer(s)
805
902
  // installed would ship a file with an unresolved import.
806
903
  const scaffolds: ManagedFile[] = []
807
- if (peers.hasQuery) scaffolds.push(queryClient)
904
+ if (peers.hasQuery && placement.relocatedQueryClient === null) scaffolds.push(queryClient)
808
905
  if (peers.hasRouter && peers.hasQuery) scaffolds.push(rootRoute)
809
906
 
810
- return [...rules, ...skills, claudeBlock, design, oxfmt, lefthook, ci, oxlintrc, ...scaffolds]
907
+ // lefthook.yml / .github/workflows/check.yml are repo-root-shaped: lefthook and GitHub Actions
908
+ // only ever read config at the repo root, so a package living in a subdirectory (a monorepo's
909
+ // `web/`) skips both rather than relocating them — the repo root very often already has its own
910
+ // lefthook/CI config, and writing over or beside it is worse than not writing at all.
911
+ const rootTooling: ManagedFile[] = placement.isPackageRepoRoot ? [lefthook, ci] : []
912
+
913
+ return [...rules, ...skills, claudeBlock, design, oxfmt, ...rootTooling, oxlintrc, ...scaffolds]
811
914
  }
812
915
 
813
916
  type Manifest = {
@@ -1041,7 +1144,8 @@ export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags =
1041
1144
  const manifest = readManifest(cwd)
1042
1145
  migrateLegacyOxfmt(cwd, ctx.pkgRoot, manifest)
1043
1146
  const peers = resolvePeerFlags(cwd, scaffoldFlags)
1044
- const files = managedFiles(peers)
1147
+ const placement = resolvePlacement(cwd)
1148
+ const files = managedFiles(peers, placement)
1045
1149
 
1046
1150
  let written = 0
1047
1151
  let skipped = 0
@@ -1082,6 +1186,28 @@ export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags =
1082
1186
  `basalt-ui init: ${missingSources.length} shipped asset(s) not present, skipped: ${missingSources.join(', ')}`,
1083
1187
  )
1084
1188
  }
1189
+ // lefthook.yml / .github/workflows/check.yml are repo-root-shaped — neither lefthook nor GitHub
1190
+ // Actions reads config from anywhere but the repo root, so a package living in a subdirectory
1191
+ // skips both rather than relocating them into a spot nothing reads.
1192
+ if (!placement.isPackageRepoRoot) {
1193
+ console.log(
1194
+ `basalt-ui init: skipped lefthook.yml (this package is not the repo root — repo root ` +
1195
+ `detected at ${placement.repoRoot}) — lefthook only reads config at the repo root; extend ` +
1196
+ 'node_modules/basalt-ui/configs/lefthook.yml from your root lefthook.yml instead.',
1197
+ )
1198
+ console.log(
1199
+ `basalt-ui init: skipped .github/workflows/check.yml (this package is not the repo root — ` +
1200
+ `repo root detected at ${placement.repoRoot}) — GitHub Actions only reads .github/ at the ` +
1201
+ 'repo root; extend node_modules/basalt-ui/configs/check.yml from your root CI workflow instead.',
1202
+ )
1203
+ }
1204
+ if (placement.relocatedQueryClient !== null) {
1205
+ console.log(
1206
+ `basalt-ui init: skipped src/query-client.ts (found an existing query client at ` +
1207
+ `${relative(cwd, placement.relocatedQueryClient)}) — import it from there instead of ` +
1208
+ 're-seeding at the original path.',
1209
+ )
1210
+ }
1085
1211
  // query-client.ts / __root.tsx reference the optional TanStack peers directly — seeding them
1086
1212
  // without the peer installed would ship an unresolved import. Hint how to opt in instead.
1087
1213
  if (!peers.hasQuery) {
@@ -1132,7 +1258,8 @@ export function sync(opts: SyncOptions = {}, cwd: string = process.cwd()): numbe
1132
1258
  const manifest = readManifest(cwd)
1133
1259
  if (!opts.check) migrateLegacyOxfmt(cwd, ctx.pkgRoot, manifest)
1134
1260
  const peers = resolvePeerFlags(cwd, {})
1135
- const files = managedFiles(peers)
1261
+ const placement = resolvePlacement(cwd)
1262
+ const files = managedFiles(peers, placement)
1136
1263
 
1137
1264
  let updated = 0
1138
1265
  let recreated = 0
@@ -1182,6 +1309,28 @@ export function sync(opts: SyncOptions = {}, cwd: string = process.cwd()): numbe
1182
1309
  else updated++
1183
1310
  }
1184
1311
 
1312
+ // Placement notices — informational, never affect the exit code (a skipped tooling seed or a
1313
+ // relocated scaffold is a legitimate consumer choice, not a sync failure).
1314
+ if (!placement.isPackageRepoRoot) {
1315
+ console.log(
1316
+ `basalt-ui sync: skipped lefthook.yml (this package is not the repo root — repo root ` +
1317
+ `detected at ${placement.repoRoot}) — lefthook only reads config at the repo root; extend ` +
1318
+ 'node_modules/basalt-ui/configs/lefthook.yml from your root lefthook.yml instead.',
1319
+ )
1320
+ console.log(
1321
+ `basalt-ui sync: skipped .github/workflows/check.yml (this package is not the repo root — ` +
1322
+ `repo root detected at ${placement.repoRoot}) — GitHub Actions only reads .github/ at the ` +
1323
+ 'repo root; extend node_modules/basalt-ui/configs/check.yml from your root CI workflow instead.',
1324
+ )
1325
+ }
1326
+ if (placement.relocatedQueryClient !== null) {
1327
+ console.log(
1328
+ `basalt-ui sync: skipped src/query-client.ts (found an existing query client at ` +
1329
+ `${relative(cwd, placement.relocatedQueryClient)}) — import it from there instead of ` +
1330
+ 're-seeding at the original path.',
1331
+ )
1332
+ }
1333
+
1185
1334
  if (opts.check) {
1186
1335
  if (driftLines.length > 0) {
1187
1336
  console.error('basalt-ui sync --check: locally-drifted managed files:')
@@ -81,6 +81,25 @@ function silenced<T>(fn: () => T): T {
81
81
  }
82
82
  }
83
83
 
84
+ /** Captures both console.log and console.error — init()/sync() interleave the two. */
85
+ function capture(fn: () => number): { code: number; log: string } {
86
+ const originalLog = console.log
87
+ const originalError = console.error
88
+ let log = ''
89
+ console.log = (...args: unknown[]) => {
90
+ log += `${args.join(' ')}\n`
91
+ }
92
+ console.error = (...args: unknown[]) => {
93
+ log += `${args.join(' ')}\n`
94
+ }
95
+ try {
96
+ return { code: fn(), log }
97
+ } finally {
98
+ console.log = originalLog
99
+ console.error = originalError
100
+ }
101
+ }
102
+
84
103
  describe('managed vs seed placement', () => {
85
104
  it('init places rules AND skills as managed files, plus the toolchain seeds', () => {
86
105
  silenced(() => init(dir))
@@ -192,3 +211,88 @@ describe('--check performs zero writes', () => {
192
211
  expect(snapshotDir()).toEqual(drifted)
193
212
  })
194
213
  })
214
+
215
+ // A real consumer's repo root (an API + collector) had its dashboard package in `web/`, one level
216
+ // below the repo `.git`. `init`/`sync` used to seed lefthook.yml, .github/workflows/check.yml, and
217
+ // src/query-client.ts relative to the PACKAGE dir regardless — none of which anything reads from
218
+ // there, and re-created on every sync even after the consumer relocated them by hand.
219
+ describe('repo-root-shaped seeds (lefthook.yml / check.yml / query-client.ts)', () => {
220
+ it('package IS the repo root (a bare .git dir present): writes lefthook.yml and check.yml, unchanged', () => {
221
+ mkdirSync(join(dir, '.git'))
222
+ silenced(() => init(dir))
223
+ expect(existsSync(join(dir, 'lefthook.yml'))).toBe(true)
224
+ expect(existsSync(join(dir, '.github/workflows/check.yml'))).toBe(true)
225
+ })
226
+
227
+ it('no .git anywhere in the tree: falls back to writing lefthook.yml and check.yml, unchanged', () => {
228
+ silenced(() => init(dir))
229
+ expect(existsSync(join(dir, 'lefthook.yml'))).toBe(true)
230
+ expect(existsSync(join(dir, '.github/workflows/check.yml'))).toBe(true)
231
+ })
232
+
233
+ it('package lives in a subdirectory of the repo: init skips both tooling seeds, notes the repo root, exits 0', () => {
234
+ mkdirSync(join(dir, '.git'))
235
+ const webDir = join(dir, 'web')
236
+ mkdirSync(webDir, { recursive: true })
237
+ writeFileSync(join(webDir, 'package.json'), JSON.stringify({ name: 'web' }))
238
+
239
+ const { code, log } = capture(() => init(webDir))
240
+
241
+ expect(code).toBe(0)
242
+ expect(existsSync(join(webDir, 'lefthook.yml'))).toBe(false)
243
+ expect(existsSync(join(webDir, '.github/workflows/check.yml'))).toBe(false)
244
+ expect(log).toContain('lefthook.yml')
245
+ expect(log).toContain('.github/workflows/check.yml')
246
+ expect(log).toContain(dir)
247
+ // Repo-root-shaped seeds are skipped; everything else still seeds normally.
248
+ expect(existsSync(join(webDir, 'DESIGN.md'))).toBe(true)
249
+ expect(existsSync(join(webDir, '.oxlintrc.json'))).toBe(true)
250
+ })
251
+
252
+ it('package lives in a subdirectory of the repo: sync ALSO skips both tooling seeds and notes it, exits 0', () => {
253
+ mkdirSync(join(dir, '.git'))
254
+ const webDir = join(dir, 'web')
255
+ mkdirSync(webDir, { recursive: true })
256
+ writeFileSync(join(webDir, 'package.json'), JSON.stringify({ name: 'web' }))
257
+ silenced(() => init(webDir))
258
+
259
+ const { code, log } = capture(() => sync({}, webDir))
260
+
261
+ expect(code).toBe(0)
262
+ expect(existsSync(join(webDir, 'lefthook.yml'))).toBe(false)
263
+ expect(existsSync(join(webDir, '.github/workflows/check.yml'))).toBe(false)
264
+ expect(log).toContain('lefthook.yml')
265
+ })
266
+
267
+ it('an existing query-client.ts elsewhere under src/ suppresses the seed and names the found file', () => {
268
+ writeFileSync(
269
+ join(dir, 'package.json'),
270
+ JSON.stringify({ name: 'fixture', dependencies: { '@tanstack/react-query': '^5.101.0' } }),
271
+ )
272
+ write('src/lib/query-client.ts', 'export const queryClient = 1\n')
273
+
274
+ const { code, log } = capture(() => init(dir))
275
+
276
+ expect(code).toBe(0)
277
+ expect(existsSync(join(dir, 'src/query-client.ts'))).toBe(false)
278
+ expect(read('src/lib/query-client.ts')).toBe('export const queryClient = 1\n')
279
+ expect(log).toContain('src/lib/query-client.ts')
280
+ })
281
+
282
+ it('an existing query-client.ts elsewhere under src/ keeps suppressing the seed on sync', () => {
283
+ writeFileSync(
284
+ join(dir, 'package.json'),
285
+ JSON.stringify({ name: 'fixture', dependencies: { '@tanstack/react-query': '^5.101.0' } }),
286
+ )
287
+ silenced(() => init(dir))
288
+ expect(existsSync(join(dir, 'src/query-client.ts'))).toBe(true)
289
+ rmSync(join(dir, 'src/query-client.ts'))
290
+ write('src/lib/query-client.ts', 'export const queryClient = 1\n')
291
+
292
+ const { code, log } = capture(() => sync({}, dir))
293
+
294
+ expect(code).toBe(0)
295
+ expect(existsSync(join(dir, 'src/query-client.ts'))).toBe(false)
296
+ expect(log).toContain('src/lib/query-client.ts')
297
+ })
298
+ })
@@ -38,6 +38,18 @@
38
38
  * @example
39
39
  * // The earned zero — a measured value worth asserting, not a card that merely has no reading.
40
40
  * <StatCard label="Downtime · last 24h" value="0 min" tone="good" />
41
+ *
42
+ * @example
43
+ * // The honest gate. A zero can be synthesized as well as measured — a downtime figure derived
44
+ * // from outage rows reads 0 both when probes ran clean AND when no probe cycle was ingested at
45
+ * // all, because a dead collector opens no outage row either. `tone` is a positive assertion
46
+ * // about a reading, so the gate belongs on whether a reading exists, not on whether the number
47
+ * // happens to be defined — those are separate facts. Gate on measured coverage instead:
48
+ * <StatCard
49
+ * label="Downtime · last 24h"
50
+ * value={`${downtimeMinutes} min`}
51
+ * tone={hasCoverage ? (downtimeMinutes === 0 ? 'good' : 'bad') : undefined}
52
+ * />
41
53
  */
42
54
  import { Box, Card, Group, VisuallyHidden } from '@mantine/core'
43
55
  import type { ReactNode } from 'react'
@@ -409,6 +409,16 @@ describe('raw-html-layout', () => {
409
409
  const f = find(`<div style={{ display: 'flex' }} /> // theme-allow`)
410
410
  expect(kinds(f)).not.toContain('raw-html-layout')
411
411
  })
412
+
413
+ it('does NOT flag a raw <div> with inline display:flex in a chart file — the Flex/Grid/Group remedy is @mantine/*, banned in the Mantine-free chart layer', () => {
414
+ const f = find(`<div style={{ display: 'flex', gap: 8 }} />`, CHART_PATH)
415
+ expect(kinds(f)).not.toContain('raw-html-layout')
416
+ })
417
+
418
+ it('still flags the same literal in a non-chart path (the check is not globally weakened)', () => {
419
+ const f = find(`<div style={{ display: 'flex', gap: 8 }} />`, PATH)
420
+ expect(kinds(f)).toContain('raw-html-layout')
421
+ })
412
422
  })
413
423
 
414
424
  // ── 10. inline-spacing ───────────────────────────────────────────────────────
@@ -527,6 +537,16 @@ describe('inline-display', () => {
527
537
  const f = find(`<Box style={{ display: 'flex' }} /> // theme-allow`)
528
538
  expect(kinds(f)).not.toContain('inline-display')
529
539
  })
540
+
541
+ it('does NOT flag display: "flex" in a chart file — the Flex/Grid/Group remedy is @mantine/*, banned in the Mantine-free chart layer', () => {
542
+ const f = find(`<Box style={{ display: 'flex' }} />`, CHART_PATH)
543
+ expect(kinds(f)).not.toContain('inline-display')
544
+ })
545
+
546
+ it('still flags the same literal in a non-chart path (the check is not globally weakened)', () => {
547
+ const f = find(`<Box style={{ display: 'flex' }} />`, PATH)
548
+ expect(kinds(f)).toContain('inline-display')
549
+ })
530
550
  })
531
551
 
532
552
  // ── 12. raw-visx-axis ────────────────────────────────────────────────────────
@@ -282,10 +282,13 @@ export const DEFAULT_GUARD_CONFIG: GuardConfig = {
282
282
  * promotion, and belongs in its own commit so the changelog says enforcement got stricter.
283
283
  *
284
284
  * `mantine-shade-index` landed in 1.7.0 and is the sole entry — it rejects `c="yellow.7"`, which
285
- * previously passed, and the one real consumer has several. Its promotion is **deferred to 1.9.0**:
286
- * 1.8.0 shipped the same day as 1.7.0, so promoting on the next minor number would have given the
287
- * consumer roughly no runway at all — which is the exact failure the grace minor exists to prevent,
288
- * and the runway is measured in consumer upgrades, not in version numbers. Promote it by deleting
285
+ * previously passed, and the one real consumer has several. Its promotion is **deferred to 1.10.0**,
286
+ * for the second time and for the same reason both times: the runway is measured in consumer
287
+ * upgrades, not in version numbers. 1.8.0 shipped the same day as 1.7.0, so promoting there would
288
+ * have given no runway at all. 1.9.0 then carried the chart-layer batch the same consumer asked
289
+ * for — bundling a build-breaking promotion into the minor they upgrade for the fixes turns a
290
+ * routine bump into an unplanned refactor, which is precisely what the grace minor exists to
291
+ * prevent. 1.10.0 is the first minor they can take the promotion on its own. Promote it by deleting
289
292
  * the entry (its own commit).
290
293
  *
291
294
  * @example
@@ -294,7 +297,7 @@ export const DEFAULT_GUARD_CONFIG: GuardConfig = {
294
297
  * }
295
298
  */
296
299
  const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, string>> = {
297
- 'mantine-shade-index': 'introduced 1.7.0 — promote to error in 1.9.0',
300
+ 'mantine-shade-index': 'introduced 1.7.0 — promote to error in 1.10.0',
298
301
  }
299
302
 
300
303
  /** A kind's effective severity: consumer override first, then the grace table, then `error`. */
@@ -688,8 +691,12 @@ export const GUARD_RULES = {
688
691
  kind: 'raw-html-layout',
689
692
  pattern: RAW_HTML_TAG, // handled inline (3-condition conjunction); entry keeps registry complete
690
693
  enabled: (cfg: GuardConfig) => cfg.rawHtmlLayout,
691
- // JSX-tag-shaped (`<div style={{...}}>`) — never appears in CSS text.
692
- appliesTo: (relPath) => !relPath.endsWith('.css'),
694
+ // JSX-tag-shaped (`<div style={{...}}>`) — never appears in CSS text. Also excluded from
695
+ // chart files: the remedy (Box/Flex/Grid/Stack/Group) is `@mantine/*`, and src/charts/** is a
696
+ // lint-enforced Mantine-free layer (basalt/token-layer-boundary) — so inside a chart file the
697
+ // finding is unactionable, not merely inconvenient, and the only "fix" would be a theme-allow
698
+ // comment written inside the very directory this rule protects.
699
+ appliesTo: (relPath) => !isChartFile(relPath) && !relPath.endsWith('.css'),
693
700
  message:
694
701
  'Raw HTML element with inline layout/surface styling — use a Mantine layout primitive (Box/Flex/Grid/Stack/Group).',
695
702
  },
@@ -704,8 +711,12 @@ export const GUARD_RULES = {
704
711
  pattern: INLINE_DISPLAY,
705
712
  enabled: (cfg: GuardConfig) => cfg.inlineDisplay,
706
713
  // JSX-object-shaped (`display: 'flex'`, quoted value) — CSS never quotes a keyword value
707
- // (`display: flex;` is bare), so the pattern's quote anchor can never match real CSS.
708
- appliesTo: (relPath) => !relPath.endsWith('.css'),
714
+ // (`display: flex;` is bare), so the pattern's quote anchor can never match real CSS. Also
715
+ // excluded from chart files: the remedy (Flex/Grid/Group) is `@mantine/*`, and src/charts/**
716
+ // is a lint-enforced Mantine-free layer (basalt/token-layer-boundary) — so inside a chart file
717
+ // the finding is unactionable, not merely inconvenient, and the only "fix" would be a
718
+ // theme-allow comment written inside the very directory this rule protects.
719
+ appliesTo: (relPath) => !isChartFile(relPath) && !relPath.endsWith('.css'),
709
720
  message: 'Use <Flex>/<Grid>/<Group> instead of an inline display:flex/grid.',
710
721
  },
711
722
  'raw-visx-axis': {