basalt-ui 1.33.0 → 1.34.1

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 +1 -1
  2. package/CHANGELOG.md +33 -0
  3. package/MIGRATING.md +115 -15
  4. package/README.md +3 -0
  5. package/agent/skills/basalt-app/SKILL.md +5 -3
  6. package/configs/oxlint-basalt.json +33 -0
  7. package/configs/oxlint-plugin.js +21 -6
  8. package/configs/oxlint-plugin.test.ts +98 -10
  9. package/configs/oxlint.json +2 -30
  10. package/dist/cli/check-theme.d.ts.map +1 -1
  11. package/dist/cli/check-theme.js +40 -36
  12. package/dist/cli/doctor.d.ts +5 -10
  13. package/dist/cli/doctor.d.ts.map +1 -1
  14. package/dist/cli/doctor.js +7 -5
  15. package/dist/cli/guard-hook.d.ts.map +1 -1
  16. package/dist/cli/guard-hook.js +6 -34
  17. package/dist/cli/index.d.ts +45 -83
  18. package/dist/cli/index.d.ts.map +1 -1
  19. package/dist/cli/index.js +73 -19
  20. package/dist/cli/sync.d.ts.map +1 -1
  21. package/dist/cli/sync.js +7 -16
  22. package/dist/common/vx-families.d.ts +7 -0
  23. package/dist/common/vx-families.d.ts.map +1 -0
  24. package/dist/common/vx-families.js +32 -0
  25. package/dist/common/vx-family.d.ts +11 -0
  26. package/dist/common/vx-family.d.ts.map +1 -0
  27. package/dist/common/vx-family.js +12 -0
  28. package/dist/guard/index.d.ts +2 -2
  29. package/dist/guard/index.d.ts.map +1 -1
  30. package/dist/guard/index.js +11 -13
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +0 -2
  34. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  35. package/dist/shell/app-mobile-nav.js +2 -1
  36. package/dist/shell/app-mobile-nav.module.css +12 -0
  37. package/dist/shell/app-sidebar.d.ts.map +1 -1
  38. package/dist/shell/app-sidebar.js +32 -36
  39. package/dist/shell/app-sidebar.module.css +6 -0
  40. package/dist/shell/index.d.ts +0 -1
  41. package/dist/shell/index.d.ts.map +1 -1
  42. package/dist/shell/index.js +0 -2
  43. package/dist/shell/nav-glyph.d.ts +14 -0
  44. package/dist/shell/nav-glyph.d.ts.map +1 -0
  45. package/dist/shell/nav-glyph.js +21 -0
  46. package/dist/shell/nav-types.d.ts +1 -0
  47. package/dist/shell/nav-types.d.ts.map +1 -1
  48. package/dist/shell/sidebar-search.d.ts.map +1 -1
  49. package/dist/shell/sidebar-search.js +1 -3
  50. package/dist/surfaces.d.ts +2 -2
  51. package/dist/surfaces.js +2 -2
  52. package/dist/tokens/index.d.ts +1 -1
  53. package/dist/tokens/index.d.ts.map +1 -1
  54. package/dist/tokens/index.js +17 -1
  55. package/dist/tokens/size-classes.d.ts +12 -0
  56. package/dist/tokens/size-classes.d.ts.map +1 -1
  57. package/dist/tokens/size-classes.js +11 -5
  58. package/llms.txt +4 -4
  59. package/package.json +1 -1
  60. package/src/cli/check-theme.ts +63 -37
  61. package/src/cli/doctor.ts +12 -15
  62. package/src/cli/guard-hook.ts +8 -35
  63. package/src/cli/index.ts +126 -113
  64. package/src/cli/sync.ts +7 -16
  65. package/src/common/vx-families.ts +36 -0
  66. package/src/common/vx-family.ts +22 -0
  67. package/src/guard/index.ts +22 -25
  68. package/src/index.ts +0 -2
  69. package/src/shell/app-mobile-nav.module.css +12 -0
  70. package/src/shell/app-mobile-nav.tsx +8 -1
  71. package/src/shell/app-sidebar.module.css +6 -0
  72. package/src/shell/app-sidebar.tsx +52 -50
  73. package/src/shell/index.tsx +0 -1
  74. package/src/shell/nav-glyph.tsx +38 -0
  75. package/src/shell/nav-types.ts +1 -0
  76. package/src/shell/sidebar-search.tsx +1 -5
  77. package/src/surfaces.ts +2 -2
  78. package/src/tokens/index.ts +28 -1
  79. package/src/tokens/size-classes.ts +18 -5
  80. package/dist/shell/use-breakpoint.d.ts +0 -10
  81. package/dist/shell/use-breakpoint.d.ts.map +0 -1
  82. package/dist/shell/use-breakpoint.js +0 -11
  83. package/src/shell/use-breakpoint.ts +0 -37
@@ -5,12 +5,12 @@
5
5
  */
6
6
  import { isAbsolute, relative, resolve } from 'node:path'
7
7
 
8
- import { DEFAULT_GUARD_CONFIG } from '../guard'
9
- import type { GuardConfig } from '../guard'
10
8
  import { evaluateGuardHook } from '../guard/guard-hook'
11
- import { SKIP, declaredProfile, defaultExempt, readBasaltConfig, resolveExemptRules } from './index'
9
+ import { SKIP, declaredProfile, defaultExempt, readBasaltConfig, resolveGuardConfig } from './index'
12
10
  import { DEFAULT_ROOTS } from './config'
13
11
 
12
+ const ALLOW = '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}\n'
13
+
14
14
  /**
15
15
  * guard-hook — PreToolUse stdin adapter.
16
16
  *
@@ -31,9 +31,7 @@ export async function guardHook(cwd: string = process.cwd()): Promise<number> {
31
31
  }
32
32
  } catch {
33
33
  // Unreadable stdin → allow
34
- process.stdout.write(
35
- '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}\n',
36
- )
34
+ process.stdout.write(ALLOW)
37
35
  return 0
38
36
  }
39
37
 
@@ -42,36 +40,13 @@ export async function guardHook(cwd: string = process.cwd()): Promise<number> {
42
40
  payload = JSON.parse(raw)
43
41
  } catch {
44
42
  // Malformed JSON → allow (never block on a bad payload)
45
- process.stdout.write(
46
- '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}\n',
47
- )
43
+ process.stdout.write(ALLOW)
48
44
  return 0
49
45
  }
50
46
 
51
47
  const cfg = readBasaltConfig(cwd)
52
- const guardCfg: GuardConfig = {
53
- spacingSteps: cfg.spacingSteps ?? DEFAULT_GUARD_CONFIG.spacingSteps,
54
- rawRadius: cfg.rawRadius ?? DEFAULT_GUARD_CONFIG.rawRadius,
55
- forbiddenAccents: cfg.forbiddenAccents ?? DEFAULT_GUARD_CONFIG.forbiddenAccents,
56
- mantineShadeIndex: cfg.mantineShadeIndex ?? DEFAULT_GUARD_CONFIG.mantineShadeIndex,
57
- rawSurface: cfg.rawSurface ?? DEFAULT_GUARD_CONFIG.rawSurface,
58
- cardWithBorder: cfg.cardWithBorder ?? DEFAULT_GUARD_CONFIG.cardWithBorder,
59
- offSystemSurfaceVar: cfg.offSystemSurfaceVar ?? DEFAULT_GUARD_CONFIG.offSystemSurfaceVar,
60
- rawHtmlLayout: cfg.rawHtmlLayout ?? DEFAULT_GUARD_CONFIG.rawHtmlLayout,
61
- inlineSpacing: cfg.inlineSpacing ?? DEFAULT_GUARD_CONFIG.inlineSpacing,
62
- inlineDisplay: cfg.inlineDisplay ?? DEFAULT_GUARD_CONFIG.inlineDisplay,
63
- rawVisxAxis: cfg.rawVisxAxis ?? DEFAULT_GUARD_CONFIG.rawVisxAxis,
64
- rawMotionValue: cfg.rawMotionValue ?? DEFAULT_GUARD_CONFIG.rawMotionValue,
65
- chartMissingAriaLabel: cfg.chartMissingAriaLabel ?? DEFAULT_GUARD_CONFIG.chartMissingAriaLabel,
66
- rawFormControl: cfg.rawFormControl ?? DEFAULT_GUARD_CONFIG.rawFormControl,
67
- sub16InputFont: cfg.sub16InputFont ?? DEFAULT_GUARD_CONFIG.sub16InputFont,
68
- allowComment: 'theme-allow',
69
- exemptRules: resolveExemptRules(cfg),
70
- severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity,
71
- // Same detection check-theme uses: the hook must never block an edit over advice the app
72
- // cannot take ("use @mantine/core's Select") in a repo with no Mantine.
73
- ...(declaredProfile(cfg, []) === 'tokens-only' ? { profile: 'tokens-only' as const } : {}),
74
- }
48
+ // Same profile as check-theme: never block an edit over advice a no-Mantine app cannot take.
49
+ const guardCfg = resolveGuardConfig(cfg, declaredProfile(cfg, []))
75
50
 
76
51
  // Honor the consumer's roots / exempt / skip config so the hook never blocks edits to exempted
77
52
  // palette source or files outside the guarded roots (mirrors checkTheme's file-walk scoping).
@@ -101,9 +76,7 @@ export async function guardHook(cwd: string = process.cwd()): Promise<number> {
101
76
  }) + '\n',
102
77
  )
103
78
  } else {
104
- process.stdout.write(
105
- '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}\n',
106
- )
79
+ process.stdout.write(ALLOW)
107
80
  }
108
81
  return 0
109
82
  }
package/src/cli/index.ts CHANGED
@@ -1,18 +1,9 @@
1
1
  /**
2
- * basalt-ui CLI — `init`, `sync`, `check-theme`, `doctor`.
3
- *
4
- * `checkTheme` is a thin FS walker over the headless guard core (`../guard`). It reads the
5
- * BasaltConfig, builds a GuardConfig, walks the source roots, calls `checkSource` per file,
6
- * collects Finding[], groups/reports findings, and returns an exit code.
7
- *
8
- * `init` / `sync` scaffold and reconcile the framework's *agentic* surface into a consumer repo:
9
- * Claude Code rules + skills, a managed CLAUDE.md block, a DESIGN.md seed, and the toolchain
10
- * seeds (oxlint / oxfmt / lefthook / CI). Both are sha256-manifest driven for safe,
11
- * idempotent three-way reconciliation. Dependency-free — Node/Bun built-ins only.
12
- *
13
- * Runtime-agnostic (Node or Bun) — built-ins only, no `bun`-module import, so the exported API is
14
- * safe to import under plain Node. Config is read from the consuming package.json `"basalt"` key;
15
- * argo's hardcoded values are the DEFAULTS.
2
+ * basalt-ui CLI — the dispatcher plus the plumbing `init`, `sync`, `check-theme` and `doctor` (each
3
+ * in its own file) share. `init`/`sync` reconcile the agentic surface and the toolchain seeds into
4
+ * a consumer repo through a sha256 manifest (three-way, idempotent). Runtime-agnostic and
5
+ * dependency-free — Node/Bun built-ins only, no `bun`-module import. Config is the consuming
6
+ * package.json's `"basalt"` key; argo's values are the defaults.
16
7
  */
17
8
  import { spawnSync } from 'node:child_process'
18
9
  import { createHash } from 'node:crypto'
@@ -43,17 +34,53 @@ import { DEFAULT_ROOT, exemptRulePaths, resolveRoots } from './config'
43
34
  import type { BasaltConfig } from './config'
44
35
 
45
36
  /**
46
- * The guard-shaped `exemptRules` — paths only. The reason the object form carries never reaches
47
- * the guard: it is accountability for a human reading a diff and for `--audit-allows`, and giving
48
- * the scan a second shape to understand would only be a way for the two to disagree.
37
+ * The consumer's `GuardConfig`: every `basalt` key the guard knows, over `DEFAULT_GUARD_CONFIG`.
38
+ * One builder for `check-theme` and `guard-hook`, so the hook never judges an edit by a different
39
+ * config than the build. `exemptRules` reaches the guard as paths only — the reason the object form
40
+ * carries is accountability for a human and for `--audit-allows`, not a second shape to scan.
41
+ */
42
+ type PassthroughKey = Exclude<keyof GuardConfig, 'allowComment' | 'exemptRules' | 'profile'>
43
+
44
+ /**
45
+ * The `basalt` keys that reach the guard verbatim — named, never matched by name at runtime.
46
+ * `satisfies Record<…>` makes a GuardConfig key missing here, or one that is not a key, a tsc error.
49
47
  */
50
- export function resolveExemptRules(cfg: BasaltConfig): GuardConfig['exemptRules'] {
51
- if (cfg.exemptRules === undefined) return DEFAULT_GUARD_CONFIG.exemptRules
52
- const out: Partial<Record<GuardKind, string[]>> = {}
53
- for (const [kind, entry] of Object.entries(cfg.exemptRules)) {
54
- out[kind as GuardKind] = exemptRulePaths(entry)
48
+ const GUARD_PASSTHROUGH = {
49
+ spacingSteps: true,
50
+ rawRadius: true,
51
+ forbiddenAccents: true,
52
+ mantineShadeIndex: true,
53
+ rawSurface: true,
54
+ cardWithBorder: true,
55
+ offSystemSurfaceVar: true,
56
+ rawHtmlLayout: true,
57
+ inlineSpacing: true,
58
+ inlineDisplay: true,
59
+ rawVisxAxis: true,
60
+ rawMotionValue: true,
61
+ chartMissingAriaLabel: true,
62
+ rawFormControl: true,
63
+ sub16InputFont: true,
64
+ severity: true,
65
+ } as const satisfies Record<PassthroughKey, true>
66
+
67
+ export function resolveGuardConfig(cfg: BasaltConfig, profile: DoctorProfile): GuardConfig {
68
+ const exemptRules: Partial<Record<GuardKind, string[]>> = {}
69
+ for (const [kind, entry] of Object.entries(cfg.exemptRules ?? {})) {
70
+ exemptRules[kind as GuardKind] = exemptRulePaths(entry)
71
+ }
72
+ // Typed as the guard's shape, so a BasaltConfig field whose type drifts from it fails to compile.
73
+ const from: Partial<Pick<GuardConfig, PassthroughKey>> = cfg
74
+ const set = (Object.keys(GUARD_PASSTHROUGH) as PassthroughKey[]).filter(
75
+ (k) => (from[k] ?? null) !== null,
76
+ )
77
+ return {
78
+ ...DEFAULT_GUARD_CONFIG,
79
+ ...Object.fromEntries(set.map((k) => [k, from[k]])),
80
+ allowComment: 'theme-allow',
81
+ exemptRules,
82
+ ...(profile === 'tokens-only' ? { profile: 'tokens-only' as const } : {}),
55
83
  }
56
- return out
57
84
  }
58
85
 
59
86
  /**
@@ -211,13 +238,12 @@ export function relativePosix(fromDir: string, toPath: string): string {
211
238
  /**
212
239
  * Where `basalt-ui` actually resolves from, seen from a consumer directory.
213
240
  *
214
- * Every toolchain seam basalt seeds (`.oxlintrc.json`'s `extends`, `lefthook.yml`'s `extends`, the
215
- * CI `run:` steps) used to hardcode `./node_modules/basalt-ui` at the repo ROOT. Under bun's
216
- * isolated linker a library package that declares basalt a peer + devDependency gets
217
- * `<pkg>/node_modules/basalt-ui` and NOTHING at the root, so all three silently failed to resolve
218
- * and `bunx` fetched a second, different copy from npm instead. Resolution therefore walks
219
- * OUTWARD from the consumer dir first, then across the workspace packages — and every caller
220
- * renders its paths from the answer rather than assuming the root.
241
+ * Every seam basalt seeds (oxlint/lefthook `extends`, the CI `run:` steps) used to hardcode
242
+ * `./node_modules/basalt-ui` at the repo ROOT; under bun's isolated linker a package that declares
243
+ * basalt a peer + devDependency gets it at `<pkg>/node_modules` and NOTHING at the root, so all three
244
+ * silently failed to resolve and `bunx` fetched a second, different copy. Resolution therefore walks
245
+ * OUTWARD from the consumer dir, then across the workspace packages — and every caller renders its
246
+ * paths from the answer rather than assuming the root.
221
247
  */
222
248
  export type BasaltInstall = {
223
249
  /** Absolute path of the resolved `node_modules/basalt-ui` directory, or null when unresolvable. */
@@ -310,23 +336,9 @@ export function resolveProjectDir(cwd: string): ProjectResolution {
310
336
  }
311
337
 
312
338
  /**
313
- * Which shape of consumer a project-scoped command is looking at.
314
- *
315
- * A tokens-only consumer took the `--vx-*` layer and nothing else. Every guard kind whose remedy is
316
- * a Mantine component or the React theme factory is meaningless there — telling a Mantine-free app
317
- * to swap its `<select>` for `@mantine/core`'s `Select` is advice it must not take.
318
- *
319
- * DECLARED, never inferred, when the answer SILENCES something. The two callers want opposite
320
- * failure directions and so do not share a default:
321
- *
322
- * - `check-theme` (`declaredProfile`) turns 17 kinds OFF, so it moves on an explicit signal only —
323
- * `--tokens-only`, or `"basalt": { "profile": "tokens-only" }`. Inferring it from the ABSENCE of
324
- * `@mantine/core` would silence the Mantine half of the guard on any repo that keeps its Mantine
325
- * dependency in a workspace package rather than the one holding the basalt config, which is
326
- * precisely this round's "reports green while enforcing nothing" failure with the guard's own
327
- * hand on the switch.
328
- * - `doctor` no longer infers a profile at all (workspace-wide Mantine detection was deleted along
329
- * with the rest of the multi-repo discovery); it reads the same declaration `check-theme` does.
339
+ * Which shape of consumer a project-scoped command is looking at — DECLARED, never inferred
340
+ * (`--tokens-only`, or `"basalt": { "profile": "tokens-only" }`), because the answer switches the
341
+ * Mantine-remedy guard kinds off. Why that direction: package CLAUDE.md § CLI.
330
342
  */
331
343
  export function declaredProfile(cfg: BasaltConfig, flags: readonly string[]): DoctorProfile {
332
344
  if (flags.includes('--tokens-only')) return 'tokens-only'
@@ -340,8 +352,21 @@ export function declaredProfile(cfg: BasaltConfig, flags: readonly string[]): Do
340
352
  * vs `--selector-attribute`. Silently preferring one meant a CI step that accumulated both flags
341
353
  * enforced whichever the code happened to test first.
342
354
  */
343
- export function conflictingProfileFlags(flags: readonly string[]): boolean {
344
- return flags.includes('--tokens-only') && flags.includes('--framework')
355
+ export function conflictingProfileFlags(command: string, flags: readonly string[]): boolean {
356
+ const both = flags.includes('--tokens-only') && flags.includes('--framework')
357
+ if (both)
358
+ console.error(
359
+ `basalt-ui ${command}: --tokens-only and --framework are alternatives — pass one.`,
360
+ )
361
+ return both
362
+ }
363
+
364
+ /** Says so when `BASALT_CWD` moved the command off the directory it was invoked in. */
365
+ export function noteRelocation(command: string, project: ProjectResolution): void {
366
+ if (project.relocatedFrom === null) return
367
+ console.log(
368
+ `basalt-ui ${command}: BASALT_CWD relocated from ${project.relocatedFrom} to ${project.dir}.`,
369
+ )
345
370
  }
346
371
 
347
372
  /**
@@ -401,19 +426,11 @@ function appShellFiles(rootAbs: string): string[] {
401
426
  const PLAIN_JSON_HINT_PATH = 'manifest.json'
402
427
 
403
428
  /**
404
- * The waiver closer for a file, gated on the profile.
405
- *
406
- * `guardWaiverHint` keys off the file class, and for a `.webmanifest` it leads with
407
- * `basaltAppPlugin` — right for a Mantine app, since a hex in a manifest can never be *right* and
408
- * the plugin removes the hand-copy entirely. A `profile: tokens-only` consumer has definitionally
409
- * opted out of that layer: rollhook's Astro site has no `index.html` for the plugin to transform,
410
- * imports no basalt JavaScript at all, and owns maskable icons the plugin does not emit. Leading
411
- * with the plugin there is advice the consumer cannot take, in the one release that finally gave
412
- * that file class a remedy.
413
- *
414
- * So under tokens-only a manifest is treated as what it is to that consumer — plain JSON, with the
415
- * member as the whole answer — plus one sentence saying which remedy is being withheld and why.
416
- * The remedy text still comes from the guard's own registry; only the class mapping is decided here.
429
+ * The waiver closer for a file, gated on the profile. `guardWaiverHint` leads a `.webmanifest` with
430
+ * `basaltAppPlugin` — right for a Mantine app, advice a `profile: tokens-only` consumer cannot take
431
+ * (rollhook's Astro site has no `index.html` for the plugin and owns its maskable icons). There the
432
+ * manifest is plain JSON — the member is the whole answer, plus one sentence on the withheld remedy.
433
+ * The remedy text still comes from the guard's registry; only the class mapping is decided here.
417
434
  */
418
435
  export function waiverHintFor(relPath: string, profile: DoctorProfile): string {
419
436
  if (profile !== 'tokens-only' || !relPath.endsWith('.webmanifest')) {
@@ -568,7 +585,7 @@ function buildTemplateVars(pkgRoot: string, cwd: string, cfg: BasaltConfig): Tem
568
585
  ACCENT_HUE: cfg.accentHue ?? 'blue',
569
586
  SERIES_MODULE_PATH: resolveSeriesModulePath(cfg),
570
587
  ROOTS_GLOBS: resolveRoots(cfg).map(toRootGlob).join(' '),
571
- OXLINT_PRESET_PATH: shippedAssetPath(install, cwd, 'configs/oxlint.json'),
588
+ OXLINT_PRESET_PATH: shippedAssetPath(install, cwd, OXLINT_FILES.preset),
572
589
  LEFTHOOK_PRESET_PATH: shippedAssetPath(install, cwd, 'configs/lefthook.yml'),
573
590
  BASALT_BIN: basaltBinCommand(install, cwd),
574
591
  }
@@ -706,6 +723,17 @@ export function findRepoRoot(dir: string): string {
706
723
  }
707
724
  }
708
725
 
726
+ /** The AST lane's other half: an `oxlint` bin in `node_modules/.bin`, `dir` up to the repo root. */
727
+ export const OXLINT_NO_BIN =
728
+ 'oxlint config found but no oxlint binary — the AST lane never runs; add oxlint as a devDependency'
729
+ export function oxlintBinResolves(dir: string): boolean {
730
+ const root = findRepoRoot(dir)
731
+ for (let d = dir; ; d = dirname(d)) {
732
+ if (existsSync(resolve(d, 'node_modules/.bin/oxlint'))) return true
733
+ if (d === root || dirname(d) === d) return false
734
+ }
735
+ }
736
+
709
737
  /**
710
738
  * First path under `dir` (recursive) whose basename is `filename`, or null. Skips dependency/build
711
739
  * dirs, mirroring `walkSourceFiles`'s scoping.
@@ -873,7 +901,7 @@ export function managedFiles(
873
901
  const oxlintrc: ManagedFile = {
874
902
  dest: '.oxlintrc.json',
875
903
  mode: 'seed',
876
- source: 'configs/oxlint.json',
904
+ source: OXLINT_FILES.preset,
877
905
  render: (ctx) => `{\n "extends": ["${ctx.vars.OXLINT_PRESET_PATH}"]\n}\n`,
878
906
  }
879
907
 
@@ -1191,16 +1219,9 @@ export function normalizeForLedger(text: string): string {
1191
1219
 
1192
1220
  /**
1193
1221
  * Write a managed unit to disk (whole file, or marker-spliced region). Returns the unit's hash —
1194
- * `sha256(normalizeForLedger(desired))`, NOT the raw bytes.
1195
- *
1196
- * This is the ledger: the manifest already records "what basalt shipped last sync", one entry per
1197
- * dest — a stored NORMALIZED hash makes that same single entry survive both a downstream formatter
1198
- * (the consumer's own lefthook oxfmt reformatting the written file/block after this function has
1199
- * already returned) and a version bump that changed the template's own words since the recorded
1200
- * entry — the argo cross-version case: current is v1.0.0's block, post-oxfmt; desired is v1.0.1's
1201
- * (different words, e.g. the CLI bin rename). Recording the raw hash can never reconcile that;
1202
- * recording the normalized hash lets `classify()` normalize `state.current` the same way and
1203
- * compare directly against it, no separate historical-hash storage required.
1222
+ * `sha256(normalizeForLedger(desired))`, NOT the raw bytes: the normalized hash is what lets one
1223
+ * manifest entry survive a downstream formatter and a cross-version template change (`classify`'s
1224
+ * path 2 is the reader).
1204
1225
  */
1205
1226
  export function writeUnit(file: ManagedFile, cwd: string, desired: string): string {
1206
1227
  const destAbs = resolve(cwd, file.dest)
@@ -1217,29 +1238,16 @@ export function writeUnit(file: ManagedFile, cwd: string, desired: string): stri
1217
1238
  * Classify a managed file against the manifest for a sync run.
1218
1239
  * - missing : the managed unit is absent on disk → recreate.
1219
1240
  * - current : on disk == desired → nothing to do.
1220
- * - unchanged: on disk matches a KNOWN-SHIPPED rendering of this template, checked three ways (any
1221
- * one is sufficient) — but != desired as-is → safe overwrite:
1222
- * 1. `sha256(current) === manifestHash` — legacy raw-hash path. An existing consumer
1223
- * manifest (written before this fix, or by a still-running older CLI) holds the RAW
1224
- * hash of what was written, not the normalized one; keep matching it directly so an
1225
- * upgrade never mass-classifies a pristine tree as drifted.
1226
- * Residual transitional gap: a manifest written by a pre-1.0.2 CLI holds a raw hash
1227
- * of the exact old bytes, so if the on-disk block was ALSO reformatted by a
1228
- * downstream formatter AND the template body changed cross-version in the same
1229
- * upgrade, none of the three paths above match and it classifies `drifted`, needing
1230
- * one `--force`. Once any sync since writes a normalized entry (path 2/3 or a
1231
- * healed write), the gap is closed for good for that file. Deliberately not solved
1232
- * by keeping a historical per-version ledger — that's a real but bounded tradeoff.
1233
- * 2. `sha256(normalizeForLedger(current)) === manifestHash` — the ledger proper.
1234
- * `manifestHash` normally holds `sha256(normalizeForLedger(<bytes written at the
1235
- * last sync>))` (see `writeUnit`); normalizing `current` the same way and comparing
1236
- * survives a downstream formatter reformatting the file AND a version bump that
1237
- * changed the template's own words between the sync that wrote this manifest entry
1238
- * and the version being synced now — the argo cross-version false positive.
1241
+ * - unchanged: on disk matches a KNOWN-SHIPPED rendering of this template (any one is sufficient) —
1242
+ * but != desired as-is → safe overwrite:
1243
+ * 1. `sha256(current) === manifestHash` — legacy raw-hash path (manifests written before
1244
+ * the ledger normalized hold the RAW hash; keep matching so an upgrade never
1245
+ * mass-classifies a pristine tree as drifted).
1246
+ * 2. `sha256(normalizeForLedger(current)) === manifestHash` — the ledger proper (see
1247
+ * `writeUnit`): survives a downstream formatter AND a version bump that changed the
1248
+ * template's own words since the manifest entry was written (the argo false positive).
1239
1249
  * 3. `normalizeForLedger(current) === normalizeForLedger(desired)` — same-version
1240
- * fallback for when there is no manifest entry to compare against at all (e.g. it
1241
- * was dropped), but the on-disk bytes still match today's rendering modulo
1242
- * formatting noise.
1250
+ * fallback when no manifest entry exists at all.
1243
1251
  * - drifted : on disk matches NONE of the above (a real, word-level local edit) → skip unless
1244
1252
  * --force.
1245
1253
  */
@@ -1461,22 +1469,26 @@ export function parseJsonc(text: string): Record<string, unknown> | null {
1461
1469
  /** Outcome of splicing the shipped preset into an `.oxlintrc.json` the consumer already had. */
1462
1470
  export type MergeLintResult = 'added' | 'already' | 'absent' | 'unreadable' | 'has-comments'
1463
1471
 
1472
+ /** The shipped oxlint files, package-relative — the one spelling every reader of them uses. */
1473
+ export const OXLINT_FILES = {
1474
+ preset: 'configs/oxlint.json',
1475
+ guards: 'configs/oxlint-basalt.json',
1476
+ plugin: 'configs/oxlint-plugin.js',
1477
+ } as const
1478
+
1479
+ /** Whether an `extends`/`jsPlugins` entry names that shipped basalt-ui file. */
1480
+ export const namesShipped = (entry: unknown, file: string): entry is string =>
1481
+ typeof entry === 'string' && entry.endsWith(`basalt-ui/${file}`)
1482
+
1464
1483
  /**
1465
- * The `extends` entry pointing at the shipped oxlint preset, or null when none does.
1466
- *
1467
- * Returns the ENTRY rather than a boolean because the string alone proves nothing: a consumer that
1468
- * upgraded (or moved to an isolated linker) keeps a perfectly well-shaped
1469
- * `./node_modules/basalt-ui/configs/oxlint.json` that resolves to nothing, and oxlint then refuses
1470
- * to start with `NotFound` while a check built to prove the framework is ON reports green. The
1471
- * caller resolves this against the config's own directory — see `extendsSeam`.
1484
+ * The `extends` entry naming a shipped preset (full or guards-only), or null. The ENTRY, not a
1485
+ * boolean: a well-shaped `./node_modules/basalt-ui/configs/oxlint.json` can resolve to nothing
1486
+ * (an upgrade, an isolated linker), and oxlint then dies `NotFound` while a check built to prove the
1487
+ * lane is ON reports green — callers resolve it against the config's own directory.
1472
1488
  */
1473
1489
  export function basaltPresetEntry(entries: unknown): string | null {
1474
- if (!Array.isArray(entries)) return null
1475
- const match = entries.find(
1476
- (entry): entry is string =>
1477
- typeof entry === 'string' && entry.endsWith('basalt-ui/configs/oxlint.json'),
1478
- )
1479
- return match ?? null
1490
+ const presets = [OXLINT_FILES.preset, OXLINT_FILES.guards]
1491
+ return [entries].flat().find((e): e is string => presets.some((f) => namesShipped(e, f))) ?? null
1480
1492
  }
1481
1493
 
1482
1494
  // ── The lefthook gate — checked via `lefthook dump`, not hand-rolled YAML parsing ────────────────
@@ -1557,13 +1569,14 @@ export function mergeOxlintExtends(cwd: string, presetPath: string): MergeLintRe
1557
1569
  if (raw === null) return 'absent'
1558
1570
  const cfg = parseJsonc(raw)
1559
1571
  if (cfg === null) return 'unreadable'
1560
- if (basaltPresetEntry(cfg['extends']) !== null) return 'already'
1572
+ if ([cfg['extends']].flat().some((e) => namesShipped(e, OXLINT_FILES.preset))) return 'already'
1561
1573
  // Rewriting a JSONC config through JSON.stringify would silently delete the consumer's comments,
1562
1574
  // which in a lint config are usually the WHY of every disabled rule. Refuse and say so.
1563
1575
  if (stripJsonc(raw) !== raw) return 'has-comments'
1564
- const existing = Array.isArray(cfg['extends'])
1565
- ? (cfg['extends'] as unknown[]).filter((entry) => typeof entry === 'string')
1566
- : []
1576
+ // The guards-only preset is a subset of the full one — upgrading replaces it, never stacks it.
1577
+ const existing = [cfg['extends']]
1578
+ .flat()
1579
+ .filter((e): e is string => typeof e === 'string' && !namesShipped(e, OXLINT_FILES.guards))
1567
1580
  // Rebuild rather than spread, so `extends` keeps its original position when it was already there
1568
1581
  // and lands first when it wasn't — a diff a human reviews should not reshuffle the whole file.
1569
1582
  const merged: Record<string, unknown> = 'extends' in cfg ? {} : { extends: [presetPath] }
package/src/cli/sync.ts CHANGED
@@ -20,6 +20,8 @@ import {
20
20
  isStringArray,
21
21
  managedFiles,
22
22
  normalizeForLedger,
23
+ noteRelocation,
24
+ OXLINT_FILES,
23
25
  parseJsonc,
24
26
  placementNotices,
25
27
  pruneRetiredManagedFiles,
@@ -44,14 +46,8 @@ import {
44
46
 
45
47
  /** The oxlint plugins the shipped preset switches on — read from the preset, never restated. */
46
48
  export function readOxlintPresetPlugins(pkgRoot: string): string[] {
47
- const raw = readSource(pkgRoot, 'configs/oxlint.json')
48
- if (raw === null) return []
49
- try {
50
- const plugins = (JSON.parse(raw) as { plugins?: unknown }).plugins
51
- return isStringArray(plugins) ? plugins : []
52
- } catch {
53
- return []
54
- }
49
+ const plugins = parseJsonc(readSource(pkgRoot, OXLINT_FILES.preset) ?? 'null')?.['plugins']
50
+ return isStringArray(plugins) ? plugins : []
55
51
  }
56
52
 
57
53
  export type SyncOptions = {
@@ -111,15 +107,10 @@ export function findManifestAbove(dir: string): string | null {
111
107
  */
112
108
  export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd()): number {
113
109
  const syncFlags = opts.flags ?? []
114
- if (conflictingProfileFlags(syncFlags)) {
115
- console.error('basalt-ui sync: --tokens-only and --framework are alternatives — pass one.')
116
- return 1
117
- }
110
+ if (conflictingProfileFlags('sync', syncFlags)) return 1
118
111
  const project = resolveProjectDir(invocationCwd)
119
112
  const cwd = project.dir
120
- if (project.relocatedFrom !== null) {
121
- console.log(`basalt-ui sync: BASALT_CWD relocated from ${project.relocatedFrom} to ${cwd}.`)
122
- }
113
+ noteRelocation('sync', project)
123
114
 
124
115
  // Profile before the refusal, because in a tokens-only consumer the refusal's own advice is
125
116
  // wrong: it names `basalt-ui init`, and `doctor` in the same directory at the same version says
@@ -265,7 +256,7 @@ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd
265
256
  console.error(
266
257
  `⚠ basalt-ui sync: .oxlintrc.json extends "${oxlintEntry}", which does not exist — oxlint ` +
267
258
  'refuses to start on a missing extends target (`NotFound`). Repoint it at ' +
268
- `"${shippedAssetPath(install, cwd, 'configs/oxlint.json')}".`,
259
+ `"${shippedAssetPath(install, cwd, OXLINT_FILES.preset)}".`,
269
260
  )
270
261
  }
271
262
 
@@ -0,0 +1,36 @@
1
+ // GENERATED by scripts/gen-vx-names.ts — do not edit. `vx-names.test.ts` fails on drift.
2
+
3
+ /**
4
+ * The families every emitted `--vx-*` name lives in (`basaltVxFamilies`) — basalt-owned:
5
+ * `unknown-vx-token` judges every name inside one, and `groupTokens`/`seriesTokens` warn on a
6
+ * consumer ref there.
7
+ */
8
+ export const BASALT_VX_FAMILIES: readonly string[] = [
9
+ 'accent',
10
+ 'area',
11
+ 'axis',
12
+ 'bad',
13
+ 'crosshair',
14
+ 'divider',
15
+ 'dot',
16
+ 'faint',
17
+ 'fill',
18
+ 'good',
19
+ 'grid',
20
+ 'hit',
21
+ 'ink',
22
+ 'keyboard',
23
+ 'legend',
24
+ 'line',
25
+ 'muted',
26
+ 'neutral',
27
+ 'prose',
28
+ 'radius',
29
+ 'shadow',
30
+ 'space',
31
+ 'status',
32
+ 'surface',
33
+ 'text',
34
+ 'tooltip',
35
+ 'warn',
36
+ ]
@@ -0,0 +1,22 @@
1
+ /**
2
+ * A `--vx-*` name's FAMILY — its leading lowercase run: `--vx-surface-bg` → `surface`, `--vx-ink2`
3
+ * → `ink`, `--vx-tooltipBg` → `tooltip`, `--vx-fillHover-cyan` → `fill`. Dependency-free, because
4
+ * three sides must agree on it byte for byte: the generator that writes `BASALT_VX_FAMILIES`, the
5
+ * guard that judges every name inside one of those families, and the token factories that refuse a
6
+ * consumer name inside one — the refusal is what makes the guard's judgement honest.
7
+ */
8
+ export function vxFamilyOf(name: string): string {
9
+ return /^--vx-([a-z]*)/.exec(name)?.[1] ?? ''
10
+ }
11
+
12
+ /**
13
+ * Families a consumer extends without any factory: the theme emits `--vx-on-<color>` for EVERY
14
+ * `theme.colors` entry, a consumer's own colors included, so `on` is never basalt's alone.
15
+ */
16
+ const CONSUMER_EXTENSIBLE_FAMILIES: ReadonlySet<string> = new Set(['on'])
17
+
18
+ /** The basalt-owned families among `names` — run over every name basalt emits (`collectVxNames`). */
19
+ export function basaltVxFamilies(names: readonly string[]): string[] {
20
+ const families = new Set(names.map(vxFamilyOf))
21
+ return [...families].filter((f) => f !== '' && !CONSUMER_EXTENSIBLE_FAMILIES.has(f)).toSorted()
22
+ }
@@ -5,6 +5,8 @@
5
5
  * checkSource: pure (text, relPath, cfg) → Finding[]. No FS, no walk, no console.
6
6
  */
7
7
  import type { Finding, GuardConfig, GuardKind, GuardSeverity } from './types'
8
+ import { vxFamilyOf } from '../common/vx-family'
9
+ import { BASALT_VX_FAMILIES } from '../common/vx-families'
8
10
  import { BASALT_VX_NAMES } from './vx-names'
9
11
 
10
12
  export type { Finding, GuardConfig, GuardKind, GuardSeverity }
@@ -327,21 +329,16 @@ const REMOVED_VX_NAMES: Readonly<Record<string, string | null>> = {
327
329
  const KNOWN_VX_NAMES: ReadonlySet<string> = new Set(BASALT_VX_NAMES)
328
330
 
329
331
  /**
330
- * The bare family roots basalt never emits — `--vx-space`, `--vx-surface`, `--vx-status`, … — derived
331
- * from the `--vx-<family>-` prefixes of the emitted names, minus any root that is itself a token
332
- * (`--vx-hit`, `--vx-accent`). Nobody emits one: basalt only ever writes `<root>-<key>`, and so does
333
- * `groupTokens`. It is the one shape of unknown name basalt can judge WITHOUT seeing the consumer's
334
- * palette.
332
+ * The families basalt owns (`vxFamilyOf`: `surface`, `space`, `ink`, `keyboard`, …), which the
333
+ * generator reads off every name basalt emits. Every name inside one is judged against the exact
334
+ * emitted set, so `--vx-surface-2` and a bare root like `--vx-space` are both findings.
335
335
  *
336
- * An unknown name INSIDE a family (`--vx-surface-2`) is deliberately not judged. `groupTokens(name,
337
- * map)` takes any `name`, so `--vx-surface-custom` may be a consumer group declared in a file this
338
- * scan cannot see, and a per-file guard has no honest way to tell that from a typo.
336
+ * That judgement is honest only because no consumer ref may point into these families:
337
+ * `groupTokens`/`seriesTokens` warn on a name inside one (and throw once this kind promotes). A name in any OTHER family (argo's
338
+ * `--vx-activity-*`, rb's `--vx-confidence-*`) may be a consumer group declared in a file this scan
339
+ * cannot see, and is never judged.
339
340
  */
340
- const BASALT_VX_FAMILY_ROOTS: ReadonlySet<string> = new Set(
341
- BASALT_VX_NAMES.map((n) => /^(--vx-[A-Za-z0-9]+)-/.exec(n)?.[1]).filter(
342
- (root): root is string => root !== undefined && !KNOWN_VX_NAMES.has(root),
343
- ),
344
- )
341
+ const BASALT_VX_FAMILY_SET: ReadonlySet<string> = new Set(BASALT_VX_FAMILIES)
345
342
 
346
343
  // Raw Mantine ramp step used for surface color — gray/dark + a step digit.
347
344
  const OFF_SYSTEM_SURFACE_VAR = /var\(--mantine-color-(gray|dark)-\d/g
@@ -919,13 +916,13 @@ export type GraceEntry = { since: string; promote: string; why: string }
919
916
  */
920
917
  export const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, GraceEntry>> = {
921
918
  'unknown-vx-token': {
922
- since: '1.33.0',
923
- promote: '1.34.0',
919
+ since: '1.34.0',
920
+ promote: '1.35.0',
924
921
  why:
925
- 'New kind (consumer-loop r2 B3): obsidian lost its 44px touch floor to a removed ' +
926
- '`var(--vx-space-touch-target)` that nothing reported. A new kind rejects code every previous ' +
927
- 'release accepted, so it lands warn for one minor. Measured at ship time: argo, linewatch, rb, ' +
928
- 'rollhook, obsidian, weatherorb, image-share, email-gateway, image-gen 0.',
922
+ 'New kind at 1.33.0 (consumer-loop r2 B3): obsidian lost its 44px touch floor to a removed ' +
923
+ '`var(--vx-space-touch-target)` that nothing reported. WIDENED at 1.34.0 (image-gen r4: ' +
924
+ '`var(--vx-surface-2)` rendered transparent, unreported) to every unemitted name inside a ' +
925
+ 'basalt-owned family, so the grace restarts: warn through 1.34.x, error at 1.35.0.',
929
926
  },
930
927
  }
931
928
 
@@ -2105,7 +2102,7 @@ export const GUARD_RULES = {
2105
2102
  // JSX-tag-shaped (`<Card withBorder>`) — never appears in CSS text.
2106
2103
  appliesTo: (relPath) => !relPath.endsWith('.css'),
2107
2104
  message:
2108
- 'withBorder on a Card/Paper double-draws the edge — card depth is --vx-shadow-card, which already bakes a 1px ring into the shadow. Drop the prop (docs/DESIGN-SPEC.md doctrine inversion #1).',
2105
+ 'withBorder on a Card/Paper double-draws the edge — card depth is --vx-shadow-card, which already bakes a 1px ring into the shadow. Drop the prop (docs/DESIGN-SPEC.md doctrine inversion #1) — and fix the inset in the same edit: an explicit p/padding or radius on that Card/Paper is the other half of the idiom, which only the oxlint lane sees (basalt/card-inset); leave both to the theme or use py="xs" px="sm".',
2109
2106
  },
2110
2107
  'off-system-surface-var': {
2111
2108
  kind: 'off-system-surface-var',
@@ -2257,14 +2254,14 @@ export const GUARD_RULES = {
2257
2254
  },
2258
2255
  'unknown-vx-token': {
2259
2256
  kind: 'unknown-vx-token',
2260
- pattern: VX_VAR_REF, // handled inline (removed map + family roots); entry keeps the registry complete
2261
- message: `var() names a --vx-* token basalt does not emit, so it resolves to its fallback (or to nothing) with no error anywhere. Two shapes are judged, the only two basalt can judge without seeing your palette: a REMOVED name — ${Object.entries(
2257
+ pattern: VX_VAR_REF, // handled inline (basalt-owned families); entry keeps the registry complete
2258
+ message: `var() names a --vx-* token basalt does not emit, so it resolves to its fallback (or to nothing) with no error anywhere. Judged: every name inside a basalt-owned family (${[...BASALT_VX_FAMILY_SET].join(', ')}) — pick the token from basalt-ui/tokens.css; your own series belong in a family of their own (groupTokens warns on one inside basalt's). Removed names and their replacements: ${Object.entries(
2262
2259
  REMOVED_VX_NAMES,
2263
2260
  )
2264
2261
  .map(([from, to]) => `${from} → ${to ?? '(deleted, no replacement)'}`)
2265
2262
  .join(
2266
2263
  '; ',
2267
- )} — and a bare family root nothing emits (${[...BASALT_VX_FAMILY_ROOTS].join(', ')}): name the token under it from basalt-ui/tokens.css. Any other unknown name is never judged, because defineSeries/groupTokens may declare it in a file this scan cannot see.`,
2264
+ )}. A name in any other family is never judged, because groupTokens may declare it in a file this scan cannot see.`,
2268
2265
  },
2269
2266
  } as const satisfies Record<GuardKind, GuardRule>
2270
2267
 
@@ -2445,13 +2442,13 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
2445
2442
  [...codeText.matchAll(VX_LOCAL_DECLARATION)].map((m) => m[1] ?? m[3] ?? m[4] ?? ''),
2446
2443
  )
2447
2444
 
2448
- // unknown-vx-token: a removed name, or a bare family root nothing emits — see the roots' doc.
2445
+ // unknown-vx-token: any unemitted name inside a basalt-owned family — see BASALT_VX_FAMILY_SET.
2449
2446
  // Scanned over the whole comment-stripped text rather than per line, so a formatter-wrapped
2450
2447
  // `var(\n --vx-x\n)` is seen; the finding lands on the line the NAME is on.
2451
2448
  for (const m of codeText.matchAll(VX_VAR_REF)) {
2452
2449
  const name = m[1] ?? ''
2453
2450
  if (locallyDeclaredVx.has(name)) continue
2454
- if (!Object.hasOwn(REMOVED_VX_NAMES, name) && !BASALT_VX_FAMILY_ROOTS.has(name)) continue
2451
+ if (KNOWN_VX_NAMES.has(name) || !BASALT_VX_FAMILY_SET.has(vxFamilyOf(name))) continue
2455
2452
  const at = (m.index ?? 0) + m[0].length - name.length
2456
2453
  push('unknown-vx-token', lineStarts.findLastIndex((start) => start <= at) + 1, name)
2457
2454
  }