basalt-ui 1.19.0 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +15 -1
- package/CHANGELOG.md +27 -0
- package/MIGRATING.md +228 -0
- package/README.md +53 -9
- package/agent/rules/basalt-app.md +43 -0
- package/agent/rules/basalt-charts.md +26 -14
- package/agent/rules/basalt-mantine.md +25 -10
- package/agent/rules/basalt-router.md +22 -10
- package/agent/rules/basalt-tokens.md +75 -6
- package/agent/skills/basalt-app/SKILL.md +42 -6
- package/configs/check.yml +11 -4
- package/configs/lefthook.yml +21 -2
- package/configs/oxlint-plugin.js +515 -25
- package/configs/oxlint-plugin.test.ts +383 -10
- package/configs/oxlint.json +10 -1
- package/dist/agent/stick-to-bottom.js +1 -1
- package/dist/agent-chat/thread-detail-panel.js +1 -1
- package/dist/agent-chat/thread-message.js +2 -2
- package/dist/agent-chat/tool-chip.js +1 -1
- package/dist/charts/kinds/Donut.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.js +1 -1
- package/dist/charts/primitives/ChartLegend.js +2 -2
- package/dist/charts/primitives/ChartTooltip.js +1 -1
- package/dist/cli/index.d.ts +107 -15
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +610 -72
- package/dist/cli/toolchain-wiring.test.d.ts +2 -0
- package/dist/cli/toolchain-wiring.test.d.ts.map +1 -0
- package/dist/dashboard/delta-badge.js +2 -2
- package/dist/dashboard/stat-card.js +1 -1
- package/dist/data/virtual-list.js +5 -5
- package/dist/guard/index.d.ts +56 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +326 -59
- package/dist/guard/types.d.ts +25 -5
- package/dist/guard/types.d.ts.map +1 -1
- package/dist/register.d.ts +25 -0
- package/dist/register.d.ts.map +1 -1
- package/dist/shell/app-mobile-nav.d.ts.map +1 -1
- package/dist/shell/app-mobile-nav.js +14 -14
- package/dist/shell/app-mobile-nav.module.css +44 -11
- package/dist/shell/app-mobile-nav.module.css.d.ts +1 -1
- package/dist/surfaces.d.ts +1 -1
- package/dist/surfaces.js +1 -1
- package/dist/tokens/index.d.ts +3 -3
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +2 -0
- package/dist/tokens/palette.d.ts +35 -27
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +24 -18
- package/dist/tokens.css +19 -17
- package/dist/vite-color-mix.js +2 -2
- package/llms.txt +3 -3
- package/package.json +4 -3
- package/src/agent/stick-to-bottom.tsx +1 -1
- package/src/agent-chat/thread-detail-panel.tsx +1 -1
- package/src/agent-chat/thread-message.tsx +3 -3
- package/src/agent-chat/tool-chip.tsx +1 -1
- package/src/charts/kinds/Donut.tsx +2 -1
- package/src/charts/primitives/ChartLegend.tsx +2 -2
- package/src/charts/primitives/ChartTooltip.tsx +1 -1
- package/src/cli/index.ts +1070 -99
- package/src/cli/init-sync.test.ts +19 -3
- package/src/cli/tokens-css.test.ts +27 -14
- package/src/cli/toolchain-wiring.test.ts +694 -0
- package/src/dashboard/delta-badge.tsx +3 -3
- package/src/dashboard/stat-card.tsx +1 -1
- package/src/data/virtual-list.tsx +8 -8
- package/src/guard/check-source.test.ts +663 -11
- package/src/guard/index.ts +727 -94
- package/src/guard/types.ts +29 -4
- package/src/register.ts +32 -0
- package/src/shell/app-mobile-nav.module.css +44 -11
- package/src/shell/app-mobile-nav.module.css.d.ts +1 -1
- package/src/shell/app-mobile-nav.test.tsx +34 -0
- package/src/shell/app-mobile-nav.tsx +16 -5
- package/src/surfaces.ts +1 -1
- package/src/theme/density-relations.test.ts +8 -5
- package/src/theme/spacing.test.ts +2 -0
- package/src/theme-toggle/index.tsx +2 -2
- package/src/tokens/index.ts +5 -3
- package/src/tokens/palette.ts +24 -18
- package/src/vite-color-mix.ts +2 -2
package/configs/oxlint-plugin.js
CHANGED
|
@@ -77,16 +77,156 @@ import { fileURLToPath } from 'node:url'
|
|
|
77
77
|
|
|
78
78
|
// ── Shared helpers ──────────────────────────────────────────────────────────────────────────────
|
|
79
79
|
|
|
80
|
-
/**
|
|
81
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Rule ids a `theme-allow` may name — this plugin's own rules plus `src/guard`'s kinds. Duplicated
|
|
82
|
+
* by hand for the same reason `majorOf` is: this file loads standalone out of a consumer's
|
|
83
|
+
* node_modules and must not import from the package.
|
|
84
|
+
*
|
|
85
|
+
* Exported, and `oxlint-plugin.test.ts` asserts it is EXACTLY the plugin's own rule ids plus
|
|
86
|
+
* `GUARD_RULES`'s keys. Forgetting an entry here is not a cosmetic omission — an id this set does
|
|
87
|
+
* not know is treated as a typo, so the annotation naming it waives nothing (see
|
|
88
|
+
* {@link parseThemeAllow}); before the fail-closed change it was worse still, silently widening to
|
|
89
|
+
* a blanket waiver.
|
|
90
|
+
*/
|
|
91
|
+
export const KNOWN_RULE_IDS = new Set([
|
|
92
|
+
// this plugin
|
|
93
|
+
'no-raw-font-size',
|
|
94
|
+
'raw-size-literal',
|
|
95
|
+
'card-inset',
|
|
96
|
+
'chart-in-raw-surface',
|
|
97
|
+
'hand-rolled-plot',
|
|
98
|
+
'chart-legend-literal',
|
|
99
|
+
'hand-rolled-shell',
|
|
100
|
+
'shadow-basalt-export',
|
|
101
|
+
'raw-scroll-container',
|
|
102
|
+
'visx-boundary',
|
|
103
|
+
'visx-tooltip',
|
|
104
|
+
'token-layer-boundary',
|
|
105
|
+
// These three honour `basalt-agent-allow`, never `theme-allow` — but they are real ids, so an
|
|
106
|
+
// annotation naming one must parse as a (useless) scoped annotation rather than as prose.
|
|
107
|
+
'agent-resume-guard',
|
|
108
|
+
'agent-no-raw-usechat',
|
|
109
|
+
'ai-sdk-major',
|
|
110
|
+
// src/guard kinds
|
|
111
|
+
'raw-hex',
|
|
112
|
+
'raw-color-fn',
|
|
113
|
+
'localstorage-theme',
|
|
114
|
+
'off-identity-accent',
|
|
115
|
+
'mantine-shade-index',
|
|
116
|
+
'raw-spacing',
|
|
117
|
+
'raw-radius',
|
|
118
|
+
'raw-surface',
|
|
119
|
+
'card-with-border',
|
|
120
|
+
'off-system-surface-var',
|
|
121
|
+
'raw-html-layout',
|
|
122
|
+
'inline-spacing',
|
|
123
|
+
'inline-display',
|
|
124
|
+
'raw-visx-axis',
|
|
125
|
+
'raw-motion-value',
|
|
126
|
+
'unframed-chart',
|
|
127
|
+
'chart-missing-aria-label',
|
|
128
|
+
'raw-form-control',
|
|
129
|
+
'sub-16-input-font',
|
|
130
|
+
'raw-font-family',
|
|
131
|
+
'theme-allow-unscoped',
|
|
132
|
+
'surface-shadow-override',
|
|
133
|
+
'css-raw-surface',
|
|
134
|
+
'inline-font-size',
|
|
135
|
+
'hidden-inline-style',
|
|
136
|
+
])
|
|
137
|
+
|
|
138
|
+
const ALLOW_RULE_TOKEN = /^(?:basalt\/)?([a-z][a-z0-9-]*)(?=$|[\s,:—–])/
|
|
139
|
+
const ALLOW_REASON_SEPARATOR = /^(?:—|–|-{1,2}|:)\s*/
|
|
140
|
+
/** Shortest string accepted as a written reason — enough to exclude a stray separator. */
|
|
141
|
+
const MIN_ALLOW_REASON_LENGTH = 4
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Parse one comment's `theme-allow` annotation, or `null` when it carries none.
|
|
145
|
+
*
|
|
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`.
|
|
155
|
+
*
|
|
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.
|
|
160
|
+
*/
|
|
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,]+/, '')
|
|
165
|
+
const rules = []
|
|
166
|
+
const unknownRules = []
|
|
167
|
+
let inIdSlot = true
|
|
168
|
+
for (;;) {
|
|
169
|
+
const token = ALLOW_RULE_TOKEN.exec(remainder)
|
|
170
|
+
if (token === null) break
|
|
171
|
+
if (!KNOWN_RULE_IDS.has(token[1])) {
|
|
172
|
+
if (inIdSlot) unknownRules.push(token[1])
|
|
173
|
+
break
|
|
174
|
+
}
|
|
175
|
+
rules.push(token[1])
|
|
176
|
+
const after = remainder.slice(token[0].length)
|
|
177
|
+
remainder = after.replace(/^[\s,]+/, '')
|
|
178
|
+
inIdSlot = /^\s*,/.test(after)
|
|
179
|
+
}
|
|
180
|
+
const reason = remainder.replace(ALLOW_REASON_SEPARATOR, '').trim()
|
|
181
|
+
return { rules, unknownRules, hasReason: reason.length >= MIN_ALLOW_REASON_LENGTH }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* True when a `theme-allow` covering `ruleId` sits on `node`'s own source line or the line above.
|
|
186
|
+
*
|
|
187
|
+
* The `ruleId` argument is what stops one exemption from being a blanket one: a `theme-allow
|
|
188
|
+
* 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
|
+
* An annotation that reached for an id and missed covers only the ids it got right.
|
|
192
|
+
*/
|
|
193
|
+
function hasThemeAllow(context, node, ruleId) {
|
|
82
194
|
const sourceCode = context.sourceCode ?? context.getSourceCode?.()
|
|
83
195
|
const comments = sourceCode?.getAllComments?.() ?? []
|
|
84
196
|
const nodeLine = node.loc.start.line
|
|
85
|
-
return comments.some(
|
|
86
|
-
(comment)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
197
|
+
return comments.some((comment) => {
|
|
198
|
+
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
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
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.
|
|
209
|
+
*
|
|
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.
|
|
215
|
+
*/
|
|
216
|
+
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
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Test/spec files — design guidance does not apply to a fixture. Mirrors `src/cli`'s own SKIP. */
|
|
226
|
+
const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|[\\/])__tests__[\\/]/
|
|
227
|
+
|
|
228
|
+
function isTestFile(context) {
|
|
229
|
+
return TEST_FILE.test(getFilename(context))
|
|
90
230
|
}
|
|
91
231
|
|
|
92
232
|
/**
|
|
@@ -146,6 +286,50 @@ const NO_RAW_FONT_SIZE_MESSAGE =
|
|
|
146
286
|
'Raw font-size literal — route through VX.text.* (numbers) or --vx-text-* (CSS); em/relative ' +
|
|
147
287
|
'ratios are allowed. (basalt/no-raw-font-size)'
|
|
148
288
|
|
|
289
|
+
/** JSX attributes whose value IS a style object — the only object literals this rule reaches into. */
|
|
290
|
+
const STYLE_ATTRS = new Set(['style', 'styles', 'sx'])
|
|
291
|
+
/** A binding or key whose name says the object is styling — `wrapperStyle`, `styles`, `rowStyles`. */
|
|
292
|
+
const STYLE_NAME = /styles?$/i
|
|
293
|
+
/** How far up the tree the style-context walk goes before giving up. */
|
|
294
|
+
const STYLE_CONTEXT_MAX_DEPTH = 12
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Is this object property part of a STYLE object?
|
|
298
|
+
*
|
|
299
|
+
* `fontSize` is not a reserved word. The rule used to report every object property spelled that
|
|
300
|
+
* way, which fired on an Obsidian `data.json` fixture — `{ settings: { fontSize: 16 } }` inside a
|
|
301
|
+
* string, in a package with no React and no Mantine — where the "route it through VX.text.*" advice
|
|
302
|
+
* is not merely unhelpful, it is about a different domain. Requiring a style context (a
|
|
303
|
+
* `style`/`styles`/`sx` JSX attribute, a `…Style(s)` binding or key, or a `CSSProperties`
|
|
304
|
+
* annotation) keeps every shape that is really styling and drops the ones that never reach CSS.
|
|
305
|
+
*/
|
|
306
|
+
function isInStyleContext(context, node) {
|
|
307
|
+
const sourceCode = context.sourceCode ?? context.getSourceCode?.()
|
|
308
|
+
let current = node.parent
|
|
309
|
+
for (let depth = 0; current !== undefined && current !== null; depth++) {
|
|
310
|
+
if (depth > STYLE_CONTEXT_MAX_DEPTH) return false
|
|
311
|
+
if (current.type === 'JSXAttribute') return STYLE_ATTRS.has(current.name?.name)
|
|
312
|
+
if (current.type === 'Property') {
|
|
313
|
+
const key = current.key
|
|
314
|
+
const name = key?.type === 'Identifier' ? key.name : key?.value
|
|
315
|
+
if (typeof name === 'string' && STYLE_NAME.test(name)) return true
|
|
316
|
+
}
|
|
317
|
+
if (current.type === 'VariableDeclarator') {
|
|
318
|
+
const id = current.id
|
|
319
|
+
if (typeof id?.name === 'string' && STYLE_NAME.test(id.name)) return true
|
|
320
|
+
const annotation = id?.typeAnnotation
|
|
321
|
+
return (
|
|
322
|
+
annotation !== undefined &&
|
|
323
|
+
annotation !== null &&
|
|
324
|
+
(sourceCode?.getText?.(annotation) ?? '').includes('CSSProperties')
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
if (current.type === 'FunctionDeclaration' || current.type === 'Program') return false
|
|
328
|
+
current = current.parent
|
|
329
|
+
}
|
|
330
|
+
return false
|
|
331
|
+
}
|
|
332
|
+
|
|
149
333
|
const noRawFontSize = {
|
|
150
334
|
meta: {
|
|
151
335
|
type: 'suggestion',
|
|
@@ -155,7 +339,7 @@ const noRawFontSize = {
|
|
|
155
339
|
schema: [],
|
|
156
340
|
},
|
|
157
341
|
create(context) {
|
|
158
|
-
if (getFilename(context).includes('/src/tokens/')) return {}
|
|
342
|
+
if (getFilename(context).includes('/src/tokens/') || isTestFile(context)) return {}
|
|
159
343
|
|
|
160
344
|
return {
|
|
161
345
|
JSXAttribute(node) {
|
|
@@ -163,7 +347,7 @@ const noRawFontSize = {
|
|
|
163
347
|
if (name !== 'fz' && name !== 'fontSize') return
|
|
164
348
|
const value = unwrapExpressionContainer(node.value)
|
|
165
349
|
if (!isNumericLiteral(value)) return
|
|
166
|
-
if (hasThemeAllow(context, node)) return
|
|
350
|
+
if (hasThemeAllow(context, node, 'no-raw-font-size')) return
|
|
167
351
|
context.report({ node, message: NO_RAW_FONT_SIZE_MESSAGE })
|
|
168
352
|
},
|
|
169
353
|
Property(node) {
|
|
@@ -173,7 +357,8 @@ const noRawFontSize = {
|
|
|
173
357
|
(key.type === 'Literal' && key.value === 'fontSize')
|
|
174
358
|
if (!isFontSizeKey) return
|
|
175
359
|
if (!isNumericLiteral(node.value)) return
|
|
176
|
-
if (
|
|
360
|
+
if (!isInStyleContext(context, node)) return
|
|
361
|
+
if (hasThemeAllow(context, node, 'no-raw-font-size')) return
|
|
177
362
|
context.report({ node, message: NO_RAW_FONT_SIZE_MESSAGE })
|
|
178
363
|
},
|
|
179
364
|
}
|
|
@@ -225,7 +410,7 @@ const rawSizeLiteral = {
|
|
|
225
410
|
if (typeof name !== 'string' || !SIZE_ATTRS.has(name)) return
|
|
226
411
|
const value = unwrapExpressionContainer(node.value)
|
|
227
412
|
if (!isCssLengthString(value)) return
|
|
228
|
-
if (hasThemeAllow(context, node)) return
|
|
413
|
+
if (hasThemeAllow(context, node, 'raw-size-literal')) return
|
|
229
414
|
context.report({ node, message: RAW_SIZE_LITERAL_MESSAGE })
|
|
230
415
|
},
|
|
231
416
|
}
|
|
@@ -276,7 +461,7 @@ const cardInset = {
|
|
|
276
461
|
const isRadius = attrName === 'radius'
|
|
277
462
|
|
|
278
463
|
if (!isOffPadding && !isOffPy && !isOffPx && !isRadius) continue
|
|
279
|
-
if (hasThemeAllow(context, attr)) continue
|
|
464
|
+
if (hasThemeAllow(context, attr, 'card-inset')) continue
|
|
280
465
|
context.report({ node: attr, message: CARD_INSET_MESSAGE })
|
|
281
466
|
}
|
|
282
467
|
},
|
|
@@ -350,7 +535,7 @@ const chartInRawSurface = {
|
|
|
350
535
|
if (!CARD_TAGS.has(tagName)) return
|
|
351
536
|
const hasChartDescendant = node.children.some((child) => subtreeHasChart(child))
|
|
352
537
|
if (!hasChartDescendant) return
|
|
353
|
-
if (hasThemeAllow(context, node)) return
|
|
538
|
+
if (hasThemeAllow(context, node, 'chart-in-raw-surface')) return
|
|
354
539
|
context.report({ node, message: CHART_IN_RAW_SURFACE_MESSAGE })
|
|
355
540
|
},
|
|
356
541
|
}
|
|
@@ -396,7 +581,7 @@ const rawScrollContainer = {
|
|
|
396
581
|
key.type === 'Identifier' ? key.name : key.type === 'Literal' ? key.value : undefined
|
|
397
582
|
if (typeof keyName !== 'string' || !OVERFLOW_KEYS.has(keyName)) return
|
|
398
583
|
if (!isStringLiteral(node.value) || !SCROLLING_VALUES.has(node.value.value)) return
|
|
399
|
-
if (hasThemeAllow(context, node)) return
|
|
584
|
+
if (hasThemeAllow(context, node, 'raw-scroll-container')) return
|
|
400
585
|
context.report({ node, message: RAW_SCROLL_CONTAINER_MESSAGE })
|
|
401
586
|
},
|
|
402
587
|
}
|
|
@@ -760,6 +945,53 @@ function nearestAiMajor(startDir) {
|
|
|
760
945
|
return result
|
|
761
946
|
}
|
|
762
947
|
|
|
948
|
+
// One parsed manifest per path — the scope walk below re-reads the same ancestors for every file.
|
|
949
|
+
const packageJsonCache = new Map()
|
|
950
|
+
|
|
951
|
+
function readPackageJson(pkgPath) {
|
|
952
|
+
if (packageJsonCache.has(pkgPath)) return packageJsonCache.get(pkgPath)
|
|
953
|
+
let pkg = null
|
|
954
|
+
try {
|
|
955
|
+
pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
956
|
+
} catch {
|
|
957
|
+
pkg = null
|
|
958
|
+
}
|
|
959
|
+
packageJsonCache.set(pkgPath, pkg)
|
|
960
|
+
return pkg
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Is this file inside a package that actually consumes basalt-ui?
|
|
965
|
+
*
|
|
966
|
+
* Without this the rule compared EVERY package's `ai` major against basalt's own peer major, and
|
|
967
|
+
* fired three errors in an rb workspace package (`apps/api`) that has no basalt-ui dependency, is
|
|
968
|
+
* outside `basalt.roots`, and pins `ai@6` deliberately. `doctor`'s `ai-major-parity` scopes the same
|
|
969
|
+
* concern correctly — two shipped enforcement surfaces disagreeing about one fact leaves a consumer
|
|
970
|
+
* unable to tell which is right, which is worse than either being wrong.
|
|
971
|
+
*
|
|
972
|
+
* In scope when an ancestor package.json either depends on `basalt-ui`, or declares `basalt.roots`
|
|
973
|
+
* that the file sits under. A declared `basalt.roots` that the file is NOT under is a positive
|
|
974
|
+
* statement that this code is not basalt's — the walk stops there rather than continuing up to a
|
|
975
|
+
* workspace root that happens to carry the dependency.
|
|
976
|
+
*/
|
|
977
|
+
function isBasaltScopedFile(filename) {
|
|
978
|
+
let dir = dirname(filename)
|
|
979
|
+
for (;;) {
|
|
980
|
+
const pkg = readPackageJson(resolvePath(dir, 'package.json'))
|
|
981
|
+
if (pkg !== null) {
|
|
982
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies }
|
|
983
|
+
if (deps['basalt-ui'] !== undefined) return true
|
|
984
|
+
const roots = pkg.basalt?.roots
|
|
985
|
+
if (Array.isArray(roots) && roots.length > 0) {
|
|
986
|
+
return roots.some((root) => filename.startsWith(`${resolvePath(dir, root)}/`))
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
const parent = dirname(dir)
|
|
990
|
+
if (parent === dir) return false
|
|
991
|
+
dir = parent
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
763
995
|
function aiSdkMajorMessage(consumerMajor) {
|
|
764
996
|
return (
|
|
765
997
|
`This file's nearest package.json declares ai@${consumerMajor}, but basalt-ui declares the ` +
|
|
@@ -792,6 +1024,7 @@ const aiSdkMajor = {
|
|
|
792
1024
|
|
|
793
1025
|
const filename = getFilename(context)
|
|
794
1026
|
if (filename.length === 0) return
|
|
1027
|
+
if (!isBasaltScopedFile(filename)) return
|
|
795
1028
|
const consumerMajor = nearestAiMajor(dirname(filename))
|
|
796
1029
|
if (consumerMajor === null) return // no `ai` in the nearest package.json — nothing to compare
|
|
797
1030
|
if (consumerMajor === BASALT_AI_MAJOR) return
|
|
@@ -818,6 +1051,19 @@ const PLOT_ASSEMBLY_TAGS = new Set([
|
|
|
818
1051
|
'Crosshair',
|
|
819
1052
|
])
|
|
820
1053
|
|
|
1054
|
+
/**
|
|
1055
|
+
* KNOWN LIMIT, deliberately not closed: a chart drawn out of DOM instead of the chart layer is
|
|
1056
|
+
* structurally invisible here.
|
|
1057
|
+
*
|
|
1058
|
+
* argo has two — a 3×3 Mantine `SimpleGrid` matrix and absolutely-positioned `Box` bullet bars —
|
|
1059
|
+
* that import only `ChartCard` and `VX`, render no basalt primitive, and therefore cannot trip any
|
|
1060
|
+
* tag-name check. The candidate widenings all fail on false positives in a rule that SHIPS to
|
|
1061
|
+
* consumers: adding `ChartTooltipFloat`/`useChartSize` to this set flags `Donut` and `Heatmap`,
|
|
1062
|
+
* which compose them legitimately; "renders `ChartCard` and a raw `<svg>`" flags an inline icon in
|
|
1063
|
+
* a card header. Detecting "this Box grid is a chart" needs intent, not syntax. Naming the gap here
|
|
1064
|
+
* is the honest answer — a noisy rule gets switched off, and then it guards nothing at all.
|
|
1065
|
+
*/
|
|
1066
|
+
|
|
821
1067
|
/**
|
|
822
1068
|
* Composing this means the file is USING the chart system rather than re-implementing it.
|
|
823
1069
|
* `ChartFrame` deliberately does NOT count: composing the frame directly and then hand-assembling
|
|
@@ -859,6 +1105,7 @@ const handRolledPlot = {
|
|
|
859
1105
|
schema: [],
|
|
860
1106
|
},
|
|
861
1107
|
create(context) {
|
|
1108
|
+
if (isTestFile(context)) return {}
|
|
862
1109
|
// File-scoped, not node-scoped: the verdict depends on whether a plot OWNER appears anywhere
|
|
863
1110
|
// in the file, which is only known once the whole file has been walked.
|
|
864
1111
|
const assemblyNodes = []
|
|
@@ -891,16 +1138,26 @@ const handRolledPlot = {
|
|
|
891
1138
|
},
|
|
892
1139
|
'Program:exit'() {
|
|
893
1140
|
if (ownsPlot || definesOwner) return
|
|
894
|
-
//
|
|
895
|
-
//
|
|
896
|
-
//
|
|
897
|
-
//
|
|
898
|
-
//
|
|
899
|
-
//
|
|
900
|
-
// one
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
1141
|
+
// The CHECK stays file-scoped — forced by the shape of the correct pattern:
|
|
1142
|
+
// `<CartesianChart>{() => <AxisLeftNumeric …/>}</CartesianChart>` puts the axis inside a
|
|
1143
|
+
// render-prop arrow, a different function from the component that owns the chart, so
|
|
1144
|
+
// component scope would flag the canonical usage.
|
|
1145
|
+
//
|
|
1146
|
+
// The SUPPRESSION no longer is. Reporting only the first node and testing the waiver there
|
|
1147
|
+
// meant one comment — any comment, about anything — bought a whole file permanent immunity:
|
|
1148
|
+
// every axis, overlay and crosshair added to that file afterwards went unreported forever
|
|
1149
|
+
// (linewatch: three files, one 604 lines). Now every assembly node is reported and waived on
|
|
1150
|
+
// its own, EXCEPT when the file carries a written declaration — a `theme-allow` that names
|
|
1151
|
+
// `basalt/hand-rolled-plot` and gives a reason. That is the shape a genuinely non-single-plot
|
|
1152
|
+
// kind already uses (`DualPanel`), and it is the difference between a line waiver and a
|
|
1153
|
+
// decision about the file.
|
|
1154
|
+
const nodes = assemblyNodes.filter((node) => chartImports.has(node.name?.name))
|
|
1155
|
+
if (nodes.length === 0) return
|
|
1156
|
+
if (hasFileDeclaration(context, 'hand-rolled-plot')) return
|
|
1157
|
+
for (const node of nodes) {
|
|
1158
|
+
if (hasThemeAllow(context, node, 'hand-rolled-plot')) continue
|
|
1159
|
+
context.report({ node, message: HAND_ROLLED_PLOT_MESSAGE })
|
|
1160
|
+
}
|
|
904
1161
|
},
|
|
905
1162
|
}
|
|
906
1163
|
},
|
|
@@ -914,6 +1171,40 @@ const CHART_LEGEND_LITERAL_MESSAGE =
|
|
|
914
1171
|
'is a second source of truth that silently goes stale: it keeps naming a series after the plot ' +
|
|
915
1172
|
'stops drawing it. (basalt/chart-legend-literal)'
|
|
916
1173
|
|
|
1174
|
+
/**
|
|
1175
|
+
* Does this expression derive from the chart's own `series`?
|
|
1176
|
+
*
|
|
1177
|
+
* The contract is derivation from `series` — not derivation from *an* array. `items={refLines.map(
|
|
1178
|
+
* (r) => ({ key, label, color, shape }))}` and `items={PACE_ZONES.map(…)}` are hand-authored
|
|
1179
|
+
* legends by any reading: every field is written at the call site, and the list can name a band the
|
|
1180
|
+
* plot no longer draws exactly like a `[...]` literal can. Walks the base of a member/call chain so
|
|
1181
|
+
* `visibleSeries.filter(…).map(…)` and `deriveLegend(series)` both read as derived.
|
|
1182
|
+
*/
|
|
1183
|
+
function derivesFromSeries(node, depth = 0) {
|
|
1184
|
+
if (node === null || node === undefined || depth > 8) return false
|
|
1185
|
+
if (node.type === 'Identifier') return /series$/i.test(node.name ?? '')
|
|
1186
|
+
if (node.type === 'MemberExpression') {
|
|
1187
|
+
return derivesFromSeries(node.object, depth + 1) || derivesFromSeries(node.property, depth + 1)
|
|
1188
|
+
}
|
|
1189
|
+
if (node.type === 'CallExpression') {
|
|
1190
|
+
if (node.callee?.type === 'Identifier' && node.callee.name === 'deriveLegend') return true
|
|
1191
|
+
return (
|
|
1192
|
+
derivesFromSeries(node.callee, depth + 1) ||
|
|
1193
|
+
(node.arguments ?? []).some((arg) => derivesFromSeries(arg, depth + 1))
|
|
1194
|
+
)
|
|
1195
|
+
}
|
|
1196
|
+
return false
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/** `X.map(…)` — the derivation-shaped form that is only derivation when `X` is the series. */
|
|
1200
|
+
function isMapCall(node) {
|
|
1201
|
+
return (
|
|
1202
|
+
node?.type === 'CallExpression' &&
|
|
1203
|
+
node.callee?.type === 'MemberExpression' &&
|
|
1204
|
+
node.callee.property?.name === 'map'
|
|
1205
|
+
)
|
|
1206
|
+
}
|
|
1207
|
+
|
|
917
1208
|
const chartLegendLiteral = {
|
|
918
1209
|
meta: {
|
|
919
1210
|
type: 'suggestion',
|
|
@@ -921,6 +1212,7 @@ const chartLegendLiteral = {
|
|
|
921
1212
|
schema: [],
|
|
922
1213
|
},
|
|
923
1214
|
create(context) {
|
|
1215
|
+
if (isTestFile(context)) return {}
|
|
924
1216
|
const chartImports = new Set()
|
|
925
1217
|
|
|
926
1218
|
return {
|
|
@@ -933,6 +1225,14 @@ const chartLegendLiteral = {
|
|
|
933
1225
|
(attr) => attr.type === 'JSXAttribute' && attr.name?.name === 'items',
|
|
934
1226
|
)
|
|
935
1227
|
const expression = items?.value?.expression
|
|
1228
|
+
// The `.map()` half — a list built by mapping over something that is NOT the series. See
|
|
1229
|
+
// `derivesFromSeries`. Reported under the same id as the array-literal half because it is
|
|
1230
|
+
// the same defect (a second source of truth for the legend), just written as a call.
|
|
1231
|
+
if (isMapCall(expression) && !derivesFromSeries(expression.callee?.object)) {
|
|
1232
|
+
if (hasThemeAllow(context, node, 'chart-legend-literal')) return
|
|
1233
|
+
context.report({ node, message: CHART_LEGEND_LITERAL_MESSAGE })
|
|
1234
|
+
return
|
|
1235
|
+
}
|
|
936
1236
|
if (expression?.type !== 'ArrayExpression') return
|
|
937
1237
|
// Only a list that is ENTIRELY object literals is a hand-authored legend. An array that
|
|
938
1238
|
// spreads or calls something (`[...deriveLegend(series), note]`) is derived-and-extended,
|
|
@@ -946,13 +1246,201 @@ const chartLegendLiteral = {
|
|
|
946
1246
|
expression.elements.length > 0 &&
|
|
947
1247
|
expression.elements.every((el) => el?.type === 'ObjectExpression')
|
|
948
1248
|
if (!allObjectLiterals) return
|
|
949
|
-
if (hasThemeAllow(context, node)) return
|
|
1249
|
+
if (hasThemeAllow(context, node, 'chart-legend-literal')) return
|
|
950
1250
|
context.report({ node, message: CHART_LEGEND_LITERAL_MESSAGE })
|
|
951
1251
|
},
|
|
952
1252
|
}
|
|
953
1253
|
},
|
|
954
1254
|
}
|
|
955
1255
|
|
|
1256
|
+
// ── Rule 14 — shadow-basalt-export ───────────────────────────────────────────────────────────────
|
|
1257
|
+
|
|
1258
|
+
/** The package root — one directory above `configs/`, repo-locally AND inside node_modules. */
|
|
1259
|
+
const PACKAGE_ROOT = resolvePath(PLUGIN_DIR, '..')
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* A component-shaped name: starts uppercase and carries a lowercase letter, which excludes the
|
|
1263
|
+
* SCREAMING_CASE constants and the two-letter token namespaces in the same barrel.
|
|
1264
|
+
*/
|
|
1265
|
+
const COMPONENT_NAME = /^[A-Z][A-Za-z0-9]*[a-z][A-Za-z0-9]*$/
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
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.
|
|
1272
|
+
*
|
|
1273
|
+
* Empty (rule silent) when `dist` is unreadable, exactly like `BASALT_AI_MAJOR` — a lint run must
|
|
1274
|
+
* not depend on a build having happened.
|
|
1275
|
+
*/
|
|
1276
|
+
const BASALT_ROOT_EXPORTS = (() => {
|
|
1277
|
+
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)) {
|
|
1281
|
+
for (const raw of block[1].split(',')) {
|
|
1282
|
+
const part = raw.trim()
|
|
1283
|
+
if (part === '' || part.startsWith('type ')) continue
|
|
1284
|
+
const name = (part.split(/\s+as\s+/).pop() ?? '').trim()
|
|
1285
|
+
if (COMPONENT_NAME.test(name)) names.add(name)
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
} catch {
|
|
1289
|
+
return names
|
|
1290
|
+
}
|
|
1291
|
+
return names
|
|
1292
|
+
})()
|
|
1293
|
+
|
|
1294
|
+
function shadowBasaltExportMessage(name) {
|
|
1295
|
+
return (
|
|
1296
|
+
`Local component '${name}' collides with the basalt-ui export of the same name — import it ` +
|
|
1297
|
+
"from 'basalt-ui' instead of re-rolling it. This is the one signal a forked composite emits: " +
|
|
1298
|
+
'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)'
|
|
1300
|
+
)
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
/**
|
|
1304
|
+
* Flags a local declaration whose name collides with a live basalt export.
|
|
1305
|
+
*
|
|
1306
|
+
* The cheapest possible detector for the whole absorption class: argo shipped its own `EmptyState`
|
|
1307
|
+
* (a full duplicate of the shipped one) and rb a local `StatCard`, and both passed `check-theme`,
|
|
1308
|
+
* `oxlint` and `doctor` clean — off-palette code fails, a forked component that uses the correct
|
|
1309
|
+
* tokens does not. `warn`, not `error`: a name collision is evidence, not proof, and a consumer
|
|
1310
|
+
* with a genuinely different `Composer` should be told once, not blocked.
|
|
1311
|
+
*/
|
|
1312
|
+
const shadowBasaltExport = {
|
|
1313
|
+
meta: {
|
|
1314
|
+
type: 'suggestion',
|
|
1315
|
+
docs: { description: 'Warn on a local component whose name collides with a basalt-ui export.' },
|
|
1316
|
+
schema: [],
|
|
1317
|
+
},
|
|
1318
|
+
create(context) {
|
|
1319
|
+
const filename = getFilename(context)
|
|
1320
|
+
// basalt's own source DEFINES these names; a consumer's does not.
|
|
1321
|
+
if (BASALT_ROOT_EXPORTS.size === 0 || filename.startsWith(`${PACKAGE_ROOT}/`)) return {}
|
|
1322
|
+
if (isTestFile(context)) return {}
|
|
1323
|
+
|
|
1324
|
+
const report = (node, name) => {
|
|
1325
|
+
if (typeof name !== 'string' || !BASALT_ROOT_EXPORTS.has(name)) return
|
|
1326
|
+
if (hasThemeAllow(context, node, 'shadow-basalt-export')) return
|
|
1327
|
+
context.report({ node, message: shadowBasaltExportMessage(name) })
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
return {
|
|
1331
|
+
FunctionDeclaration(node) {
|
|
1332
|
+
report(node, node.id?.name)
|
|
1333
|
+
},
|
|
1334
|
+
VariableDeclarator(node) {
|
|
1335
|
+
report(node, node.id?.name)
|
|
1336
|
+
},
|
|
1337
|
+
ClassDeclaration(node) {
|
|
1338
|
+
report(node, node.id?.name)
|
|
1339
|
+
},
|
|
1340
|
+
}
|
|
1341
|
+
},
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// ── Rule 15 — hand-rolled-shell ──────────────────────────────────────────────────────────────────
|
|
1345
|
+
|
|
1346
|
+
/** Mantine parts that only ever appear when someone is assembling an application shell by hand. */
|
|
1347
|
+
// `NavLink` is deliberately NOT here: Mantine's NavLink is a legitimate standalone list row, and a
|
|
1348
|
+
// rule that fires on one inside a settings page would be switched off within a day. `Burger` and the
|
|
1349
|
+
// `AppShell.*` parts have no use outside assembling a shell.
|
|
1350
|
+
const SHELL_ASSEMBLY_TAGS = new Set(['Burger'])
|
|
1351
|
+
const SHELL_ASSEMBLY_MEMBER_OBJECT = 'AppShell'
|
|
1352
|
+
|
|
1353
|
+
const HAND_ROLLED_SHELL_MESSAGE =
|
|
1354
|
+
'Application shell assembled by hand — AppShell parts / Burger / NavLink in a file that does not ' +
|
|
1355
|
+
'render BasaltShell. The shell is the framework: BasaltShell owns the sidebar, the mobile tab ' +
|
|
1356
|
+
'bar and its More sheet, breadcrumbs, the page header and the collapse state, all driven by ONE ' +
|
|
1357
|
+
'typed nav definition (defineNav + useNav). A hand-rolled AppShell + burger drawer is the exact ' +
|
|
1358
|
+
'layer the 1.19 nav contract replaces, and no palette guard can see it — it uses the right ' +
|
|
1359
|
+
'tokens. (basalt/hand-rolled-shell)'
|
|
1360
|
+
|
|
1361
|
+
/**
|
|
1362
|
+
* The `hand-rolled-plot` shape, one layer up. rb shipped 266 lines of `AppShell` + `NavLink` +
|
|
1363
|
+
* `Burger` + `useDisclosure` mobile drawer and every gate passed it green.
|
|
1364
|
+
*
|
|
1365
|
+
* File-scoped and reported once: which shell a file uses is one decision, so one comment settles
|
|
1366
|
+
* it. The module that DEFINES `BasaltShell` is exempt by declaration, not by path — a rule saying
|
|
1367
|
+
* "compose X" cannot fire inside X.
|
|
1368
|
+
*/
|
|
1369
|
+
const handRolledShell = {
|
|
1370
|
+
meta: {
|
|
1371
|
+
type: 'suggestion',
|
|
1372
|
+
docs: { description: 'Disallow assembling an app shell by hand instead of using BasaltShell.' },
|
|
1373
|
+
schema: [],
|
|
1374
|
+
},
|
|
1375
|
+
create(context) {
|
|
1376
|
+
if (isTestFile(context)) return {}
|
|
1377
|
+
|
|
1378
|
+
const assemblyNodes = []
|
|
1379
|
+
let mantineImported = false
|
|
1380
|
+
let ownsShell = false
|
|
1381
|
+
|
|
1382
|
+
const notesOwnerDefinition = (name) => {
|
|
1383
|
+
if (name === 'BasaltShell') ownsShell = true
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
return {
|
|
1387
|
+
ImportDeclaration(node) {
|
|
1388
|
+
if ((node.source?.value ?? '').startsWith('@mantine/')) mantineImported = true
|
|
1389
|
+
},
|
|
1390
|
+
FunctionDeclaration(node) {
|
|
1391
|
+
notesOwnerDefinition(node.id?.name)
|
|
1392
|
+
},
|
|
1393
|
+
VariableDeclarator(node) {
|
|
1394
|
+
notesOwnerDefinition(node.id?.name)
|
|
1395
|
+
},
|
|
1396
|
+
JSXOpeningElement(node) {
|
|
1397
|
+
const name = node.name
|
|
1398
|
+
if (name?.type === 'JSXMemberExpression') {
|
|
1399
|
+
if (name.object?.name === SHELL_ASSEMBLY_MEMBER_OBJECT) assemblyNodes.push(node)
|
|
1400
|
+
return
|
|
1401
|
+
}
|
|
1402
|
+
if (name?.name === 'BasaltShell') ownsShell = true
|
|
1403
|
+
else if (SHELL_ASSEMBLY_TAGS.has(name?.name)) assemblyNodes.push(node)
|
|
1404
|
+
},
|
|
1405
|
+
'Program:exit'() {
|
|
1406
|
+
if (ownsShell || !mantineImported) return
|
|
1407
|
+
const [first] = assemblyNodes
|
|
1408
|
+
if (first === undefined) return
|
|
1409
|
+
if (hasThemeAllow(context, first, 'hand-rolled-shell')) return
|
|
1410
|
+
if (hasFileDeclaration(context, 'hand-rolled-shell')) return
|
|
1411
|
+
context.report({ node: first, message: HAND_ROLLED_SHELL_MESSAGE })
|
|
1412
|
+
},
|
|
1413
|
+
}
|
|
1414
|
+
},
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// ── Grace ledger ────────────────────────────────────────────────────────────────────────────────
|
|
1418
|
+
|
|
1419
|
+
/**
|
|
1420
|
+
* Plugin rules still inside their grace minor — `warn` in the shipped consumer preset, `error`
|
|
1421
|
+
* repo-locally, with the promotion note as the value. This is the plugin's counterpart to
|
|
1422
|
+
* `src/guard`'s `GRACE_PERIOD_KINDS`, and it exists because its ABSENCE is what let three rules sit
|
|
1423
|
+
* at `warn` for up to twelve minors with nothing tracking them: `configs/oxlint.json` is generated
|
|
1424
|
+
* and its top-level keys are fixed by oxlint's own parser, so the ledger cannot live there.
|
|
1425
|
+
*
|
|
1426
|
+
* `oxlint-plugin.test.ts` asserts the two agree in both directions — a rule listed here must be
|
|
1427
|
+
* `warn` in the shipped preset, and one absent from here must be `error`. Deleting an entry IS the
|
|
1428
|
+
* promotion, and the test makes the config change mandatory in the same commit.
|
|
1429
|
+
*/
|
|
1430
|
+
export const PLUGIN_RULE_GRACE = {
|
|
1431
|
+
'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.',
|
|
1435
|
+
'chart-legend-literal':
|
|
1436
|
+
'widened in the round-4 guard minor (a `.map()` over a non-series array now counts) — promote ' +
|
|
1437
|
+
'to error one minor later, for the same reason as hand-rolled-plot above.',
|
|
1438
|
+
'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.',
|
|
1441
|
+
'hand-rolled-shell': 'new in the round-4 guard minor — promote to error one minor later.',
|
|
1442
|
+
}
|
|
1443
|
+
|
|
956
1444
|
// ── Plugin export ───────────────────────────────────────────────────────────────────────────────
|
|
957
1445
|
|
|
958
1446
|
export default {
|
|
@@ -964,6 +1452,8 @@ export default {
|
|
|
964
1452
|
'chart-in-raw-surface': chartInRawSurface,
|
|
965
1453
|
'hand-rolled-plot': handRolledPlot,
|
|
966
1454
|
'chart-legend-literal': chartLegendLiteral,
|
|
1455
|
+
'shadow-basalt-export': shadowBasaltExport,
|
|
1456
|
+
'hand-rolled-shell': handRolledShell,
|
|
967
1457
|
'raw-scroll-container': rawScrollContainer,
|
|
968
1458
|
'visx-boundary': visxBoundary,
|
|
969
1459
|
'visx-tooltip': visxTooltip,
|