basalt-ui 1.19.0 → 1.20.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 (83) hide show
  1. package/AGENTS.md +15 -1
  2. package/CHANGELOG.md +27 -0
  3. package/MIGRATING.md +228 -0
  4. package/README.md +53 -9
  5. package/agent/rules/basalt-app.md +43 -0
  6. package/agent/rules/basalt-charts.md +26 -14
  7. package/agent/rules/basalt-mantine.md +25 -10
  8. package/agent/rules/basalt-router.md +22 -10
  9. package/agent/rules/basalt-tokens.md +75 -6
  10. package/agent/skills/basalt-app/SKILL.md +42 -6
  11. package/configs/check.yml +11 -4
  12. package/configs/lefthook.yml +21 -2
  13. package/configs/oxlint-plugin.js +515 -25
  14. package/configs/oxlint-plugin.test.ts +383 -10
  15. package/configs/oxlint.json +10 -1
  16. package/dist/agent/stick-to-bottom.js +1 -1
  17. package/dist/agent-chat/thread-detail-panel.js +1 -1
  18. package/dist/agent-chat/thread-message.js +2 -2
  19. package/dist/agent-chat/tool-chip.js +1 -1
  20. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  21. package/dist/charts/kinds/Donut.js +1 -1
  22. package/dist/charts/primitives/ChartLegend.js +2 -2
  23. package/dist/charts/primitives/ChartTooltip.js +1 -1
  24. package/dist/cli/index.d.ts +107 -15
  25. package/dist/cli/index.d.ts.map +1 -1
  26. package/dist/cli/index.js +610 -72
  27. package/dist/cli/toolchain-wiring.test.d.ts +2 -0
  28. package/dist/cli/toolchain-wiring.test.d.ts.map +1 -0
  29. package/dist/dashboard/delta-badge.js +2 -2
  30. package/dist/dashboard/stat-card.js +1 -1
  31. package/dist/data/virtual-list.js +5 -5
  32. package/dist/guard/index.d.ts +56 -2
  33. package/dist/guard/index.d.ts.map +1 -1
  34. package/dist/guard/index.js +326 -59
  35. package/dist/guard/types.d.ts +25 -5
  36. package/dist/guard/types.d.ts.map +1 -1
  37. package/dist/register.d.ts +25 -0
  38. package/dist/register.d.ts.map +1 -1
  39. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  40. package/dist/shell/app-mobile-nav.js +14 -14
  41. package/dist/shell/app-mobile-nav.module.css +44 -11
  42. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -1
  43. package/dist/surfaces.d.ts +1 -1
  44. package/dist/surfaces.js +1 -1
  45. package/dist/tokens/index.d.ts +3 -3
  46. package/dist/tokens/index.d.ts.map +1 -1
  47. package/dist/tokens/index.js +2 -0
  48. package/dist/tokens/palette.d.ts +35 -27
  49. package/dist/tokens/palette.d.ts.map +1 -1
  50. package/dist/tokens/palette.js +24 -18
  51. package/dist/tokens.css +19 -17
  52. package/dist/vite-color-mix.js +2 -2
  53. package/llms.txt +3 -3
  54. package/package.json +4 -3
  55. package/src/agent/stick-to-bottom.tsx +1 -1
  56. package/src/agent-chat/thread-detail-panel.tsx +1 -1
  57. package/src/agent-chat/thread-message.tsx +3 -3
  58. package/src/agent-chat/tool-chip.tsx +1 -1
  59. package/src/charts/kinds/Donut.tsx +2 -1
  60. package/src/charts/primitives/ChartLegend.tsx +2 -2
  61. package/src/charts/primitives/ChartTooltip.tsx +1 -1
  62. package/src/cli/index.ts +1070 -99
  63. package/src/cli/init-sync.test.ts +19 -3
  64. package/src/cli/tokens-css.test.ts +27 -14
  65. package/src/cli/toolchain-wiring.test.ts +694 -0
  66. package/src/dashboard/delta-badge.tsx +3 -3
  67. package/src/dashboard/stat-card.tsx +1 -1
  68. package/src/data/virtual-list.tsx +8 -8
  69. package/src/guard/check-source.test.ts +663 -11
  70. package/src/guard/index.ts +727 -94
  71. package/src/guard/types.ts +29 -4
  72. package/src/register.ts +32 -0
  73. package/src/shell/app-mobile-nav.module.css +44 -11
  74. package/src/shell/app-mobile-nav.module.css.d.ts +1 -1
  75. package/src/shell/app-mobile-nav.test.tsx +34 -0
  76. package/src/shell/app-mobile-nav.tsx +16 -5
  77. package/src/surfaces.ts +1 -1
  78. package/src/theme/density-relations.test.ts +8 -5
  79. package/src/theme/spacing.test.ts +2 -0
  80. package/src/theme-toggle/index.tsx +2 -2
  81. package/src/tokens/index.ts +5 -3
  82. package/src/tokens/palette.ts +24 -18
  83. package/src/vite-color-mix.ts +2 -2
@@ -11,6 +11,9 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
11
11
  import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
12
12
  import { tmpdir } from 'node:os'
13
13
  import { dirname, resolve } from 'node:path'
14
+ // eslint-disable-next-line -- the plugin is plain JS; the ledger + id set are named exports beside it
15
+ import basaltPlugin, { KNOWN_RULE_IDS, PLUGIN_RULE_GRACE } from './oxlint-plugin.js'
16
+ import { GUARD_RULES } from '../src/guard/index.ts'
14
17
 
15
18
  const PLUGIN_PATH = resolve(import.meta.dirname, 'oxlint-plugin.js')
16
19
  const OXLINT_BIN = resolve(import.meta.dirname, '..', '..', '..', 'node_modules', '.bin', 'oxlint')
@@ -30,6 +33,8 @@ beforeEach(() => {
30
33
  'basalt/chart-in-raw-surface': 'error',
31
34
  'basalt/hand-rolled-plot': 'error',
32
35
  'basalt/chart-legend-literal': 'error',
36
+ 'basalt/shadow-basalt-export': 'error',
37
+ 'basalt/hand-rolled-shell': 'error',
33
38
  'basalt/visx-boundary': 'error',
34
39
  'basalt/visx-tooltip': 'error',
35
40
  'basalt/token-layer-boundary': 'error',
@@ -355,28 +360,40 @@ describe('basalt/agent-no-raw-usechat', () => {
355
360
 
356
361
  describe('basalt/ai-sdk-major', () => {
357
362
  it("flags when the nearest package.json declares a different ai major (5 vs basalt's 7)", () => {
358
- writeFileSync(resolve(dir, 'package.json'), JSON.stringify({ dependencies: { ai: '5.0.196' } }))
363
+ writeFileSync(
364
+ resolve(dir, 'package.json'),
365
+ JSON.stringify({ dependencies: { 'basalt-ui': '^1.19.1', ai: '5.0.196' } }),
366
+ )
359
367
  const { code, rules } = run(`import { streamText } from 'ai'\n`, 'lib.ts')
360
368
  expect(code).toBe(1)
361
369
  expect(rules).toContain('ai-sdk-major')
362
370
  })
363
371
 
364
372
  it('does NOT flag when the nearest package.json declares the same major (^7.0.18)', () => {
365
- writeFileSync(resolve(dir, 'package.json'), JSON.stringify({ dependencies: { ai: '^7.0.18' } }))
373
+ writeFileSync(
374
+ resolve(dir, 'package.json'),
375
+ JSON.stringify({ dependencies: { 'basalt-ui': '^1.19.1', ai: '^7.0.18' } }),
376
+ )
366
377
  const { code, rules } = run(`import { streamText } from 'ai'\n`, 'lib.ts')
367
378
  expect(code).toBe(0)
368
379
  expect(rules).not.toContain('ai-sdk-major')
369
380
  })
370
381
 
371
382
  it('does NOT flag when there is no ai in the nearest package.json', () => {
372
- writeFileSync(resolve(dir, 'package.json'), JSON.stringify({ dependencies: {} }))
383
+ writeFileSync(
384
+ resolve(dir, 'package.json'),
385
+ JSON.stringify({ dependencies: { 'basalt-ui': '^1.19.1' } }),
386
+ )
373
387
  const { code, rules } = run(`import { streamText } from 'ai'\n`, 'lib.ts')
374
388
  expect(code).toBe(0)
375
389
  expect(rules).not.toContain('ai-sdk-major')
376
390
  })
377
391
 
378
392
  it('does NOT flag an unrelated import even with a skewed nearest package.json', () => {
379
- writeFileSync(resolve(dir, 'package.json'), JSON.stringify({ dependencies: { ai: '5.0.196' } }))
393
+ writeFileSync(
394
+ resolve(dir, 'package.json'),
395
+ JSON.stringify({ dependencies: { 'basalt-ui': '^1.19.1', ai: '5.0.196' } }),
396
+ )
380
397
  const { code, rules } = run(`import { useState } from 'react'\n`, 'lib.ts')
381
398
  expect(code).toBe(0)
382
399
  expect(rules).not.toContain('ai-sdk-major')
@@ -385,14 +402,20 @@ describe('basalt/ai-sdk-major', () => {
385
402
  // The matcher covers TWO forms (source === 'ai' OR source.startsWith('@ai-sdk/')) — every
386
403
  // fixture above only exercises the bare 'ai' specifier; this pins the scoped-package half.
387
404
  it("flags a scoped '@ai-sdk/*' import when the nearest package.json declares a different ai major", () => {
388
- writeFileSync(resolve(dir, 'package.json'), JSON.stringify({ dependencies: { ai: '5.0.196' } }))
405
+ writeFileSync(
406
+ resolve(dir, 'package.json'),
407
+ JSON.stringify({ dependencies: { 'basalt-ui': '^1.19.1', ai: '5.0.196' } }),
408
+ )
389
409
  const { code, rules } = run(`import { openai } from '@ai-sdk/openai'\n`, 'lib.ts')
390
410
  expect(code).toBe(1)
391
411
  expect(rules).toContain('ai-sdk-major')
392
412
  })
393
413
 
394
414
  it("does NOT flag a scoped '@ai-sdk/*' import when the nearest package.json declares the same major", () => {
395
- writeFileSync(resolve(dir, 'package.json'), JSON.stringify({ dependencies: { ai: '^7.0.18' } }))
415
+ writeFileSync(
416
+ resolve(dir, 'package.json'),
417
+ JSON.stringify({ dependencies: { 'basalt-ui': '^1.19.1', ai: '^7.0.18' } }),
418
+ )
396
419
  const { code, rules } = run(`import { openai } from '@ai-sdk/openai'\n`, 'lib.ts')
397
420
  expect(code).toBe(0)
398
421
  expect(rules).not.toContain('ai-sdk-major')
@@ -402,7 +425,10 @@ describe('basalt/ai-sdk-major', () => {
402
425
  // token as its two agent-chat siblings above — not `theme-allow`. A skewed producer/consumer
403
426
  // import that is genuinely intentional (see doctor's `aiMajorSkewReason`) can mark the line.
404
427
  it('does NOT flag an import marked basalt-agent-allow, even with a skewed nearest package.json', () => {
405
- writeFileSync(resolve(dir, 'package.json'), JSON.stringify({ dependencies: { ai: '5.0.196' } }))
428
+ writeFileSync(
429
+ resolve(dir, 'package.json'),
430
+ JSON.stringify({ dependencies: { 'basalt-ui': '^1.19.1', ai: '5.0.196' } }),
431
+ )
406
432
  const { code, rules } = run(
407
433
  `// basalt-agent-allow\nimport { streamText } from 'ai'\n`,
408
434
  'lib.ts',
@@ -412,11 +438,44 @@ describe('basalt/ai-sdk-major', () => {
412
438
  })
413
439
 
414
440
  it('STILL flags an import marked only theme-allow (wrong escape token)', () => {
415
- writeFileSync(resolve(dir, 'package.json'), JSON.stringify({ dependencies: { ai: '5.0.196' } }))
441
+ writeFileSync(
442
+ resolve(dir, 'package.json'),
443
+ JSON.stringify({ dependencies: { 'basalt-ui': '^1.19.1', ai: '5.0.196' } }),
444
+ )
416
445
  const { code, rules } = run(`// theme-allow\nimport { streamText } from 'ai'\n`, 'lib.ts')
417
446
  expect(code).toBe(1)
418
447
  expect(rules).toContain('ai-sdk-major')
419
448
  })
449
+
450
+ // The scoping fix: a workspace package with no basalt-ui dependency is not a basalt consumer, so
451
+ // its `ai` pin is none of this rule's business. rb's `apps/api` took 3 errors for exactly this,
452
+ // while `doctor` — which scopes the same concern by workspace — reported green.
453
+ it('does NOT flag a package that does not depend on basalt-ui at all', () => {
454
+ writeFileSync(resolve(dir, 'package.json'), JSON.stringify({ dependencies: { ai: '5.0.196' } }))
455
+ const { code, rules } = run(`import { streamText } from 'ai'\n`, 'lib.ts')
456
+ expect(code).toBe(0)
457
+ expect(rules).not.toContain('ai-sdk-major')
458
+ })
459
+
460
+ it('does NOT flag a file outside a declared basalt.roots', () => {
461
+ writeFileSync(
462
+ resolve(dir, 'package.json'),
463
+ JSON.stringify({ basalt: { roots: ['web/src'] }, dependencies: { ai: '5.0.196' } }),
464
+ )
465
+ const { code, rules } = run(`import { streamText } from 'ai'\n`, 'api/src/llm.ts')
466
+ expect(code).toBe(0)
467
+ expect(rules).not.toContain('ai-sdk-major')
468
+ })
469
+
470
+ it('DOES flag a file inside a declared basalt.roots', () => {
471
+ writeFileSync(
472
+ resolve(dir, 'package.json'),
473
+ JSON.stringify({ basalt: { roots: ['web/src'] }, dependencies: { ai: '5.0.196' } }),
474
+ )
475
+ const { code, rules } = run(`import { streamText } from 'ai'\n`, 'web/src/llm.ts')
476
+ expect(code).toBe(1)
477
+ expect(rules).toContain('ai-sdk-major')
478
+ })
420
479
  })
421
480
 
422
481
  // ── raw-scroll-container — promotion (off → warn → error shipped) ───────────
@@ -500,11 +559,13 @@ describe('basalt/hand-rolled-plot', () => {
500
559
  expect(rules).not.toContain('hand-rolled-plot')
501
560
  })
502
561
 
503
- it('reports ONCE per file — declaring the exception is a file-level decision', () => {
562
+ // A `theme-allow` that NAMES the rule and gives a reason is a written declaration about the file,
563
+ // and still covers every assembly node in it — the DualPanel shape.
564
+ it('a named-with-a-reason declaration covers the whole file', () => {
504
565
  const { code, rules } = run(
505
566
  `${CHARTS_IMPORT}export const C = () => (
506
567
  <svg>
507
- {/* theme-allow: multi-pane shape, not a single cartesian plot */}
568
+ {/* theme-allow basalt/hand-rolled-plot: multi-pane shape, not a single cartesian plot */}
508
569
  <AxisLeftNumeric scale={a} />
509
570
  <HoverOverlay width={1} height={1} />
510
571
  <Crosshair x={0} top={0} bottom={1} />
@@ -515,6 +576,37 @@ describe('basalt/hand-rolled-plot', () => {
515
576
  expect(rules).not.toContain('hand-rolled-plot')
516
577
  })
517
578
 
579
+ // The linewatch defect: a BARE comment used to buy the whole file permanent immunity, because
580
+ // only the first node was reported and the waiver was tested there. It now waives its own node
581
+ // and nothing else, so everything added to the file afterwards is still reported.
582
+ it('a bare theme-allow waives only the node it sits above, not the file', () => {
583
+ const { code, rules } = run(
584
+ `${CHARTS_IMPORT}export const C = () => (
585
+ <svg>
586
+ {/* theme-allow */}
587
+ <AxisLeftNumeric scale={a} />
588
+ <HoverOverlay width={1} height={1} />
589
+ </svg>
590
+ )\n`,
591
+ )
592
+ expect(code).toBe(1)
593
+ expect(rules).toContain('hand-rolled-plot')
594
+ })
595
+
596
+ // A comment written about something else must not double as a licence to hand-assemble a plot.
597
+ it('a theme-allow scoped to a DIFFERENT rule does not waive this one', () => {
598
+ const { code, rules } = run(
599
+ `${CHARTS_IMPORT}export const C = () => (
600
+ <svg>
601
+ {/* theme-allow raw-hex — the swatch below is a measured pixel */}
602
+ <AxisLeftNumeric scale={a} />
603
+ </svg>
604
+ )\n`,
605
+ )
606
+ expect(code).toBe(1)
607
+ expect(rules).toContain('hand-rolled-plot')
608
+ })
609
+
518
610
  it('does NOT flag a same-named component that is NOT a basalt chart primitive', () => {
519
611
  const { code, rules } = run(
520
612
  `import { Crosshair } from './my-own-widgets'
@@ -583,3 +675,284 @@ describe('basalt/chart-legend-literal', () => {
583
675
  expect(rules).not.toContain('chart-legend-literal')
584
676
  })
585
677
  })
678
+
679
+ // ── shadow-basalt-export ─────────────────────────────────────────────────────
680
+
681
+ describe('basalt/shadow-basalt-export', () => {
682
+ it('flags a local component that collides with a basalt-ui root export', () => {
683
+ const { code, rules } = run(`export function EmptyState() { return null }\n`)
684
+ expect(code).toBe(1)
685
+ expect(rules).toContain('shadow-basalt-export')
686
+ })
687
+
688
+ it('flags the arrow/const form too (rb shipped a local StatCard)', () => {
689
+ const { code, rules } = run(`const StatCard = () => null\nexport default StatCard\n`)
690
+ expect(code).toBe(1)
691
+ expect(rules).toContain('shadow-basalt-export')
692
+ })
693
+
694
+ it('does NOT flag a name basalt does not export', () => {
695
+ const { code, rules } = run(`export function ExerciseSummaryCard() { return null }\n`)
696
+ expect(code).toBe(0)
697
+ expect(rules).not.toContain('shadow-basalt-export')
698
+ })
699
+
700
+ // The export list is derived from the real barrel, so a TYPE export must not create a collision:
701
+ // a local `StatCardProps` is an ordinary thing for a consumer to declare.
702
+ it('does NOT flag a name that basalt exports only as a type', () => {
703
+ const { code, rules } = run(`export type StatCardProps = { label: string }\n`, 'types.ts')
704
+ expect(code).toBe(0)
705
+ expect(rules).not.toContain('shadow-basalt-export')
706
+ })
707
+
708
+ it('is waivable with a scoped theme-allow', () => {
709
+ const { code, rules } = run(
710
+ `// theme-allow basalt/shadow-basalt-export — domain-specific, unrelated to the shipped one\nexport function EmptyState() { return null }\n`,
711
+ )
712
+ expect(code).toBe(0)
713
+ expect(rules).not.toContain('shadow-basalt-export')
714
+ })
715
+ })
716
+
717
+ // ── hand-rolled-shell ────────────────────────────────────────────────────────
718
+
719
+ describe('basalt/hand-rolled-shell', () => {
720
+ it('flags a hand-assembled Mantine AppShell', () => {
721
+ const { code, rules } = run(
722
+ `import { AppShell, Burger } from '@mantine/core'
723
+ export const Shell = () => (
724
+ <AppShell>
725
+ <AppShell.Header><Burger opened={false} /></AppShell.Header>
726
+ <AppShell.Navbar>nav</AppShell.Navbar>
727
+ </AppShell>
728
+ )\n`,
729
+ )
730
+ expect(code).toBe(1)
731
+ expect(rules).toContain('hand-rolled-shell')
732
+ })
733
+
734
+ it('does NOT flag a file that renders BasaltShell', () => {
735
+ const { code, rules } = run(
736
+ `import { AppShell } from '@mantine/core'
737
+ import { BasaltShell } from 'basalt-ui'
738
+ export const Shell = () => <BasaltShell sections={[]}><AppShell.Main>x</AppShell.Main></BasaltShell>\n`,
739
+ )
740
+ expect(code).toBe(0)
741
+ expect(rules).not.toContain('hand-rolled-shell')
742
+ })
743
+
744
+ // Exempt by DECLARATION, not by path — a rule saying "compose X" cannot fire inside X. (The
745
+ // fixture also trips `shadow-basalt-export`, which is correct for a consumer file and irrelevant
746
+ // here, so this asserts the rule set rather than the exit code.)
747
+ it('does NOT flag the module that DEFINES BasaltShell', () => {
748
+ const { rules } = run(
749
+ `import { AppShell } from '@mantine/core'
750
+ export function BasaltShell() { return <AppShell.Navbar>nav</AppShell.Navbar> }\n`,
751
+ )
752
+ expect(rules).not.toContain('hand-rolled-shell')
753
+ })
754
+
755
+ it("does NOT flag a consumer's own AppShell-named component with no @mantine import", () => {
756
+ const { code, rules } = run(
757
+ `import { AppShell } from './my-layout'
758
+ export const C = () => <AppShell.Navbar>nav</AppShell.Navbar>\n`,
759
+ )
760
+ expect(code).toBe(0)
761
+ expect(rules).not.toContain('hand-rolled-shell')
762
+ })
763
+
764
+ it('does NOT flag a standalone Mantine NavLink (deliberately not a trigger)', () => {
765
+ const { code, rules } = run(
766
+ `import { NavLink } from '@mantine/core'
767
+ export const C = () => <NavLink label="Settings" />\n`,
768
+ )
769
+ expect(code).toBe(0)
770
+ expect(rules).not.toContain('hand-rolled-shell')
771
+ })
772
+ })
773
+
774
+ // ── grace ledger ↔ shipped preset ────────────────────────────────────────────
775
+
776
+ describe('PLUGIN_RULE_GRACE', () => {
777
+ const shipped = JSON.parse(readFileSync(resolve(import.meta.dirname, 'oxlint.json'), 'utf8')) as {
778
+ rules: Record<string, unknown>
779
+ }
780
+ const graceIds = Object.keys(PLUGIN_RULE_GRACE)
781
+ const shippedBasaltIds = Object.keys(shipped.rules).filter((id) => id.startsWith('basalt/'))
782
+
783
+ // The mechanism whose ABSENCE let three rules sit at `warn` for up to twelve minors with nothing
784
+ // tracking them. Deleting a ledger entry IS the promotion, and this test makes flipping the
785
+ // shipped level part of the same commit.
786
+ it('every ledger entry is warn in the shipped preset', () => {
787
+ for (const id of graceIds) expect(shipped.rules[`basalt/${id}`]).toBe('warn')
788
+ })
789
+
790
+ it('every shipped rule NOT in the ledger is error', () => {
791
+ for (const id of shippedBasaltIds) {
792
+ if (graceIds.includes(id.slice('basalt/'.length))) continue
793
+ expect([id, shipped.rules[id]]).toEqual([id, 'error'])
794
+ }
795
+ })
796
+
797
+ it('every ledger entry carries a written promotion note', () => {
798
+ for (const id of graceIds) {
799
+ expect((PLUGIN_RULE_GRACE as Record<string, string>)[id]?.length ?? 0).toBeGreaterThan(40)
800
+ }
801
+ })
802
+ })
803
+
804
+ // ── chart-legend-literal: the .map() half ────────────────────────────────────
805
+
806
+ describe('basalt/chart-legend-literal — derived-from-series', () => {
807
+ const IMPORT = `import { ChartLegend } from 'basalt-ui/charts'\n`
808
+
809
+ // Deriving from *an* array is not deriving from *the* series: every field of a
810
+ // `refLines.map(...)` / `PACE_ZONES.map(...)` legend is authored at the call site, and the list
811
+ // can keep naming a band the plot no longer draws exactly like a `[...]` literal can.
812
+ it('flags a .map() over a non-series array', () => {
813
+ const { code, rules } = run(
814
+ `${IMPORT}export const C = () => <ChartLegend items={refLines.map((r) => ({ key: r.k, label: r.l }))} />\n`,
815
+ )
816
+ expect(code).toBe(1)
817
+ expect(rules).toContain('chart-legend-literal')
818
+ })
819
+
820
+ it('does NOT flag a .map() over the series', () => {
821
+ const { code, rules } = run(
822
+ `${IMPORT}export const C = () => <ChartLegend items={series.map((s) => ({ key: s.key }))} />\n`,
823
+ )
824
+ expect(code).toBe(0)
825
+ expect(rules).not.toContain('chart-legend-literal')
826
+ })
827
+
828
+ it('does NOT flag a .map() over a derived series binding', () => {
829
+ const { code, rules } = run(
830
+ `${IMPORT}export const C = () => <ChartLegend items={visibleSeries.map((s) => ({ key: s.key }))} />\n`,
831
+ )
832
+ expect(code).toBe(0)
833
+ expect(rules).not.toContain('chart-legend-literal')
834
+ })
835
+
836
+ it('does NOT flag deriveLegend(series)', () => {
837
+ const { code, rules } = run(
838
+ `${IMPORT}export const C = () => <ChartLegend items={deriveLegend(series)} />\n`,
839
+ )
840
+ expect(code).toBe(0)
841
+ expect(rules).not.toContain('chart-legend-literal')
842
+ })
843
+ })
844
+
845
+ // ── no-raw-font-size: style-context scoping ──────────────────────────────────
846
+
847
+ describe('basalt/no-raw-font-size — style context', () => {
848
+ it('flags a fontSize inside a style attribute', () => {
849
+ const { code, rules } = run(`export const C = () => <div style={{ fontSize: 11 }} />\n`)
850
+ expect(code).toBe(1)
851
+ expect(rules).toContain('no-raw-font-size')
852
+ })
853
+
854
+ it('flags a fontSize inside a Mantine styles per-part object', () => {
855
+ const { code, rules } = run(
856
+ `export const C = () => <TextInput styles={{ input: { fontSize: 12 } }} />\n`,
857
+ )
858
+ expect(code).toBe(1)
859
+ expect(rules).toContain('no-raw-font-size')
860
+ })
861
+
862
+ it('flags a fontSize in a CSSProperties-annotated const', () => {
863
+ const { code, rules } = run(
864
+ `import type { CSSProperties } from 'react'\nconst rowLook: CSSProperties = { fontSize: 11 }\nexport { rowLook }\n`,
865
+ 'look.ts',
866
+ )
867
+ expect(code).toBe(1)
868
+ expect(rules).toContain('no-raw-font-size')
869
+ })
870
+
871
+ // The obsidian false positive: a `fontSize` key in a plain data object, in a package with no
872
+ // React and no Mantine, where "route it through VX.text.*" is advice about another domain.
873
+ it('does NOT flag a fontSize key in a plain data object', () => {
874
+ const { code, rules } = run(
875
+ `export const iconizeData = { settings: { fontSize: 16 }, Inbox: 'LiInbox' }\n`,
876
+ 'data.ts',
877
+ )
878
+ expect(code).toBe(0)
879
+ expect(rules).not.toContain('no-raw-font-size')
880
+ })
881
+
882
+ it('does NOT run in a test file at all', () => {
883
+ const { code, rules } = run(
884
+ `export const C = () => <div style={{ fontSize: 11 }} />\n`,
885
+ 'nav-config.test.tsx',
886
+ )
887
+ expect(code).toBe(0)
888
+ expect(rules).not.toContain('no-raw-font-size')
889
+ })
890
+ })
891
+
892
+ // ── KNOWN_RULE_IDS — the hand-maintained list a typo'd theme-allow is measured against ──────────
893
+
894
+ describe('KNOWN_RULE_IDS', () => {
895
+ // Forgetting an entry is not cosmetic: an id this set does not know is treated as a typo, so the
896
+ // annotation naming it waives nothing. Before the fail-closed fix it was worse — an unknown id
897
+ // fell through to the empty-`rules` branch and became a BLANKET waiver, which is how a
898
+ // hand-maintained duplicate turned a missing entry into a hole in the guard.
899
+ it('contains every rule the plugin actually exports', () => {
900
+ for (const id of Object.keys(basaltPlugin.rules)) {
901
+ expect([id, KNOWN_RULE_IDS.has(id)]).toEqual([id, true])
902
+ }
903
+ })
904
+
905
+ it('contains every guard kind', () => {
906
+ for (const kind of Object.keys(GUARD_RULES)) {
907
+ expect([kind, KNOWN_RULE_IDS.has(kind)]).toEqual([kind, true])
908
+ }
909
+ })
910
+
911
+ it('contains nothing else — it is exactly the union of the two registries', () => {
912
+ const expected = [...Object.keys(basaltPlugin.rules), ...Object.keys(GUARD_RULES)].toSorted()
913
+ expect([...KNOWN_RULE_IDS].toSorted()).toEqual(expected)
914
+ })
915
+ })
916
+
917
+ // ── theme-allow fails closed on a typo ─────────────────────────────────────────────────────────
918
+
919
+ describe('theme-allow with an unrecognized rule id', () => {
920
+ it('waives nothing rather than escalating to a blanket suppression', () => {
921
+ // `no-raw-font-size` would be waived by a bare `theme-allow`. A typo'd id must not do that.
922
+ const { code, rules } = run(
923
+ `export const C = () => <Text fz={10} /> // theme-allow no-raw-font-sizee — legacy\n`,
924
+ )
925
+ expect(code).toBe(1)
926
+ expect(rules).toContain('no-raw-font-size')
927
+ })
928
+
929
+ it('a correctly spelled id still waives', () => {
930
+ const { code, rules } = run(
931
+ `export const C = () => <Text fz={10} /> // theme-allow no-raw-font-size — legacy\n`,
932
+ )
933
+ expect(rules).not.toContain('no-raw-font-size')
934
+ expect(code).toBe(0)
935
+ })
936
+
937
+ it('a bare theme-allow still waives everything', () => {
938
+ const { rules } = run(`export const C = () => <Text fz={10} /> // theme-allow\n`)
939
+ expect(rules).not.toContain('no-raw-font-size')
940
+ })
941
+
942
+ it('does not resolve an Object.prototype key as a rule id', () => {
943
+ const { code, rules } = run(
944
+ `export const C = () => <Text fz={10} /> // theme-allow constructor — inherited key\n`,
945
+ )
946
+ expect(code).toBe(1)
947
+ expect(rules).toContain('no-raw-font-size')
948
+ })
949
+
950
+ // The id slot closes at the first space no comma opened — mirrors `parseAllowAnnotation`.
951
+ it('reads an unknown word after a resolved id as the reason, not as a second id', () => {
952
+ const { code, rules } = run(
953
+ `export const C = () => <Text fz={10} /> // theme-allow no-raw-font-size sub-scale micro label\n`,
954
+ )
955
+ expect(rules).not.toContain('no-raw-font-size')
956
+ expect(code).toBe(0)
957
+ })
958
+ })
@@ -16,12 +16,21 @@
16
16
  "typescript/no-explicit-any": "warn",
17
17
  "import/no-duplicates": "error",
18
18
  "import/no-unassigned-import": "off",
19
+ "jsx-a11y/prefer-tag-over-role": "off",
20
+ "no-underscore-dangle": [
21
+ "warn",
22
+ {
23
+ "allow": ["__APP_VERSION__"]
24
+ }
25
+ ],
19
26
  "basalt/no-raw-font-size": "error",
20
- "basalt/raw-size-literal": "warn",
27
+ "basalt/raw-size-literal": "error",
21
28
  "basalt/card-inset": "error",
22
29
  "basalt/chart-in-raw-surface": "error",
23
30
  "basalt/hand-rolled-plot": "warn",
24
31
  "basalt/chart-legend-literal": "warn",
32
+ "basalt/shadow-basalt-export": "warn",
33
+ "basalt/hand-rolled-shell": "warn",
25
34
  "basalt/raw-scroll-container": "error",
26
35
  "basalt/visx-boundary": "error",
27
36
  "basalt/visx-tooltip": "error",
@@ -3,7 +3,7 @@ import { lazy, Suspense } from "react";
3
3
  import { VX } from "../tokens/index.js";
4
4
  function PlainContainerFallback({ children, className, style }) {
5
5
  const props = {
6
- // theme-allow — scroll anchoring needs a real scrollable node it owns, not a ScrollArea.
6
+ // theme-allow raw-scroll-container — scroll anchoring needs a real scrollable node it owns, not a ScrollArea.
7
7
  style: { overflowY: "auto", ...style }
8
8
  };
9
9
  if (className !== void 0) props.className = className;
@@ -67,7 +67,7 @@ function ThreadDetailPanel({
67
67
  {
68
68
  style: {
69
69
  height: "100%",
70
- // theme-allow — BasaltStickToBottom owns this scroll node (see stick-to-bottom.tsx).
70
+ // theme-allow raw-scroll-container — BasaltStickToBottom owns this scroll node (see stick-to-bottom.tsx).
71
71
  overflowY: "auto",
72
72
  padding: "var(--vx-space-agent-transcript-inset)"
73
73
  },
@@ -242,7 +242,7 @@ const DEFAULT_VIRTUALIZE_INITIAL_SCROLL = "end";
242
242
  const MAX_INITIAL_SCROLL_ATTEMPTS = 5;
243
243
  function NonVirtualizedRows({ rows, height }) {
244
244
  return (
245
- // theme-allow — degrade target owns its own scroll node, same as the real virtualizer below.
245
+ // theme-allow raw-scroll-container — degrade target owns its own scroll node, same as the real virtualizer below.
246
246
  /* @__PURE__ */ jsx(Box, { style: { height, overflow: "auto" }, children: /* @__PURE__ */ jsx(Stack, { gap: "sm", children: rows.map((row) => /* @__PURE__ */ jsx(Fragment, { children: row.node }, row.key)) }) })
247
247
  );
248
248
  }
@@ -331,7 +331,7 @@ const LazyVirtualizedRows = lazy(
331
331
  );
332
332
  });
333
333
  return (
334
- // theme-allow — TanStack Virtual measures/scrolls this element (never nest in BasaltStickToBottom, see ./virtualize).
334
+ // theme-allow raw-scroll-container — TanStack Virtual measures/scrolls this element (never nest in BasaltStickToBottom, see ./virtualize).
335
335
  /* @__PURE__ */ jsx(Box, { ref: parentRef, style: { height, overflow: "auto" }, children: /* @__PURE__ */ jsx(Box, { style: { height: virtualizer.getTotalSize(), position: "relative" }, children: virtualizer.getVirtualItems().map((virtualItem) => {
336
336
  const row = rows[virtualItem.index];
337
337
  if (row === void 0) return null;
@@ -73,7 +73,7 @@ function ToolChip({
73
73
  height: 6,
74
74
  flexShrink: 0,
75
75
  borderRadius: "50%",
76
- // theme-allow: a 6px state dot is a circle, not a surface corner — no radius token expresses it
76
+ // theme-allow raw-surface — a 6px state dot is a circle, not a surface corner
77
77
  backgroundColor: dotToken
78
78
  }
79
79
  }
@@ -1 +1 @@
1
- {"version":3,"file":"Donut.d.ts","sourceRoot":"","sources":["../../../src/charts/kinds/Donut.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAgB,SAAS,EAAE,MAAM,OAAO,CAAA;AAKpD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAE/C;;;;GAIG;AACH,MAAM,MAAM,UAAU,CAAC,CAAC,SAAS,MAAM,GAAG,SAAS,IAAI;IAAE,GAAG,EAAE,CAAC,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAA;AAEhF,MAAM,MAAM,UAAU,CAAC,CAAC,SAAS,MAAM,GAAG,SAAS,IAAI;IACrD,IAAI,EAAE,UAAU,CAAC,CAAC,CAAC,EAAE,CAAA;IACrB,mFAAmF;IACnF,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,WAAW,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,MAAM,CAAA;IAC/B,WAAW,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAA;IAClC,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAA;IACrC,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,aAAa,CAAC,EAAE,SAAS,CAAA;IACzB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,+FAA+F;IAC/F,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,4FAA4F;IAC5F,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB,CAAA;AAED;;;;;;;;GAQG;AACH,iBAAS,UAAU,CAAC,CAAC,SAAS,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,UAAU,CAAC,CAAC,CAAC,+BAoBrE;AA2JD;;;GAGG;AACH,eAAO,MAAM,KAAK,EAAuB,OAAO,UAAU,CAAA"}
1
+ {"version":3,"file":"Donut.d.ts","sourceRoot":"","sources":["../../../src/charts/kinds/Donut.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAgB,SAAS,EAAE,MAAM,OAAO,CAAA;AAKpD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAE/C;;;;GAIG;AACH,MAAM,MAAM,UAAU,CAAC,CAAC,SAAS,MAAM,GAAG,SAAS,IAAI;IAAE,GAAG,EAAE,CAAC,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAA;AAEhF,MAAM,MAAM,UAAU,CAAC,CAAC,SAAS,MAAM,GAAG,SAAS,IAAI;IACrD,IAAI,EAAE,UAAU,CAAC,CAAC,CAAC,EAAE,CAAA;IACrB,mFAAmF;IACnF,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,WAAW,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,MAAM,CAAA;IAC/B,WAAW,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAA;IAClC,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAA;IACrC,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,aAAa,CAAC,EAAE,SAAS,CAAA;IACzB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,+FAA+F;IAC/F,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,4FAA4F;IAC5F,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB,CAAA;AAED;;;;;;;;GAQG;AACH,iBAAS,UAAU,CAAC,CAAC,SAAS,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,UAAU,CAAC,CAAC,CAAC,+BAoBrE;AA4JD;;;GAGG;AACH,eAAO,MAAM,KAAK,EAAuB,OAAO,UAAU,CAAA"}
@@ -129,8 +129,8 @@ function DonutPlot(props) {
129
129
  style: {
130
130
  position: "absolute",
131
131
  inset: 0,
132
+ // Centering over the donut hole; charts/ is Mantine-free, so Center is unavailable here.
132
133
  display: "flex",
133
- // theme-allow: centering over the donut hole — charts/ is Mantine-free, so Center is unavailable here
134
134
  alignItems: "center",
135
135
  justifyContent: "center",
136
136
  pointerEvents: "none"
@@ -109,7 +109,7 @@ function LegendSwatch({ item, idPrefix }) {
109
109
  width: 11,
110
110
  height: 11,
111
111
  borderRadius: 3,
112
- // theme-allow: sub-scale legend-swatch corner, below the 4px radius floor
112
+ // theme-allow raw-surface — sub-scale legend-swatch corner, below the 4px radius floor
113
113
  backgroundColor: item.color,
114
114
  opacity: item.fillOpacity ?? 0.7,
115
115
  flexShrink: 0
@@ -152,7 +152,7 @@ function LegendChildSwatch({ item }) {
152
152
  width: 10,
153
153
  height: 10,
154
154
  borderRadius: 2,
155
- // theme-allow: sub-scale legend-swatch corner, below the 4px radius floor
155
+ // theme-allow raw-surface — sub-scale legend-swatch corner, below the 4px radius floor
156
156
  backgroundColor: item.color,
157
157
  opacity: item.fillOpacity ?? 0.7,
158
158
  flexShrink: 0
@@ -101,7 +101,7 @@ function TooltipRow({
101
101
  width: 8,
102
102
  height: 8,
103
103
  borderRadius: 2,
104
- // theme-allow: sub-scale tooltip micro-corner, below the 4px radius floor
104
+ // theme-allow raw-surface — sub-scale tooltip micro-corner, below the 4px radius floor
105
105
  backgroundColor: color,
106
106
  flexShrink: 0
107
107
  }