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.
- package/AGENTS.md +15 -1
- package/CHANGELOG.md +27 -0
- package/MIGRATING.md +228 -0
- package/README.md +53 -9
- package/agent/rules/basalt-app.md +43 -0
- package/agent/rules/basalt-charts.md +26 -14
- package/agent/rules/basalt-mantine.md +25 -10
- package/agent/rules/basalt-router.md +22 -10
- package/agent/rules/basalt-tokens.md +75 -6
- package/agent/skills/basalt-app/SKILL.md +42 -6
- package/configs/check.yml +11 -4
- package/configs/lefthook.yml +21 -2
- package/configs/oxlint-plugin.js +515 -25
- package/configs/oxlint-plugin.test.ts +383 -10
- package/configs/oxlint.json +10 -1
- package/dist/agent/stick-to-bottom.js +1 -1
- package/dist/agent-chat/thread-detail-panel.js +1 -1
- package/dist/agent-chat/thread-message.js +2 -2
- package/dist/agent-chat/tool-chip.js +1 -1
- package/dist/charts/kinds/Donut.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.js +1 -1
- package/dist/charts/primitives/ChartLegend.js +2 -2
- package/dist/charts/primitives/ChartTooltip.js +1 -1
- package/dist/cli/index.d.ts +107 -15
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +610 -72
- package/dist/cli/toolchain-wiring.test.d.ts +2 -0
- package/dist/cli/toolchain-wiring.test.d.ts.map +1 -0
- package/dist/dashboard/delta-badge.js +2 -2
- package/dist/dashboard/stat-card.js +1 -1
- package/dist/data/virtual-list.js +5 -5
- package/dist/guard/index.d.ts +56 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +326 -59
- package/dist/guard/types.d.ts +25 -5
- package/dist/guard/types.d.ts.map +1 -1
- package/dist/register.d.ts +25 -0
- package/dist/register.d.ts.map +1 -1
- package/dist/shell/app-mobile-nav.d.ts.map +1 -1
- package/dist/shell/app-mobile-nav.js +14 -14
- package/dist/shell/app-mobile-nav.module.css +44 -11
- package/dist/shell/app-mobile-nav.module.css.d.ts +1 -1
- package/dist/surfaces.d.ts +1 -1
- package/dist/surfaces.js +1 -1
- package/dist/tokens/index.d.ts +3 -3
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +2 -0
- package/dist/tokens/palette.d.ts +35 -27
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +24 -18
- package/dist/tokens.css +19 -17
- package/dist/vite-color-mix.js +2 -2
- package/llms.txt +3 -3
- package/package.json +4 -3
- package/src/agent/stick-to-bottom.tsx +1 -1
- package/src/agent-chat/thread-detail-panel.tsx +1 -1
- package/src/agent-chat/thread-message.tsx +3 -3
- package/src/agent-chat/tool-chip.tsx +1 -1
- package/src/charts/kinds/Donut.tsx +2 -1
- package/src/charts/primitives/ChartLegend.tsx +2 -2
- package/src/charts/primitives/ChartTooltip.tsx +1 -1
- package/src/cli/index.ts +1070 -99
- package/src/cli/init-sync.test.ts +19 -3
- package/src/cli/tokens-css.test.ts +27 -14
- package/src/cli/toolchain-wiring.test.ts +694 -0
- package/src/dashboard/delta-badge.tsx +3 -3
- package/src/dashboard/stat-card.tsx +1 -1
- package/src/data/virtual-list.tsx +8 -8
- package/src/guard/check-source.test.ts +663 -11
- package/src/guard/index.ts +727 -94
- package/src/guard/types.ts +29 -4
- package/src/register.ts +32 -0
- package/src/shell/app-mobile-nav.module.css +44 -11
- package/src/shell/app-mobile-nav.module.css.d.ts +1 -1
- package/src/shell/app-mobile-nav.test.tsx +34 -0
- package/src/shell/app-mobile-nav.tsx +16 -5
- package/src/surfaces.ts +1 -1
- package/src/theme/density-relations.test.ts +8 -5
- package/src/theme/spacing.test.ts +2 -0
- package/src/theme-toggle/index.tsx +2 -2
- package/src/tokens/index.ts +5 -3
- package/src/tokens/palette.ts +24 -18
- 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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
+
})
|
package/configs/oxlint.json
CHANGED
|
@@ -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": "
|
|
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
|
|
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;
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|