basalt-ui 1.26.0 → 1.27.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 +1 -1
- package/CHANGELOG.md +8 -0
- package/MIGRATING.md +299 -33
- package/agent/rules/basalt-controls.md +17 -12
- package/agent/rules/basalt-state.md +5 -0
- package/configs/oxlint-plugin.js +251 -66
- package/configs/oxlint-plugin.test.ts +243 -0
- package/configs/oxlint.json +5 -5
- package/dist/controls/enum-filter.d.ts +11 -2
- package/dist/controls/enum-filter.d.ts.map +1 -1
- package/dist/controls/enum-filter.js +4 -2
- package/dist/controls/filter-context.d.ts +7 -2
- package/dist/controls/filter-context.d.ts.map +1 -1
- package/dist/controls/index.d.ts +1 -0
- package/dist/controls/index.d.ts.map +1 -1
- package/dist/controls/index.js +2 -0
- package/dist/controls/multi-select-filter.js +2 -2
- package/dist/controls/number-filter.d.ts +26 -0
- package/dist/controls/number-filter.d.ts.map +1 -0
- package/dist/controls/number-filter.js +143 -0
- package/dist/controls/range-filter.d.ts +19 -5
- package/dist/controls/range-filter.d.ts.map +1 -1
- package/dist/controls/range-filter.js +22 -7
- package/dist/controls/range-filter.type-guard.test.d.ts +2 -0
- package/dist/controls/range-filter.type-guard.test.d.ts.map +1 -0
- package/dist/controls/search-filter.js +1 -1
- package/dist/controls/toggle-filter.js +1 -1
- package/dist/dashboard/index.d.ts +1 -1
- package/dist/dashboard/index.d.ts.map +1 -1
- package/dist/dashboard/index.js +3 -1
- package/dist/dashboard/stat-card.d.ts +52 -1
- package/dist/dashboard/stat-card.d.ts.map +1 -1
- package/dist/dashboard/stat-card.js +42 -15
- package/dist/dashboard/stat-card.module.css +71 -0
- package/dist/dashboard/stat-card.module.css.d.ts +4 -0
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +8 -0
- package/dist/guard/grace.test.d.ts.map +1 -1
- package/dist/guard/index.d.ts +1 -1
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +6 -10
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/router-tanstack/field.d.ts +1 -1
- package/dist/router-tanstack/field.d.ts.map +1 -1
- package/dist/router-tanstack/index.d.ts +1 -1
- package/dist/router-tanstack/index.d.ts.map +1 -1
- package/dist/router-tanstack/search-store.d.ts +4 -1
- package/dist/router-tanstack/search-store.d.ts.map +1 -1
- package/dist/router-tanstack/search-store.js +91 -7
- package/dist/shell/page-bar.d.ts +1 -1
- package/dist/state/fields.d.ts +209 -36
- package/dist/state/fields.d.ts.map +1 -1
- package/dist/state/fields.js +68 -32
- package/dist/state/persisted.d.ts +12 -0
- package/dist/state/persisted.d.ts.map +1 -1
- package/dist/state/persisted.js +36 -18
- package/dist/state/persisted.test.d.ts +2 -0
- package/dist/state/persisted.test.d.ts.map +1 -0
- package/dist/state.d.ts +1 -1
- package/dist/state.d.ts.map +1 -1
- package/dist/surfaces.d.ts +1 -1
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +4 -3
- package/dist/widget-header/widget-header.d.ts +31 -1
- package/dist/widget-header/widget-header.d.ts.map +1 -1
- package/dist/widget-header/widget-header.js +13 -1
- package/dist/widget-header/widget-header.module.css +15 -0
- package/dist/widget-header/widget-header.module.css.d.ts +1 -0
- package/llms.txt +3 -3
- package/package.json +1 -1
- package/src/controls/controls.router.test.tsx +416 -0
- package/src/controls/enum-filter.tsx +14 -3
- package/src/controls/filter-context.tsx +7 -2
- package/src/controls/index.ts +1 -0
- package/src/controls/multi-select-filter.tsx +2 -2
- package/src/controls/number-filter.tsx +291 -0
- package/src/controls/props-contract.test.tsx +31 -0
- package/src/controls/range-filter.tsx +64 -11
- package/src/controls/range-filter.type-guard.test.tsx +122 -0
- package/src/controls/search-filter.tsx +1 -1
- package/src/controls/toggle-filter.tsx +1 -1
- package/src/dashboard/index.ts +6 -1
- package/src/dashboard/stat-card.module.css +71 -0
- package/src/dashboard/stat-card.module.css.d.ts +4 -0
- package/src/dashboard/stat-card.test.tsx +231 -0
- package/src/dashboard/stat-card.tsx +81 -0
- package/src/data/data-table.tsx +8 -0
- package/src/guard/check-source.test.ts +35 -2
- package/src/guard/grace.test.ts +14 -5
- package/src/guard/index.ts +30 -17
- package/src/index.ts +5 -0
- package/src/router-tanstack/field.ts +4 -0
- package/src/router-tanstack/index.ts +4 -0
- package/src/router-tanstack/search-store.router.test.tsx +232 -11
- package/src/router-tanstack/search-store.test.ts +160 -0
- package/src/router-tanstack/search-store.ts +177 -11
- package/src/shell/page-bar.tsx +1 -1
- package/src/state/fields.test.tsx +254 -0
- package/src/state/fields.ts +328 -70
- package/src/state/persisted.test.ts +91 -0
- package/src/state/persisted.ts +76 -26
- package/src/state.ts +6 -0
- package/src/surfaces.ts +4 -3
- package/src/widget-header/widget-header.module.css +15 -0
- package/src/widget-header/widget-header.module.css.d.ts +1 -0
- package/src/widget-header/widget-header.tsx +46 -1
|
@@ -42,6 +42,77 @@
|
|
|
42
42
|
min-width: 0;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/*
|
|
46
|
+
* The `breakdown` rows — the two or three parts a hero number is made of, under it.
|
|
47
|
+
*
|
|
48
|
+
* NO hairline, anywhere in this block, and that is a law rather than a taste: `docs/CONTROLS-SPEC.md`
|
|
49
|
+
* §2.1 puts a horizontal rule between OPTION rows and nowhere else, and `theme/divider-law.test.ts`
|
|
50
|
+
* inventories every one basalt draws. Three rules inside a KPI card would read as a table wearing a
|
|
51
|
+
* card's clothes. The rows are separated by the stack rhythm and by weight — a muted label against
|
|
52
|
+
* an ink value — which is the same separation the sheet's own field groups use.
|
|
53
|
+
*/
|
|
54
|
+
.breakdown {
|
|
55
|
+
display: flex;
|
|
56
|
+
flex-direction: column;
|
|
57
|
+
margin: var(--vx-space-stack-sm) 0 0;
|
|
58
|
+
padding: 0;
|
|
59
|
+
min-width: 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/*
|
|
63
|
+
* One row, one line, at the TAG tier (`controlHeightTag`, 20px at level 0) — the smallest control
|
|
64
|
+
* step basalt declares, and a token rather than a literal so the rows track the density knob with
|
|
65
|
+
* everything else. `justify-content: space-between` is what makes the pair read as a row without a
|
|
66
|
+
* grid: the label leads, the value trails, and neither needs a column to align against because
|
|
67
|
+
* every row in one card is the same box.
|
|
68
|
+
*/
|
|
69
|
+
.breakdownRow {
|
|
70
|
+
display: flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
justify-content: space-between;
|
|
73
|
+
gap: var(--vx-space-control-gap);
|
|
74
|
+
min-height: var(--vx-space-control-height-tag);
|
|
75
|
+
min-width: 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.breakdownLabel {
|
|
79
|
+
overflow: hidden;
|
|
80
|
+
text-overflow: ellipsis;
|
|
81
|
+
white-space: nowrap;
|
|
82
|
+
font-size: var(--vx-text-sm);
|
|
83
|
+
color: var(--vx-muted);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* Mono and ink — a breakdown value is a number, and it is the half of the row worth reading. Never
|
|
87
|
+
* shrinks: the LABEL is the prose half and the one that may ellipsize. */
|
|
88
|
+
.breakdownValue {
|
|
89
|
+
flex-shrink: 0;
|
|
90
|
+
margin: 0;
|
|
91
|
+
font-family: var(--basalt-font-mono);
|
|
92
|
+
font-size: var(--vx-text-sm);
|
|
93
|
+
font-weight: 500;
|
|
94
|
+
color: var(--vx-ink);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/*
|
|
98
|
+
* A row past a threshold. Same vocabulary and the same per-scheme solids as the card's own tone rail
|
|
99
|
+
* (`stat-card.tsx`'s `VX.status[tone]`), so a row saying `bad` and a rail saying `bad` are one claim
|
|
100
|
+
* at two scales. Colour is not the only carrier at the CARD level (the rail ships a
|
|
101
|
+
* `VisuallyHidden` verdict); at the ROW level the label is the carrier — a row reading
|
|
102
|
+
* `Paid · 1,204` states its own subject either way, and the tint only ranks it.
|
|
103
|
+
*/
|
|
104
|
+
.breakdownRow[data-tone='good'] .breakdownValue {
|
|
105
|
+
color: var(--vx-status-good);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.breakdownRow[data-tone='warn'] .breakdownValue {
|
|
109
|
+
color: var(--vx-status-warn);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.breakdownRow[data-tone='bad'] .breakdownValue {
|
|
113
|
+
color: var(--vx-status-bad);
|
|
114
|
+
}
|
|
115
|
+
|
|
45
116
|
.sparklineBleed {
|
|
46
117
|
margin-top: auto;
|
|
47
118
|
margin-inline: calc(-1 * var(--mantine-spacing-sm));
|
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
declare const classes: {
|
|
6
6
|
readonly body: string
|
|
7
7
|
readonly header: string
|
|
8
|
+
readonly breakdown: string
|
|
9
|
+
readonly breakdownRow: string
|
|
10
|
+
readonly breakdownLabel: string
|
|
11
|
+
readonly breakdownValue: string
|
|
8
12
|
readonly sparklineBleed: string
|
|
9
13
|
readonly sparklineRight: string
|
|
10
14
|
}
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { MantineProvider } from '@mantine/core'
|
|
23
23
|
import { describe, expect, test } from 'bun:test'
|
|
24
|
+
import { readFileSync } from 'node:fs'
|
|
25
|
+
import { resolve } from 'node:path'
|
|
24
26
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
25
27
|
import { StatCard } from './stat-card'
|
|
26
28
|
import type { StatCardTone } from './stat-card'
|
|
@@ -152,3 +154,232 @@ describe('sparklinePlacement', () => {
|
|
|
152
154
|
expect(markup).toContain('data-placement="right"')
|
|
153
155
|
})
|
|
154
156
|
})
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* `unit` and `breakdown` — the two props that existed as hand-rolled cards in three consumers
|
|
160
|
+
* before they existed here. Both are asserted through the same static markup as the rail above; the
|
|
161
|
+
* CSS half (which cannot resolve under `bun test`, where a `.module.css` import is `undefined`) is
|
|
162
|
+
* pinned against the shipped stylesheet TEXT at the bottom of this file, the pattern
|
|
163
|
+
* `page-bar.test.tsx` uses.
|
|
164
|
+
*/
|
|
165
|
+
describe('unit — the hero value is a number and a unit, not one string', () => {
|
|
166
|
+
function renderUnit(props: { unit?: string; value?: string }) {
|
|
167
|
+
return renderToStaticMarkup(
|
|
168
|
+
<MantineProvider>
|
|
169
|
+
<StatCard title="Training load" value="412" {...props} />
|
|
170
|
+
</MantineProvider>,
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
test('renders after the value, and both are present', () => {
|
|
175
|
+
const markup = renderUnit({ unit: 'TSS' })
|
|
176
|
+
expect(markup).toContain('412')
|
|
177
|
+
expect(markup).toContain('TSS')
|
|
178
|
+
// Order matters — a unit BEFORE the numeral reads as a currency prefix, which it is not.
|
|
179
|
+
expect(markup.indexOf('412')).toBeLessThan(markup.indexOf('TSS'))
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('omitting it renders nothing extra', () => {
|
|
183
|
+
expect(renderUnit({})).not.toContain('TSS')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('it is not the same channel as `subtitle` — a card may carry both', () => {
|
|
187
|
+
const markup = renderToStaticMarkup(
|
|
188
|
+
<MantineProvider>
|
|
189
|
+
<StatCard title="Training load" value="412" unit="TSS" subtitle="7-day rolling" />
|
|
190
|
+
</MantineProvider>,
|
|
191
|
+
)
|
|
192
|
+
expect(markup).toContain('TSS')
|
|
193
|
+
expect(markup).toContain('7-day rolling')
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* `deltaFormat` — because not every delta is a percentage, and the default said it was.
|
|
199
|
+
*
|
|
200
|
+
* A pace card's trend is `0:12 /km` and a speed card's is `0.3 km/h`; both rendered as `0.3%`, which
|
|
201
|
+
* is a wrong unit on a KPI — the failure worse than showing no chip at all, and the reason the
|
|
202
|
+
* consumer that needed one kept its card hand-rolled (the `HeroCard` fork `shadow-basalt-export`
|
|
203
|
+
* reports). The prop is a FUNCTION over the signed number, not a label string: `delta` still drives
|
|
204
|
+
* the tone and the glyph, so there is one place the sign is decided.
|
|
205
|
+
*/
|
|
206
|
+
describe('deltaFormat — a delta that is not a percentage', () => {
|
|
207
|
+
function renderDelta(props: {
|
|
208
|
+
delta?: number
|
|
209
|
+
deltaFormat?: (delta: number) => string
|
|
210
|
+
deltaGlyph?: boolean
|
|
211
|
+
deltaPeriod?: string
|
|
212
|
+
}) {
|
|
213
|
+
return renderToStaticMarkup(
|
|
214
|
+
<MantineProvider>
|
|
215
|
+
<StatCard title="Pace" value="5:31" unit="/km" {...props} />
|
|
216
|
+
</MantineProvider>,
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
test('the default is still a one-decimal percentage — no existing card moves', () => {
|
|
221
|
+
const markup = renderDelta({ delta: 0.3 })
|
|
222
|
+
expect(markup).toContain('0.3%')
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
test('a formatter replaces the label and the percent is gone', () => {
|
|
226
|
+
const markup = renderDelta({ delta: 0.3, deltaFormat: (v) => `${v.toFixed(1)} km/h` })
|
|
227
|
+
expect(markup).toContain('0.3 km/h')
|
|
228
|
+
expect(markup).not.toContain('0.3%')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('the formatter receives the SIGNED value, so it can print an absolute delta itself', () => {
|
|
232
|
+
// argo's walking-pad pace card: the delta is seconds per km, faster is a negative number, and
|
|
233
|
+
// the sign belongs in the string. `deltaGlyph={false}` stops the ▼ saying it a second time.
|
|
234
|
+
const markup = renderDelta({
|
|
235
|
+
delta: -12,
|
|
236
|
+
deltaFormat: (s) => `${s < 0 ? '−' : '+'}0:${String(Math.abs(s)).padStart(2, '0')} /km`,
|
|
237
|
+
deltaGlyph: false,
|
|
238
|
+
})
|
|
239
|
+
// The chip's whole text, so nothing survives beside it — the glyph, or a stray `12.0%`.
|
|
240
|
+
expect(markup).toContain('>−0:12 /km</span>')
|
|
241
|
+
expect(markup).not.toContain('▼')
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test('the glyph and the tone still come off the number, not the label', () => {
|
|
245
|
+
const markup = renderDelta({ delta: -12, deltaFormat: (s) => `${Math.abs(s)} s` })
|
|
246
|
+
// Negative reads as danger and keeps its ▼ — the formatter only owns the magnitude string.
|
|
247
|
+
expect(markup).toContain('▼')
|
|
248
|
+
expect(markup).toContain('var(--vx-status-bad)')
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test('deltaPeriod still rides beside a formatted delta', () => {
|
|
252
|
+
const markup = renderDelta({
|
|
253
|
+
delta: 0.3,
|
|
254
|
+
deltaFormat: (v) => `${v.toFixed(1)} km/h`,
|
|
255
|
+
deltaPeriod: 'WoW',
|
|
256
|
+
})
|
|
257
|
+
expect(markup).toContain('0.3 km/h')
|
|
258
|
+
expect(markup).toContain('WoW')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
test('a formatter with no delta renders no chip at all', () => {
|
|
262
|
+
expect(renderDelta({ deltaFormat: (v) => `${v} km/h` })).not.toContain('km/h')
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
describe('breakdown — the parts the hero number is made of', () => {
|
|
267
|
+
const ROWS = [
|
|
268
|
+
{ label: 'Paid', value: '1,204' },
|
|
269
|
+
{ label: 'Organic', value: '842' },
|
|
270
|
+
{ label: 'Referral', value: '31', tone: 'bad' as const },
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
function renderBreakdown(breakdown?: readonly { label: string; value: string }[]) {
|
|
274
|
+
return renderToStaticMarkup(
|
|
275
|
+
<MantineProvider>
|
|
276
|
+
<StatCard
|
|
277
|
+
title="Orders"
|
|
278
|
+
value="2,077"
|
|
279
|
+
{...(breakdown !== undefined ? { breakdown } : {})}
|
|
280
|
+
/>
|
|
281
|
+
</MantineProvider>,
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
test('every row renders its label and its value', () => {
|
|
286
|
+
const markup = renderBreakdown(ROWS)
|
|
287
|
+
for (const row of ROWS) {
|
|
288
|
+
expect(markup).toContain(row.label)
|
|
289
|
+
expect(markup).toContain(row.value)
|
|
290
|
+
}
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
test('the rows are a description list — each row IS a term and its value', () => {
|
|
294
|
+
const markup = renderBreakdown(ROWS)
|
|
295
|
+
expect(markup).toContain('<dl')
|
|
296
|
+
expect((markup.match(/<dt/g) ?? []).length).toBe(3)
|
|
297
|
+
expect((markup.match(/<dd/g) ?? []).length).toBe(3)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
test('a toned row carries data-tone; an untoned one carries nothing', () => {
|
|
301
|
+
const markup = renderBreakdown(ROWS)
|
|
302
|
+
expect(markup).toContain('data-tone="bad"')
|
|
303
|
+
// Omission is not `good`, exactly as it is not on the card itself.
|
|
304
|
+
expect(markup).not.toContain('data-tone="good"')
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
test('omitted, and an empty array, render no list at all', () => {
|
|
308
|
+
expect(renderBreakdown()).not.toContain('<dl')
|
|
309
|
+
expect(renderBreakdown([])).not.toContain('<dl')
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
// The reason the rows live INSIDE the header block rather than beside it: with
|
|
313
|
+
// `sparklinePlacement="right"` the card body is a flex ROW, so a third child there would sit next
|
|
314
|
+
// to the trend instead of under the number it splits.
|
|
315
|
+
test('the list sits inside the header block, before the sparkline slot', () => {
|
|
316
|
+
const markup = renderToStaticMarkup(
|
|
317
|
+
<MantineProvider>
|
|
318
|
+
<StatCard
|
|
319
|
+
title="Orders"
|
|
320
|
+
value="2,077"
|
|
321
|
+
breakdown={ROWS}
|
|
322
|
+
sparklinePlacement="right"
|
|
323
|
+
sparkline={<span>spark</span>}
|
|
324
|
+
/>
|
|
325
|
+
</MantineProvider>,
|
|
326
|
+
)
|
|
327
|
+
expect(markup.indexOf('<dl')).toBeLessThan(markup.indexOf('spark'))
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The CSS half, pinned against the shipped module text. A `.module.css` resolves to `undefined`
|
|
333
|
+
* under `bun test` (no bundler in the loop), so the class names never reach the markup and the only
|
|
334
|
+
* checkable thing is the stylesheet itself.
|
|
335
|
+
*/
|
|
336
|
+
describe('stat-card.module.css — the breakdown block', () => {
|
|
337
|
+
const css = readFileSync(resolve(import.meta.dirname, 'stat-card.module.css'), 'utf8')
|
|
338
|
+
/** Declarations only — the comments above them discuss hairlines and borders by name. */
|
|
339
|
+
const decls = css.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
340
|
+
|
|
341
|
+
function rule(selector: string): string {
|
|
342
|
+
const start = decls.indexOf(`${selector} {`)
|
|
343
|
+
expect(start).toBeGreaterThan(-1)
|
|
344
|
+
return decls.slice(start, decls.indexOf('}', start))
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
test('a row is one line at the TAG tier, from the token and never a literal', () => {
|
|
348
|
+
expect(rule('.breakdownRow')).toContain('min-height: var(--vx-space-control-height-tag)')
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
// THE law this block had to not break: `theme/divider-law.test.ts` inventories every horizontal
|
|
352
|
+
// hairline basalt draws, and §2.1 puts one between OPTION rows and nowhere else. Three rules
|
|
353
|
+
// inside a KPI card would read as a table wearing a card's clothes — and would fail that test.
|
|
354
|
+
test('no row draws a hairline — not a border, not a rule, not a divider', () => {
|
|
355
|
+
expect(decls).not.toMatch(/\.breakdown[^{]*\{[^}]*border/)
|
|
356
|
+
expect(decls).not.toContain('--vx-surface-hairline')
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
test('the label is muted prose and the value is mono ink — the separation is weight, not a line', () => {
|
|
360
|
+
expect(rule('.breakdownLabel')).toContain('color: var(--vx-muted)')
|
|
361
|
+
expect(rule('.breakdownValue')).toContain('font-family: var(--basalt-font-mono)')
|
|
362
|
+
expect(rule('.breakdownValue')).toContain('color: var(--vx-ink)')
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
test('a toned row reads the per-scheme status solid, never a hex', () => {
|
|
366
|
+
for (const tone of ['good', 'warn', 'bad']) {
|
|
367
|
+
expect(rule(`.breakdownRow[data-tone='${tone}'] .breakdownValue`)).toContain(
|
|
368
|
+
`color: var(--vx-status-${tone})`,
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
test('the unit beside the hero value is mono, muted and small — in widget-header, its owner', () => {
|
|
374
|
+
const headerCss = readFileSync(
|
|
375
|
+
resolve(import.meta.dirname, '..', 'widget-header', 'widget-header.module.css'),
|
|
376
|
+
'utf8',
|
|
377
|
+
).replace(/\/\*[\s\S]*?\*\//g, '')
|
|
378
|
+
const start = headerCss.indexOf('.unit {')
|
|
379
|
+
expect(start).toBeGreaterThan(-1)
|
|
380
|
+
const unit = headerCss.slice(start, headerCss.indexOf('}', start))
|
|
381
|
+
expect(unit).toContain('font-family: var(--basalt-font-mono)')
|
|
382
|
+
expect(unit).toContain('font-size: var(--vx-text-sm)')
|
|
383
|
+
expect(unit).toContain('color: var(--vx-muted)')
|
|
384
|
+
})
|
|
385
|
+
})
|
|
@@ -100,6 +100,20 @@ import classes from './stat-card.module.css'
|
|
|
100
100
|
/** A threshold verdict on the card's value. `undefined` is never tinted — see the module docblock. */
|
|
101
101
|
export type StatCardTone = 'good' | 'warn' | 'bad'
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* One row of a {@link StatCardProps.breakdown} — the two or three parts a hero number is made of.
|
|
105
|
+
*
|
|
106
|
+
* `value` is a pre-formatted `string` for the same reason the hero's is: the card cannot know the
|
|
107
|
+
* locale, the currency or the precision the number was measured at. `tone` marks ONE row as past a
|
|
108
|
+
* threshold and is the same vocabulary as the card's own `tone`, so a row saying `bad` and a rail
|
|
109
|
+
* saying `bad` are the same claim at two scales; omitting it is untinted, never `'good'`.
|
|
110
|
+
*/
|
|
111
|
+
export type StatCardBreakdownRow = {
|
|
112
|
+
readonly label: string
|
|
113
|
+
readonly value: string
|
|
114
|
+
readonly tone?: StatCardTone
|
|
115
|
+
}
|
|
116
|
+
|
|
103
117
|
/**
|
|
104
118
|
* The `'right'` placement's slot width, in px — a flex BASIS, not a fixed size. 72 is the reference
|
|
105
119
|
* KPI card's trend width and the slot asks for exactly that; when the hero row needs the space (a
|
|
@@ -187,10 +201,48 @@ export type StatCardProps = {
|
|
|
187
201
|
info?: string
|
|
188
202
|
/** Pre-formatted KPI value string (mono ~24px, weight 600, ink) — the hero-row value. */
|
|
189
203
|
value: string
|
|
204
|
+
/** The value's unit, muted and mono at `--vx-text-sm`, immediately after it on the hero row —
|
|
205
|
+
* forwarded to `WidgetHeader`. `412` + `TSS`, not `"412 TSS"`: a unit painted at the hero's 24px
|
|
206
|
+
* reads as a second numeral. The BASIS (`7-day rolling`) is still `subtitle`; see
|
|
207
|
+
* `WidgetHeaderProps.unit`. */
|
|
208
|
+
unit?: string
|
|
209
|
+
/**
|
|
210
|
+
* The parts the hero number is made of — compact rows under it, one line each, no hairlines.
|
|
211
|
+
*
|
|
212
|
+
* It exists because three consumers wanted a KPI card that also SPLITS its number (revenue by
|
|
213
|
+
* channel, uptime by probe) and the only shapes basalt offered were a `subtitle` (one muted line,
|
|
214
|
+
* no second column) and a table. Both hand-rolled a card instead — which is the fork
|
|
215
|
+
* `shadow-basalt-export` reports as a `HeroCard`.
|
|
216
|
+
*
|
|
217
|
+
* Deliberately not a table and deliberately unruled: the divider law puts a hairline between
|
|
218
|
+
* OPTION rows and nowhere else (`docs/CONTROLS-SPEC.md` §2.1), and a KPI card that draws three
|
|
219
|
+
* of them stops reading as one card. Keep it to two or three rows — past that the card is a
|
|
220
|
+
* table, and a table is `BasaltDataTable` in a `Section`.
|
|
221
|
+
*/
|
|
222
|
+
breakdown?: readonly StatCardBreakdownRow[]
|
|
190
223
|
/** Signed delta rendered via `DeltaBadge`; omit to hide the trend chip entirely. */
|
|
191
224
|
delta?: number
|
|
192
225
|
/** Optional comparison timeframe shown after the delta (e.g. `MoM`) — forwarded to `DeltaBadge`. */
|
|
193
226
|
deltaPeriod?: string
|
|
227
|
+
/**
|
|
228
|
+
* Formats `delta` into the chip's label — forwarded to `WidgetHeader` and on to `DeltaBadge`.
|
|
229
|
+
* Defaults to `${Math.abs(delta).toFixed(1)}%`.
|
|
230
|
+
*
|
|
231
|
+
* A delta is not always a percentage, and the default silently claimed it was: a pace card's trend
|
|
232
|
+
* (`0:12 /km`) and a speed card's (`0.3 km/h`) both rendered as `0.3%` — a wrong unit on a KPI is
|
|
233
|
+
* the one failure worse than no chip, and it is why the consumer that needed it kept the card
|
|
234
|
+
* hand-rolled instead (the `HeroCard` fork `shadow-basalt-export` reports).
|
|
235
|
+
*
|
|
236
|
+
* @example
|
|
237
|
+
* // An absolute delta that prints its own sign — no percent, no glyph saying it twice.
|
|
238
|
+
* <StatCard title="Pace" value="5:31" unit="/km" delta={-12}
|
|
239
|
+
* deltaFormat={(s) => `${s < 0 ? '−' : '+'}0:${String(Math.abs(s)).padStart(2, '0')} /km`}
|
|
240
|
+
* deltaGlyph={false} />
|
|
241
|
+
*/
|
|
242
|
+
deltaFormat?: (delta: number) => string
|
|
243
|
+
/** Render the ▲/▼ glyph on the delta chip. Defaults to `true`; a zero delta never shows one. Pass
|
|
244
|
+
* `false` when `deltaFormat` prints the sign itself. */
|
|
245
|
+
deltaGlyph?: boolean
|
|
194
246
|
/**
|
|
195
247
|
* Optional trend visual. Either a node, or a RENDER PROP receiving the slot's measured box.
|
|
196
248
|
*
|
|
@@ -223,8 +275,12 @@ export function StatCard({
|
|
|
223
275
|
subtitle,
|
|
224
276
|
info,
|
|
225
277
|
value,
|
|
278
|
+
unit,
|
|
279
|
+
breakdown,
|
|
226
280
|
delta,
|
|
227
281
|
deltaPeriod,
|
|
282
|
+
deltaFormat,
|
|
283
|
+
deltaGlyph,
|
|
228
284
|
sparkline,
|
|
229
285
|
sparklinePlacement = 'bleed',
|
|
230
286
|
actions,
|
|
@@ -291,8 +347,11 @@ export function StatCard({
|
|
|
291
347
|
{...(subtitle !== undefined && { subtitle })}
|
|
292
348
|
{...(info !== undefined && { info })}
|
|
293
349
|
value={value}
|
|
350
|
+
{...(unit !== undefined && { unit })}
|
|
294
351
|
{...(delta !== undefined && { delta })}
|
|
295
352
|
{...(deltaPeriod !== undefined && { deltaPeriod })}
|
|
353
|
+
{...(deltaFormat !== undefined && { deltaFormat })}
|
|
354
|
+
{...(deltaGlyph !== undefined && { deltaGlyph })}
|
|
296
355
|
// `tier="widget"`, not the default `ctl`: the header row this slot sits in is 28px
|
|
297
356
|
// (`--vx-space-widget-header-height`) and a 30px `ctl` control grew it to 30, so a card
|
|
298
357
|
// with a kebab sat 2px below a card without one in the same grid row. See
|
|
@@ -301,6 +360,28 @@ export function StatCard({
|
|
|
301
360
|
actions: <CtlSlot tier="widget">{actions}</CtlSlot>,
|
|
302
361
|
})}
|
|
303
362
|
/>
|
|
363
|
+
|
|
364
|
+
{breakdown !== undefined &&
|
|
365
|
+
breakdown.length > 0 && (
|
|
366
|
+
// A `<dl>`, not a table and not a stack of divs: each row IS a term and its value, which
|
|
367
|
+
// is the one semantic that survives a reader meeting the card out of context.
|
|
368
|
+
//
|
|
369
|
+
// INSIDE `.header`, not beside it: with `sparklinePlacement="right"` the body is a flex
|
|
370
|
+
// ROW, so a third child here would sit next to the trend rather than under the number
|
|
371
|
+
// it splits. The header block is the card's text column in both placements.
|
|
372
|
+
<dl className={classes.breakdown}>
|
|
373
|
+
{breakdown.map((row) => (
|
|
374
|
+
<div
|
|
375
|
+
key={row.label}
|
|
376
|
+
className={classes.breakdownRow}
|
|
377
|
+
{...(row.tone !== undefined && { 'data-tone': row.tone })}
|
|
378
|
+
>
|
|
379
|
+
<dt className={classes.breakdownLabel}>{row.label}</dt>
|
|
380
|
+
<dd className={classes.breakdownValue}>{row.value}</dd>
|
|
381
|
+
</div>
|
|
382
|
+
))}
|
|
383
|
+
</dl>
|
|
384
|
+
)}
|
|
304
385
|
</div>
|
|
305
386
|
|
|
306
387
|
{sparkline !== undefined && (
|
package/src/data/data-table.tsx
CHANGED
|
@@ -189,6 +189,10 @@ function facetEnumHandle<T>(
|
|
|
189
189
|
},
|
|
190
190
|
] as const
|
|
191
191
|
},
|
|
192
|
+
// A facet column has no lane to unset: clearing the column filter IS its fallback state.
|
|
193
|
+
clear: () => {
|
|
194
|
+
column.setFilterValue(undefined)
|
|
195
|
+
},
|
|
192
196
|
isDefault: (value) => value === FACET_ALL_VALUE,
|
|
193
197
|
}
|
|
194
198
|
}
|
|
@@ -215,6 +219,10 @@ function facetMultiHandle<T>(
|
|
|
215
219
|
},
|
|
216
220
|
] as const
|
|
217
221
|
},
|
|
222
|
+
// Same as the single-select facet: an empty column filter is this handle's fallback.
|
|
223
|
+
clear: () => {
|
|
224
|
+
column.setFilterValue(undefined)
|
|
225
|
+
},
|
|
218
226
|
isDefault: (value) => value.length === 0,
|
|
219
227
|
}
|
|
220
228
|
}
|
|
@@ -2480,9 +2480,11 @@ describe('in-body-page-title', () => {
|
|
|
2480
2480
|
expect(kinds(f)).not.toContain('in-body-page-title')
|
|
2481
2481
|
})
|
|
2482
2482
|
|
|
2483
|
-
|
|
2483
|
+
// Promoted at 1.27.0 — its GRACE_PERIOD_KINDS entry is gone, and so is the plugin rule of the
|
|
2484
|
+
// same id from PLUGIN_RULE_GRACE. One law, two lanes, one promotion.
|
|
2485
|
+
it('lands error now that the grace entry is gone (C16)', () => {
|
|
2484
2486
|
const f = find(`<Title order={1}>Analytics</Title>`)
|
|
2485
|
-
expect(f.find((x) => x.kind === 'in-body-page-title')?.severity).toBe('
|
|
2487
|
+
expect(f.find((x) => x.kind === 'in-body-page-title')?.severity).toBe('error')
|
|
2486
2488
|
})
|
|
2487
2489
|
})
|
|
2488
2490
|
|
|
@@ -2538,6 +2540,37 @@ describe('raw-selection-control', () => {
|
|
|
2538
2540
|
expect(kinds(f)).not.toContain('raw-selection-control')
|
|
2539
2541
|
})
|
|
2540
2542
|
|
|
2543
|
+
// The CROSS-FILE exemption — the same basename convention the plugin's `control-outside-home`
|
|
2544
|
+
// applies, so both lanes agree on the same file (one law, two lanes, one exemption). argo carried
|
|
2545
|
+
// 9 of these: a control in a modal/form module whose `<Modal>` is rendered by the parent, which is
|
|
2546
|
+
// outside the 12-line host window because the host tag is not in the file at all.
|
|
2547
|
+
it.each([
|
|
2548
|
+
'src/edit-session-modal.tsx',
|
|
2549
|
+
'src/filters-drawer.tsx',
|
|
2550
|
+
'src/column-popover.tsx',
|
|
2551
|
+
'src/detail-panel.tsx',
|
|
2552
|
+
'src/booking-form.tsx',
|
|
2553
|
+
])('does NOT flag %s — the overlay filename convention', (relPath) => {
|
|
2554
|
+
expect(kinds(find(`<Select data={[]} />`, relPath))).not.toContain('raw-selection-control')
|
|
2555
|
+
})
|
|
2556
|
+
|
|
2557
|
+
it('still flags a page module beside them', () => {
|
|
2558
|
+
expect(kinds(find(`<Select data={[]} />`, 'src/bookings-page.tsx'))).toContain(
|
|
2559
|
+
'raw-selection-control',
|
|
2560
|
+
)
|
|
2561
|
+
})
|
|
2562
|
+
|
|
2563
|
+
// Basename only, and it needs the leading subject: a `modal/` DIRECTORY holds the page pieces
|
|
2564
|
+
// around the modals too, and a bare `modal.tsx` is a page module in every consumer that has one.
|
|
2565
|
+
it.each(['src/modal/session.tsx', 'src/modal.tsx'])(
|
|
2566
|
+
'is a subject-prefixed BASENAME convention, not %s',
|
|
2567
|
+
(relPath) => {
|
|
2568
|
+
expect(kinds(find(`<Select data={[]} />`, relPath))).toContain('raw-selection-control')
|
|
2569
|
+
},
|
|
2570
|
+
)
|
|
2571
|
+
|
|
2572
|
+
// Still warn at 1.27.0 — this kind and its AST twin `basalt/control-outside-home` are the C1 pair
|
|
2573
|
+
// that moved to `promote: '1.28.0'` while the other wave-6 entries promoted.
|
|
2541
2574
|
it('lands warn while the grace entry stands (C16)', () => {
|
|
2542
2575
|
const f = find(`<Select data={[]} />`)
|
|
2543
2576
|
expect(f.find((x) => x.kind === 'raw-selection-control')?.severity).toBe('warn')
|
package/src/guard/grace.test.ts
CHANGED
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
* `assertGraceLedger` is tested directly against synthetic entries below rather than only through
|
|
10
10
|
* the real ledger: every pre-existing entry promoted or moved to `PLUGIN_RULE_ADVISORY`'s guard-side
|
|
11
11
|
* sibling, and an `it.each` over the empty ledger that left behind ran zero assertions, which proved
|
|
12
|
-
* nothing about the gate actually firing. The ledger
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* nothing about the gate actually firing. The ledger carries ONE wave-6 control kind at 1.27.0 —
|
|
13
|
+
* `raw-selection-control`, re-dated to `promote: '1.28.0'` with its AST twin
|
|
14
|
+
* `basalt/control-outside-home` (`in-body-page-title` promoted on schedule and its entry is gone) —
|
|
15
|
+
* and the real-ledger check below runs against it.
|
|
15
16
|
*
|
|
16
17
|
* This gate measures the version already PUBLISHED, so it can only go red after the release that
|
|
17
18
|
* shipped a due entry. `scripts/check-grace.ts` is the other end — `scripts/release.sh` runs it
|
|
@@ -138,8 +139,16 @@ describe('scripts/check-grace.ts', () => {
|
|
|
138
139
|
expect(stderr).toContain('basalt/control-outside-home')
|
|
139
140
|
})
|
|
140
141
|
|
|
141
|
-
|
|
142
|
-
|
|
142
|
+
// 1.27.0 promoted five of the six wave-6 plugin rules and one of the two guard kinds; what is
|
|
143
|
+
// LEFT is the C1 pair (`raw-selection-control` + `basalt/control-outside-home`), re-dated to
|
|
144
|
+
// 1.28.0 against a measurement rather than a hunch — see either entry's `why`. So the release
|
|
145
|
+
// this file used to refuse now passes, and the refusal moved with the pair.
|
|
146
|
+
it('passes 1.27.0 — the five entries due there promoted', () => {
|
|
147
|
+
expect(runCheckGrace('1.27.0').code).toBe(0)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('refuses the version the remaining C1 pair is due in (1.28.0)', () => {
|
|
151
|
+
expect(runCheckGrace('1.28.0').code).toBe(1)
|
|
143
152
|
})
|
|
144
153
|
|
|
145
154
|
it('exits 2 on a missing or malformed version rather than passing vacuously', () => {
|
package/src/guard/index.ts
CHANGED
|
@@ -522,9 +522,25 @@ const CONTROL_HOST_TAG =
|
|
|
522
522
|
*/
|
|
523
523
|
const CONTROL_HOST_WINDOW_LINES = 12
|
|
524
524
|
|
|
525
|
+
/**
|
|
526
|
+
* A file whose BASENAME declares it is an overlay's or a form's own body — the cross-file half of
|
|
527
|
+
* {@link CONTROL_HOST_TAG}, and the same regex the plugin's `OVERLAY_CONVENTION_FILE` applies to
|
|
528
|
+
* `basalt/control-outside-home` (one law, two lanes, one exemption).
|
|
529
|
+
*
|
|
530
|
+
* Law C1's cross-file case is advisory by declaration (`docs/CONTROLS-SPEC.md` §6, "Honest
|
|
531
|
+
* coverage"), and this kind was paying for it: a `<Select>` inside `edit-session-modal.tsx` whose
|
|
532
|
+
* `<Modal>` is rendered by the parent route is outside BOTH the 12-line host window and any
|
|
533
|
+
* ancestry walk, because the host tag is not in the file at all. argo carried 9 of them.
|
|
534
|
+
*
|
|
535
|
+
* The trade is that a whole file goes unscanned on a naming convention — the same bargain
|
|
536
|
+
* {@link MANTINE_FORM_IMPORT} already buys, and a smaller one than promoting the kind with 9 known
|
|
537
|
+
* false positives. Basename only: a `modal/` DIRECTORY holds the page pieces around the modals too.
|
|
538
|
+
*/
|
|
539
|
+
const OVERLAY_CONVENTION_FILE = /(?:^|\/)[^/]*-(?:modal|drawer|popover|panel|form)\.[jt]sx$/
|
|
540
|
+
|
|
525
541
|
/** A name whose DECLARATION means this file DEFINES a basalt control rather than consuming one. */
|
|
526
542
|
const CONTROL_OWNER_DEF =
|
|
527
|
-
/\b(?:function|const|class)\s+(?:RangeFilter|CompareFilter|SelectFilter|MultiSelectFilter|SearchFilter|ToggleFilter|ViewTabs|FilterSet|FilterPill|SyncButton|ActionGroup|OverflowMenu|CtlSlot)(?![\w])/
|
|
543
|
+
/\b(?:function|const|class)\s+(?:RangeFilter|CompareFilter|SelectFilter|MultiSelectFilter|NumberFilter|SearchFilter|ToggleFilter|ViewTabs|FilterSet|FilterPill|SyncButton|ActionGroup|OverflowMenu|CtlSlot)(?![\w])/
|
|
528
544
|
|
|
529
545
|
/** `@mantine/form` — a form is C1's third home, and its inputs are not filters. */
|
|
530
546
|
const MANTINE_FORM_IMPORT = /from\s+['"]@mantine\/form['"]/
|
|
@@ -635,24 +651,19 @@ export type GraceEntry = { since: string; promote: string; why: string }
|
|
|
635
651
|
* }
|
|
636
652
|
*/
|
|
637
653
|
export const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, GraceEntry>> = {
|
|
638
|
-
'in-body-page-title': {
|
|
639
|
-
since: '1.26.0',
|
|
640
|
-
promote: '1.27.0',
|
|
641
|
-
why:
|
|
642
|
-
'new in the wave-6 control guards (docs/CONTROLS-SPEC.md §6, law C8). Its AST twin is the ' +
|
|
643
|
-
'oxlint rule of the SAME id, whose PLUGIN_RULE_GRACE entry carries the same dates — one law, ' +
|
|
644
|
-
'two lanes, one promotion. Warn for a minor because the fix is a route/breadcrumb change ' +
|
|
645
|
-
'(the page is named by staticData.title), not a prop edit, so a consumer needs to schedule it.',
|
|
646
|
-
},
|
|
647
654
|
'raw-selection-control': {
|
|
648
655
|
since: '1.26.0',
|
|
649
|
-
promote: '1.
|
|
656
|
+
promote: '1.28.0',
|
|
650
657
|
why:
|
|
651
658
|
'new in the wave-6 control guards (docs/CONTROLS-SPEC.md §6, law C1). The text lane cannot ' +
|
|
652
659
|
'see ancestry, so "no home" is approximated by a 12-line host-tag window — the loosest ' +
|
|
653
660
|
'reading in the guard, and the reason this one lands warn rather than error even though its ' +
|
|
654
|
-
'law is settled.
|
|
655
|
-
'
|
|
661
|
+
'law is settled. It moved from 1.27.0 to 1.28.0 with its AST twin, `basalt/control-outside-' +
|
|
662
|
+
'home`, whose PLUGIN_RULE_GRACE entry carries the measurement: the wave-7 run left 9 warns ' +
|
|
663
|
+
'in argo, all of them a control in a modal/form module whose `<Modal>` is rendered by the ' +
|
|
664
|
+
'PARENT — law C1 cross-file, which neither lane can see. `OVERLAY_CONVENTION_FILE` (this ' +
|
|
665
|
+
'file, and the same regex in the plugin) exempts that declared naming convention in both ' +
|
|
666
|
+
'lanes; 1.28.0 is when the remainder is re-measured. One law, two lanes, one promotion.',
|
|
656
667
|
},
|
|
657
668
|
}
|
|
658
669
|
|
|
@@ -1901,7 +1912,7 @@ export const GUARD_RULES = {
|
|
|
1901
1912
|
enabled: (cfg: GuardConfig) => cfg.rawHtmlLayout,
|
|
1902
1913
|
appliesTo: (relPath) => !isChartFile(relPath) && !relPath.endsWith('.css'),
|
|
1903
1914
|
message:
|
|
1904
|
-
'Raw HTML element with inline layout/surface styling that the line scan cannot see — the tag is formatted across lines, or the style object is hoisted to a const. Same violation as raw-html-layout: use a Mantine layout primitive (Box/Flex/Grid/Stack/Group). Promoted to error
|
|
1915
|
+
'Raw HTML element with inline layout/surface styling that the line scan cannot see — the tag is formatted across lines, or the style object is hoisted to a const. Same violation as raw-html-layout: use a Mantine layout primitive (Box/Flex/Grid/Stack/Group). Promoted to error at 1.26.0 (C16) with severity only — the kind stayed standalone rather than folding into raw-html-layout as originally planned; the merge is still open, tracked as a follow-up, not done here.',
|
|
1905
1916
|
},
|
|
1906
1917
|
'in-body-page-title': {
|
|
1907
1918
|
kind: 'in-body-page-title',
|
|
@@ -2369,11 +2380,13 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
|
|
|
2369
2380
|
}
|
|
2370
2381
|
|
|
2371
2382
|
// raw-selection-control — full-text tag scan plus the host WINDOW (see CONTROL_HOST_WINDOW_LINES).
|
|
2372
|
-
//
|
|
2373
|
-
// a file that DEFINES a basalt control cannot be told to use one,
|
|
2374
|
-
// `@mantine/form` is a form — C1's third home, whose inputs are not filters
|
|
2383
|
+
// Three file-level exemptions mirror the plugin rule's, so the two lanes agree on the same file:
|
|
2384
|
+
// a file that DEFINES a basalt control cannot be told to use one, a file importing
|
|
2385
|
+
// `@mantine/form` is a form — C1's third home, whose inputs are not filters — and a file NAMED as
|
|
2386
|
+
// an overlay body carries its `<Modal>` in the parent, where no scan of this file can reach it.
|
|
2375
2387
|
if (
|
|
2376
2388
|
ruleApplies('raw-selection-control', relPath) &&
|
|
2389
|
+
!OVERLAY_CONVENTION_FILE.test(relPath) &&
|
|
2377
2390
|
!CONTROL_OWNER_DEF.test(codeText) &&
|
|
2378
2391
|
!MANTINE_FORM_IMPORT.test(codeText)
|
|
2379
2392
|
) {
|