basalt-ui 1.5.0 → 1.6.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.
@@ -2,7 +2,8 @@
2
2
  * Shadow-surface coverage — closes a class of bug `check-theme`'s regex scans cannot see: applying
3
3
  * the depth token to the WRONG box.
4
4
  *
5
- * `--vx-shadow-card` (and `--vx-shadow-ctrl`) bakes a 1px ring into the shadow value itself (see
5
+ * `--vx-shadow-card` (and `--vx-shadow-ctrl`/`--vx-shadow-raised`) bakes a 1px ring into the shadow
6
+ * value itself (see
6
7
  * `tokens/palette.ts`'s `SHADOW`). THE INVARIANT: an element applying the token must carry the
7
8
  * surface's OWN `border-radius` — the ring is drawn by the shadow's rendering box and follows THAT
8
9
  * box's own corners, not any other element's. We shipped a bug where `box-shadow: var(--vx-shadow-
@@ -16,10 +17,16 @@
16
17
  * mechanical inventory of every site that APPLIES the token would have caught it: a source-level
17
18
  * guard can only flag a bad value, never a bad ELEMENT.
18
19
  *
20
+ * `--vx-shadow-raised`'s ring is INSET rather than outset, which changes nothing about the
21
+ * invariant: an inset ring is still drawn by the shadow's rendering box and still follows that
22
+ * box's own corners, so a radius mismatch leaks the same way (nubs on the inside of the corner
23
+ * instead of the outside). It is registered here for exactly the same reason as the other two.
24
+ *
19
25
  * This test IS that inventory: it scans basalt-ui's own source for every place that applies
20
- * `--vx-shadow-card`/`--vx-shadow-ctrl` (a `box-shadow`/CSS-custom-property declaration in
21
- * `*.module.css`, or a `boxShadow` object property — literal, `VX.shadowCard`/`VX.shadowCtrl`, or a
22
- * template-literal composite of either — in `*.ts`/`*.tsx`) and asserts every one is registered in
26
+ * `--vx-shadow-card`/`--vx-shadow-ctrl`/`--vx-shadow-raised` (a `box-shadow`/CSS-custom-property
27
+ * declaration in `*.module.css`, or a `boxShadow` object property — literal, `VX.shadowCard`/
28
+ * `VX.shadowCtrl`/`VX.shadowRaised`, or a template-literal composite of any — in `*.ts`/`*.tsx`)
29
+ * and asserts every one is registered in
23
30
  * `SHADOW_SURFACES` below with a written `roundedBy` reason naming exactly where that box's
24
31
  * `border-radius` comes from. Mere mentions in comments/docstrings and the token DEFINITION itself
25
32
  * (`tokens/index.ts`, `tokens/palette.ts`) are excluded — this test only cares about APPLICATION.
@@ -34,8 +41,12 @@
34
41
  * so plainly; a square ring on a square surface is correct, not a gap.
35
42
  *
36
43
  * THIS TEST IS DELIBERATELY COUPLED TO OUR OWN SOURCE. Adding a new `box-shadow: var(--vx-shadow-
37
- * card)` (or `-ctrl`) anywhere SHOULD fail this test until it's registered — that failure IS the
38
- * point. Do NOT "fix" a future failure by loosening the scan or bulk-registering with a placeholder
44
+ * card)` (or `-ctrl`/`-raised`) anywhere SHOULD fail this test until it's registered — that failure
45
+ * IS the point. A NEW depth token is the one case the scan cannot fail loudly on: the two regexes
46
+ * below name their tokens explicitly, so an unlisted `--vx-shadow-*` slips through silently and
47
+ * vacuously. Adding a token to `SHADOW` therefore means adding it to BOTH regexes in the same
48
+ * commit — `raised` is the precedent.
49
+ * Do NOT "fix" a future failure by loosening the scan or bulk-registering with a placeholder
39
50
  * reason; open the file, find the element the shadow is declared on, prove (or fix) that IT carries
40
51
  * the surface's own border-radius, and write down where that radius comes from.
41
52
  *
@@ -95,7 +106,7 @@ function stripTsComments(text: string): string {
95
106
  // bare mention of `--vx-shadow-card` in a comment never matches — comments are stripped first, and
96
107
  // this requires an actual `property:` declaration.
97
108
  const CSS_SHADOW_TOKEN =
98
- /(?<![\w-])(?:box-shadow|--[\w-]+)\s*:\s*[^;]*var\(--vx-shadow-(?:card|ctrl)\)/
109
+ /(?<![\w-])(?:box-shadow|--[\w-]+)\s*:\s*[^;]*var\(--vx-shadow-(?:card|ctrl|raised)\)/
99
110
 
100
111
  // A real application in TS/TSX: a `boxShadow` object property whose value is the literal CSS var
101
112
  // string, `VX.shadowCard`/`VX.shadowCtrl`, or a template literal compositing either (e.g. the
@@ -103,7 +114,7 @@ const CSS_SHADOW_TOKEN =
103
114
  // `--vx-shadow-overlay` (a different, unrelated token) or a `boxShadow` string literal that doesn't
104
115
  // reference either token (data-table's scroll-shadow, guard/index.ts's own regex source, etc.).
105
116
  const TS_SHADOW_TOKEN =
106
- /\bboxShadow\s*:\s*(?:'[^']*var\(--vx-shadow-(?:card|ctrl)\)[^']*'|`[^`]*(?:var\(--vx-shadow-(?:card|ctrl)\)|VX\.shadow(?:Card|Ctrl))[^`]*`|VX\.shadow(?:Card|Ctrl))/
117
+ /\bboxShadow\s*:\s*(?:'[^']*var\(--vx-shadow-(?:card|ctrl|raised)\)[^']*'|`[^`]*(?:var\(--vx-shadow-(?:card|ctrl|raised)\)|VX\.shadow(?:Card|Ctrl|Raised))[^`]*`|VX\.shadow(?:Card|Ctrl|Raised))/
107
118
 
108
119
  type ShadowSite = {
109
120
  /** Path relative to `src/`, e.g. `theme/controls.module.css`. */
@@ -116,8 +127,13 @@ type ShadowSite = {
116
127
 
117
128
  /** Every rule in a CSS module whose declarations apply the token, keyed by the rule's own selector
118
129
  * (multi-line selectors are collapsed to one line). Assumes flat, non-nested CSS (no native
119
- * nesting) — the one `@media` block in this codebase sits after every shadow site, so a naive
120
- * brace-pair scan never needs to understand at-rule nesting to find them. */
130
+ * nesting). A rule nested one level inside an at-rule IS still found — the brace-pair regex matches
131
+ * INNERMOST pairs, so `@media (hover: hover) { .x:hover { … } }` resolves as the inner `.x:hover`
132
+ * rule. (No such site exists today — depth is static — but the scan must keep finding one if a
133
+ * future state-scoped depth rule lands inside an at-rule.) What the
134
+ * naive scan does NOT do is carry the at-rule condition into the site label: such a site registers
135
+ * under its bare inner selector, with the `@media` context stated in its `roundedBy` reason
136
+ * instead. That is a labeling limitation, never a coverage one. */
121
137
  function cssShadowSites(relPath: string, text: string): ShadowSite[] {
122
138
  const clean = stripCssComments(text)
123
139
  const sites: ShadowSite[] = []
@@ -173,7 +189,7 @@ function tsShadowSites(relPath: string, text: string): ShadowSite[] {
173
189
  return sites
174
190
  }
175
191
 
176
- /** Every application of `--vx-shadow-card`/`--vx-shadow-ctrl` across `src/**`. */
192
+ /** Every application of `--vx-shadow-card`/`--vx-shadow-ctrl`/`--vx-shadow-raised` across `src/**`. */
177
193
  function scanShadowSites(): ShadowSite[] {
178
194
  const files: string[] = []
179
195
  collectSourceFiles(SRC_DIR, files)
@@ -221,21 +237,55 @@ export const SHADOW_SURFACES: readonly ShadowSurfaceEntry[] = [
221
237
  '<input> element itself — the custom property inherits from wrapper to input, landing on the ' +
222
238
  'exact element the shadow lands on).',
223
239
  },
240
+ // The five Button/ActionIcon entries below all resolve their radius the same way, and all set the
241
+ // token on `--ctrl-depth` rather than on `box-shadow` directly — the single `box-shadow:
242
+ // var(--ctrl-depth)` declaration lives on the shared `.buttonRoot, .actionIconRoot` base rule
243
+ // (which is therefore NOT a token-application site itself and correctly goes unregistered). The
244
+ // indirection changes nothing about this invariant: the variable is consumed on the very same
245
+ // element these selectors target, so the ring is still drawn by a box carrying the control's own
246
+ // `--button-radius`/`--ai-radius`.
224
247
  {
225
248
  file: 'theme/controls.module.css',
226
- site: ".buttonRoot[data-variant='default']",
249
+ site:
250
+ ".buttonRoot[data-variant='default'], .buttonRoot[data-variant='filled'], " +
251
+ ".buttonRoot[data-variant='light']",
227
252
  roundedBy:
228
253
  "--button-radius (Mantine's own Button.css declares `border-radius: var(--button-radius, " +
229
254
  'var(--mantine-radius-default))` directly on the button root — the same element `.buttonRoot` ' +
230
- 'targets).',
255
+ 'targets). This is the raised family on the shared `shadow-raised`, and its ring is INSET — a ' +
256
+ 'radius mismatch would leak nubs on the INSIDE of each corner rather than the outside, so the ' +
257
+ 'invariant bites exactly as hard here as on an outset ring.',
231
258
  },
232
259
  {
233
260
  file: 'theme/controls.module.css',
234
- site: ".actionIconRoot[data-variant='default']",
261
+ site: ".buttonRoot[data-variant='outline']",
262
+ roundedBy:
263
+ '--button-radius (same chain as the raised-family entry above — Button.css declares ' +
264
+ '`border-radius: var(--button-radius, var(--mantine-radius-default))` on the button root; the ' +
265
+ 'variant changes only which depth token lands, never which element carries it). `outline` is ' +
266
+ 'the one raised variant on ring-FREE `shadow-ctrl`, because its real 1px accent border already ' +
267
+ 'IS its edge and an inset ring tucked just inside a border is muddy — so no ring is at risk ' +
268
+ 'here at all, and the registration exists to keep the inventory exhaustive.',
269
+ },
270
+ {
271
+ file: 'theme/controls.module.css',
272
+ site:
273
+ ".actionIconRoot[data-variant='default'], .actionIconRoot[data-variant='filled'], " +
274
+ ".actionIconRoot[data-variant='light']",
235
275
  roundedBy:
236
276
  "--ai-radius (Mantine's own ActionIcon.css declares `border-radius: var(--ai-radius, " +
237
277
  'var(--mantine-radius-default))` directly on the root — the same element `.actionIconRoot` ' +
238
- 'targets).',
278
+ 'targets). Same inset-ring reasoning as the Button raised-family entry above.',
279
+ },
280
+ {
281
+ file: 'theme/controls.module.css',
282
+ site: ".actionIconRoot[data-variant='outline']",
283
+ roundedBy:
284
+ '--ai-radius (same chain as the entry above — ActionIcon.css declares `border-radius: ' +
285
+ 'var(--ai-radius, var(--mantine-radius-default))` on the root). Ring-free `shadow-ctrl`, same ' +
286
+ "reason as Button's `outline`. ActionIcon has no `subtle` counterpart to Button's " +
287
+ "hover-materialized entry by design: its `subtle` IS the spec's separate ghost-icon-button " +
288
+ 'idiom (DESIGN-SPEC §5), whose hover state is an ink tint, not a raise.',
239
289
  },
240
290
  {
241
291
  file: 'theme/controls.module.css',
@@ -251,17 +301,8 @@ export const SHADOW_SURFACES: readonly ShadowSurfaceEntry[] = [
251
301
  "--chip-radius (Mantine's own Chip.css declares `border-radius: var(--chip-radius, 1000rem)` " +
252
302
  'directly on the label part — the same element `.chipLabel` targets).',
253
303
  },
254
- // ── theme/nav-link.module.css ────────────────────────────────────────────────────────────────
255
- {
256
- file: 'theme/nav-link.module.css',
257
- site: ".root[data-active], .root[aria-current='page']",
258
- roundedBy:
259
- "borderRadius: 6 (theme/index.ts's NavLink.extend styles.root — Mantine renders this as an " +
260
- 'inline style on the root part, wired via `classNames: { root: navLinkClasses.root }` to the ' +
261
- "exact `.root` element this rule's box-shadow lands on). Verified NOT a square surface: " +
262
- "Mantine's own NavLink.css declares no border-radius at all for .root — the 6px comes only " +
263
- 'from our own theme override.',
264
- },
304
+ // theme/nav-link.module.css applies no shadow token: the active nav row is a selected row (ink
305
+ // tint), never a raised control — see that file's header comment.
265
306
  // ── shell/app-mobile-nav.module.css ──────────────────────────────────────────────────────────
266
307
  {
267
308
  file: 'shell/app-mobile-nav.module.css',
@@ -326,11 +367,9 @@ export const SHADOW_SURFACES: readonly ShadowSurfaceEntry[] = [
326
367
  roundedBy: 'borderRadius: 8 (co-declared in the same TOOLTIP_STYLES object).',
327
368
  },
328
369
  // ── agent-chat ───────────────────────────────────────────────────────────────────────────────
329
- {
330
- file: 'agent-chat/composer.tsx',
331
- site: 'input',
332
- roundedBy: 'borderRadius: 8 (co-declared in the same styles.input object).',
333
- },
370
+ // agent-chat/composer.tsx applies no shadow token of its own: it takes the themed `.input` depth
371
+ // (theme/controls.module.css) so it also inherits that class's disabled grounding — an inline
372
+ // `boxShadow` in its `styles` object used to beat the class and leave a disabled composer raised.
334
373
  {
335
374
  file: 'agent-chat/thread-outcome-card.tsx',
336
375
  site: 'default',
@@ -380,7 +419,7 @@ describe('SHADOW_SURFACES is well-formed', () => {
380
419
  })
381
420
  })
382
421
 
383
- describe('every applied --vx-shadow-card / --vx-shadow-ctrl site is registered', () => {
422
+ describe('every applied --vx-shadow-card / -ctrl / -raised site is registered', () => {
384
423
  // The coverage assertion below is `expect(gaps).toEqual([])`, which passes VACUOUSLY if the scan
385
424
  // finds nothing. That is exactly the failure mode `border-coverage.test.ts` already had to guard
386
425
  // against once — this is the same tripwire: fail loudly here if the scan stops resolving real
@@ -408,7 +447,7 @@ describe('every applied --vx-shadow-card / --vx-shadow-ctrl site is registered',
408
447
  .filter((site) => !registered.has(siteKey(site)))
409
448
  .map(
410
449
  (site) =>
411
- `${site.file} (${site.site}): applies --vx-shadow-card/--vx-shadow-ctrl but has no ` +
450
+ `${site.file} (${site.site}): applies --vx-shadow-card/-ctrl/-raised but has no ` +
412
451
  'entry in SHADOW_SURFACES (src/theme/shadow-surfaces.test.ts). The ring baked into the ' +
413
452
  "token follows THIS box's own border-radius — find where this box's radius comes from " +
414
453
  '(a co-declared border-radius, or a Mantine CSS var/prop, verified against the shipped ' +
@@ -202,6 +202,9 @@ export const VX = {
202
202
  // Depth shadows — a whisper shadow + a 1px ring baked into the value (never a `border` prop).
203
203
  shadowCard: 'var(--vx-shadow-card)',
204
204
  shadowCtrl: 'var(--vx-shadow-ctrl)',
205
+ // Raised interactive controls — drop + a 1px INSET edge, the one depth every raised Button/
206
+ // ActionIcon variant shares (works over a fill, a tint or a panel alike; see SHADOW.raised).
207
+ shadowRaised: 'var(--vx-shadow-raised)',
205
208
  // Floating-layer elevation (menus, popovers, tooltips, modals, drawers) — a real drop shadow.
206
209
  shadowOverlay: 'var(--vx-shadow-overlay)',
207
210
 
@@ -333,6 +336,7 @@ function frameworkPrimitives(side: Side, data: PaletteData, legacyAliases: boole
333
336
  decl('divider', su.divider[side]),
334
337
  decl('shadow-card', SHADOW.card[side]),
335
338
  decl('shadow-ctrl', SHADOW.ctrl[side]),
339
+ decl('shadow-raised', SHADOW.raised[side]),
336
340
  decl('shadow-overlay', SHADOW.overlay[side]),
337
341
  decl('surface-bg', su.bg[side]),
338
342
  decl('surface-panel', su.panel[side]),
@@ -104,6 +104,37 @@ export const SHADOW = {
104
104
  light: '0 1px 2px rgba(28,25,23,0.12)',
105
105
  dark: '0 1px 2px rgba(0,0,0,0.35)',
106
106
  },
107
+ /**
108
+ * Interactive-control depth — the ONE value every raised Button/ActionIcon/Input variant shares
109
+ * (`docs/DESIGN-SPEC.md` §5's Button idiom). Exists rather than reusing `card` because `card`'s
110
+ * ring is OUTSET, and an outset ring paints in the color of whatever it is drawn over: fine
111
+ * between neutral panels, but on light scheme it resolves to `--vx-surface-hairline` and draws a
112
+ * pale grey outline around a saturated `filled` button, which reads as a rendering defect.
113
+ *
114
+ * THE TWO SCHEMES ARE SHAPED DIFFERENTLY, and the asymmetry is the whole design — do NOT "tidy"
115
+ * them into one shape. Both were arrived at by elimination, on screen:
116
+ *
117
+ * - DARK = drop + a full inset rim. Its edge is a LIGHTENING, so it cannot collide with the
118
+ * dark drop below the box; a uniform lightening on a dark surface reads as rim light wrapping
119
+ * a raised object, which is exactly right.
120
+ *
121
+ * - LIGHT = drop ONLY, no edge at all. A light-mode edge would have to be a DARKENING (there is
122
+ * nothing lighter than a near-white control to lighten with), and every position for one
123
+ * fails: a uniform dark ring does not read as depth, it reads as a BORDER — it made `default`
124
+ * and `light` look like outlined buttons and every form field look boxed. Moving it to the
125
+ * bottom edge alone then put a dark inner line directly above the dark drop, i.e. a visible
126
+ * DOUBLE BOTTOM BORDER. There is no third position, because a light-mode raised object's only
127
+ * honest cue is the shadow it casts. So light casts one and draws nothing.
128
+ *
129
+ * Consequence to know, on light only: a `default` control nested in a Card shares
130
+ * `--vx-surface-panel` with it and has no boundary but the drop. That is accepted — a wrong-
131
+ * looking border on every control in the app is worse than a soft edge in one nesting case. If it
132
+ * ever needs more, raise the DROP. Do not reintroduce an edge; both shapes have been tried.
133
+ */
134
+ raised: {
135
+ light: '0 1px 2px rgba(28,25,23,0.10)',
136
+ dark: '0 1px 2px rgba(0,0,0,0.35), inset 0 0 0 1px color-mix(in srgb, #ffffff 8%, transparent)',
137
+ },
107
138
  // Floating-layer elevation (menus, popovers, tooltips, modals, drawers) — a REAL drop shadow, a
108
139
  // step above `card`, so detached surfaces read as lifted off the page (`docs/DESIGN-SPEC.md` §5).
109
140
  // Unlike `card`, no embedded ring: floating surfaces carry a real 1px `--vx-surface-border` so