basalt-ui 1.32.1 → 1.34.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 (185) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +65 -0
  3. package/MIGRATING.md +218 -25
  4. package/README.md +3 -0
  5. package/agent/rules/basalt-state.md +2 -2
  6. package/agent/rules/basalt-tokens.md +1 -1
  7. package/agent/skills/basalt-app/SKILL.md +5 -3
  8. package/configs/oxlint-basalt.json +33 -0
  9. package/configs/oxlint-plugin.js +89 -67
  10. package/configs/oxlint-plugin.test.ts +224 -19
  11. package/configs/oxlint.json +2 -30
  12. package/dist/agent/coalesce.d.ts.map +1 -1
  13. package/dist/agent/coalesce.js +1 -2
  14. package/dist/agent/merge.d.ts.map +1 -1
  15. package/dist/agent/merge.js +1 -2
  16. package/dist/agent-chat/relative-time.d.ts +9 -0
  17. package/dist/agent-chat/relative-time.d.ts.map +1 -1
  18. package/dist/agent-chat/relative-time.js +1 -2
  19. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  20. package/dist/agent-chat/thread-message.js +1 -2
  21. package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
  22. package/dist/charts/kinds/BandStrip.js +3 -3
  23. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  24. package/dist/charts/kinds/Bars.js +1 -2
  25. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  26. package/dist/charts/kinds/StackedArea.js +1 -2
  27. package/dist/charts/primitives/legend-disclosure.d.ts.map +1 -1
  28. package/dist/cli/audit-allows.d.ts +1 -1
  29. package/dist/cli/audit-allows.d.ts.map +1 -1
  30. package/dist/cli/audit-allows.js +4 -4
  31. package/dist/cli/check-theme.d.ts.map +1 -1
  32. package/dist/cli/check-theme.js +38 -38
  33. package/dist/cli/config.d.ts +197 -0
  34. package/dist/cli/config.d.ts.map +1 -0
  35. package/dist/cli/config.js +20 -0
  36. package/dist/cli/doctor.d.ts.map +1 -1
  37. package/dist/cli/doctor.js +4 -6
  38. package/dist/cli/guard-hook.d.ts.map +1 -1
  39. package/dist/cli/guard-hook.js +7 -41
  40. package/dist/cli/index.d.ts +31 -257
  41. package/dist/cli/index.d.ts.map +1 -1
  42. package/dist/cli/index.js +64 -37
  43. package/dist/cli/sync.d.ts.map +1 -1
  44. package/dist/cli/sync.js +7 -16
  45. package/dist/commands/define-commands.d.ts.map +1 -1
  46. package/dist/commands/define-commands.js +2 -2
  47. package/dist/commands/define-overlays.d.ts.map +1 -1
  48. package/dist/commands/define-overlays.js +1 -2
  49. package/dist/common/index.d.ts +0 -1
  50. package/dist/common/index.d.ts.map +1 -1
  51. package/dist/common/index.js +0 -2
  52. package/dist/common/validate.d.ts +2 -2
  53. package/dist/common/validate.d.ts.map +1 -1
  54. package/dist/common/validate.js +2 -3
  55. package/dist/common/vx-families.d.ts +7 -0
  56. package/dist/common/vx-families.d.ts.map +1 -0
  57. package/dist/common/vx-families.js +32 -0
  58. package/dist/common/vx-family.d.ts +11 -0
  59. package/dist/common/vx-family.d.ts.map +1 -0
  60. package/dist/common/vx-family.js +12 -0
  61. package/dist/content/article-card.d.ts.map +1 -1
  62. package/dist/content/article-card.js +4 -1
  63. package/dist/controls/enum-filter.d.ts.map +1 -1
  64. package/dist/controls/enum-filter.js +3 -13
  65. package/dist/controls/filter-pill.d.ts +8 -0
  66. package/dist/controls/filter-pill.d.ts.map +1 -1
  67. package/dist/controls/filter-pill.js +15 -0
  68. package/dist/controls/multi-select-filter.d.ts.map +1 -1
  69. package/dist/controls/multi-select-filter.js +3 -13
  70. package/dist/controls/number-filter.d.ts +3 -0
  71. package/dist/controls/number-filter.d.ts.map +1 -1
  72. package/dist/controls/number-filter.js +12 -4
  73. package/dist/controls/range-filter.d.ts.map +1 -1
  74. package/dist/controls/range-filter.js +1 -2
  75. package/dist/data/data-table.d.ts.map +1 -1
  76. package/dist/data/data-table.js +2 -3
  77. package/dist/guard/index.d.ts +17 -3
  78. package/dist/guard/index.d.ts.map +1 -1
  79. package/dist/guard/index.js +62 -23
  80. package/dist/guard/types.d.ts +3 -3
  81. package/dist/guard/types.d.ts.map +1 -1
  82. package/dist/guard/vx-names.d.ts +3 -0
  83. package/dist/guard/vx-names.d.ts.map +1 -0
  84. package/dist/guard/vx-names.js +257 -0
  85. package/dist/index.d.ts +1 -1
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +0 -2
  88. package/dist/notifications/mount-guard.d.ts.map +1 -1
  89. package/dist/notifications/mount-guard.js +1 -2
  90. package/dist/provider/index.d.ts.map +1 -1
  91. package/dist/provider/index.js +2 -3
  92. package/dist/provider/lab-theme.d.ts +5 -4
  93. package/dist/provider/lab-theme.d.ts.map +1 -1
  94. package/dist/provider/lab-theme.js +1 -3
  95. package/dist/router-tanstack/search-store.d.ts +18 -0
  96. package/dist/router-tanstack/search-store.d.ts.map +1 -1
  97. package/dist/router-tanstack/search-store.js +33 -1
  98. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  99. package/dist/shell/app-mobile-nav.js +2 -1
  100. package/dist/shell/app-mobile-nav.module.css +12 -0
  101. package/dist/shell/app-sidebar.d.ts.map +1 -1
  102. package/dist/shell/app-sidebar.js +32 -36
  103. package/dist/shell/app-sidebar.module.css +6 -0
  104. package/dist/shell/index.d.ts +0 -1
  105. package/dist/shell/index.d.ts.map +1 -1
  106. package/dist/shell/index.js +0 -2
  107. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  108. package/dist/shell/mobile-nav-model.js +3 -4
  109. package/dist/shell/nav-glyph.d.ts +14 -0
  110. package/dist/shell/nav-glyph.d.ts.map +1 -0
  111. package/dist/shell/nav-glyph.js +21 -0
  112. package/dist/shell/nav-types.d.ts +1 -0
  113. package/dist/shell/nav-types.d.ts.map +1 -1
  114. package/dist/shell/page-bar.d.ts.map +1 -1
  115. package/dist/shell/page-bar.js +1 -2
  116. package/dist/shell/sidebar-search.d.ts.map +1 -1
  117. package/dist/shell/sidebar-search.js +1 -3
  118. package/dist/surfaces.d.ts +3 -3
  119. package/dist/surfaces.d.ts.map +1 -1
  120. package/dist/surfaces.js +4 -2
  121. package/dist/tokens/index.d.ts +1 -1
  122. package/dist/tokens/index.d.ts.map +1 -1
  123. package/dist/tokens/index.js +24 -2
  124. package/dist/tokens/size-classes.d.ts +25 -0
  125. package/dist/tokens/size-classes.d.ts.map +1 -1
  126. package/dist/tokens/size-classes.js +14 -0
  127. package/llms.txt +4 -4
  128. package/package.json +1 -1
  129. package/src/agent/coalesce.ts +1 -2
  130. package/src/agent/merge.ts +1 -2
  131. package/src/agent-chat/relative-time.ts +1 -2
  132. package/src/agent-chat/thread-message.tsx +2 -3
  133. package/src/charts/kinds/BandStrip.tsx +7 -3
  134. package/src/charts/kinds/Bars.tsx +1 -2
  135. package/src/charts/kinds/StackedArea.tsx +4 -2
  136. package/src/charts/primitives/legend-disclosure.tsx +1 -0
  137. package/src/cli/audit-allows.ts +10 -9
  138. package/src/cli/check-theme.ts +61 -39
  139. package/src/cli/config.ts +209 -0
  140. package/src/cli/doctor.ts +5 -7
  141. package/src/cli/guard-hook.ts +9 -42
  142. package/src/cli/index.ts +101 -294
  143. package/src/cli/sync.ts +7 -16
  144. package/src/commands/define-commands.ts +2 -2
  145. package/src/commands/define-overlays.ts +1 -2
  146. package/src/common/index.ts +0 -1
  147. package/src/common/validate.ts +4 -5
  148. package/src/common/vx-families.ts +36 -0
  149. package/src/common/vx-family.ts +22 -0
  150. package/src/content/article-card.tsx +1 -0
  151. package/src/controls/enum-filter.tsx +3 -13
  152. package/src/controls/filter-pill.tsx +20 -0
  153. package/src/controls/multi-select-filter.tsx +3 -13
  154. package/src/controls/number-filter.tsx +24 -3
  155. package/src/controls/range-filter.tsx +1 -2
  156. package/src/data/data-table.tsx +2 -3
  157. package/src/guard/index.ts +134 -47
  158. package/src/guard/types.ts +3 -2
  159. package/src/guard/vx-names.ts +257 -0
  160. package/src/index.ts +0 -2
  161. package/src/notifications/mount-guard.ts +1 -2
  162. package/src/provider/index.tsx +2 -3
  163. package/src/provider/lab-theme.ts +28 -21
  164. package/src/router-tanstack/search-store.ts +73 -1
  165. package/src/shell/app-mobile-nav.module.css +12 -0
  166. package/src/shell/app-mobile-nav.tsx +8 -1
  167. package/src/shell/app-sidebar.module.css +6 -0
  168. package/src/shell/app-sidebar.tsx +52 -50
  169. package/src/shell/index.tsx +0 -1
  170. package/src/shell/mobile-nav-model.ts +3 -5
  171. package/src/shell/nav-glyph.tsx +38 -0
  172. package/src/shell/nav-types.ts +1 -0
  173. package/src/shell/page-bar.tsx +1 -4
  174. package/src/shell/sidebar-search.tsx +1 -5
  175. package/src/surfaces.ts +4 -2
  176. package/src/tokens/index.ts +34 -2
  177. package/src/tokens/size-classes.ts +27 -0
  178. package/dist/common/is-dev.d.ts +0 -3
  179. package/dist/common/is-dev.d.ts.map +0 -1
  180. package/dist/common/is-dev.js +0 -6
  181. package/dist/shell/use-breakpoint.d.ts +0 -10
  182. package/dist/shell/use-breakpoint.d.ts.map +0 -1
  183. package/dist/shell/use-breakpoint.js +0 -11
  184. package/src/common/is-dev.ts +0 -4
  185. package/src/shell/use-breakpoint.ts +0 -37
@@ -5,17 +5,11 @@
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 {
12
- DEFAULT_ROOTS,
13
- SKIP,
14
- declaredProfile,
15
- defaultExempt,
16
- readBasaltConfig,
17
- resolveExemptRules,
18
- } from './index'
9
+ import { SKIP, declaredProfile, defaultExempt, readBasaltConfig, resolveGuardConfig } from './index'
10
+ import { DEFAULT_ROOTS } from './config'
11
+
12
+ const ALLOW = '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}\n'
19
13
 
20
14
  /**
21
15
  * guard-hook — PreToolUse stdin adapter.
@@ -37,9 +31,7 @@ export async function guardHook(cwd: string = process.cwd()): Promise<number> {
37
31
  }
38
32
  } catch {
39
33
  // Unreadable stdin → allow
40
- process.stdout.write(
41
- '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}\n',
42
- )
34
+ process.stdout.write(ALLOW)
43
35
  return 0
44
36
  }
45
37
 
@@ -48,36 +40,13 @@ export async function guardHook(cwd: string = process.cwd()): Promise<number> {
48
40
  payload = JSON.parse(raw)
49
41
  } catch {
50
42
  // Malformed JSON → allow (never block on a bad payload)
51
- process.stdout.write(
52
- '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}\n',
53
- )
43
+ process.stdout.write(ALLOW)
54
44
  return 0
55
45
  }
56
46
 
57
47
  const cfg = readBasaltConfig(cwd)
58
- const guardCfg: GuardConfig = {
59
- spacingSteps: cfg.spacingSteps ?? DEFAULT_GUARD_CONFIG.spacingSteps,
60
- rawRadius: cfg.rawRadius ?? DEFAULT_GUARD_CONFIG.rawRadius,
61
- forbiddenAccents: cfg.forbiddenAccents ?? DEFAULT_GUARD_CONFIG.forbiddenAccents,
62
- mantineShadeIndex: cfg.mantineShadeIndex ?? DEFAULT_GUARD_CONFIG.mantineShadeIndex,
63
- rawSurface: cfg.rawSurface ?? DEFAULT_GUARD_CONFIG.rawSurface,
64
- cardWithBorder: cfg.cardWithBorder ?? DEFAULT_GUARD_CONFIG.cardWithBorder,
65
- offSystemSurfaceVar: cfg.offSystemSurfaceVar ?? DEFAULT_GUARD_CONFIG.offSystemSurfaceVar,
66
- rawHtmlLayout: cfg.rawHtmlLayout ?? DEFAULT_GUARD_CONFIG.rawHtmlLayout,
67
- inlineSpacing: cfg.inlineSpacing ?? DEFAULT_GUARD_CONFIG.inlineSpacing,
68
- inlineDisplay: cfg.inlineDisplay ?? DEFAULT_GUARD_CONFIG.inlineDisplay,
69
- rawVisxAxis: cfg.rawVisxAxis ?? DEFAULT_GUARD_CONFIG.rawVisxAxis,
70
- rawMotionValue: cfg.rawMotionValue ?? DEFAULT_GUARD_CONFIG.rawMotionValue,
71
- chartMissingAriaLabel: cfg.chartMissingAriaLabel ?? DEFAULT_GUARD_CONFIG.chartMissingAriaLabel,
72
- rawFormControl: cfg.rawFormControl ?? DEFAULT_GUARD_CONFIG.rawFormControl,
73
- sub16InputFont: cfg.sub16InputFont ?? DEFAULT_GUARD_CONFIG.sub16InputFont,
74
- allowComment: 'theme-allow',
75
- exemptRules: resolveExemptRules(cfg),
76
- severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity,
77
- // Same detection check-theme uses: the hook must never block an edit over advice the app
78
- // cannot take ("use @mantine/core's Select") in a repo with no Mantine.
79
- ...(declaredProfile(cfg, []) === 'tokens-only' ? { profile: 'tokens-only' as const } : {}),
80
- }
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, []))
81
50
 
82
51
  // Honor the consumer's roots / exempt / skip config so the hook never blocks edits to exempted
83
52
  // palette source or files outside the guarded roots (mirrors checkTheme's file-walk scoping).
@@ -107,9 +76,7 @@ export async function guardHook(cwd: string = process.cwd()): Promise<number> {
107
76
  }) + '\n',
108
77
  )
109
78
  } else {
110
- process.stdout.write(
111
- '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}\n',
112
- )
79
+ process.stdout.write(ALLOW)
113
80
  }
114
81
  return 0
115
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'
@@ -39,225 +30,57 @@ import { COMMAND_OPTIONS, USAGE, unknownFlag } from './help'
39
30
  import { init } from './init'
40
31
  import { sync } from './sync'
41
32
  import { fontsCss, normalizeColorFunctions, tokensCss } from './tokens-css'
42
-
43
- /** Shape of the optional `"basalt"` key in a consumer's package.json. */
44
- export type BasaltConfig = {
45
- /** Source roots to scan. Default: `['src']`. Set explicitly for a monorepo layout. */
46
- roots?: string[]
47
- /** Files exempt from the scan (they ARE the palette source). Default: argo's exempt set. */
48
- exempt?: string[]
49
- /**
50
- * Declares this package a tokens-only consumer: it uses the `--vx-*` layer and no Mantine. Turns
51
- * off every guard kind whose remedy is a Mantine component, a Mantine prop or the React theme
52
- * factory, leaving the color and typography kinds live. Default: `'mantine'`.
53
- *
54
- * Deliberately not inferred from a missing `@mantine/core`: silencing 17 rules (the live count is
55
- * `TOKENS_ONLY_DISABLED_KINDS.size`, which the check-theme banner prints) is a decision, and
56
- * a repo that keeps Mantine in a workspace package would otherwise switch off half its own guard
57
- * without saying so. `basalt-ui doctor` detects the shape and tells you to set this.
58
- */
59
- profile?: 'mantine' | 'tokens-only'
60
- /**
61
- * Extra files to scan, named individually and relative to this package — for a design surface
62
- * that lives outside every root and outside the `index.html` / `public/` conventions the walker
63
- * already derives. Also the ONLY way a `.json` is ever scanned: the guard understands JSON as
64
- * markup, but a consumer repo's JSON is overwhelmingly config, fixtures and lockfiles, so
65
- * blanket-scanning it is a false-positive generator rather than a guard.
66
- *
67
- * @example
68
- * { include: ['app/manifest.json', 'emails/template.html'] }
69
- */
70
- include?: string[]
71
- /** Named spacing-scale steps (px) flagged when used as a raw spacing prop. Default: 10/12/16/20/32. */
72
- spacingSteps?: number[]
73
- /** Per-kind severity override — `'warn'` reports without failing, `'error'` fails the build.
74
- * See `GuardSeverity` in ../guard/types for the grace-minor doctrine behind it. */
75
- severity?: Partial<Record<GuardKind, GuardSeverity>>
76
- /** Off-identity Mantine accent families forbidden as chrome accents. Default: argo's set. */
77
- forbiddenAccents?: string[]
78
- /** Earned accent hue recorded in DESIGN.md `{{ACCENT_HUE}}`. Default: `blue`. */
79
- accentHue?: string
80
- /**
81
- * Flag any numeric radius prop literal (radius={6}) — use the radius token scale instead.
82
- * Default: `true` (ON). Set `false` to disable the `raw-radius` check (e.g. a repo that DEFINES
83
- * the radius primitives).
84
- */
85
- rawRadius?: boolean
86
- /**
87
- * Flag ad-hoc inline surface styling (`border`/`borderRadius`/`boxShadow` literals in a `style={{}}`)
88
- * that bypasses the radius-token + `VX.surface.*` + `VX.shadowCard` system. Default: `true` (ON).
89
- * Set `false` to disable the `raw-surface` check.
90
- */
91
- rawSurface?: boolean
92
- /**
93
- * Flag `withBorder` on a `Card` / `Paper`. Card depth is `--vx-shadow-card` — a whisper shadow
94
- * with the 1px ring baked into the SAME value — so `withBorder` draws a SECOND, real border over
95
- * that ring and the card reads heavy/boxed (docs/DESIGN-SPEC.md doctrine inversion #1).
96
- * `withBorder={false}` and `<Card.Section withBorder>` (a section divider) both pass.
97
- * Default: `true` (ON). Set `false` to disable the `card-with-border` check.
98
- */
99
- cardWithBorder?: boolean
100
- /**
101
- * Flag references to a raw Mantine ramp step used for surface color
102
- * (`var(--mantine-color-gray-N)` / `var(--mantine-color-dark-N)`) — these bypass the basalt
103
- * surface tokens. Default: `true` (ON). Set `false` to disable the `off-system-surface-var` check.
104
- */
105
- offSystemSurfaceVar?: boolean
106
- /**
107
- * Flag a SHADE-PINNED Mantine color — `c="yellow.7"`, `bg="blue.4"`,
108
- * `var(--mantine-color-red-6)`. A pinned step is one fixed swatch in BOTH color schemes, so it
109
- * cannot stay legible in either; route a verdict color through `VX.status.*` / `--vx-status-*`,
110
- * or drop the index (`c="red"`) and let the theme resolve the shade per scheme. `gray-*`/`dark-*`
111
- * in `var()` form belong to `off-system-surface-var` instead. Default: `true` (ON). Set `false`
112
- * to disable the `mantine-shade-index` check.
113
- */
114
- mantineShadeIndex?: boolean
115
- /**
116
- * Flag raw lowercase JSX layout/surface elements (`div`/`span`/`section`/…) carrying an inline
117
- * `style={{}}` with a layout/surface property — steer to a Mantine layout primitive
118
- * (`Box`/`Flex`/`Grid`/`Stack`/`Group`/`SimpleGrid`/`Paper`). Default: `true` (ON). Set `false`
119
- * to disable the `raw-html-layout` check.
120
- */
121
- rawHtmlLayout?: boolean
122
- /**
123
- * Flag spacing/sizing literals inside an inline `style={{}}` (`padding`/`margin`/`gap`/…) — the
124
- * `raw-spacing` check only catches the Mantine prop syntax. Default: `true` (ON). Set `false` to
125
- * disable the `inline-spacing` check.
126
- */
127
- inlineSpacing?: boolean
128
- /**
129
- * Flag `display: 'flex' | 'grid' | 'inline-flex' | 'inline-grid'` in an inline `style={{}}` —
130
- * steer to `<Flex>`/`<Grid>`/`<Group>`. Default: `true` (ON). Set `false` to disable the
131
- * `inline-display` check.
132
- */
133
- inlineDisplay?: boolean
134
- /**
135
- * Flag raw `<AxisLeft>` / `<AxisBottom>` / `<AxisRight>` visx JSX inside chart files (a path
136
- * containing `/charts/`) — these bypass the `AxisLeftNumeric` / `AxisBottomDate` /
137
- * `AxisRightNumeric` primitives that carry the theme tokens + smart ticks. The legitimate wrapper
138
- * (`Axes.tsx`, which IS the primitive) is exempt. Default: `true` (ON). Set `false` to disable the
139
- * `raw-visx-axis` check.
140
- */
141
- rawVisxAxis?: boolean
142
- /**
143
- * Flag a hardcoded duration/spring/ease literal inline in a `transition={{...}}` prop — route it
144
- * through `MOTION_DURATION` / `MOTION_SPRING` / `MOTION_EASE_STANDARD` instead. Default: `true`
145
- * (ON). Set `false` to disable the `raw-motion-value` check.
146
- */
147
- rawMotionValue?: boolean
148
- /**
149
- * Flag a chart entry-point JSX tag (`MultiLine`/`Bars`/`Donut`/`DualPanel`/`Heatmap`/`ZonedLine`/
150
- * `StackedArea`/`LineSparkline`/`BarSparkline`) missing an `ariaLabel` prop. Default: `true`
151
- * (ON). Set `false` to disable the `chart-missing-aria-label` check.
152
- */
153
- chartMissingAriaLabel?: boolean
154
- /**
155
- * Flag a raw lowercase `<input>`/`<select>`/`<textarea>` JSX element — it bypasses the entire
156
- * theme, not just the iOS font-size floor. Use the Mantine equivalents (`TextInput`,
157
- * `NumberInput`, `Select`, `Textarea`, …) instead. Default: `true` (ON). Set `false` to disable
158
- * the `raw-form-control` check.
159
- */
160
- rawFormControl?: boolean
161
- /**
162
- * Flag a `fontSize`/`font-size` literal below 16 inside a `style={{…}}` on a raw form control,
163
- * or a Mantine `styles={{ input: {…} }}` per-part style — the `styles.css` iOS floor is
164
- * `!important`, so the override is dead code. Default: `true` (ON). Set `false` to disable the
165
- * `sub-16-input-font` check.
166
- */
167
- sub16InputFont?: boolean
168
- /**
169
- * Path of the consumer's guard-exempt series file, for DESIGN.md `{{SERIES_MODULE_PATH}}`.
170
- * Default: `<first basalt.root>/lib/series.ts` — see `resolveSeriesModulePath`.
171
- */
172
- seriesModulePath?: string
173
- /**
174
- * Per-rule, per-path exemptions — complements whole-file `exempt` (which skips ALL rules for a
175
- * file) and the hardcoded per-kind `appliesTo` scoping (e.g. `raw-visx-axis` → chart files
176
- * only). Each value is a list of patterns matched against a finding's relative path. A pattern
177
- * may be a whole path segment (`'agent'` matches `src/agent/x.tsx`, not `src/agenting.ts`), a
178
- * relative path (`'public/site.webmanifest'`), a directory prefix, or a glob — `*` stops at `/`,
179
- * `**` does not, and a slash-free glob also matches the basename, so `'*.module.css'` works.
180
- * A trailing `/` is stripped. An entry that suppresses nothing is reported, and
181
- * `check-theme --audit-allows` exits 1 on it. Default: `{}` (no exemptions).
182
- *
183
- * Two forms per kind. The bare array is paths only. The object form adds the REASON, which is
184
- * the half a `theme-allow` carries and this key could not: JSON has no comments, so a
185
- * `.webmanifest` / `.json` finding's only escape is this key, and it was un-reviewable by
186
- * construction — the rationale ended up in a CLAUDE.md paragraph nobody reads next to the diff.
187
- * `check-theme --audit-allows` prints the reason (or names its absence) beside what the entry
188
- * still suppresses. The array form stays supported and unchanged; nothing here is required.
189
- *
190
- * @example
191
- * { exemptRules: { 'inline-display': ['agent'] } } // inline-display never fires under src/agent/**
192
- * @example
193
- * { exemptRules: { 'raw-hex': { paths: ['site.webmanifest'],
194
- * reason: 'a PWA manifest theme_color MUST be a literal hex — JSON cannot reference a CSS var' } } }
195
- */
196
- exemptRules?: Partial<Record<GuardKind, ExemptRuleEntry>>
197
- /**
198
- * Declares an intentional `ai` package major-version skew across workspace packages, exempting
199
- * `doctor`'s `ai-major-parity` hard check. The value IS the reason — a bare `true` is rejected,
200
- * because the whole point of this key is that the pairing is written down, not just switched off.
201
- * A written declaration is the pin `basalt/ai-sdk-major`'s own comment notes is otherwise missing.
202
- *
203
- * Semantics: absent (or present but not a non-empty string) → the skew still hard-fails exactly as
204
- * without this key. A non-empty string with a skew present → `doctor` passes, echoing both the
205
- * skew and this reason. A non-empty string with NO skew present → `doctor` warns that the
206
- * exemption is stale and can be deleted (an exemption nobody re-checks is how a real, later skew
207
- * slips through unnoticed).
208
- *
209
- * @example
210
- * // apps/api streams on ai@5; apps/dashboard parses on ai@7; a producer-side TransformStream
211
- * // neutralizes the one enum value that differs between the two majors.
212
- * { aiMajorSkewReason: 'apps/api pinned to ai@5, apps/dashboard on ai@7 — the skew is neutralized '
213
- * + 'by a producer-side TransformStream in apps/api/src/stream-transform.ts' }
214
- */
215
- aiMajorSkewReason?: string
216
- }
33
+ import { DEFAULT_ROOT, exemptRulePaths, resolveRoots } from './config'
34
+ import type { BasaltConfig } from './config'
217
35
 
218
36
  /**
219
- * One `basalt.exemptRules` value: the historical bare path list, or the same list with the reason
220
- * it exists. See `BasaltConfig.exemptRules`.
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.
221
41
  */
222
- export type ExemptRuleEntry = string[] | { paths: string[]; reason: string }
223
-
224
- /** The paths half of an exemption entry, whichever form it was written in. */
225
- export function exemptRulePaths(entry: ExemptRuleEntry | undefined): string[] {
226
- if (entry === undefined) return []
227
- return Array.isArray(entry) ? entry : entry.paths
228
- }
229
-
230
- /** The recorded reason for an exemption entry, or null for the bare-array form. */
231
- export function exemptRuleReason(entry: ExemptRuleEntry | undefined): string | null {
232
- if (entry === undefined || Array.isArray(entry)) return null
233
- return entry.reason.trim().length > 0 ? entry.reason.trim() : null
234
- }
42
+ type PassthroughKey = Exclude<keyof GuardConfig, 'allowComment' | 'exemptRules' | 'profile'>
235
43
 
236
44
  /**
237
- * The guard-shaped `exemptRules` — paths only. The reason the object form carries never reaches
238
- * the guard: it is accountability for a human reading a diff and for `--audit-allows`, and giving
239
- * the scan a second shape to understand would only be a way for the two to disagree.
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.
240
47
  */
241
- export function resolveExemptRules(cfg: BasaltConfig): GuardConfig['exemptRules'] {
242
- if (cfg.exemptRules === undefined) return DEFAULT_GUARD_CONFIG.exemptRules
243
- const out: Partial<Record<GuardKind, string[]>> = {}
244
- for (const [kind, entry] of Object.entries(cfg.exemptRules)) {
245
- 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 } : {}),
246
83
  }
247
- return out
248
- }
249
-
250
- export const DEFAULT_ROOT = 'src'
251
- export const DEFAULT_ROOTS = [DEFAULT_ROOT]
252
-
253
- /**
254
- * The configured source roots, or the built-in default. The ONE resolution every roots-derived seed
255
- * reads — an empty `roots: []` falls back rather than resolving to nothing, because a bare `??`
256
- * would let `[]` through and render an empty oxfmt glob into the seeded CI, reproducing the exact
257
- * "matches zero files" break this derivation exists to prevent.
258
- */
259
- export function resolveRoots(cfg: BasaltConfig): string[] {
260
- return cfg.roots?.length ? cfg.roots : DEFAULT_ROOTS
261
84
  }
262
85
 
263
86
  /**
@@ -514,23 +337,9 @@ export function resolveProjectDir(cwd: string): ProjectResolution {
514
337
  }
515
338
 
516
339
  /**
517
- * Which shape of consumer a project-scoped command is looking at.
518
- *
519
- * A tokens-only consumer took the `--vx-*` layer and nothing else. Every guard kind whose remedy is
520
- * a Mantine component or the React theme factory is meaningless there — telling a Mantine-free app
521
- * to swap its `<select>` for `@mantine/core`'s `Select` is advice it must not take.
522
- *
523
- * DECLARED, never inferred, when the answer SILENCES something. The two callers want opposite
524
- * failure directions and so do not share a default:
525
- *
526
- * - `check-theme` (`declaredProfile`) turns 17 kinds OFF, so it moves on an explicit signal only —
527
- * `--tokens-only`, or `"basalt": { "profile": "tokens-only" }`. Inferring it from the ABSENCE of
528
- * `@mantine/core` would silence the Mantine half of the guard on any repo that keeps its Mantine
529
- * dependency in a workspace package rather than the one holding the basalt config, which is
530
- * precisely this round's "reports green while enforcing nothing" failure with the guard's own
531
- * hand on the switch.
532
- * - `doctor` no longer infers a profile at all (workspace-wide Mantine detection was deleted along
533
- * with the rest of the multi-repo discovery); it reads the same declaration `check-theme` does.
340
+ * Which shape of consumer a project-scoped command is looking at — DECLARED, never inferred
341
+ * (`--tokens-only`, or `"basalt": { "profile": "tokens-only" }`), because the answer switches the
342
+ * Mantine-remedy guard kinds off. Why that direction: package CLAUDE.md § CLI.
534
343
  */
535
344
  export function declaredProfile(cfg: BasaltConfig, flags: readonly string[]): DoctorProfile {
536
345
  if (flags.includes('--tokens-only')) return 'tokens-only'
@@ -544,8 +353,21 @@ export function declaredProfile(cfg: BasaltConfig, flags: readonly string[]): Do
544
353
  * vs `--selector-attribute`. Silently preferring one meant a CI step that accumulated both flags
545
354
  * enforced whichever the code happened to test first.
546
355
  */
547
- export function conflictingProfileFlags(flags: readonly string[]): boolean {
548
- return flags.includes('--tokens-only') && flags.includes('--framework')
356
+ export function conflictingProfileFlags(command: string, flags: readonly string[]): boolean {
357
+ const both = flags.includes('--tokens-only') && flags.includes('--framework')
358
+ if (both)
359
+ console.error(
360
+ `basalt-ui ${command}: --tokens-only and --framework are alternatives — pass one.`,
361
+ )
362
+ return both
363
+ }
364
+
365
+ /** Says so when `BASALT_CWD` moved the command off the directory it was invoked in. */
366
+ export function noteRelocation(command: string, project: ProjectResolution): void {
367
+ if (project.relocatedFrom === null) return
368
+ console.log(
369
+ `basalt-ui ${command}: BASALT_CWD relocated from ${project.relocatedFrom} to ${project.dir}.`,
370
+ )
549
371
  }
550
372
 
551
373
  /**
@@ -605,19 +427,11 @@ function appShellFiles(rootAbs: string): string[] {
605
427
  const PLAIN_JSON_HINT_PATH = 'manifest.json'
606
428
 
607
429
  /**
608
- * The waiver closer for a file, gated on the profile.
609
- *
610
- * `guardWaiverHint` keys off the file class, and for a `.webmanifest` it leads with
611
- * `basaltAppPlugin` — right for a Mantine app, since a hex in a manifest can never be *right* and
612
- * the plugin removes the hand-copy entirely. A `profile: tokens-only` consumer has definitionally
613
- * opted out of that layer: rollhook's Astro site has no `index.html` for the plugin to transform,
614
- * imports no basalt JavaScript at all, and owns maskable icons the plugin does not emit. Leading
615
- * with the plugin there is advice the consumer cannot take, in the one release that finally gave
616
- * that file class a remedy.
617
- *
618
- * So under tokens-only a manifest is treated as what it is to that consumer — plain JSON, with the
619
- * member as the whole answer — plus one sentence saying which remedy is being withheld and why.
620
- * The remedy text still comes from the guard's own registry; only the class mapping is decided here.
430
+ * The waiver closer for a file, gated on the profile. `guardWaiverHint` leads a `.webmanifest` with
431
+ * `basaltAppPlugin` — right for a Mantine app, advice a `profile: tokens-only` consumer cannot take
432
+ * (rollhook's Astro site has no `index.html` for the plugin and owns its maskable icons). There the
433
+ * manifest is plain JSON — the member is the whole answer, plus one sentence on the withheld remedy.
434
+ * The remedy text still comes from the guard's registry; only the class mapping is decided here.
621
435
  */
622
436
  export function waiverHintFor(relPath: string, profile: DoctorProfile): string {
623
437
  if (profile !== 'tokens-only' || !relPath.endsWith('.webmanifest')) {
@@ -772,7 +586,7 @@ function buildTemplateVars(pkgRoot: string, cwd: string, cfg: BasaltConfig): Tem
772
586
  ACCENT_HUE: cfg.accentHue ?? 'blue',
773
587
  SERIES_MODULE_PATH: resolveSeriesModulePath(cfg),
774
588
  ROOTS_GLOBS: resolveRoots(cfg).map(toRootGlob).join(' '),
775
- OXLINT_PRESET_PATH: shippedAssetPath(install, cwd, 'configs/oxlint.json'),
589
+ OXLINT_PRESET_PATH: shippedAssetPath(install, cwd, OXLINT_FILES.preset),
776
590
  LEFTHOOK_PRESET_PATH: shippedAssetPath(install, cwd, 'configs/lefthook.yml'),
777
591
  BASALT_BIN: basaltBinCommand(install, cwd),
778
592
  }
@@ -1077,7 +891,7 @@ export function managedFiles(
1077
891
  const oxlintrc: ManagedFile = {
1078
892
  dest: '.oxlintrc.json',
1079
893
  mode: 'seed',
1080
- source: 'configs/oxlint.json',
894
+ source: OXLINT_FILES.preset,
1081
895
  render: (ctx) => `{\n "extends": ["${ctx.vars.OXLINT_PRESET_PATH}"]\n}\n`,
1082
896
  }
1083
897
 
@@ -1395,16 +1209,9 @@ export function normalizeForLedger(text: string): string {
1395
1209
 
1396
1210
  /**
1397
1211
  * Write a managed unit to disk (whole file, or marker-spliced region). Returns the unit's hash —
1398
- * `sha256(normalizeForLedger(desired))`, NOT the raw bytes.
1399
- *
1400
- * This is the ledger: the manifest already records "what basalt shipped last sync", one entry per
1401
- * dest — a stored NORMALIZED hash makes that same single entry survive both a downstream formatter
1402
- * (the consumer's own lefthook oxfmt reformatting the written file/block after this function has
1403
- * already returned) and a version bump that changed the template's own words since the recorded
1404
- * entry — the argo cross-version case: current is v1.0.0's block, post-oxfmt; desired is v1.0.1's
1405
- * (different words, e.g. the CLI bin rename). Recording the raw hash can never reconcile that;
1406
- * recording the normalized hash lets `classify()` normalize `state.current` the same way and
1407
- * compare directly against it, no separate historical-hash storage required.
1212
+ * `sha256(normalizeForLedger(desired))`, NOT the raw bytes: the normalized hash is what lets one
1213
+ * manifest entry survive a downstream formatter and a cross-version template change (`classify`'s
1214
+ * path 2 is the reader).
1408
1215
  */
1409
1216
  export function writeUnit(file: ManagedFile, cwd: string, desired: string): string {
1410
1217
  const destAbs = resolve(cwd, file.dest)
@@ -1427,13 +1234,8 @@ export function writeUnit(file: ManagedFile, cwd: string, desired: string): stri
1427
1234
  * manifest (written before this fix, or by a still-running older CLI) holds the RAW
1428
1235
  * hash of what was written, not the normalized one; keep matching it directly so an
1429
1236
  * upgrade never mass-classifies a pristine tree as drifted.
1430
- * Residual transitional gap: a manifest written by a pre-1.0.2 CLI holds a raw hash
1431
- * of the exact old bytes, so if the on-disk block was ALSO reformatted by a
1432
- * downstream formatter AND the template body changed cross-version in the same
1433
- * upgrade, none of the three paths above match and it classifies `drifted`, needing
1434
- * one `--force`. Once any sync since writes a normalized entry (path 2/3 or a
1435
- * healed write), the gap is closed for good for that file. Deliberately not solved
1436
- * by keeping a historical per-version ledger — that's a real but bounded tradeoff.
1237
+ * (A pre-1.0.2 raw entry whose block was ALSO reformatted across a template change
1238
+ * needs one `--force`; bounded, and deliberately not solved with a version ledger.)
1437
1239
  * 2. `sha256(normalizeForLedger(current)) === manifestHash` — the ledger proper.
1438
1240
  * `manifestHash` normally holds `sha256(normalizeForLedger(<bytes written at the
1439
1241
  * last sync>))` (see `writeUnit`); normalizing `current` the same way and comparing
@@ -1665,22 +1467,26 @@ export function parseJsonc(text: string): Record<string, unknown> | null {
1665
1467
  /** Outcome of splicing the shipped preset into an `.oxlintrc.json` the consumer already had. */
1666
1468
  export type MergeLintResult = 'added' | 'already' | 'absent' | 'unreadable' | 'has-comments'
1667
1469
 
1470
+ /** The shipped oxlint files, package-relative — the one spelling every reader of them uses. */
1471
+ export const OXLINT_FILES = {
1472
+ preset: 'configs/oxlint.json',
1473
+ guards: 'configs/oxlint-basalt.json',
1474
+ plugin: 'configs/oxlint-plugin.js',
1475
+ } as const
1476
+
1477
+ /** Whether an `extends`/`jsPlugins` entry names that shipped basalt-ui file. */
1478
+ export const namesShipped = (entry: unknown, file: string): entry is string =>
1479
+ typeof entry === 'string' && entry.endsWith(`basalt-ui/${file}`)
1480
+
1668
1481
  /**
1669
- * The `extends` entry pointing at the shipped oxlint preset, or null when none does.
1670
- *
1671
- * Returns the ENTRY rather than a boolean because the string alone proves nothing: a consumer that
1672
- * upgraded (or moved to an isolated linker) keeps a perfectly well-shaped
1673
- * `./node_modules/basalt-ui/configs/oxlint.json` that resolves to nothing, and oxlint then refuses
1674
- * to start with `NotFound` while a check built to prove the framework is ON reports green. The
1675
- * caller resolves this against the config's own directory — see `extendsSeam`.
1482
+ * The `extends` entry naming a shipped preset (full or guards-only), or null. The ENTRY, not a
1483
+ * boolean: a well-shaped `./node_modules/basalt-ui/configs/oxlint.json` can resolve to nothing
1484
+ * (an upgrade, an isolated linker), and oxlint then dies `NotFound` while a check built to prove the
1485
+ * lane is ON reports green — callers resolve it against the config's own directory.
1676
1486
  */
1677
1487
  export function basaltPresetEntry(entries: unknown): string | null {
1678
- if (!Array.isArray(entries)) return null
1679
- const match = entries.find(
1680
- (entry): entry is string =>
1681
- typeof entry === 'string' && entry.endsWith('basalt-ui/configs/oxlint.json'),
1682
- )
1683
- return match ?? null
1488
+ const presets = [OXLINT_FILES.preset, OXLINT_FILES.guards]
1489
+ return [entries].flat().find((e): e is string => presets.some((f) => namesShipped(e, f))) ?? null
1684
1490
  }
1685
1491
 
1686
1492
  // ── The lefthook gate — checked via `lefthook dump`, not hand-rolled YAML parsing ────────────────
@@ -1761,13 +1567,14 @@ export function mergeOxlintExtends(cwd: string, presetPath: string): MergeLintRe
1761
1567
  if (raw === null) return 'absent'
1762
1568
  const cfg = parseJsonc(raw)
1763
1569
  if (cfg === null) return 'unreadable'
1764
- if (basaltPresetEntry(cfg['extends']) !== null) return 'already'
1570
+ if ([cfg['extends']].flat().some((e) => namesShipped(e, OXLINT_FILES.preset))) return 'already'
1765
1571
  // Rewriting a JSONC config through JSON.stringify would silently delete the consumer's comments,
1766
1572
  // which in a lint config are usually the WHY of every disabled rule. Refuse and say so.
1767
1573
  if (stripJsonc(raw) !== raw) return 'has-comments'
1768
- const existing = Array.isArray(cfg['extends'])
1769
- ? (cfg['extends'] as unknown[]).filter((entry) => typeof entry === 'string')
1770
- : []
1574
+ // The guards-only preset is a subset of the full one — upgrading replaces it, never stacks it.
1575
+ const existing = [cfg['extends']]
1576
+ .flat()
1577
+ .filter((e): e is string => typeof e === 'string' && !namesShipped(e, OXLINT_FILES.guards))
1771
1578
  // Rebuild rather than spread, so `extends` keeps its original position when it was already there
1772
1579
  // and lands first when it wasn't — a diff a human reviews should not reshuffle the whole file.
1773
1580
  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
 
@@ -24,7 +24,6 @@
24
24
  */
25
25
  import type { ReactNode } from 'react'
26
26
  import type { Slot } from '../register'
27
- import { isDev } from '../common/is-dev'
28
27
 
29
28
  // ── Context ───────────────────────────────────────────────────────────────────
30
29
 
@@ -172,7 +171,8 @@ export function defineCommand(cmd: Command): Command {
172
171
  export function runCommand(id: CommandId, ctx?: CommandRunContext): void | Promise<void> {
173
172
  const cmd = activeCommands[id as string]
174
173
  if (cmd === undefined) {
175
- if (isDev()) console.warn(`[basalt] runCommand: no command registered for "${id}"`)
174
+ if (process.env.NODE_ENV !== 'production')
175
+ console.warn(`[basalt] runCommand: no command registered for "${id}"`)
176
176
  return
177
177
  }
178
178
  return cmd.run(ctx)