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
@@ -0,0 +1,694 @@
1
+ /**
2
+ * Round-4 regression suite: the toolchain reported green while enforcing nothing.
3
+ *
4
+ * Five consumers hit one bug in five shapes — `doctor` counting checks it could not run as passes,
5
+ * `init` scaffolding a guard that scans zero files, seeded configs whose `extends` resolved to
6
+ * nothing, and `tokens:css` output basalt's own guard rejected. Every test below pins one of those
7
+ * false-greens shut. The unit under test is always "does the tool TELL you", not "does it work".
8
+ */
9
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
10
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
11
+ import { tmpdir } from 'node:os'
12
+ import { join, resolve } from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
14
+
15
+ import { buildPaletteCss } from '../tokens'
16
+ import {
17
+ checkTheme,
18
+ doctor,
19
+ fontsCss,
20
+ init,
21
+ MANIFEST_PATH,
22
+ normalizeColorFunctions,
23
+ tokensCss,
24
+ } from './index.ts'
25
+
26
+ const PKG_ROOT = fileURLToPath(new URL('../../', import.meta.url))
27
+ const CLI_VERSION = (
28
+ JSON.parse(readFileSync(resolve(PKG_ROOT, 'package.json'), 'utf8')) as { version: string }
29
+ ).version
30
+
31
+ let dir: string
32
+
33
+ function write(relPath: string, content: string): void {
34
+ const abs = join(dir, relPath)
35
+ mkdirSync(resolve(abs, '..'), { recursive: true })
36
+ writeFileSync(abs, content, 'utf8')
37
+ }
38
+
39
+ function read(relPath: string): string {
40
+ return readFileSync(join(dir, relPath), 'utf8')
41
+ }
42
+
43
+ function readPkg(relPath = 'package.json'): Record<string, unknown> {
44
+ return JSON.parse(read(relPath)) as Record<string, unknown>
45
+ }
46
+
47
+ /** Captures both streams — doctor/init/check-theme pick one or the other by outcome. */
48
+ function capture(fn: () => number): { code: number; log: string } {
49
+ const originalLog = console.log
50
+ const originalError = console.error
51
+ let log = ''
52
+ const sink = (...args: unknown[]) => {
53
+ log += `${args.join(' ')}\n`
54
+ }
55
+ console.log = sink
56
+ console.error = sink
57
+ try {
58
+ return { code: fn(), log }
59
+ } finally {
60
+ console.log = originalLog
61
+ console.error = originalError
62
+ }
63
+ }
64
+
65
+ /** The install `doctor` should find — same version as the running CLI, so version checks pass. */
66
+ function installBasalt(at = ''): void {
67
+ write(
68
+ join(at, 'node_modules/basalt-ui/package.json'),
69
+ JSON.stringify({ name: 'basalt-ui', version: CLI_VERSION }),
70
+ )
71
+ }
72
+
73
+ /** A repo where every framework-profile doctor check passes. */
74
+ function healthyFixture(): void {
75
+ write('package.json', JSON.stringify({ name: 'fixture', basalt: { roots: ['src'] } }))
76
+ write('src/app.tsx', 'export const App = () => null\n')
77
+ write('.oxlintrc.json', '{ "extends": ["./node_modules/basalt-ui/configs/oxlint.json"] }')
78
+ write(MANIFEST_PATH, JSON.stringify({ version: 1, files: {}, basaltVersion: CLI_VERSION }))
79
+ installBasalt()
80
+ }
81
+
82
+ beforeEach(() => {
83
+ dir = mkdtempSync(resolve(tmpdir(), 'basalt-wiring-'))
84
+ })
85
+
86
+ afterEach(() => {
87
+ rmSync(dir, { recursive: true, force: true })
88
+ })
89
+
90
+ describe('doctor — a check that cannot run is not a check that passed', () => {
91
+ it('is green on a fully-wired repo', () => {
92
+ healthyFixture()
93
+ const { code, log } = capture(() => doctor(dir))
94
+ expect(log).toContain('All checks passed.')
95
+ expect(code).toBe(0)
96
+ })
97
+
98
+ it('reports SKIPPED and exits non-zero when basalt-ui does not resolve, instead of dropping the checks', () => {
99
+ healthyFixture()
100
+ rmSync(join(dir, 'node_modules'), { recursive: true, force: true })
101
+ const { code, log } = capture(() => doctor(dir))
102
+ expect(log).toContain('SKIPPED')
103
+ expect(log).toContain('does not resolve')
104
+ expect(log).not.toContain('All checks passed.')
105
+ expect(code).toBe(1)
106
+ })
107
+
108
+ it('finds an install that lives beside a workspace package rather than at the root', () => {
109
+ healthyFixture()
110
+ rmSync(join(dir, 'node_modules'), { recursive: true, force: true })
111
+ write(
112
+ 'package.json',
113
+ JSON.stringify({ name: 'root', workspaces: ['packages/*'], basalt: { roots: ['src'] } }),
114
+ )
115
+ write('packages/lib/package.json', JSON.stringify({ name: 'lib' }))
116
+ installBasalt('packages/lib')
117
+ const { log } = capture(() => doctor(dir))
118
+ expect(log).toContain('packages/lib/node_modules/basalt-ui')
119
+ expect(log).not.toContain('SKIPPED')
120
+ })
121
+
122
+ it('hard-fails when check-theme would scan zero files — the two commands may not disagree', () => {
123
+ healthyFixture()
124
+ rmSync(join(dir, 'src'), { recursive: true, force: true })
125
+ const { code, log } = capture(() => doctor(dir))
126
+ expect(log).toContain('guard-scan: check-theme would scan 0 files')
127
+ expect(code).toBe(1)
128
+ })
129
+
130
+ it('hard-fails when .oxlintrc.json does not extend the shipped preset', () => {
131
+ healthyFixture()
132
+ write('.oxlintrc.json', '{ "rules": {} }')
133
+ const { code, log } = capture(() => doctor(dir))
134
+ expect(log).toContain('does NOT extend the shipped preset')
135
+ expect(log).toContain('--merge-lint')
136
+ expect(code).toBe(1)
137
+ })
138
+
139
+ it('hard-fails when .oxlintrc.json is missing entirely', () => {
140
+ healthyFixture()
141
+ rmSync(join(dir, '.oxlintrc.json'), { force: true })
142
+ const { code, log } = capture(() => doctor(dir))
143
+ expect(log).toContain('.oxlintrc.json missing')
144
+ expect(code).toBe(1)
145
+ })
146
+
147
+ it('relocates to the single workspace package carrying the basalt config', () => {
148
+ write('package.json', JSON.stringify({ name: 'root', workspaces: ['apps/*'] }))
149
+ write('apps/web/package.json', JSON.stringify({ name: 'web', basalt: { roots: ['src'] } }))
150
+ write('apps/web/src/app.tsx', 'export const App = () => null\n')
151
+ write(
152
+ 'apps/web/.oxlintrc.json',
153
+ '{ "extends": ["./node_modules/basalt-ui/configs/oxlint.json"] }',
154
+ )
155
+ write(
156
+ join('apps/web', MANIFEST_PATH),
157
+ JSON.stringify({ version: 1, files: {}, basaltVersion: CLI_VERSION }),
158
+ )
159
+ installBasalt('apps/web')
160
+ const { code, log } = capture(() => doctor(dir))
161
+ expect(log).toContain('reporting on ./apps/web')
162
+ expect(code).toBe(0)
163
+ })
164
+ })
165
+
166
+ describe('doctor — tokens-only profile', () => {
167
+ function tokensOnlyFixture(): void {
168
+ write('package.json', JSON.stringify({ name: 'static-site' }))
169
+ installBasalt()
170
+ }
171
+
172
+ it('does not tell a Mantine-free consumer to run init, and passes on a matching version', () => {
173
+ tokensOnlyFixture()
174
+ const { code, log } = capture(() => doctor(dir))
175
+ expect(log).toContain('profile: tokens-only')
176
+ expect(log).not.toContain('run `basalt-ui init` to scaffold')
177
+ expect(log).toContain('matches the installed basalt-ui')
178
+ expect(code).toBe(0)
179
+ })
180
+
181
+ it('still reaches the CLI-vs-installed version check that the manifest failure used to shadow', () => {
182
+ write('package.json', JSON.stringify({ name: 'static-site' }))
183
+ write(
184
+ 'node_modules/basalt-ui/package.json',
185
+ JSON.stringify({ name: 'basalt-ui', version: '0.4.2' }),
186
+ )
187
+ const { code, log } = capture(() => doctor(dir))
188
+ expect(log).toContain('0.4.2')
189
+ expect(code).toBe(0) // a stale bunx fetch is a warning, not a hard failure
190
+ })
191
+
192
+ it('--framework forces the full profile on the same repo', () => {
193
+ tokensOnlyFixture()
194
+ const { code, log } = capture(() => doctor(dir, ['--framework']))
195
+ expect(log).toContain(`${MANIFEST_PATH} missing`)
196
+ expect(code).toBe(1)
197
+ })
198
+
199
+ it('--tokens-only forces the token profile on a Mantine repo', () => {
200
+ healthyFixture()
201
+ const { log } = capture(() => doctor(dir, ['--tokens-only']))
202
+ expect(log).toContain('profile: tokens-only')
203
+ expect(log).toContain('n/a')
204
+ })
205
+ })
206
+
207
+ describe('init — describing the repo it is actually in', () => {
208
+ it('writes basalt.roots from the bun workspaces layout', () => {
209
+ write('package.json', JSON.stringify({ name: 'root', workspaces: ['apps/*', 'packages/*'] }))
210
+ write(
211
+ 'apps/web/package.json',
212
+ JSON.stringify({ name: 'web', dependencies: { 'basalt-ui': '^1' } }),
213
+ )
214
+ write('apps/web/src/app.tsx', 'export const App = () => null\n')
215
+ write('packages/api/package.json', JSON.stringify({ name: 'api' }))
216
+ write('packages/api/src/server.ts', 'export const serve = () => null\n')
217
+
218
+ const { log } = capture(() => init(dir))
219
+ // Only the basalt-consuming package is a design-guard root — a backend `src/` is not.
220
+ expect((readPkg()['basalt'] as { roots: string[] }).roots).toEqual(['apps/web/src'])
221
+ expect(log).toContain('"roots"')
222
+ expect(read('.github/workflows/check.yml')).toContain("'apps/web/src/**'")
223
+ })
224
+
225
+ it("writes basalt.roots ['src'] for a single-package repo", () => {
226
+ write('package.json', JSON.stringify({ name: 'app' }))
227
+ write('src/app.tsx', 'export const App = () => null\n')
228
+ init(dir)
229
+ expect((readPkg()['basalt'] as { roots: string[] }).roots).toEqual(['src'])
230
+ })
231
+
232
+ it('says so loudly when it cannot infer roots, instead of seeding one that matches nothing', () => {
233
+ write('package.json', JSON.stringify({ name: 'app' }))
234
+ const { log } = capture(() => init(dir))
235
+ expect(log).toContain('could not infer "basalt.roots"')
236
+ expect(readPkg()['basalt']).toBeUndefined()
237
+ })
238
+
239
+ it('never overwrites a roots the consumer already set', () => {
240
+ write('package.json', JSON.stringify({ name: 'app', basalt: { roots: ['custom'] } }))
241
+ write('src/app.tsx', 'export const App = () => null\n')
242
+ init(dir)
243
+ expect((readPkg()['basalt'] as { roots: string[] }).roots).toEqual(['custom'])
244
+ })
245
+
246
+ it('adds a lint:basalt script wiring oxlint and the guard together', () => {
247
+ write('package.json', JSON.stringify({ name: 'app' }))
248
+ write('src/app.tsx', 'export const App = () => null\n')
249
+ init(dir)
250
+ const scripts = readPkg()['scripts'] as Record<string, string>
251
+ expect(scripts['lint:basalt']).toContain('oxlint .')
252
+ expect(scripts['lint:basalt']).toContain('check-theme')
253
+ })
254
+
255
+ it('leaves an existing lint:basalt script alone', () => {
256
+ write('package.json', JSON.stringify({ name: 'app', scripts: { 'lint:basalt': 'mine' } }))
257
+ init(dir)
258
+ expect((readPkg()['scripts'] as Record<string, string>)['lint:basalt']).toBe('mine')
259
+ })
260
+
261
+ it('names every kept file AND what keeping it costs', () => {
262
+ write('package.json', JSON.stringify({ name: 'app' }))
263
+ write('src/app.tsx', 'export const App = () => null\n')
264
+ write('.oxlintrc.json', '{ "rules": {} }')
265
+ const { log } = capture(() => init(dir))
266
+ expect(log).toContain('Kept (already present')
267
+ expect(log).toContain('.oxlintrc.json')
268
+ expect(log).toContain('is NOT active')
269
+ // The aggregate count stays, but it is no longer the only thing said.
270
+ expect(log).toMatch(/\d+ written, \d+ kept/)
271
+ })
272
+
273
+ it('warns that adoption on an existing app is a lint-debt event, with the plugin count', () => {
274
+ write('package.json', JSON.stringify({ name: 'app' }))
275
+ const { log } = capture(() => init(dir))
276
+ expect(log).toContain('Lint debt')
277
+ expect(log).toContain('jsx-a11y')
278
+ expect(log).toContain('lefthook install')
279
+ })
280
+
281
+ it('--merge-lint splices the shipped preset into an .oxlintrc.json init would otherwise keep', () => {
282
+ write('package.json', JSON.stringify({ name: 'app' }))
283
+ write('.oxlintrc.json', JSON.stringify({ rules: { 'no-debugger': 'error' } }, null, 2))
284
+ const { log } = capture(() => init(dir, { mergeLint: true }))
285
+ const cfg = JSON.parse(read('.oxlintrc.json')) as { extends: string[]; rules: unknown }
286
+ expect(cfg.extends[0]).toContain('basalt-ui/configs/oxlint.json')
287
+ expect(cfg.rules).toEqual({ 'no-debugger': 'error' })
288
+ expect(log).toContain('--merge-lint')
289
+ })
290
+
291
+ it('--merge-lint is idempotent', () => {
292
+ write('package.json', JSON.stringify({ name: 'app' }))
293
+ write('.oxlintrc.json', '{ "extends": ["./node_modules/basalt-ui/configs/oxlint.json"] }')
294
+ const { log } = capture(() => init(dir, { mergeLint: true }))
295
+ expect(log).toContain('already extends')
296
+ })
297
+ })
298
+
299
+ describe('init — seeds resolve basalt where it actually installed', () => {
300
+ it('renders the extends paths and the CI bin from a workspace-level install, not the root', () => {
301
+ write('package.json', JSON.stringify({ name: 'root', workspaces: ['packages/*'] }))
302
+ write('packages/lib/package.json', JSON.stringify({ name: 'lib' }))
303
+ write('packages/lib/src/index.ts', 'export const x = 1\n')
304
+ installBasalt('packages/lib')
305
+
306
+ capture(() => init(dir))
307
+
308
+ expect(read('.oxlintrc.json')).toContain(
309
+ './packages/lib/node_modules/basalt-ui/configs/oxlint.json',
310
+ )
311
+ expect(read('lefthook.yml')).toContain(
312
+ 'packages/lib/node_modules/basalt-ui/configs/lefthook.yml',
313
+ )
314
+ // The CI step runs the LOCAL bin — bunx would silently fetch a different copy from npm.
315
+ expect(read('.github/workflows/check.yml')).toContain(
316
+ 'packages/lib/node_modules/.bin/basalt-ui check-theme',
317
+ )
318
+ expect(read('.github/workflows/check.yml')).not.toContain('{{BASALT_BIN}}')
319
+ })
320
+
321
+ it('warns loudly when basalt-ui resolves nowhere, rather than seeding a silent dead path', () => {
322
+ write('package.json', JSON.stringify({ name: 'app' }))
323
+ write('src/app.tsx', 'export const App = () => null\n')
324
+ const { log } = capture(() => init(dir))
325
+ expect(log).toContain('does not resolve from')
326
+ })
327
+ })
328
+
329
+ describe('check-theme — finds the config a root-invoked hook cannot see', () => {
330
+ it('relocates to the one workspace package carrying a basalt config', () => {
331
+ write('package.json', JSON.stringify({ name: 'root', workspaces: ['apps/*'] }))
332
+ write('apps/web/package.json', JSON.stringify({ name: 'web', basalt: { roots: ['src'] } }))
333
+ write('apps/web/src/app.tsx', 'export const App = () => null\n')
334
+ const { code, log } = capture(() => checkTheme(dir))
335
+ expect(log).toContain('running in ./apps/web')
336
+ expect(log).toContain('no off-palette colors')
337
+ expect(code).toBe(0)
338
+ })
339
+
340
+ it('reports ambiguity rather than guessing when two packages carry a config', () => {
341
+ ambiguousWorkspace()
342
+ const { code, log } = capture(() => checkTheme(dir))
343
+ expect(log).toContain('2 workspace packages carry one')
344
+ expect(log).toContain('BASALT_CWD')
345
+ expect(code).toBe(1)
346
+ })
347
+ })
348
+
349
+ /** A root with no basalt config and two workspace packages that each carry one. */
350
+ function ambiguousWorkspace(): void {
351
+ write('package.json', JSON.stringify({ name: 'root', workspaces: ['apps/*'] }))
352
+ write('apps/a/package.json', JSON.stringify({ name: 'a', basalt: { roots: ['src'] } }))
353
+ write('apps/a/src/app.tsx', 'export const A = () => null\n')
354
+ write('apps/b/package.json', JSON.stringify({ name: 'b', basalt: { roots: ['src'] } }))
355
+ write('apps/b/src/app.tsx', 'export const B = () => null\n')
356
+ }
357
+
358
+ describe('doctor — an ambiguous project is terminal, exactly as it is for check-theme', () => {
359
+ it('short-circuits instead of running every check against the wrong root', () => {
360
+ ambiguousWorkspace()
361
+ const { code, log } = capture(() => doctor(dir))
362
+ expect(log).toContain('2 workspace packages carry one')
363
+ expect(log).toContain('BASALT_CWD')
364
+ expect(code).toBe(1)
365
+ // The bug this pins shut: falling back to the invocation cwd ran the remaining checks against
366
+ // a root that has no config, burying the real error under failures caused by looking there.
367
+ expect(log).not.toContain('would scan 0 files')
368
+ expect(log).not.toContain('.oxlintrc.json missing')
369
+ expect(log).not.toContain('manifest.json missing')
370
+ })
371
+
372
+ it('agrees with check-theme on the same tree', () => {
373
+ ambiguousWorkspace()
374
+ expect(capture(() => doctor(dir)).code).toBe(capture(() => checkTheme(dir)).code)
375
+ })
376
+
377
+ it('still runs normally once BASALT_CWD picks a package', () => {
378
+ ambiguousWorkspace()
379
+ const { code, log } = capture(() => doctor(join(dir, 'apps/a')))
380
+ expect(log).not.toContain('workspace packages carry one')
381
+ expect(code).toBe(1) // no install / no manifest there — but the checks RAN
382
+ expect(log).toContain('guard-scan')
383
+ })
384
+ })
385
+
386
+ describe('doctor — no check may vanish from the report', () => {
387
+ it('SKIPS the spacing-scale check when there is no manifest, rather than printing nothing', () => {
388
+ // A Mantine app that never ran `init`: framework profile, no manifest to compare against.
389
+ write(
390
+ 'package.json',
391
+ JSON.stringify({
392
+ name: 'app',
393
+ dependencies: { '@mantine/core': '^9.0.0' },
394
+ basalt: { roots: ['src'] },
395
+ }),
396
+ )
397
+ write('src/app.tsx', 'export const App = () => null\n')
398
+ write('.oxlintrc.json', '{ "extends": ["./node_modules/basalt-ui/configs/oxlint.json"] }')
399
+ installBasalt()
400
+ const { code, log } = capture(() => doctor(dir))
401
+ expect(log).toContain('spacing scale')
402
+ expect(log).toContain('SKIPPED')
403
+ expect(code).toBe(1)
404
+ })
405
+
406
+ it('calls the spacing check n/a — not silent — for a tokens-only consumer', () => {
407
+ write('package.json', JSON.stringify({ name: 'site', basalt: { profile: 'tokens-only' } }))
408
+ installBasalt()
409
+ const { log } = capture(() => doctor(dir))
410
+ expect(log).toContain('spacing scale: n/a')
411
+ })
412
+ })
413
+
414
+ describe('--tokens-only and --framework are alternatives, not a precedence question', () => {
415
+ it('check-theme rejects both together', () => {
416
+ write('package.json', JSON.stringify({ name: 'app', basalt: { roots: ['src'] } }))
417
+ write('src/app.tsx', 'export const App = () => null\n')
418
+ const { code, log } = capture(() => checkTheme(dir, ['--tokens-only', '--framework']))
419
+ expect(code).toBe(1)
420
+ expect(log).toContain('alternatives')
421
+ })
422
+
423
+ it('doctor rejects both together', () => {
424
+ healthyFixture()
425
+ const { code, log } = capture(() => doctor(dir, ['--tokens-only', '--framework']))
426
+ expect(code).toBe(1)
427
+ expect(log).toContain('alternatives')
428
+ })
429
+ })
430
+
431
+ describe('check-theme — the markup scan reaches the app shell', () => {
432
+ it('scans index.html and public/*.webmanifest beside a root, not just inside it', () => {
433
+ // The exact argo shape: roots point at `apps/dashboard/src`, the raw hex lives one level up in
434
+ // index.html and in public/site.webmanifest. Widening the extension filter alone missed both.
435
+ write('package.json', JSON.stringify({ name: 'app', basalt: { roots: ['apps/web/src'] } }))
436
+ write('apps/web/src/app.tsx', 'export const App = () => null\n')
437
+ write('apps/web/index.html', '<meta name="theme-color" content="#EDEFF2" />\n')
438
+ write('apps/web/public/site.webmanifest', '{ "theme_color": "#242424" }\n')
439
+ const { code, log } = capture(() => checkTheme(dir))
440
+ expect(log).toContain('apps/web/index.html')
441
+ expect(log).toContain('apps/web/public/site.webmanifest')
442
+ expect(code).toBe(1)
443
+ })
444
+
445
+ it('leaves a sibling docs/ full of throwaway HTML alone', () => {
446
+ write('package.json', JSON.stringify({ name: 'app', basalt: { roots: ['src'] } }))
447
+ write('src/app.tsx', 'export const App = () => null\n')
448
+ write('docs/poc.html', '<div style="color: #ff0000">x</div>\n')
449
+ const { code, log } = capture(() => checkTheme(dir))
450
+ expect(log).not.toContain('docs/poc.html')
451
+ expect(code).toBe(0)
452
+ })
453
+
454
+ it('never blanket-scans .json, but scans one named in basalt.include', () => {
455
+ write('package.json', JSON.stringify({ name: 'app', basalt: { roots: ['src'] } }))
456
+ write('src/app.tsx', 'export const App = () => null\n')
457
+ write('src/fixtures/data.json', '{ "note": "#ff0000 in a test fixture" }\n')
458
+ expect(capture(() => checkTheme(dir)).code).toBe(0)
459
+
460
+ write(
461
+ 'package.json',
462
+ JSON.stringify({ name: 'app', basalt: { roots: ['src'], include: ['app/manifest.json'] } }),
463
+ )
464
+ write('app/manifest.json', '{ "theme_color": "#EDEFF2" }\n')
465
+ const { code, log } = capture(() => checkTheme(dir))
466
+ expect(log).toContain('app/manifest.json')
467
+ expect(log).not.toContain('data.json')
468
+ expect(code).toBe(1)
469
+ })
470
+ })
471
+
472
+ describe('check-theme — tokens-only profile', () => {
473
+ function mantineFreeApp(): void {
474
+ write(
475
+ 'package.json',
476
+ JSON.stringify({ name: 'site', basalt: { roots: ['src'], profile: 'tokens-only' } }),
477
+ )
478
+ write('src/form.tsx', "export const F = () => <input />\nexport const C = '#ff0000'\n")
479
+ }
480
+
481
+ it('drops the Mantine-coupled kinds while the color kinds keep firing', () => {
482
+ mantineFreeApp()
483
+ const { log } = capture(() => checkTheme(dir))
484
+ expect(log).toContain('tokens-only profile')
485
+ expect(log).not.toContain('raw-form-control')
486
+ expect(log).toContain('raw-hex')
487
+ })
488
+
489
+ it('--framework forces the full kind set back on', () => {
490
+ mantineFreeApp()
491
+ const { log } = capture(() => checkTheme(dir, ['--framework']))
492
+ expect(log).not.toContain('tokens-only profile')
493
+ expect(log).toContain('raw-form-control')
494
+ })
495
+
496
+ it('is never INFERRED — an undeclared Mantine-free repo keeps every kind live', () => {
497
+ // Silencing 17 kinds off the mere absence of @mantine/core is this round's own failure mode:
498
+ // a repo holding Mantine in a workspace package would switch off half its guard silently.
499
+ write('package.json', JSON.stringify({ name: 'app', basalt: { roots: ['src'] } }))
500
+ write('src/form.tsx', 'export const F = () => <input />\n')
501
+ const { log } = capture(() => checkTheme(dir))
502
+ expect(log).not.toContain('tokens-only profile')
503
+ expect(log).toContain('raw-form-control')
504
+ })
505
+
506
+ it('doctor infers the shape and tells the consumer to declare it', () => {
507
+ write('package.json', JSON.stringify({ name: 'site' }))
508
+ installBasalt()
509
+ const { log } = capture(() => doctor(dir))
510
+ expect(log).toContain('profile: tokens-only')
511
+ expect(log).toContain('"profile": "tokens-only"')
512
+ })
513
+ })
514
+
515
+ describe('tokens:css — a committable artifact', () => {
516
+ // Restated verbatim on purpose: the guard compares line 1 byte-for-byte, so a reword is a
517
+ // contract change. It is command-NEUTRAL — it used to name `tokens:css`, which told anyone
518
+ // reading a fonts:css file to overwrite it with the palette sheet.
519
+ const GENERATED_FIRST_LINE =
520
+ '/* @generated basalt-ui — do not edit; regenerate with the command on the next line */'
521
+
522
+ function emit(flags: string[], out = 'out.css'): string {
523
+ write('package.json', JSON.stringify({ name: 'fixture' }))
524
+ expect(tokensCss([...flags, '--out', out], dir)).toBe(0)
525
+ return read(out)
526
+ }
527
+
528
+ it('opens with the exact @generated marker the theme guard skips on', () => {
529
+ const css = emit([])
530
+ expect(css.split('\n')[0]).toBe(GENERATED_FIRST_LINE)
531
+ expect(css.split('\n')[1]).toContain(`basalt-ui ${CLI_VERSION}`)
532
+ })
533
+
534
+ it('ends with a trailing newline and normalizes rgba() argument spacing', () => {
535
+ const css = emit([])
536
+ expect(css.endsWith('}\n')).toBe(true)
537
+ expect(css).not.toMatch(/rgba\(\d+,\d/)
538
+ expect(css).toContain('rgba(255, 255, 255, 0.6)')
539
+ })
540
+
541
+ it('changes no token VALUE — the body is buildPaletteCss modulo the formatting', () => {
542
+ const body = emit([]).split('\n').slice(2).join('\n')
543
+ expect(body).toBe(`${normalizeColorFunctions(buildPaletteCss())}\n`)
544
+ })
545
+
546
+ it('--selector-class emits a class selector instead of an attribute one', () => {
547
+ const css = emit(['--selector-class', 'dark', '--default-scheme', 'light'])
548
+ expect(css).toContain(':root.dark {')
549
+ expect(css).toContain(':root,\n:root.light {')
550
+ expect(css).not.toContain('data-basalt-scheme-class')
551
+ })
552
+
553
+ it('rejects --selector-class together with --selector-attribute', () => {
554
+ write('package.json', JSON.stringify({ name: 'fixture' }))
555
+ const { code } = capture(() =>
556
+ tokensCss(
557
+ ['--selector-class', 'dark', '--selector-attribute', 'data-x', '--out', 'o.css'],
558
+ dir,
559
+ ),
560
+ )
561
+ expect(code).toBe(1)
562
+ })
563
+
564
+ it('rejects a --selector-class that is not a plain class name', () => {
565
+ write('package.json', JSON.stringify({ name: 'fixture' }))
566
+ expect(tokensCss(['--selector-class', "dark'] { x", '--out', 'o.css'], dir)).toBe(1)
567
+ })
568
+
569
+ it('--check passes on an up-to-date file and fails on drift', () => {
570
+ emit(['--only', 'core'])
571
+ expect(
572
+ capture(() => tokensCss(['--only', 'core', '--out', 'out.css', '--check'], dir)).code,
573
+ ).toBe(0)
574
+ write('out.css', 'nope\n')
575
+ const { code, log } = capture(() =>
576
+ tokensCss(['--only', 'core', '--out', 'out.css', '--check'], dir),
577
+ )
578
+ expect(code).toBe(1)
579
+ expect(log).toContain('differs from what')
580
+ })
581
+
582
+ it('--check fails when the file has never been generated', () => {
583
+ write('package.json', JSON.stringify({ name: 'fixture' }))
584
+ const { code, log } = capture(() => tokensCss(['--out', 'missing.css', '--check'], dir))
585
+ expect(code).toBe(1)
586
+ expect(log).toContain('does not exist')
587
+ })
588
+
589
+ it('--check without --out is an error, not a silent stdout dump', () => {
590
+ write('package.json', JSON.stringify({ name: 'fixture' }))
591
+ expect(capture(() => tokensCss(['--check'], dir)).code).toBe(1)
592
+ })
593
+ })
594
+
595
+ describe('the @generated marker, end to end — emit, then scan', () => {
596
+ /** A tokens-only consumer whose only CSS is the sheet `tokens:css` writes into its own src/. */
597
+ function siteWithEmittedTokens(flags: string[] = []): void {
598
+ write(
599
+ 'package.json',
600
+ JSON.stringify({ name: 'site', basalt: { roots: ['src'], profile: 'tokens-only' } }),
601
+ )
602
+ write('src/app.tsx', 'export const App = () => null\n')
603
+ expect(tokensCss([...flags, '--out', 'src/tokens.css'], dir)).toBe(0)
604
+ }
605
+
606
+ it('the sheet tokens:css just wrote scans to zero findings — 116 was the round-4 bug', () => {
607
+ siteWithEmittedTokens(['--only', 'core'])
608
+ // Sanity: it really is full of the raw hex the guard would otherwise report.
609
+ expect(read('src/tokens.css').match(/#[0-9a-f]{6}/gi)?.length ?? 0).toBeGreaterThan(50)
610
+ const { code, log } = capture(() => checkTheme(dir))
611
+ expect(log).not.toContain('src/tokens.css')
612
+ expect(code).toBe(0)
613
+ })
614
+
615
+ it('holds for every emission shape, not just the default', () => {
616
+ for (const flags of [
617
+ [],
618
+ ['--only', 'all'],
619
+ ['--selector-class', 'dark'],
620
+ ['--default-scheme', 'light'],
621
+ ]) {
622
+ rmSync(join(dir, 'src'), { recursive: true, force: true })
623
+ siteWithEmittedTokens(flags)
624
+ expect(capture(() => checkTheme(dir)).code).toBe(0)
625
+ }
626
+ })
627
+
628
+ it('a HAND-FORGED marker on ordinary source suppresses nothing', () => {
629
+ siteWithEmittedTokens(['--only', 'core'])
630
+ const header = read('src/tokens.css').split('\n').slice(0, 2).join('\n')
631
+ // Exactly what an agent (or anyone) could do: copy the two header lines onto a real file.
632
+ write('src/forged.css', `${header}\n.btn { color: #ff0000; }\n`)
633
+ const { code, log } = capture(() => checkTheme(dir))
634
+ expect(log).toContain('src/forged.css')
635
+ expect(log).toContain('raw-hex')
636
+ expect(code).toBe(1)
637
+ })
638
+
639
+ it('a STRIPPED marker makes the emitted sheet ordinary source again', () => {
640
+ siteWithEmittedTokens(['--only', 'core'])
641
+ const stripped = read('src/tokens.css').split('\n').slice(2).join('\n')
642
+ write('src/tokens.css', stripped)
643
+ const { code, log } = capture(() => checkTheme(dir))
644
+ expect(log).toContain('src/tokens.css')
645
+ expect(code).toBe(1)
646
+ })
647
+
648
+ it('appending a real rule to the emitted sheet un-exempts it', () => {
649
+ siteWithEmittedTokens(['--only', 'core'])
650
+ write('src/tokens.css', `${read('src/tokens.css')}.btn { color: #ff0000; }\n`)
651
+ const { code, log } = capture(() => checkTheme(dir))
652
+ expect(log).toContain('src/tokens.css')
653
+ expect(code).toBe(1)
654
+ })
655
+ })
656
+
657
+ describe('fonts:css — the typeface half of the token layer', () => {
658
+ it('emits the shipped --basalt-font-* stacks under the generated header', () => {
659
+ write('package.json', JSON.stringify({ name: 'fixture' }))
660
+ expect(fontsCss(['--out', 'fonts.css'], dir)).toBe(0)
661
+ const css = read('fonts.css')
662
+ expect(css.split('\n')[0]).toContain('@generated basalt-ui')
663
+ expect(css).toContain('--basalt-font-sans:')
664
+ expect(css).toContain('--basalt-font-head:')
665
+ expect(css).toContain('--basalt-font-mono:')
666
+ expect(css).toContain('Nunito Sans Variable')
667
+ expect(css.endsWith('}\n')).toBe(true)
668
+ })
669
+
670
+ it('emits a stylesheet check-theme accepts end to end', () => {
671
+ write('package.json', JSON.stringify({ name: 'site', basalt: { roots: ['src'] } }))
672
+ write('src/app.tsx', 'export const App = () => null\n')
673
+ expect(fontsCss(['--out', 'src/fonts.css'], dir)).toBe(0)
674
+ expect(capture(() => checkTheme(dir)).code).toBe(0)
675
+ })
676
+
677
+ // The header used to hard-code `regenerate with \`bunx basalt-ui tokens:css\`` on every emitted
678
+ // file — following it on a fonts sheet overwrote the typefaces with the palette.
679
+ it('never tells the reader to regenerate a fonts sheet with tokens:css', () => {
680
+ write('package.json', JSON.stringify({ name: 'fixture' }))
681
+ expect(fontsCss(['--out', 'fonts.css'], dir)).toBe(0)
682
+ const [marker, provenance] = read('fonts.css').split('\n')
683
+ expect(marker).not.toContain('tokens:css')
684
+ expect(provenance).toContain('`basalt-ui fonts:css')
685
+ })
686
+
687
+ it('has the same --check drift gate as tokens:css', () => {
688
+ write('package.json', JSON.stringify({ name: 'fixture' }))
689
+ fontsCss(['--out', 'fonts.css'], dir)
690
+ expect(capture(() => fontsCss(['--out', 'fonts.css', '--check'], dir)).code).toBe(0)
691
+ write('fonts.css', ':root {}\n')
692
+ expect(capture(() => fontsCss(['--out', 'fonts.css', '--check'], dir)).code).toBe(1)
693
+ })
694
+ })