basalt-ui 1.20.0 → 1.21.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 (87) hide show
  1. package/AGENTS.md +9 -5
  2. package/CHANGELOG.md +19 -0
  3. package/MIGRATING.md +147 -49
  4. package/README.md +42 -8
  5. package/agent/rules/basalt-app.md +41 -0
  6. package/agent/rules/basalt-charts.md +15 -12
  7. package/agent/rules/basalt-content.md +4 -0
  8. package/agent/rules/basalt-router.md +57 -5
  9. package/agent/rules/basalt-state.md +21 -3
  10. package/agent/rules/basalt-tokens.md +73 -16
  11. package/agent/skills/basalt-app/SKILL.md +4 -1
  12. package/agent/skills/basalt-charts/SKILL.md +5 -3
  13. package/agent/templates/CLAUDE-block.md.tpl +11 -5
  14. package/configs/lefthook.yml +48 -15
  15. package/configs/oxlint-plugin.js +181 -68
  16. package/configs/oxlint-plugin.test.ts +215 -9
  17. package/dist/charts/kinds/Bars.d.ts +1 -1
  18. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  19. package/dist/charts/kinds/Donut.d.ts +1 -1
  20. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  21. package/dist/charts/kinds/DualPanel.d.ts +1 -1
  22. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  23. package/dist/charts/kinds/Heatmap.d.ts +1 -1
  24. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  25. package/dist/charts/kinds/MultiLine.d.ts +1 -1
  26. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  27. package/dist/charts/kinds/StackedArea.d.ts +1 -1
  28. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  29. package/dist/charts/kinds/ZonedLine.d.ts +1 -1
  30. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  31. package/dist/cli/index.d.ts +48 -14
  32. package/dist/cli/index.d.ts.map +1 -1
  33. package/dist/cli/index.js +328 -66
  34. package/dist/cli/waiver-audit.test.d.ts +2 -0
  35. package/dist/cli/waiver-audit.test.d.ts.map +1 -0
  36. package/dist/guard/index.d.ts +47 -2
  37. package/dist/guard/index.d.ts.map +1 -1
  38. package/dist/guard/index.js +176 -31
  39. package/dist/router-tanstack/multi-search-param-store.d.ts +13 -0
  40. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  41. package/dist/router-tanstack/multi-search-param-store.js +4 -1
  42. package/dist/router-tanstack/search-param-store.d.ts +29 -5
  43. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  44. package/dist/router-tanstack/search-param-store.js +31 -3
  45. package/dist/router-tanstack/search-param-store.test.d.ts +2 -0
  46. package/dist/router-tanstack/search-param-store.test.d.ts.map +1 -0
  47. package/dist/shell/index.d.ts +12 -5
  48. package/dist/shell/index.d.ts.map +1 -1
  49. package/dist/shell/index.js +24 -6
  50. package/dist/tokens/index.d.ts +3 -1
  51. package/dist/tokens/index.d.ts.map +1 -1
  52. package/dist/tokens/index.js +5 -1
  53. package/dist/tokens/palette.d.ts +2 -2
  54. package/dist/tokens/palette.js +2 -2
  55. package/dist/tokens/text-scale.test.d.ts +2 -0
  56. package/dist/tokens/text-scale.test.d.ts.map +1 -0
  57. package/dist/tokens.css +4 -2
  58. package/dist/vite.d.ts +24 -2
  59. package/dist/vite.d.ts.map +1 -1
  60. package/dist/vite.js +54 -13
  61. package/llms.txt +2 -2
  62. package/package.json +1 -1
  63. package/src/charts/kinds/Bars.tsx +1 -1
  64. package/src/charts/kinds/Donut.tsx +1 -1
  65. package/src/charts/kinds/DualPanel.tsx +2 -2
  66. package/src/charts/kinds/Heatmap.tsx +1 -1
  67. package/src/charts/kinds/MultiLine.tsx +1 -1
  68. package/src/charts/kinds/StackedArea.tsx +1 -1
  69. package/src/charts/kinds/ZonedLine.tsx +1 -1
  70. package/src/cli/index.ts +607 -128
  71. package/src/cli/init-sync.test.ts +2 -0
  72. package/src/cli/template-ledger.test.ts +1 -1
  73. package/src/cli/toolchain-wiring.test.ts +162 -0
  74. package/src/cli/waiver-audit.test.ts +131 -0
  75. package/src/guard/check-source.test.ts +441 -1
  76. package/src/guard/index.ts +430 -60
  77. package/src/router-tanstack/multi-search-param-store.ts +16 -1
  78. package/src/router-tanstack/search-param-store.test.ts +135 -0
  79. package/src/router-tanstack/search-param-store.ts +90 -8
  80. package/src/shell/app-mobile-nav.tsx +1 -1
  81. package/src/shell/index.test.tsx +52 -0
  82. package/src/shell/index.tsx +64 -13
  83. package/src/tokens/index.ts +24 -1
  84. package/src/tokens/palette.ts +2 -2
  85. package/src/tokens/text-scale.test.ts +51 -0
  86. package/src/vite.test.ts +125 -6
  87. package/src/vite.ts +148 -20
@@ -9,10 +9,13 @@
9
9
  * `./oxlint-plugin.js` — a consumer `extends`-ing that preset inherits `jsPlugins` too, and the
10
10
  * path resolves relative to the preset file, not the consumer's own config.
11
11
  *
12
- * The four design-guard rules below support the same `theme-allow` escape as `src/guard`: skip a
13
- * reported node if a line comment containing `theme-allow` sits on the node's own line or the line
14
- * immediately above it. The three boundary rules deliberately do NOT — see their own comment for
15
- * why.
12
+ * The design-guard rules below support the same `theme-allow` escape as `src/guard`, and the same
13
+ * two-scope grammar: `theme-allow [<id>…] [— <why>]` at the START of a comment on the reported
14
+ * node's own line or the line above skips THAT node; `theme-allow-file <id>… — <why>` anywhere in
15
+ * the file skips every node of the named rules in it. Both halves are load-bearing — see
16
+ * {@link parseThemeAllows} for why the prefix matters and {@link hasFileDeclaration} for why file
17
+ * scope has to be spelled. The three boundary rules deliberately honour neither — see their own
18
+ * comment for why.
16
19
  *
17
20
  * ── The two agent-chat guard rules (`agent-resume-guard` / `agent-no-raw-usechat`) + `ai-sdk-major` ──
18
21
  * These are also correctness/architecture boundaries, not design guidance, so they do NOT honour
@@ -141,27 +144,30 @@ const ALLOW_REASON_SEPARATOR = /^(?:—|–|-{1,2}|:)\s*/
141
144
  const MIN_ALLOW_REASON_LENGTH = 4
142
145
 
143
146
  /**
144
- * Parse one comment's `theme-allow` annotation, or `null` when it carries none.
147
+ * The annotation token, with its optional `-file` suffix.
145
148
  *
146
- * `{ rules: [], unknownRules: [] }` is the legacy bare form and covers every rule; a non-empty
147
- * `rules` scopes the exception to exactly those ids. A word that occupies the id slot but names no
148
- * known rule lands in `unknownRules` and FAILS CLOSED — the annotation then waives only the ids it
149
- * got right, never everything. `theme-allow raw-hexx — reason` used to consume no id, fall through
150
- * to the empty-`rules` branch and be read as the blanket form, so one mistyped character escalated
151
- * a scoped waiver into a whole-line one, i.e. weaker than before the scoping existed.
152
- *
153
- * A prose reason is therefore introduced with a separator (`—`, `–`, `-`, `:`), which is how every
154
- * annotation in the wild already writes it. Mirrors `parseAllowAnnotation` in `src/guard/index.ts`.
149
+ * `(?![\w-])` keeps the two forms apart AND stops a longer word from parsing as the bare form:
150
+ * `theme-allow-unscoped` — the KIND NAME, written in prose constantly — used to consume no id, fall
151
+ * through to `rules: []` and read as a blanket waiver.
152
+ */
153
+ const ALLOW_TOKEN = /theme-allow(-file)?(?![\w-])/
154
+
155
+ /**
156
+ * Everything permitted between the start of a line INSIDE the comment and the token.
155
157
  *
156
- * The id slot closes at the first space that no comma opened: the first word is always a claimed
157
- * id (that is where a typo lands), but after a resolved id only a `,` keeps the list open, so
158
- * `theme-allow raw-surface sub-scale legend corner` reads `sub-scale` as prose rather than as a
159
- * typo. See the guard's copy for the full reasoning.
158
+ * The annotation has to START its comment (or a line of one — the `*` is a block-comment gutter).
159
+ * Without this the parser did a bare `indexOf('theme-allow')`, so a comment that merely MENTIONED
160
+ * the token in prose parsed as the blanket form and switched every rule off on the node below it.
161
+ * linewatch documented its own waivers in a docblock and thereby disarmed the file: a false
162
+ * NEGATIVE, and worse than the whole-file hole 1.20.0 closed. `src/guard`'s `ANNOTATION_PREFIX` is
163
+ * the same test against raw source text (which still carries its `//` / `/*` opener; a comment
164
+ * NODE's `value` does not, hence the two spellings).
160
165
  */
161
- function parseThemeAllow(commentValue) {
162
- const at = commentValue.indexOf('theme-allow')
163
- if (at === -1) return null
164
- let remainder = commentValue.slice(at + 'theme-allow'.length).replace(/^[\s,]+/, '')
166
+ const ALLOW_LINE_PREFIX = /^\s*\*?\s*$/
167
+
168
+ /** Parse the text following the token — ids, then the reason. See {@link parseThemeAllows}. */
169
+ function parseAllowBody(rest, scope) {
170
+ let remainder = rest.replace(/^[\s,]+/, '')
165
171
  const rules = []
166
172
  const unknownRules = []
167
173
  let inIdSlot = true
@@ -178,48 +184,105 @@ function parseThemeAllow(commentValue) {
178
184
  inIdSlot = /^\s*,/.test(after)
179
185
  }
180
186
  const reason = remainder.replace(ALLOW_REASON_SEPARATOR, '').trim()
181
- return { rules, unknownRules, hasReason: reason.length >= MIN_ALLOW_REASON_LENGTH }
187
+ return { rules, unknownRules, hasReason: reason.length >= MIN_ALLOW_REASON_LENGTH, scope }
188
+ }
189
+
190
+ /**
191
+ * Every annotation one comment carries. A docblock can hold more than one, so this returns a list.
192
+ *
193
+ * The grammar, and it is the same one `src/guard` parses:
194
+ *
195
+ * ```text
196
+ * theme-allow → this node, EVERY rule (the legacy bare form)
197
+ * theme-allow <id>[, <id>…] [— <why>] → this node, those rules
198
+ * theme-allow-file <id>[, <id>…] — <why> → the WHOLE FILE, those rules
199
+ * ```
200
+ *
201
+ * `{ rules: [], unknownRules: [] }` is the bare form; a non-empty `rules` scopes the exception to
202
+ * exactly those ids. A word that occupies the id slot but names no known rule lands in
203
+ * `unknownRules` and FAILS CLOSED — the annotation then waives only the ids it got right, never
204
+ * everything. `theme-allow raw-hexx — reason` used to consume no id, fall through to the
205
+ * empty-`rules` branch and be read as the blanket form, so one mistyped character escalated a
206
+ * scoped waiver into a whole-line one, i.e. weaker than before the scoping existed.
207
+ *
208
+ * A prose reason is therefore introduced with a separator (`—`, `–`, `-`, `:`), which is how every
209
+ * annotation in the wild already writes it.
210
+ *
211
+ * The id slot closes at the first space that no comma opened: the first word is always a claimed
212
+ * id (that is where a typo lands), but after a resolved id only a `,` keeps the list open, so
213
+ * `theme-allow raw-surface sub-scale legend corner` reads `sub-scale` as prose rather than as a
214
+ * typo. See the guard's copy for the full reasoning.
215
+ */
216
+ function parseThemeAllows(commentValue) {
217
+ const out = []
218
+ for (const rawLine of commentValue.split('\n')) {
219
+ const token = ALLOW_TOKEN.exec(rawLine)
220
+ if (token === null) continue
221
+ if (!ALLOW_LINE_PREFIX.test(rawLine.slice(0, token.index))) continue
222
+ const rest = rawLine.slice(token.index + token[0].length)
223
+ out.push(parseAllowBody(rest, token[1] === undefined ? 'line' : 'file'))
224
+ }
225
+ return out
226
+ }
227
+
228
+ /**
229
+ * Does one annotation cover `ruleId`?
230
+ *
231
+ * Only a BARE **node** annotation covers every rule. A bare `theme-allow-file` covers nothing: the
232
+ * blanket form is tolerable on one line, where a reader sees what it sits on; over a whole file it
233
+ * is a config exemption without the config review, so the widest waiver in the contract is the one
234
+ * that has to name what it waives.
235
+ */
236
+ function allowCovers(allow, ruleId) {
237
+ if (allow.rules.includes(ruleId)) return true
238
+ if (allow.scope === 'file') return false
239
+ return allow.rules.length === 0 && allow.unknownRules.length === 0
240
+ }
241
+
242
+ function allComments(context) {
243
+ const sourceCode = context.sourceCode ?? context.getSourceCode?.()
244
+ return sourceCode?.getAllComments?.() ?? []
182
245
  }
183
246
 
184
247
  /**
185
- * True when a `theme-allow` covering `ruleId` sits on `node`'s own source line or the line above.
248
+ * True when a NODE-scoped `theme-allow` covering `ruleId` sits on `node`'s own source line or the
249
+ * line above — or when the file carries a `theme-allow-file` declaration for it.
186
250
  *
187
251
  * The `ruleId` argument is what stops one exemption from being a blanket one: a `theme-allow
188
252
  * raw-hex — …` written for a color no longer silently switches off `card-inset` on the same line.
189
- * Only a BARE `theme-allow` still covers everything — that is the shape every existing consumer
190
- * has, and `src/guard`'s `theme-allow-unscoped` is the (warning) nudge off it, not a hard break.
191
253
  * An annotation that reached for an id and missed covers only the ids it got right.
192
254
  */
193
255
  function hasThemeAllow(context, node, ruleId) {
194
- const sourceCode = context.sourceCode ?? context.getSourceCode?.()
195
- const comments = sourceCode?.getAllComments?.() ?? []
256
+ if (hasFileDeclaration(context, ruleId)) return true
196
257
  const nodeLine = node.loc.start.line
197
- return comments.some((comment) => {
258
+ return allComments(context).some((comment) => {
198
259
  if (comment.loc.end.line !== nodeLine && comment.loc.end.line !== nodeLine - 1) return false
199
- const allow = parseThemeAllow(comment.value)
200
- if (allow === null) return false
201
- if (allow.rules.includes(ruleId)) return true
202
- return allow.rules.length === 0 && allow.unknownRules.length === 0
260
+ return parseThemeAllows(comment.value).some(
261
+ (allow) => allow.scope === 'line' && allowCovers(allow, ruleId),
262
+ )
203
263
  })
204
264
  }
205
265
 
206
266
  /**
207
- * True when the FILE carries a written declaration for `ruleId` — a `theme-allow` that both names
208
- * the rule and gives a reason, anywhere in the file.
267
+ * True when the FILE carries a written declaration for `ruleId` — a `theme-allow-file` naming it,
268
+ * anywhere in the file.
209
269
  *
210
- * This is what a file-scoped exception costs now. `hand-rolled-plot` used to grant a whole file
211
- * permanent immunity off any `theme-allow` that happened to sit on its first assembly node — a
212
- * 604-line chart file went unpoliced forever, and a comment written about a colour disabled the
213
- * chart rule as a side effect. Splitting the two placements apart is the fix: a bare or unrelated
214
- * comment waives ONE node, a named-with-a-reason comment declares the whole file.
270
+ * File scope has to be SPELLED, and that is the second half of the 1.20.0 fix rather than a new
271
+ * idea. The first half landed: `hand-rolled-plot` stopped granting a whole file permanent immunity
272
+ * off any comment that happened to sit on its first assembly node, and now reports every node. The
273
+ * second half did not, because the promotion rule was "names a rule AND gives a reason" — which is
274
+ * exactly what the rule's own message asks a consumer to write, and what `theme-allow-unscoped`
275
+ * reports them for omitting. So there was one legal annotation shape and it was whole-file:
276
+ * `hasFileDeclaration` still returned early for every node, forever. linewatch ended up with one
277
+ * whole-file declaration per chart file — the thing 1.20.0 set out to eliminate — because per-node
278
+ * scoping was not expressible. Now it is: `theme-allow` is the node, `theme-allow-file` is the file.
215
279
  */
216
280
  function hasFileDeclaration(context, ruleId) {
217
- const sourceCode = context.sourceCode ?? context.getSourceCode?.()
218
- const comments = sourceCode?.getAllComments?.() ?? []
219
- return comments.some((comment) => {
220
- const allow = parseThemeAllow(comment.value)
221
- return allow !== null && allow.hasReason && allow.rules.includes(ruleId)
222
- })
281
+ return allComments(context).some((comment) =>
282
+ parseThemeAllows(comment.value).some(
283
+ (allow) => allow.scope === 'file' && allow.rules.includes(ruleId),
284
+ ),
285
+ )
223
286
  }
224
287
 
225
288
  /** Test/spec files — design guidance does not apply to a fixture. Mirrors `src/cli`'s own SKIP. */
@@ -973,18 +1036,26 @@ function readPackageJson(pkgPath) {
973
1036
  * that the file sits under. A declared `basalt.roots` that the file is NOT under is a positive
974
1037
  * statement that this code is not basalt's — the walk stops there rather than continuing up to a
975
1038
  * workspace root that happens to carry the dependency.
1039
+ *
1040
+ * **`roots` is consulted BEFORE the dependency, within one manifest**, and that ordering is the
1041
+ * whole fix. `init` produces exactly the layout that breaks the other way round: a monorepo ROOT
1042
+ * carrying `basalt-ui` as the devDependency the CLI is run from, AND `basalt.roots: ["apps/web/
1043
+ * src"]` naming where the app actually is. Dep-first returned in-scope on that root before `roots`
1044
+ * was ever read, so rb's `apps/api` — no basalt dependency, deliberately on a different `ai` major,
1045
+ * explicitly outside the declared roots — took three errors and needed a permanent override. The
1046
+ * more specific statement wins: `roots` says WHERE, the dependency only says whether.
976
1047
  */
977
1048
  function isBasaltScopedFile(filename) {
978
1049
  let dir = dirname(filename)
979
1050
  for (;;) {
980
1051
  const pkg = readPackageJson(resolvePath(dir, 'package.json'))
981
1052
  if (pkg !== null) {
982
- const deps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies }
983
- if (deps['basalt-ui'] !== undefined) return true
984
1053
  const roots = pkg.basalt?.roots
985
1054
  if (Array.isArray(roots) && roots.length > 0) {
986
1055
  return roots.some((root) => filename.startsWith(`${resolvePath(dir, root)}/`))
987
1056
  }
1057
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies }
1058
+ if (deps['basalt-ui'] !== undefined) return true
988
1059
  }
989
1060
  const parent = dirname(dir)
990
1061
  if (parent === dir) return false
@@ -1093,7 +1164,9 @@ const HAND_ROLLED_PLOT_MESSAGE =
1093
1164
  'owns the measured margins, both y scales, the axes, grid, shared cursor, crosshair and ' +
1094
1165
  'tooltip. Hand-assembling them is how two charts stop matching. Compose CartesianChart and ' +
1095
1166
  'render only marks. A genuinely non-single-plot shape (multi-pane, radial, matrix) composes ' +
1096
- 'ChartFrame instead — add a theme-allow comment to declare that. (basalt/hand-rolled-plot)'
1167
+ 'ChartFrame instead — declare that with `theme-allow-file hand-rolled-plot — <why>` anywhere in ' +
1168
+ 'the file. To waive just THIS node and stay policed on the rest, write `theme-allow ' +
1169
+ 'hand-rolled-plot — <why>` on its own line above it. (basalt/hand-rolled-plot)'
1097
1170
 
1098
1171
  const handRolledPlot = {
1099
1172
  meta: {
@@ -1265,19 +1338,53 @@ const PACKAGE_ROOT = resolvePath(PLUGIN_DIR, '..')
1265
1338
  const COMPONENT_NAME = /^[A-Z][A-Za-z0-9]*[a-z][A-Za-z0-9]*$/
1266
1339
 
1267
1340
  /**
1268
- * Every component-shaped VALUE export of the root barrel, read once at module load from
1269
- * `../dist/index.d.ts` — the real barrel, not a hand-maintained list that would go stale the first
1270
- * time an export was renamed. `type` specifiers are skipped: a local `StatCardProps` is a normal
1271
- * thing to write, a local `StatCard` is not.
1341
+ * The barrels a consumer can import a component FROM. The root one is not the whole surface: a
1342
+ * chart-layer fork (`ChartFrame`, `CartesianChart`, `ChartCard`, …) collided with nothing, because
1343
+ * `./charts` is deliberately NOT re-exported from `.` — so a consumer whose forks all live in the
1344
+ * chart layer, which is the layer forks actually live in, could never trip the rule. It was
1345
+ * correctly silent there for the wrong reason.
1346
+ *
1347
+ * Enumerated rather than globbed: `dist/**\/index.d.ts` would also sweep internal directory
1348
+ * barrels that are not published subpaths, and a name that is not importable is not a fork of
1349
+ * anything. Keep this in step with the `exports` map in package.json.
1350
+ */
1351
+ const BASALT_BARRELS = [
1352
+ ['index.d.ts'],
1353
+ ['charts', 'index.d.ts'],
1354
+ ['content', 'index.d.ts'],
1355
+ ['data', 'index.d.ts'],
1356
+ ['forms', 'index.d.ts'],
1357
+ ['agent', 'index.d.ts'],
1358
+ ['commands', 'index.d.ts'],
1359
+ ['notifications', 'index.d.ts'],
1360
+ ['connectivity', 'index.d.ts'],
1361
+ ]
1362
+
1363
+ /**
1364
+ * Every component-shaped VALUE export of basalt's published barrels, read once at module load from
1365
+ * the real `.d.ts` files — not a hand-maintained list that would go stale the first time an export
1366
+ * was renamed. `type` specifiers are skipped: a local `StatCardProps` is a normal thing to write, a
1367
+ * local `StatCard` is not.
1272
1368
  *
1273
1369
  * Empty (rule silent) when `dist` is unreadable, exactly like `BASALT_AI_MAJOR` — a lint run must
1274
1370
  * not depend on a build having happened.
1275
1371
  */
1276
- const BASALT_ROOT_EXPORTS = (() => {
1372
+ const BASALT_EXPORTS = (() => {
1277
1373
  const names = new Set()
1278
- try {
1279
- const text = readFileSync(resolvePath(PACKAGE_ROOT, 'dist', 'index.d.ts'), 'utf8')
1280
- for (const block of text.matchAll(/export\s*\{([^}]*)\}/g)) {
1374
+ for (const segments of BASALT_BARRELS) {
1375
+ let text
1376
+ try {
1377
+ text = readFileSync(resolvePath(PACKAGE_ROOT, 'dist', ...segments), 'utf8')
1378
+ } catch {
1379
+ continue
1380
+ }
1381
+ for (const block of text.matchAll(/export\s*\{([^}]*)\}(?:\s*from\s*['"]([^'"]*)['"])?/g)) {
1382
+ // A pass-through re-export of a THIRD-PARTY name is not a basalt composite. `./charts`
1383
+ // re-exports `Bar`, `Line`, `Pie`, `AreaClosed` … straight from `@visx/shape`, and a local
1384
+ // `Bar` is not a fork of anything basalt wrote — the playground's own demo tripped on it the
1385
+ // moment this rule started reading the charts barrel. Relative sources only.
1386
+ const source = block[2]
1387
+ if (source !== undefined && !source.startsWith('.')) continue
1281
1388
  for (const raw of block[1].split(',')) {
1282
1389
  const part = raw.trim()
1283
1390
  if (part === '' || part.startsWith('type ')) continue
@@ -1285,8 +1392,6 @@ const BASALT_ROOT_EXPORTS = (() => {
1285
1392
  if (COMPONENT_NAME.test(name)) names.add(name)
1286
1393
  }
1287
1394
  }
1288
- } catch {
1289
- return names
1290
1395
  }
1291
1396
  return names
1292
1397
  })()
@@ -1296,7 +1401,10 @@ function shadowBasaltExportMessage(name) {
1296
1401
  `Local component '${name}' collides with the basalt-ui export of the same name — import it ` +
1297
1402
  "from 'basalt-ui' instead of re-rolling it. This is the one signal a forked composite emits: " +
1298
1403
  'the palette guard can never see it, because a fork written by a token-fluent author uses ' +
1299
- 'exactly the right tokens and passes every other check. (basalt/shadow-basalt-export)'
1404
+ 'exactly the right tokens and passes every other check. A NAME collision is the whole of the ' +
1405
+ 'signal, so this is a tripwire, not coverage: rename the fork and the rule goes quiet ' +
1406
+ "(linewatch's forks are `Cell` and `Box`, rb's is `Stat`). Silence here is not evidence that " +
1407
+ 'nothing is forked. (basalt/shadow-basalt-export)'
1300
1408
  )
1301
1409
  }
1302
1410
 
@@ -1318,11 +1426,11 @@ const shadowBasaltExport = {
1318
1426
  create(context) {
1319
1427
  const filename = getFilename(context)
1320
1428
  // basalt's own source DEFINES these names; a consumer's does not.
1321
- if (BASALT_ROOT_EXPORTS.size === 0 || filename.startsWith(`${PACKAGE_ROOT}/`)) return {}
1429
+ if (BASALT_EXPORTS.size === 0 || filename.startsWith(`${PACKAGE_ROOT}/`)) return {}
1322
1430
  if (isTestFile(context)) return {}
1323
1431
 
1324
1432
  const report = (node, name) => {
1325
- if (typeof name !== 'string' || !BASALT_ROOT_EXPORTS.has(name)) return
1433
+ if (typeof name !== 'string' || !BASALT_EXPORTS.has(name)) return
1326
1434
  if (hasThemeAllow(context, node, 'shadow-basalt-export')) return
1327
1435
  context.report({ node, message: shadowBasaltExportMessage(name) })
1328
1436
  }
@@ -1356,7 +1464,8 @@ const HAND_ROLLED_SHELL_MESSAGE =
1356
1464
  'bar and its More sheet, breadcrumbs, the page header and the collapse state, all driven by ONE ' +
1357
1465
  'typed nav definition (defineNav + useNav). A hand-rolled AppShell + burger drawer is the exact ' +
1358
1466
  'layer the 1.19 nav contract replaces, and no palette guard can see it — it uses the right ' +
1359
- 'tokens. (basalt/hand-rolled-shell)'
1467
+ 'tokens. Declare a deliberate exception with `theme-allow-file hand-rolled-shell — <why>`. ' +
1468
+ '(basalt/hand-rolled-shell)'
1360
1469
 
1361
1470
  /**
1362
1471
  * The `hand-rolled-plot` shape, one layer up. rb shipped 266 lines of `AppShell` + `NavLink` +
@@ -1429,15 +1538,19 @@ const handRolledShell = {
1429
1538
  */
1430
1539
  export const PLUGIN_RULE_GRACE = {
1431
1540
  'hand-rolled-plot':
1432
- 'widened in the round-4 guard minor (suppression scoped to the node; a file-scoped waiver now ' +
1433
- 'needs a written declaration) — promote to error one minor later, once consumers have moved ' +
1434
- 'their bare `theme-allow` comments to the declaration form.',
1541
+ 'widened AGAIN in the round-5 guard minor (file scope must now be spelled `theme-allow-file`, ' +
1542
+ 'so the named-with-a-reason shape every consumer wrote at 1.20.0 is a NODE waiver and the ' +
1543
+ 'remaining assembly nodes report) — grace restarts with the widening, per the "a rule the ' +
1544
+ 'current minor widens does not promote in that minor" doctrine. Promote one minor after ' +
1545
+ 'consumers have moved their file declarations to `theme-allow-file`.',
1435
1546
  'chart-legend-literal':
1436
1547
  'widened in the round-4 guard minor (a `.map()` over a non-series array now counts) — promote ' +
1437
1548
  'to error one minor later, for the same reason as hand-rolled-plot above.',
1438
1549
  'shadow-basalt-export':
1439
- 'new in the round-4 guard minor — promote to error one minor later, if ever. A name collision ' +
1440
- 'is strong evidence and not proof; this may be a permanent warn.',
1550
+ 'new in the round-4 guard minor, widened in round-5 (reads every published barrel, not just ' +
1551
+ 'the root one, so a chart-layer fork is visible) — promote to error one minor later, if ever. ' +
1552
+ 'A name collision is strong evidence and not proof, and renaming defeats it outright, so this ' +
1553
+ 'may be a permanent warn.',
1441
1554
  'hand-rolled-shell': 'new in the round-4 guard minor — promote to error one minor later.',
1442
1555
  }
1443
1556