basalt-ui 1.7.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.
- package/CHANGELOG.md +24 -0
- package/agent/rules/basalt-charts.md +74 -5
- package/agent/skills/basalt-design/SKILL.md +5 -2
- package/dist/charts/hooks/useHoverSync.d.ts +18 -2
- package/dist/charts/hooks/useHoverSync.d.ts.map +1 -1
- package/dist/charts/hooks/useHoverSync.js +12 -7
- package/dist/charts/index.d.ts +1 -0
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/index.js +6 -0
- package/dist/charts/kinds/Bars.d.ts +2 -0
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.js +3 -1
- package/dist/charts/kinds/Donut.d.ts +2 -0
- package/dist/charts/kinds/Donut.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.js +8 -4
- package/dist/charts/kinds/DualPanel.d.ts +2 -0
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.js +2 -1
- package/dist/charts/kinds/Heatmap.d.ts +8 -0
- package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
- package/dist/charts/kinds/Heatmap.js +7 -3
- package/dist/charts/kinds/MultiLine.d.ts +2 -0
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.js +2 -1
- package/dist/charts/kinds/StackedArea.d.ts +2 -0
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.js +2 -1
- package/dist/charts/kinds/ZonedLine.d.ts +2 -0
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.js +2 -1
- package/dist/charts/primitives/Axes.d.ts +6 -1
- package/dist/charts/primitives/Axes.d.ts.map +1 -1
- package/dist/charts/primitives/Axes.js +3 -2
- package/dist/charts/primitives/ChartFrame.d.ts +8 -1
- package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.js +8 -4
- package/dist/charts/primitives/ChartPending.d.ts +34 -0
- package/dist/charts/primitives/ChartPending.d.ts.map +1 -0
- package/dist/charts/primitives/ChartPending.js +23 -0
- package/dist/charts/primitives/ChartTooltip.d.ts +5 -2
- package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
- package/dist/charts/primitives/ChartTooltip.js +6 -1
- package/dist/charts/primitives/HoverOverlay.d.ts +5 -4
- package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
- package/dist/charts/primitives/HoverOverlay.js +4 -2
- package/dist/cli/index.d.ts +22 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +86 -5
- package/dist/dashboard/stat-card.d.ts +4 -2
- package/dist/dashboard/stat-card.d.ts.map +1 -1
- package/dist/dashboard/stat-card.js +6 -1
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +13 -5
- package/llms.txt +2 -2
- package/package.json +3 -5
- package/src/charts/hooks/useHoverSync.ts +43 -7
- package/src/charts/index.ts +6 -0
- package/src/charts/kinds/Bars.tsx +4 -0
- package/src/charts/kinds/Donut.tsx +10 -4
- package/src/charts/kinds/DualPanel.tsx +5 -1
- package/src/charts/kinds/Heatmap.tsx +19 -2
- package/src/charts/kinds/MultiLine.tsx +4 -1
- package/src/charts/kinds/StackedArea.tsx +4 -1
- package/src/charts/kinds/ZonedLine.tsx +4 -1
- package/src/charts/primitives/Axes.tsx +7 -1
- package/src/charts/primitives/ChartFrame.test.tsx +64 -0
- package/src/charts/primitives/ChartFrame.tsx +17 -4
- package/src/charts/primitives/ChartPending.tsx +59 -0
- package/src/charts/primitives/ChartTooltip.tsx +15 -3
- package/src/charts/primitives/HoverOverlay.tsx +12 -6
- package/src/cli/index.ts +154 -5
- package/src/cli/placement-engine.test.ts +104 -0
- package/src/dashboard/stat-card.test.tsx +81 -0
- package/src/dashboard/stat-card.tsx +30 -4
- package/src/guard/check-source.test.ts +22 -1
- package/src/guard/index.ts +21 -7
- package/src/theme/contrast.test.ts +18 -1
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
|
-
|
|
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
|
|
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
|
|
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
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `StatCard`'s threshold rail — the one behavior in this component that carries information rather
|
|
3
|
+
* than layout, and the one where a regression is silent: a wrong rail colour still renders a
|
|
4
|
+
* perfectly good-looking card.
|
|
5
|
+
*
|
|
6
|
+
* Two invariants, both asserted against real rendered markup:
|
|
7
|
+
*
|
|
8
|
+
* 1. EVERY tone states its verdict in TEXT. The rail is `aria-hidden` decoration; colour alone must
|
|
9
|
+
* never be the only carrier of a threshold (WCAG 1.4.1), so a `VisuallyHidden` string ships with
|
|
10
|
+
* it — for `good` no less than for `bad`.
|
|
11
|
+
* 2. OMISSION IS NOT `good`. `tone` undefined means "fine, OR nothing measured", and those two are
|
|
12
|
+
* the same untinted card on purpose. A card with no reading must not be able to render green by
|
|
13
|
+
* leaving the prop off, so the absence case asserts no rail and no verdict text at all — which a
|
|
14
|
+
* future `tone = tone ?? 'good'` style default would immediately fail.
|
|
15
|
+
*
|
|
16
|
+
* No jsdom is configured in this repo (see `theme/use-basalt-spacing.test.tsx`'s doc) —
|
|
17
|
+
* `renderToStaticMarkup` inside a real `MantineProvider` is enough here, since the rail is an inline
|
|
18
|
+
* style on server-rendered markup.
|
|
19
|
+
*/
|
|
20
|
+
import { MantineProvider } from '@mantine/core'
|
|
21
|
+
import { describe, expect, test } from 'bun:test'
|
|
22
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
23
|
+
import { StatCard } from './stat-card'
|
|
24
|
+
import type { StatCardTone } from './stat-card'
|
|
25
|
+
|
|
26
|
+
function render(tone?: StatCardTone) {
|
|
27
|
+
return renderToStaticMarkup(
|
|
28
|
+
<MantineProvider>
|
|
29
|
+
<StatCard label="Downtime · last 24h" value="0 min" tone={tone} />
|
|
30
|
+
</MantineProvider>,
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const TONES: { tone: StatCardTone; token: string; verdict: string }[] = [
|
|
35
|
+
{ tone: 'good', token: 'var(--vx-status-good)', verdict: 'Within the good threshold' },
|
|
36
|
+
{ tone: 'warn', token: 'var(--vx-status-warn)', verdict: 'Past the warning threshold' },
|
|
37
|
+
{ tone: 'bad', token: 'var(--vx-status-bad)', verdict: 'Past the severe threshold' },
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
describe('every tone draws its rail from the per-scheme status token', () => {
|
|
41
|
+
for (const { tone, token } of TONES) {
|
|
42
|
+
test(tone, () => {
|
|
43
|
+
const markup = render(tone)
|
|
44
|
+
// A hex here would be one scheme's colour frozen into both — the rail has to re-resolve with
|
|
45
|
+
// the palette, including under a consumer's `derive` retune.
|
|
46
|
+
expect(markup).toContain(`background:${token}`)
|
|
47
|
+
expect(markup).toContain(`data-tone="${tone}"`)
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('every tone also states its verdict in text', () => {
|
|
53
|
+
for (const { tone, verdict } of TONES) {
|
|
54
|
+
test(tone, () => {
|
|
55
|
+
expect(render(tone)).toContain(verdict)
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('omitting tone is not "good" — it is untinted, and says nothing', () => {
|
|
61
|
+
const markup = render()
|
|
62
|
+
|
|
63
|
+
test('no rail is drawn', () => {
|
|
64
|
+
expect(markup).not.toContain('var(--vx-status-')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('the card carries no data-tone at all', () => {
|
|
68
|
+
// Part of the same contract, and the half a consumer can style against: `[data-tone]` must not
|
|
69
|
+
// match an unmarked card. A default of any kind — including a placeholder like `tone ?? 'none'`
|
|
70
|
+
// that draws no rail — would put the attribute back and break that selector silently.
|
|
71
|
+
expect(markup).not.toContain('data-tone')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('no verdict is announced', () => {
|
|
75
|
+
for (const { verdict } of TONES) expect(markup).not.toContain(verdict)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('the value still renders — the card is normal, just unmarked', () => {
|
|
79
|
+
expect(markup).toContain('0 min')
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -15,7 +15,10 @@
|
|
|
15
15
|
* the guard has no way to recognize as one. The rail is the largest signal available without
|
|
16
16
|
* touching the value's own type: full card height, overlaying the card's edge rather than adding to
|
|
17
17
|
* it, so layout is unchanged. `undefined` renders nothing at all — absence of a reading is neither a
|
|
18
|
-
* good one nor a bad one, and must never be tinted.
|
|
18
|
+
* good one nor a bad one, and must never be tinted. `'good'` is a deliberate positive verdict, never
|
|
19
|
+
* the meaning of omission: it exists for the threshold where the calm number IS the finding (zero
|
|
20
|
+
* downtime over the window), and a card that has measured nothing must not be able to reach green by
|
|
21
|
+
* leaving the prop off.
|
|
19
22
|
*
|
|
20
23
|
* @example
|
|
21
24
|
* import { StatCard } from 'basalt-ui'
|
|
@@ -31,6 +34,22 @@
|
|
|
31
34
|
* @example
|
|
32
35
|
* // Past a threshold — an accent rail down the leading edge, value untouched.
|
|
33
36
|
* <StatCard label="Downtime" value="29 min" tone="bad" />
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* // The earned zero — a measured value worth asserting, not a card that merely has no reading.
|
|
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
|
+
* />
|
|
34
53
|
*/
|
|
35
54
|
import { Box, Card, Group, VisuallyHidden } from '@mantine/core'
|
|
36
55
|
import type { ReactNode } from 'react'
|
|
@@ -38,13 +57,14 @@ import { DeltaBadge } from './delta-badge'
|
|
|
38
57
|
import { VX } from '../tokens'
|
|
39
58
|
|
|
40
59
|
/** A threshold verdict on the card's value. `undefined` is never tinted — see the module docblock. */
|
|
41
|
-
export type StatCardTone = 'warn' | 'bad'
|
|
60
|
+
export type StatCardTone = 'good' | 'warn' | 'bad'
|
|
42
61
|
|
|
43
62
|
/** The rail's width, in px. Wide enough to read as a deliberate mark at a glance, narrow enough
|
|
44
63
|
* that it cannot be mistaken for the card's own edge. */
|
|
45
64
|
const TONE_RAIL_WIDTH = 3
|
|
46
65
|
|
|
47
66
|
const TONE_LABEL: Record<StatCardTone, string> = {
|
|
67
|
+
good: 'Within the good threshold',
|
|
48
68
|
warn: 'Past the warning threshold',
|
|
49
69
|
bad: 'Past the severe threshold',
|
|
50
70
|
}
|
|
@@ -64,7 +84,9 @@ export type StatCardProps = {
|
|
|
64
84
|
/** Optional header-right slot (e.g. a ghost "..." menu trigger) — consumer-owned. */
|
|
65
85
|
menu?: ReactNode
|
|
66
86
|
/** Threshold verdict — draws an accent rail down the card's leading edge and announces itself to
|
|
67
|
-
* assistive tech.
|
|
87
|
+
* assistive tech. Omitting it is NOT `'good'`: omitted covers a reading that is fine AND one that
|
|
88
|
+
* is absent, and stays untinted so a card with nothing measured can never render green. Pass
|
|
89
|
+
* `'good'` only to assert a measured value that has earned the verdict. */
|
|
68
90
|
tone?: StatCardTone
|
|
69
91
|
}
|
|
70
92
|
|
|
@@ -111,7 +133,11 @@ export function StatCard({
|
|
|
111
133
|
insetBlock: 0,
|
|
112
134
|
insetInlineStart: 0,
|
|
113
135
|
width: TONE_RAIL_WIDTH,
|
|
114
|
-
|
|
136
|
+
// `StatCardTone`'s members are `VX.status` keys by construction, so the rail reads the
|
|
137
|
+
// per-scheme `--vx-status-*` solid directly — never a hex, so it re-resolves in both
|
|
138
|
+
// schemes and under a consumer's `derive` retune. Contrast against
|
|
139
|
+
// `--vx-surface-panel` is measured in `theme/contrast.test.ts`.
|
|
140
|
+
background: VX.status[tone],
|
|
115
141
|
}}
|
|
116
142
|
/>
|
|
117
143
|
</>
|
|
@@ -209,7 +209,8 @@ describe('mantine-shade-index', () => {
|
|
|
209
209
|
})
|
|
210
210
|
|
|
211
211
|
// The grace-minor doctrine (package CLAUDE.md): a kind that rejects previously-passing code
|
|
212
|
-
// lands as `warn` for one minor. Deleting its GRACE_PERIOD_KINDS entry
|
|
212
|
+
// lands as `warn` for one minor. Deleting its GRACE_PERIOD_KINDS entry flips this — scheduled
|
|
213
|
+
// for 1.9.0, deferred one minor because 1.8.0 shipped the same day as 1.7.0 (see the entry).
|
|
213
214
|
it('lands as a warning, not an error, for its grace minor', () => {
|
|
214
215
|
const f = find(`<Text c="yellow.7" />`)
|
|
215
216
|
expect(f.find((x) => x.kind === 'mantine-shade-index')?.severity).toBe('warn')
|
|
@@ -408,6 +409,16 @@ describe('raw-html-layout', () => {
|
|
|
408
409
|
const f = find(`<div style={{ display: 'flex' }} /> // theme-allow`)
|
|
409
410
|
expect(kinds(f)).not.toContain('raw-html-layout')
|
|
410
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
|
+
})
|
|
411
422
|
})
|
|
412
423
|
|
|
413
424
|
// ── 10. inline-spacing ───────────────────────────────────────────────────────
|
|
@@ -526,6 +537,16 @@ describe('inline-display', () => {
|
|
|
526
537
|
const f = find(`<Box style={{ display: 'flex' }} /> // theme-allow`)
|
|
527
538
|
expect(kinds(f)).not.toContain('inline-display')
|
|
528
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
|
+
})
|
|
529
550
|
})
|
|
530
551
|
|
|
531
552
|
// ── 12. raw-visx-axis ────────────────────────────────────────────────────────
|
package/src/guard/index.ts
CHANGED
|
@@ -282,8 +282,14 @@ 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.
|
|
286
|
-
*
|
|
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
|
|
292
|
+
* the entry (its own commit).
|
|
287
293
|
*
|
|
288
294
|
* @example
|
|
289
295
|
* const GRACE: Partial<Record<GuardKind, string>> = {
|
|
@@ -291,7 +297,7 @@ export const DEFAULT_GUARD_CONFIG: GuardConfig = {
|
|
|
291
297
|
* }
|
|
292
298
|
*/
|
|
293
299
|
const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, string>> = {
|
|
294
|
-
'mantine-shade-index': 'introduced 1.7.0 — promote to error in 1.
|
|
300
|
+
'mantine-shade-index': 'introduced 1.7.0 — promote to error in 1.10.0',
|
|
295
301
|
}
|
|
296
302
|
|
|
297
303
|
/** A kind's effective severity: consumer override first, then the grace table, then `error`. */
|
|
@@ -685,8 +691,12 @@ export const GUARD_RULES = {
|
|
|
685
691
|
kind: 'raw-html-layout',
|
|
686
692
|
pattern: RAW_HTML_TAG, // handled inline (3-condition conjunction); entry keeps registry complete
|
|
687
693
|
enabled: (cfg: GuardConfig) => cfg.rawHtmlLayout,
|
|
688
|
-
// JSX-tag-shaped (`<div style={{...}}>`) — never appears in CSS text.
|
|
689
|
-
|
|
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'),
|
|
690
700
|
message:
|
|
691
701
|
'Raw HTML element with inline layout/surface styling — use a Mantine layout primitive (Box/Flex/Grid/Stack/Group).',
|
|
692
702
|
},
|
|
@@ -701,8 +711,12 @@ export const GUARD_RULES = {
|
|
|
701
711
|
pattern: INLINE_DISPLAY,
|
|
702
712
|
enabled: (cfg: GuardConfig) => cfg.inlineDisplay,
|
|
703
713
|
// JSX-object-shaped (`display: 'flex'`, quoted value) — CSS never quotes a keyword value
|
|
704
|
-
// (`display: flex;` is bare), so the pattern's quote anchor can never match real CSS.
|
|
705
|
-
|
|
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'),
|
|
706
720
|
message: 'Use <Flex>/<Grid>/<Group> instead of an inline display:flex/grid.',
|
|
707
721
|
},
|
|
708
722
|
'raw-visx-axis': {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
import { DEFAULT_THEME, getPrimaryShade, mergeMantineTheme } from '@mantine/core'
|
|
25
25
|
import type { MantineTheme } from '@mantine/core'
|
|
26
26
|
import { describe, expect, test } from 'bun:test'
|
|
27
|
-
import { ACCENT, FILL, SURFACE } from '../tokens/palette'
|
|
27
|
+
import { ACCENT, FILL, STATUS, SURFACE } from '../tokens/palette'
|
|
28
28
|
import { baseTheme, cssVariablesResolver } from './index'
|
|
29
29
|
|
|
30
30
|
const theme: MantineTheme = mergeMantineTheme(DEFAULT_THEME, baseTheme)
|
|
@@ -324,6 +324,23 @@ describe('doctrine inversion #1 reaches every default-variant CONTROL, not just
|
|
|
324
324
|
})
|
|
325
325
|
})
|
|
326
326
|
|
|
327
|
+
describe("the StatCard tone rail reads against the panel it's drawn on", () => {
|
|
328
|
+
// The rail (`dashboard/stat-card.tsx`) is a 3px non-text mark carrying a threshold verdict, so it
|
|
329
|
+
// takes WCAG's 3:1 non-text floor against `--vx-surface-panel` — the card background it overlays,
|
|
330
|
+
// not the page — in BOTH schemes. Worth measuring rather than eyeballing for the same reason the
|
|
331
|
+
// fill band is: the status solids are DERIVED, so retuning the generator could sink one of them
|
|
332
|
+
// into the panel it sits on without anything else failing.
|
|
333
|
+
const TONES = ['good', 'warn', 'bad'] as const
|
|
334
|
+
|
|
335
|
+
for (const scheme of SCHEMES) {
|
|
336
|
+
for (const tone of TONES) {
|
|
337
|
+
test(`${tone} / ${scheme}`, () => {
|
|
338
|
+
expect(contrastRatio(STATUS[tone][scheme], SURFACE.panel[scheme])).toBeGreaterThanOrEqual(3)
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
})
|
|
343
|
+
|
|
327
344
|
describe('the shipped palette is the generator output at DEFAULT_DERIVE_CONFIG', () => {
|
|
328
345
|
// A handful of exact hexes from `deriveTokens(DEFAULT_DERIVE_CONFIG)` (`tokens/derive.ts`) — the
|
|
329
346
|
// shipped default identity (seed #0077bd, zinc, all four knobs at level 0). Drift here means
|