basalt-ui 1.29.1 → 1.30.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 (119) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/MIGRATING.md +701 -369
  3. package/README.md +23 -2
  4. package/agent/rules/basalt-controls.md +34 -34
  5. package/agent/skills/basalt-app/SKILL.md +4 -2
  6. package/agent/templates/query-client.ts.tpl +18 -4
  7. package/configs/lefthook.yml +51 -19
  8. package/configs/oxlint-plugin.js +210 -84
  9. package/configs/oxlint-plugin.test.ts +182 -5
  10. package/configs/oxlint.json +4 -4
  11. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  12. package/dist/charts/primitives/ChartCard.js +2 -2
  13. package/dist/charts/primitives/ChartFrame.d.ts +23 -3
  14. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  15. package/dist/charts/primitives/ChartFrame.js +4 -4
  16. package/dist/charts/primitives/chart-frame-layout.d.ts +55 -0
  17. package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
  18. package/dist/charts/primitives/chart-frame-layout.js +16 -0
  19. package/dist/cli/check-theme.d.ts.map +1 -1
  20. package/dist/cli/check-theme.js +6 -0
  21. package/dist/cli/doctor.d.ts +10 -4
  22. package/dist/cli/doctor.d.ts.map +1 -1
  23. package/dist/cli/doctor.js +28 -1
  24. package/dist/cli/help.d.ts.map +1 -1
  25. package/dist/cli/help.js +2 -1
  26. package/dist/cli/index.d.ts +12 -0
  27. package/dist/cli/index.d.ts.map +1 -1
  28. package/dist/cli/index.js +29 -0
  29. package/dist/cli/init.d.ts.map +1 -1
  30. package/dist/cli/init.js +4 -15
  31. package/dist/cli/sync.d.ts.map +1 -1
  32. package/dist/cli/sync.js +8 -14
  33. package/dist/controls/actions.d.ts.map +1 -1
  34. package/dist/controls/actions.js +12 -2
  35. package/dist/dashboard/section.module.css +15 -7
  36. package/dist/dashboard/stat-card.d.ts.map +1 -1
  37. package/dist/dashboard/stat-card.js +7 -2
  38. package/dist/dashboard/stat-group.d.ts +8 -2
  39. package/dist/dashboard/stat-group.d.ts.map +1 -1
  40. package/dist/dashboard/stat-group.js +23 -15
  41. package/dist/dashboard/stat-group.module.css +85 -9
  42. package/dist/dashboard/stat-group.module.css.d.ts +1 -0
  43. package/dist/dashboard/widget-grid.d.ts.map +1 -1
  44. package/dist/dashboard/widget-grid.js +4 -5
  45. package/dist/dashboard/widget-grid.module.css +64 -21
  46. package/dist/dashboard/widget-grid.module.css.d.ts +1 -0
  47. package/dist/guard/index.d.ts +19 -2
  48. package/dist/guard/index.d.ts.map +1 -1
  49. package/dist/guard/index.js +27 -4
  50. package/dist/index.d.ts +9 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +2 -0
  53. package/dist/query-client.d.ts +20 -2
  54. package/dist/query-client.d.ts.map +1 -1
  55. package/dist/shell/app-brand.module.css +12 -3
  56. package/dist/shell/app-header.module.css +49 -9
  57. package/dist/shell/app-main.module.css +24 -4
  58. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  59. package/dist/shell/app-mobile-nav.js +98 -51
  60. package/dist/shell/app-mobile-nav.module.css +99 -11
  61. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  62. package/dist/shell/app-sidebar.module.css +55 -2
  63. package/dist/shell/index.d.ts.map +1 -1
  64. package/dist/shell/index.js +3 -3
  65. package/dist/shell/mobile-nav-model.d.ts +26 -7
  66. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  67. package/dist/shell/mobile-nav-model.js +12 -3
  68. package/dist/shell/page-aside.module.css +8 -6
  69. package/dist/shell/page-bar.d.ts +13 -0
  70. package/dist/shell/page-bar.d.ts.map +1 -1
  71. package/dist/shell/page-bar.js +13 -3
  72. package/dist/shell/page-bar.module.css +20 -12
  73. package/dist/shell/page-title.module.css +3 -1
  74. package/dist/theme/use-basalt-spacing.d.ts.map +1 -1
  75. package/dist/tokens/index.d.ts.map +1 -1
  76. package/dist/tokens/index.js +51 -7
  77. package/dist/tokens/palette.d.ts +98 -17
  78. package/dist/tokens/palette.d.ts.map +1 -1
  79. package/dist/tokens/palette.js +110 -18
  80. package/dist/tokens.css +17 -5
  81. package/llms.txt +2 -2
  82. package/package.json +1 -1
  83. package/src/charts/primitives/ChartCard.tsx +12 -7
  84. package/src/charts/primitives/ChartFrame.tsx +30 -15
  85. package/src/charts/primitives/chart-frame-layout.ts +86 -0
  86. package/src/cli/check-theme.ts +13 -0
  87. package/src/cli/doctor.ts +49 -8
  88. package/src/cli/help.ts +5 -3
  89. package/src/cli/index.ts +49 -0
  90. package/src/cli/init.ts +6 -27
  91. package/src/cli/sync.ts +11 -22
  92. package/src/controls/actions.tsx +51 -0
  93. package/src/dashboard/section.module.css +15 -7
  94. package/src/dashboard/stat-card.tsx +7 -2
  95. package/src/dashboard/stat-group.module.css +85 -9
  96. package/src/dashboard/stat-group.module.css.d.ts +1 -0
  97. package/src/dashboard/stat-group.tsx +40 -20
  98. package/src/dashboard/widget-grid.module.css +64 -21
  99. package/src/dashboard/widget-grid.module.css.d.ts +1 -0
  100. package/src/dashboard/widget-grid.tsx +30 -12
  101. package/src/guard/index.ts +79 -11
  102. package/src/index.ts +9 -0
  103. package/src/query-client.ts +17 -5
  104. package/src/shell/app-brand.module.css +12 -3
  105. package/src/shell/app-header.module.css +49 -9
  106. package/src/shell/app-main.module.css +24 -4
  107. package/src/shell/app-mobile-nav.module.css +99 -11
  108. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  109. package/src/shell/app-mobile-nav.tsx +225 -83
  110. package/src/shell/app-sidebar.module.css +55 -2
  111. package/src/shell/index.tsx +26 -6
  112. package/src/shell/mobile-nav-model.ts +37 -9
  113. package/src/shell/page-aside.module.css +8 -6
  114. package/src/shell/page-bar.module.css +20 -12
  115. package/src/shell/page-bar.tsx +39 -5
  116. package/src/shell/page-title.module.css +3 -1
  117. package/src/theme/use-basalt-spacing.ts +8 -6
  118. package/src/tokens/index.ts +90 -6
  119. package/src/tokens/palette.ts +111 -18
package/src/cli/doctor.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * managed-file manifest path) back from `./index`, this package's shared CLI helpers.
5
5
  */
6
6
  import { existsSync } from 'node:fs'
7
- import { resolve } from 'node:path'
7
+ import { relative, resolve } from 'node:path'
8
8
 
9
9
  import type { BasaltConfig } from './index'
10
10
  import {
@@ -19,7 +19,10 @@ import {
19
19
  parseJsonc,
20
20
  readBasaltConfig,
21
21
  readIfExists,
22
+ readManifest,
22
23
  resolveProjectDir,
24
+ resolveRoots,
25
+ scannableFiles,
23
26
  shippedAssetPath,
24
27
  } from './index'
25
28
  import { findManifestAbove, parentInstallAdvice } from './sync'
@@ -102,16 +105,22 @@ function resolveAiMajorSkewReason(cfg: BasaltConfig): {
102
105
  * Check a consumer repo's basalt integration and print a pass/warn report.
103
106
  *
104
107
  * Narrowed to what a single-directory-scoped run can actually verify — no workspace-glob walking,
105
- * no ascend/descend project discovery (see {@link resolveProjectDir}). Four checks, numbered in
108
+ * no ascend/descend project discovery (see {@link resolveProjectDir}). Six checks, numbered in
106
109
  * the order they run and print:
107
110
  *
108
111
  * Hard failures (exit non-zero):
109
112
  * 1. `.basalt/manifest.json` exists (`basalt-ui init` was run).
110
- * 2. `oxlint-preset` ("rules in sync"): the consumer's `.oxlintrc.json` extends the shipped
113
+ * 2. `install-parity`: basalt-ui resolves, doctor prints WHERE and at what version, and that
114
+ * version agrees with the manifest's `basaltVersion`.
115
+ * 3. `guard-scan`: `check-theme` would scan more than zero files, sharing `scannableFiles` so the
116
+ * two cannot disagree. Both RESTORED in 1.30.0 after four minors gone with no MIGRATING row —
117
+ * README § `doctor` carries what their absence cost (a no-op CI gate, a green guard scanning
118
+ * nothing, and two basalt-WRITTEN docs describing output the CLI no longer produced).
119
+ * 4. `oxlint-preset` ("rules in sync"): the consumer's `.oxlintrc.json` extends the shipped
111
120
  * preset AND that path resolves. `init` keeps an existing config, so the framework's whole
112
121
  * lint half can be silently off — one repo carried six real violations invisibly across five
113
122
  * minors this way.
114
- * 4. `ai-major-parity`: within THIS package.json, `dependencies`/`devDependencies`/
123
+ * 6. `ai-major-parity`: within THIS package.json, `dependencies`/`devDependencies`/
115
124
  * `peerDependencies` agree on the `ai` package's major. `basalt/ai-sdk-major` (the oxlint
116
125
  * plugin rule) already catches this per linted FILE against its nearest package.json; this
117
126
  * check exists for the shape a lint run cannot see — a package whose own manifest asks its
@@ -121,7 +130,7 @@ function resolveAiMajorSkewReason(cfg: BasaltConfig): {
121
130
  * already agree warns that the exemption is stale.
122
131
  *
123
132
  * Warning (non-fatal):
124
- * 3. `lefthook-preset` ("lefthook wired"): a pre-commit command runs `check-theme`, read from
133
+ * 5. `lefthook-preset` ("lefthook wired"): a pre-commit command runs `check-theme`, read from
125
134
  * `lefthook dump --format json` (resolves `extends`/`include`/`root:` the way lefthook itself
126
135
  * does — see {@link inspectLefthookGate}). No lefthook config at all is a pass (a consumer may
127
136
  * gate elsewhere); a config that resolves but wires no guard command warns; a config lefthook
@@ -189,7 +198,39 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
189
198
 
190
199
  const install = findBasaltInstall(cwd)
191
200
 
192
- // ── Hard check 2: the consumer's oxlint config extends the shipped preset ──
201
+ // ── Hard check 2: install matches the manifest, and doctor says WHERE (the CLAUDE-block's claim)
202
+ const manifestVersion = profile === 'tokens-only' ? undefined : readManifest(cwd).basaltVersion
203
+ const at = install.dir === null ? '' : `${relative(cwd, install.dir)} (${install.version})`
204
+ if (install.dir === null) {
205
+ fail(
206
+ 'install-parity: basalt-ui does not resolve here, above, or in a sibling workspace package.',
207
+ )
208
+ } else if (manifestVersion === undefined || install.version === manifestVersion) {
209
+ const matches = manifestVersion === undefined ? '' : " and matches the manifest's basaltVersion"
210
+ pass(`basalt-ui resolves at ${at}${matches}`)
211
+ } else {
212
+ fail(
213
+ `install-parity: basalt-ui resolves at ${at}, but ${MANIFEST_PATH} was last written by ` +
214
+ `${manifestVersion} — the managed layer is from a different release than the code. Run ` +
215
+ '`basalt-ui sync` (or `bun install`, if the lockfile is what is stale).',
216
+ )
217
+ }
218
+
219
+ // ── Hard check 3: the guard sees files — zero-file roots are a green guard enforcing nothing ──
220
+ const scanned = profile === 'tokens-only' ? null : scannableFiles(cwd, cfg).length
221
+ if (scanned === null) {
222
+ pass('guard-scan: n/a — a tokens-only consumer runs the token lane, not the file walk')
223
+ } else if (scanned === 0) {
224
+ fail(
225
+ 'guard-scan: `check-theme` would scan ZERO files here — either `basalt.roots` points at ' +
226
+ 'paths that do not exist, or this is not the package the config lives in (BASALT_CWD, ' +
227
+ 'else the cwd; nothing is inferred).',
228
+ )
229
+ } else {
230
+ pass(`guard-scan: check-theme covers ${scanned} file(s) under ${resolveRoots(cfg).join(', ')}`)
231
+ }
232
+
233
+ // ── Hard check 4: the consumer's oxlint config extends the shipped preset ──
193
234
  // `init` KEEPS an existing `.oxlintrc.json`, so a repo can carry the whole scaffold with the
194
235
  // framework's lint half switched off and nothing anywhere saying so. One repo ran five minors
195
236
  // that way and surfaced six real `basalt/no-raw-font-size` errors the moment it was wired.
@@ -235,7 +276,7 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
235
276
  }
236
277
  }
237
278
 
238
- // ── Warn check 3: the pre-commit hook actually runs the guard ──────────────
279
+ // ── Warn check 5: the pre-commit hook actually runs the guard ──────────────
239
280
  // Read via `lefthook dump --format json`, which resolves `extends`/`include`/remote configs/
240
281
  // `root:` the way lefthook itself does — see {@link inspectLefthookGate}.
241
282
  const repoRoot = findRepoRoot(cwd)
@@ -262,7 +303,7 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
262
303
  )
263
304
  }
264
305
 
265
- // ── Hard check 4: ai package major version parity within THIS package.json ─
306
+ // ── Hard check 6: ai package major version parity within THIS package.json ─
266
307
  // basalt/ai-sdk-major (the lint rule) is per-file against the NEAREST package.json, so a lint
267
308
  // run scoped to one workspace package only ever sees that package's own `ai` major and is
268
309
  // perfectly happy. This check reads the SAME package.json across its dependencies/
package/src/cli/help.ts CHANGED
@@ -21,6 +21,7 @@ export const COMMAND_OPTIONS: Record<string, Record<string, { type: 'string' | '
21
21
  sync: {
22
22
  force: { type: 'boolean' },
23
23
  check: { type: 'boolean' },
24
+ verbose: { type: 'boolean' },
24
25
  'tokens-only': { type: 'boolean' },
25
26
  framework: { type: 'boolean' },
26
27
  },
@@ -65,7 +66,7 @@ export function unknownFlag(cmd: string, flags: readonly string[]): string | nul
65
66
  /** The one usage string — printed by `basalt help` / `--help` / `-h` AND the unknown-command fallback. */
66
67
  export const USAGE =
67
68
  'Usage: basalt-ui <--version | init [--with-router] [--with-query] [--merge-lint] |\n' +
68
- ' sync [--force] [--check] [--tokens-only|--framework] |\n' +
69
+ ' sync [--force] [--check] [--verbose] [--tokens-only|--framework] |\n' +
69
70
  ' check-theme [--audit-allows] |\n' +
70
71
  ' doctor [--tokens-only|--framework] | guard-hook | tokens:css | fonts:css | help>\n\n' +
71
72
  'check-theme [--tokens-only|--framework] [--audit-allows]\n' +
@@ -89,9 +90,10 @@ export const USAGE =
89
90
  ' Checks THIS project only: the manifest exists, the oxlint preset is wired, the pre-commit\n' +
90
91
  ' hook runs check-theme (via `lefthook dump`), and the ai package major is consistent within\n' +
91
92
  ' this package.json. Profile is DECLARED, same as check-theme — never inferred.\n\n' +
92
- 'sync [--force] [--check] [--tokens-only|--framework]\n' +
93
+ 'sync [--force] [--check] [--verbose] [--tokens-only|--framework]\n' +
93
94
  ' Refreshes an EXISTING install; it never creates one (that is init). A tokens-only consumer\n' +
94
- ' has no scaffold to reconcile, so sync reports n/a and exits 0 there — as doctor does.\n\n' +
95
+ ' has no scaffold to reconcile, so sync reports n/a and exits 0 there — as doctor does.\n' +
96
+ ' --check prints the permanent placement skips as a COUNT; --verbose spells them out again.\n\n' +
95
97
  'check-theme / doctor / sync all resolve their project the same way: BASALT_CWD, else the\n' +
96
98
  'invocation cwd — nothing inferred, nothing ascended or descended into.\n' +
97
99
  '--tokens-only and --framework are alternatives; passing both is an error.\n\n' +
package/src/cli/index.ts CHANGED
@@ -333,6 +333,17 @@ function sanitizeSeverity(cfg: BasaltConfig): BasaltConfig {
333
333
  return { ...cfg, severity: kept as Partial<Record<GuardKind, GuardSeverity>> }
334
334
  }
335
335
 
336
+ /** Does `cwd`'s own `package.json` carry a `basalt` key? — "misconfigured roots" vs "wrong
337
+ * directory", the failure a root-invoked hook hits since 1.29.0's single resolver. Never a walk. */
338
+ export function hasBasaltKey(cwd: string): boolean {
339
+ const raw = readIfExists(resolve(cwd, 'package.json'))
340
+ try {
341
+ return raw !== null && (JSON.parse(raw) as { basalt?: unknown }).basalt !== undefined
342
+ } catch {
343
+ return false
344
+ }
345
+ }
346
+
336
347
  export function readBasaltConfig(cwd: string): BasaltConfig {
337
348
  try {
338
349
  const pkg = JSON.parse(readFileSync(resolve(cwd, 'package.json'), 'utf8')) as {
@@ -1114,6 +1125,44 @@ export function managedFiles(
1114
1125
  return [...rules, ...skills, claudeBlock, design, oxfmt, ...rootTooling, oxlintrc, ...scaffolds]
1115
1126
  }
1116
1127
 
1128
+ /**
1129
+ * The PERMANENT placement skips as sentences — one text for `init` and `sync`, which carried two
1130
+ * near-copies that had already drifted apart. Correct and unchanging, which is why `sync --check`
1131
+ * hides them behind `--verbose`. `extends` alone is NOT the recipe for the hook, and saying only
1132
+ * that shipped a broken one: lefthook merges the preset's commands but runs every one at the REPO
1133
+ * ROOT, with no oxlint config, no local binary and no basalt config. `root:` moves the invocation
1134
+ * cwd per command and merges (the preset does not define it) — see the preset's MONOREPO RECIPE.
1135
+ */
1136
+ export function placementNotices(cwd: string, placement: PlacementFlags): string[] {
1137
+ const out: string[] = []
1138
+ const install = findBasaltInstall(cwd)
1139
+ const asset = (n: string): string => shippedAssetPath(install, placement.repoRoot, n)
1140
+ // Bare, no `./` — a lefthook `root:` and an Actions `working-directory:` are already repo-relative.
1141
+ const pkg = relativePosix(placement.repoRoot, cwd).replace(/^\.\//, '')
1142
+ const notRoot = `(not the repo root — repo root detected at ${placement.repoRoot})`
1143
+ if (!placement.isPackageRepoRoot) {
1144
+ out.push(
1145
+ `skipped lefthook.yml ${notRoot} — lefthook only reads config at the repo root. Extend ` +
1146
+ `${asset('configs/lefthook.yml')} from your root lefthook.yml AND give every merged ` +
1147
+ `command \`root: '${pkg}/'\` — \`pre-commit.commands.{oxlint,oxfmt,check-theme}.root: ` +
1148
+ `'${pkg}/'\` — or they run at the root where your oxlint config, your binaries and your ` +
1149
+ 'basalt config are not.',
1150
+ `skipped .github/workflows/check.yml ${notRoot} — GitHub Actions only reads .github/ at the ` +
1151
+ `repo root; copy the steps from ${asset('configs/check.yml')} into your root CI workflow ` +
1152
+ `instead, and give each one \`working-directory: ${pkg}\` — the same relocation \`root:\` ` +
1153
+ 'performs for lefthook, and needed for the same reason.',
1154
+ )
1155
+ }
1156
+ if (placement.relocatedQueryClient !== null) {
1157
+ out.push(
1158
+ `skipped src/query-client.ts (found an existing query client at ` +
1159
+ `${relative(cwd, placement.relocatedQueryClient)}) — import it from there instead of ` +
1160
+ 're-seeding at the original path.',
1161
+ )
1162
+ }
1163
+ return out
1164
+ }
1165
+
1117
1166
  export type Manifest = {
1118
1167
  version: 1
1119
1168
  /** The basalt-ui version that last wrote this manifest — `doctor`'s one reconciliation axis. */
package/src/cli/init.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * oxlint-preset plugin reader from `./sync` (also called there).
5
5
  */
6
6
  import { existsSync } from 'node:fs'
7
- import { relative, resolve } from 'node:path'
7
+ import { resolve } from 'node:path'
8
8
 
9
9
  import { deriveSpacing } from '../tokens'
10
10
  import type { MergeLintResult, ScaffoldFlags } from './index'
@@ -15,17 +15,16 @@ import {
15
15
  mergeOxlintExtends,
16
16
  normalizeForLedger,
17
17
  patchPackageJson,
18
+ placementNotices,
18
19
  pruneRetiredManagedFiles,
19
20
  readManifest,
20
21
  reconcileRoots,
21
- relativePosix,
22
22
  renderContext,
23
23
  reportPrune,
24
24
  resolvePeerFlags,
25
25
  resolvePlacement,
26
26
  rootsNotices,
27
27
  sha256,
28
- shippedAssetPath,
29
28
  unitState,
30
29
  writeFileEnsuringDir,
31
30
  writeUnit,
@@ -166,30 +165,10 @@ export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags =
166
165
  if (seededLintScript) {
167
166
  console.log(`basalt-ui init: added the "lint:basalt" script (${lintScript}) to package.json.`)
168
167
  }
169
- // lefthook.yml / .github/workflows/check.yml are repo-root-shaped — neither lefthook nor GitHub
170
- // Actions reads config from anywhere but the repo root, so a package living in a subdirectory
171
- // skips both rather than relocating them into a spot nothing reads.
172
- if (!placement.isPackageRepoRoot) {
173
- console.log(
174
- `basalt-ui init: skipped lefthook.yml (this package is not the repo root — repo root ` +
175
- `detected at ${placement.repoRoot}) — lefthook only reads config at the repo root; extend ` +
176
- `${ctx.vars.LEFTHOOK_PRESET_PATH} from your root lefthook.yml instead, and give its ` +
177
- `check-theme command \`env: { BASALT_CWD: ${relativePosix(placement.repoRoot, cwd)} }\` ` +
178
- 'so the guard runs where your basalt config actually lives.',
179
- )
180
- console.log(
181
- `basalt-ui init: skipped .github/workflows/check.yml (this package is not the repo root — ` +
182
- `repo root detected at ${placement.repoRoot}) — GitHub Actions only reads .github/ at the ` +
183
- `repo root; copy the steps from ${shippedAssetPath(install, placement.repoRoot, 'configs/check.yml')} ` +
184
- 'into your root CI workflow instead.',
185
- )
186
- }
187
- if (placement.relocatedQueryClient !== null) {
188
- console.log(
189
- `basalt-ui init: skipped src/query-client.ts (found an existing query client at ` +
190
- `${relative(cwd, placement.relocatedQueryClient)}) — import it from there instead of ` +
191
- 're-seeding at the original path.',
192
- )
168
+ // The permanent placement skips (repo-root-shaped tooling, a relocated scaffold) — one text,
169
+ // shared with `sync`. See {@link placementNotices}.
170
+ for (const line of placementNotices(cwd, placement)) {
171
+ console.log(`basalt-ui init: ${line}`)
193
172
  }
194
173
  // query-client.ts / __root.tsx reference the optional TanStack peers directly — seeding them
195
174
  // without the peer installed would ship an unresolved import. Hint how to opt in instead.
package/src/cli/sync.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * resolution back from `./index`, this package's shared CLI helpers.
6
6
  */
7
7
  import { existsSync } from 'node:fs'
8
- import { relative, resolve } from 'node:path'
8
+ import { resolve } from 'node:path'
9
9
 
10
10
  import { deriveSpacing } from '../tokens'
11
11
  import {
@@ -21,6 +21,7 @@ import {
21
21
  managedFiles,
22
22
  normalizeForLedger,
23
23
  parseJsonc,
24
+ placementNotices,
24
25
  pruneRetiredManagedFiles,
25
26
  readBasaltConfig,
26
27
  readIfExists,
@@ -232,26 +233,12 @@ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd
232
233
  })
233
234
  staleForCheck += pruned.removed.length + pruned.drifted.length
234
235
 
235
- // Placement notices — informational, never affect the exit code (a skipped tooling seed or a
236
- // relocated scaffold is a legitimate consumer choice, not a sync failure).
237
- if (!placement.isPackageRepoRoot) {
238
- console.log(
239
- `basalt-ui sync: skipped lefthook.yml (this package is not the repo root — repo root ` +
240
- `detected at ${placement.repoRoot}) — lefthook only reads config at the repo root; extend ` +
241
- 'node_modules/basalt-ui/configs/lefthook.yml from your root lefthook.yml instead.',
242
- )
243
- console.log(
244
- `basalt-ui sync: skipped .github/workflows/check.yml (this package is not the repo root — ` +
245
- `repo root detected at ${placement.repoRoot}) — GitHub Actions only reads .github/ at the ` +
246
- 'repo root; extend node_modules/basalt-ui/configs/check.yml from your root CI workflow instead.',
247
- )
248
- }
249
- if (placement.relocatedQueryClient !== null) {
250
- console.log(
251
- `basalt-ui sync: skipped src/query-client.ts (found an existing query client at ` +
252
- `${relative(cwd, placement.relocatedQueryClient)}) — import it from there instead of ` +
253
- 're-seeding at the original path.',
254
- )
236
+ // Placement notices — informational, never affecting the exit code, and PERMANENT: `--check`
237
+ // hides them behind `--verbose` (it sits in consumers' CI chains, where three unchanging
238
+ // "skipped" paragraphs per run are noise indistinguishable from drift) but still prints the COUNT.
239
+ const placementSkips = placementNotices(cwd, placement)
240
+ if (opts.check !== true || syncFlags.includes('--verbose')) {
241
+ for (const line of placementSkips) console.log(`basalt-ui sync: ${line}`)
255
242
  }
256
243
 
257
244
  const rootsMessages = rootsNotices(rootsState)
@@ -298,7 +285,9 @@ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd
298
285
  console.error(`basalt-ui sync --check: ${staleForCheck} managed file(s) out of date.`)
299
286
  return 1
300
287
  }
301
- console.log('✓ basalt-ui sync --check: all managed files current.')
288
+ const n = placementSkips.length
289
+ const skips = n === 0 ? '' : ` (${n} permanent placement skip(s) — --verbose for detail)`
290
+ console.log(`✓ basalt-ui sync --check: all managed files current.${skips}`)
302
291
  return 0
303
292
  }
304
293
 
@@ -13,6 +13,7 @@
13
13
  * media query, so there is no first-paint flash and no hook that re-renders on resize.
14
14
  */
15
15
  import { ActionIcon, Button, Group, Menu } from '@mantine/core'
16
+ import type { MantineBreakpoint } from '@mantine/core'
16
17
  import { createContext, isValidElement, useContext, useEffect } from 'react'
17
18
  import type { ReactNode } from 'react'
18
19
  import type { BasaltProps } from '../common/props'
@@ -187,6 +188,19 @@ const MOBILE_PRIMARY_PADDING_X = 10
187
188
  */
188
189
  const BAR_GAP = 6
189
190
 
191
+ /**
192
+ * A declared WIDTH TIER for one rendered form of an action — Mantine's own `visibleFrom`/`hiddenFrom`
193
+ * style props, forwarded verbatim, which is the same CSS-only mechanism this file's `sm` swap uses
194
+ * (see the module header). Never a JS media query: no first-paint flash, no resize re-render.
195
+ *
196
+ * It exists because `sm` is not the only width at which this row runs out of room — see
197
+ * {@link BarEntry}'s `md` label fold for the arithmetic.
198
+ */
199
+ type TierVisibility = {
200
+ visibleFrom?: MantineBreakpoint
201
+ hiddenFrom?: MantineBreakpoint
202
+ }
203
+
190
204
  /**
191
205
  * One action as a button. No `size` prop anywhere: the enclosing `CtlSlot` (mounted by the home,
192
206
  * never by the action) resolves the `ctl` tier — law C5.
@@ -195,15 +209,19 @@ function BarButton({
195
209
  action,
196
210
  variant,
197
211
  px,
212
+ tier,
198
213
  }: {
199
214
  action: BarActionItem
200
215
  variant: 'filled' | 'default'
201
216
  /** Overrides the tier's horizontal inset — only the compact mobile primary passes it. */
202
217
  px?: number
218
+ /** The declared width tier this form paints at — see {@link TierVisibility}. */
219
+ tier?: TierVisibility
203
220
  }): ReactNode {
204
221
  const shared = {
205
222
  variant,
206
223
  ...(px !== undefined && { px }),
224
+ ...tier,
207
225
  disabled: action.disabled === true,
208
226
  loading: action.loading === true,
209
227
  // EVERY icon in this file goes through `IconSlot` — the box is the framework's, never the
@@ -234,13 +252,17 @@ function BarButton({
234
252
  function BarIconButton({
235
253
  action,
236
254
  variant,
255
+ tier,
237
256
  }: {
238
257
  action: BarActionItem
239
258
  /** `default` is the bordered desktop form — a joined `ControlGroup` member (see {@link BarEntry}). */
240
259
  variant: 'filled' | 'subtle' | 'default'
260
+ /** The declared width tier this form paints at — see {@link TierVisibility}. */
261
+ tier?: TierVisibility
241
262
  }): ReactNode {
242
263
  const shared = {
243
264
  variant,
265
+ ...tier,
244
266
  'aria-label': action.label,
245
267
  disabled: action.disabled === true,
246
268
  loading: action.loading === true,
@@ -698,6 +720,35 @@ function BarEntry({
698
720
  if (iconOnly === true && action.icon !== undefined) {
699
721
  return <BarIconButton action={action} variant="default" />
700
722
  }
723
+ // THE `md` LABEL FOLD — the second declared tier, and the only fold this row had between `sm`
724
+ // (48em, where the whole mobile swap happens) and the width at which a desktop header is
725
+ // genuinely wide. Above `sm` the only fold was `DESKTOP_SECONDARY_MAX`, which is a COUNT and
726
+ // never a width: three labelled secondaries are three labelled secondaries at 768px and at
727
+ // 2560px.
728
+ //
729
+ // The arithmetic, on /dashboard at exactly 768px — the width where the navbar appears and takes
730
+ // 256px: 512px of header left, against ~554px of row 1 + ~102px of globals + ~48px of gaps ≈
731
+ // 704px. ~192px over, and nothing above `sm` folded — the breadcrumb absorbed it by shrinking to
732
+ // zero (`shell/app-header.module.css`'s `.lead`, now floored at 96px, which this tier pays for).
733
+ // Dropping up to `DESKTOP_SECONDARY_MAX` labelled secondaries to their 30px icon form returns
734
+ // roughly 180-240px on that row: a labelled `ctl` button is its icon slot + label + insets
735
+ // (~90-110px depending on the word), an icon-only one is the tier's 30px square.
736
+ //
737
+ // The PRIMARY keeps its label at every width — it is the one action a reader must be able to
738
+ // name without hovering (law C6: exactly one per home), so `emphasis === 'primary'` never enters
739
+ // this branch. An icon-LESS secondary keeps its label too: there is nothing to fall back to, the
740
+ // same reason `MobileBarEntry` renders a compact labelled button below `sm`.
741
+ //
742
+ // Both forms are rendered and CSS picks one, the mechanism the `sm` swap already uses — that is
743
+ // law C9 being HONOURED, not breached: the swap belongs to the control, and this is the control.
744
+ if (emphasis === 'secondary' && action.icon !== undefined) {
745
+ return (
746
+ <>
747
+ <BarIconButton action={action} variant="default" tier={{ hiddenFrom: 'md' }} />
748
+ <BarButton action={action} variant="default" tier={{ visibleFrom: 'md' }} />
749
+ </>
750
+ )
751
+ }
701
752
  return <BarButton action={action} variant={emphasis === 'primary' ? 'filled' : 'default'} />
702
753
  }
703
754
 
@@ -6,20 +6,27 @@
6
6
 
7
7
  /*
8
8
  * 12px from the header to the body, and the same between two body children — one rhythm, read from
9
- * the stack scale (`--vx-space-stack-md`) instead of the card-inset scale
10
- * (`--mantine-spacing-sm`, 13px). A Section is a page-level GROUPING, so its gaps belong to the
11
- * vertical rhythm, not to the inset ladder its cards use.
9
+ * the stack scale (`SPACE.stackMd`, via the `--vx-space-section-gap` role token) instead of the
10
+ * card-inset scale (13px). A Section is a page-level GROUPING, so its gaps belong to the vertical
11
+ * rhythm, not to the inset ladder its cards use.
12
+ *
13
+ * Below `sm` it steps to `stackSm` (8px), together with the cards' own horizontal inset. That step
14
+ * is NOT written here: it belongs to the mobile inset ladder, whose ONE home is `spaceMobileDecls`
15
+ * in `tokens/index.ts` — this rule only reads the role token and gets the responsive value for
16
+ * free. See that function's doc for why the law lives in the emitter rather than in the three
17
+ * stylesheets that consume it (two of the three are inline styles, which cannot hold a media
18
+ * query at all).
12
19
  */
13
20
  .root {
14
21
  display: flex;
15
22
  flex-direction: column;
16
- gap: var(--vx-space-stack-md, 0.75rem);
23
+ gap: var(--vx-space-section-gap, 0.75rem);
17
24
  }
18
25
 
19
26
  .body {
20
27
  display: flex;
21
28
  flex-direction: column;
22
- gap: var(--vx-space-stack-md, 0.75rem);
29
+ gap: var(--vx-space-section-gap, 0.75rem);
23
30
  }
24
31
 
25
32
  /*
@@ -48,8 +55,9 @@
48
55
  * The collapse chevron — a plain control, not a Mantine ActionIcon, so it sits directly in the header
49
56
  * row (its own CtlSlot fragment would nest a theme provider around one glyph). It is sized to the
50
57
  * `ctl` tier BY HAND for exactly that reason: a 12px glyph in a 20px box was a different height from
51
- * every other control in the same header, which is what made the section header read as ragged. 30px
52
- * square, glyph centred, no padding — the box IS the tier.
58
+ * every other control in the same header, which is what made the section header read as ragged. 32px
59
+ * square (the `ctl` tier, 30 before the desktop-shell pass), glyph centred, no padding — the box IS
60
+ * the tier.
53
61
  */
54
62
  .chevron {
55
63
  /* `relative` is the anchor for the C15 hit-area overlay in the `< sm` block at the bottom. */
@@ -378,7 +378,12 @@ export function StatCard({
378
378
  <Card
379
379
  className={cx(classNames?.root, className)}
380
380
  style={{
381
- // Card inset = spacing xs / sm, matching every other basalt card. `overflow: hidden` clips
381
+ // Card inset = the `cardInsetY`/`cardInsetX` role tokens (11/13 at level 0 — the same
382
+ // `xs`/`sm` rungs this used to spell as `--mantine-spacing-*`), matching every other basalt
383
+ // card because `ChartCard` reads the SAME two vars. Below `sm` the horizontal one steps a
384
+ // rung down on its own: the responsive half of the ladder lives in `spaceMobileDecls`
385
+ // (`tokens/index.ts`), which is the only home that can reach an inline style like this one.
386
+ // `overflow: hidden` clips
382
387
  // the full-bleed sparkline to the card's rounded corners; an element's own `box-shadow`
383
388
  // renders outside its border box and is NOT clipped by its own `overflow`, so the
384
389
  // shadow-card ring is unaffected (verified — only an ANCESTOR's overflow clips a
@@ -390,7 +395,7 @@ export function StatCard({
390
395
  // body is `height: 100%` and the sparkline is `margin-top: auto`. The content is what states
391
396
  // the height now (~88-96px), and four cards in a `SimpleGrid` still match because a grid row
392
397
  // stretches its items.
393
- padding: 'var(--mantine-spacing-xs) var(--mantine-spacing-sm)',
398
+ padding: 'var(--vx-space-card-inset-y, 0.6875rem) var(--vx-space-card-inset-x, 0.8125rem)',
394
399
  overflow: 'hidden',
395
400
  // Anchors the tone rail. Set unconditionally so a card's stacking context does not change
396
401
  // depending on whether it happens to have crossed a threshold this render.
@@ -1,10 +1,41 @@
1
1
  /*
2
- * StatGroup's column law and its optional rail, in CSS (law C9). The literals are Mantine's
3
- * `--mantine-breakpoint-sm` (48em) and `--mantine-breakpoint-lg` (75em) — a `@media` condition
4
- * cannot read a custom property, so the number has to be written; the COUNTS are custom properties
5
- * the component sets per instance.
2
+ * StatGroup's column law and its optional rail, in CSS (law C9).
3
+ *
4
+ * **The law keys on the CONTAINER, not the viewport.** A viewport breakpoint answers the wrong
5
+ * question — the same one `resolveChartTier` refuses to ask (`charts/primitives/chart-frame-layout.
6
+ * ts`): the shell has regions that change the AVAILABLE width with the viewport held still (a
7
+ * `PageAside` claiming its 300px, the sidebar collapsing 256px → its 48px rail, a consumer's own
8
+ * split layout), and `@media` cannot see any of them. Measured defect: a `PageAside` mounted on a
9
+ * 1512px desktop left the KPI row ~956px wide, `@media` still resolved `lg`, and four cells of
10
+ * 239px truncated their own values mid-word ("8,7…", "Organic se…").
11
+ *
12
+ * `.container` is what makes the queries answerable — `StatGroup` renders it around the grid, so
13
+ * the container context ALWAYS exists and is ALWAYS the box the grid actually fills, whatever the
14
+ * shell or the consumer wrapped it in. It deliberately does not depend on the shell establishing
15
+ * one: a query with no container resolves to false, so a shell-only context would have silently
16
+ * pinned every non-shell consumer to the base count.
17
+ *
18
+ * The `@supports not` block below it is the pre-2023 fallback and the ONLY place the old viewport
19
+ * law survives. It is written as an exclusive alternative rather than as a base the container
20
+ * queries override, because the rail's `:nth-child` suppressors are MORE specific than the rule
21
+ * that draws the border: two overlapping laws would each suppress a different set of leading cells
22
+ * and the rail would lose hairlines in the middle of a row. One law at a time, chosen by support.
23
+ *
24
+ * The literals: `@container` conditions are stated in px, `@media` ones in Mantine's own `em`
25
+ * (`--mantine-breakpoint-sm` 48em, `--mantine-breakpoint-lg` 75em). Both spellings name the same
26
+ * boundary at the 16px initial font size — 768px and 1200px — but `em` inside a `@container`
27
+ * condition resolves against the CONTAINER's font-size rather than the root's, so a consumer panel
28
+ * with smaller text would silently move the breakpoint. px is the only spelling that cannot drift.
29
+ * (`47.99375em` → `767.9px`, `74.99375em` → `1199.9px`; the same complements `stat-card.module.css`
30
+ * uses.) A condition still cannot read a custom property either way, so the numbers are written;
31
+ * the COUNTS are custom properties the component sets per instance.
6
32
  */
7
33
 
34
+ .container {
35
+ container-type: inline-size;
36
+ container-name: basalt-stat-group;
37
+ }
38
+
8
39
  .root {
9
40
  display: grid;
10
41
  /* Two KPIs abreast on a phone, never one: a KPI is a short pre-formatted number, and a single
@@ -13,13 +44,13 @@
13
44
  gap: var(--vx-space-stack-md);
14
45
  }
15
46
 
16
- @media (min-width: 48em) {
47
+ @container basalt-stat-group (min-width: 768px) {
17
48
  .root {
18
49
  grid-template-columns: repeat(var(--basalt-stat-group-cols-sm), minmax(0, 1fr));
19
50
  }
20
51
  }
21
52
 
22
- @media (min-width: 75em) {
53
+ @container basalt-stat-group (min-width: 1200px) {
23
54
  .root {
24
55
  grid-template-columns: repeat(var(--basalt-stat-group-cols-lg), minmax(0, 1fr));
25
56
  }
@@ -40,14 +71,14 @@
40
71
  padding-inline-start: var(--vx-space-stack-md);
41
72
  }
42
73
 
43
- @media (max-width: 47.99375em) {
74
+ @container basalt-stat-group (max-width: 767.9px) {
44
75
  .root[data-divided] > *:nth-child(2n + 1) {
45
76
  border-inline-start: 0;
46
77
  padding-inline-start: 0;
47
78
  }
48
79
  }
49
80
 
50
- @media (min-width: 48em) and (max-width: 74.99375em) {
81
+ @container basalt-stat-group (min-width: 768px) and (max-width: 1199.9px) {
51
82
  .root[data-divided][data-sm-cols='2'] > *:nth-child(2n + 1),
52
83
  .root[data-divided][data-sm-cols='3'] > *:nth-child(3n + 1) {
53
84
  border-inline-start: 0;
@@ -55,7 +86,7 @@
55
86
  }
56
87
  }
57
88
 
58
- @media (min-width: 75em) {
89
+ @container basalt-stat-group (min-width: 1200px) {
59
90
  .root[data-divided][data-cols='2'] > *:nth-child(2n + 1),
60
91
  .root[data-divided][data-cols='3'] > *:nth-child(3n + 1),
61
92
  .root[data-divided][data-cols='4'] > *:nth-child(4n + 1),
@@ -64,3 +95,48 @@
64
95
  padding-inline-start: 0;
65
96
  }
66
97
  }
98
+
99
+ /*
100
+ * The pre-container-query fallback — Safari < 16, Chrome < 106, Firefox < 110. Verbatim the law
101
+ * this file shipped before, keyed on the viewport, and reachable ONLY where `@container` is not.
102
+ * A browser that cannot parse `container-type` also cannot parse the `@container` blocks above, so
103
+ * the two sets are never live at once.
104
+ */
105
+ @supports not (container-type: inline-size) {
106
+ @media (min-width: 48em) {
107
+ .root {
108
+ grid-template-columns: repeat(var(--basalt-stat-group-cols-sm), minmax(0, 1fr));
109
+ }
110
+ }
111
+
112
+ @media (min-width: 75em) {
113
+ .root {
114
+ grid-template-columns: repeat(var(--basalt-stat-group-cols-lg), minmax(0, 1fr));
115
+ }
116
+ }
117
+
118
+ @media (max-width: 47.99375em) {
119
+ .root[data-divided] > *:nth-child(2n + 1) {
120
+ border-inline-start: 0;
121
+ padding-inline-start: 0;
122
+ }
123
+ }
124
+
125
+ @media (min-width: 48em) and (max-width: 74.99375em) {
126
+ .root[data-divided][data-sm-cols='2'] > *:nth-child(2n + 1),
127
+ .root[data-divided][data-sm-cols='3'] > *:nth-child(3n + 1) {
128
+ border-inline-start: 0;
129
+ padding-inline-start: 0;
130
+ }
131
+ }
132
+
133
+ @media (min-width: 75em) {
134
+ .root[data-divided][data-cols='2'] > *:nth-child(2n + 1),
135
+ .root[data-divided][data-cols='3'] > *:nth-child(3n + 1),
136
+ .root[data-divided][data-cols='4'] > *:nth-child(4n + 1),
137
+ .root[data-divided][data-cols='5'] > *:nth-child(5n + 1) {
138
+ border-inline-start: 0;
139
+ padding-inline-start: 0;
140
+ }
141
+ }
142
+ }
@@ -3,6 +3,7 @@
3
3
  * signature — so dot access stays verbatim under the package's strict flags.
4
4
  */
5
5
  declare const classes: {
6
+ readonly container: string
6
7
  readonly root: string
7
8
  }
8
9
  export default classes