basalt-ui 1.5.0 → 1.7.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 +25 -0
- package/agent/rules/basalt-mantine.md +80 -40
- package/agent/rules/basalt-tokens.md +3 -1
- package/agent/skills/basalt-design/SKILL.md +21 -7
- package/agent/templates/CLAUDE-block.md.tpl +3 -2
- package/agent/templates/DESIGN.md.tpl +2 -1
- package/configs/oxlint-plugin.js +75 -1
- package/configs/oxlint.json +1 -0
- package/dist/agent/stick-to-bottom.d.ts.map +1 -1
- package/dist/agent/stick-to-bottom.js +2 -1
- package/dist/agent-chat/composer.d.ts.map +1 -1
- package/dist/agent-chat/composer.js +5 -4
- package/dist/cli/index.d.ts +9 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +2 -0
- package/dist/dashboard/index.d.ts +1 -1
- package/dist/dashboard/index.d.ts.map +1 -1
- package/dist/dashboard/stat-card.d.ts +6 -1
- package/dist/dashboard/stat-card.d.ts.map +1 -1
- package/dist/dashboard/stat-card.js +37 -4
- package/dist/guard/index.d.ts +7 -1
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +16 -1
- package/dist/guard/types.d.ts +18 -3
- package/dist/guard/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/shell/app-sidebar.module.css +4 -6
- package/dist/shell/sidebar-search.module.css +4 -2
- package/dist/surfaces.d.ts +2 -2
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +8 -2
- package/dist/theme/controls.module.css +167 -26
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +36 -19
- package/dist/theme/nav-link.module.css +10 -10
- package/dist/tokens/index.d.ts +1 -0
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +4 -0
- package/dist/tokens/palette.d.ts +31 -0
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +31 -0
- package/dist/tokens.css +2 -0
- package/dist/vite.d.ts +6 -0
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +21 -1
- package/llms.txt +3 -3
- package/package.json +1 -1
- package/src/agent/stick-to-bottom.tsx +2 -1
- package/src/agent-chat/composer.tsx +5 -4
- package/src/cli/index.ts +11 -0
- package/src/dashboard/index.ts +1 -1
- package/src/dashboard/stat-card.tsx +61 -2
- package/src/guard/check-source.test.ts +63 -1
- package/src/guard/index.ts +39 -3
- package/src/guard/types.ts +18 -2
- package/src/index.ts +1 -0
- package/src/shell/app-sidebar.module.css +4 -6
- package/src/shell/sidebar-search.module.css +4 -2
- package/src/surfaces.ts +8 -2
- package/src/theme/controls.module.css +167 -26
- package/src/theme/index.ts +50 -19
- package/src/theme/nav-link.module.css +10 -10
- package/src/theme/shadow-surfaces.test.ts +72 -33
- package/src/tokens/index.ts +4 -0
- package/src/tokens/palette.ts +31 -0
- package/src/vite.ts +62 -1
package/src/theme/index.ts
CHANGED
|
@@ -342,6 +342,24 @@ const basaltVariantColorResolver: VariantColorsResolver = (input) => {
|
|
|
342
342
|
return autoContrast ? { ...resolved, color: on } : resolved
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
+
// `subtle`: hover is a NEUTRAL ink tint, not Mantine's accent tint
|
|
346
|
+
// (`--mantine-color-{c}-light`). `docs/DESIGN-SPEC.md` §5 specifies the ghost idiom as "hover
|
|
347
|
+
// ink-6% + ink icon", and a subtle Button had never followed it — it hovered into a saturated
|
|
348
|
+
// accent block that read as a filled control appearing under the cursor. Fixing it HERE rather
|
|
349
|
+
// than in controls.module.css is what makes Button and ActionIcon agree: both resolve `subtle`
|
|
350
|
+
// through this one function, and §5's ghost icon button and a `variant="subtle"` Button are meant
|
|
351
|
+
// to be the same idiom at two sizes.
|
|
352
|
+
//
|
|
353
|
+
// Scoped to the DEFAULT color on purpose. An explicitly-colored subtle control (`color="red"` on a
|
|
354
|
+
// destructive text action) carries its hue as the whole signal, so neutralizing its hover would
|
|
355
|
+
// erase the one thing that marks it dangerous — those keep Mantine's own tinted hover.
|
|
356
|
+
if (input.variant === 'subtle') {
|
|
357
|
+
const isDefaultColor = input.color === undefined || input.color === input.theme.primaryColor
|
|
358
|
+
return isDefaultColor
|
|
359
|
+
? { ...resolved, hover: `color-mix(in srgb, var(--vx-ink) 6%, transparent)` }
|
|
360
|
+
: resolved
|
|
361
|
+
}
|
|
362
|
+
|
|
345
363
|
if (input.variant !== 'light') return resolved
|
|
346
364
|
const ink = `var(--mantine-color-${colorName}-light-color)`
|
|
347
365
|
return {
|
|
@@ -526,17 +544,26 @@ function buildTheme(data: PaletteData, options: BuildThemeOptions = {}): Mantine
|
|
|
526
544
|
},
|
|
527
545
|
}),
|
|
528
546
|
// "Ink earns its color" — a nav selection is UI state, not the identity accent on the LABEL.
|
|
529
|
-
// The active item is
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
547
|
+
// The active item is a SELECTED ROW, not a raised control: the same ink-tint idiom as the
|
|
548
|
+
// hover state (one step stronger, ink-9%), no panel fill and no `shadow-card` — a raised,
|
|
549
|
+
// panel-filled row read as a button sitting in the nav rather than as the current location.
|
|
550
|
+
// Selection is carried by the tint + ink text + weight 600 + the accent icon instead. Forced
|
|
551
|
+
// here at the THEME level via NavLink's `--nl-*` vars so it holds for every render path —
|
|
552
|
+
// including a consumer's router `<Link>` via `renderNavLink`, which never sees the shell CSS
|
|
553
|
+
// module; the active ICON is accent-colored via `nav-link.module.css` (targets the
|
|
554
|
+
// `[data-position='left']` leftSection, which the flat `vars`/`styles` API can't express
|
|
555
|
+
// conditionally on `[data-active]`).
|
|
556
|
+
//
|
|
557
|
+
// `--nl-hover` (ink-13%) only ever reaches an ACTIVE row in practice — the inactive hover is
|
|
558
|
+
// set at ink-6% by higher-specificity `:not([data-active])` rules in `nav-link.module.css` /
|
|
559
|
+
// `app-sidebar.module.css` — so it exists to keep the active row responding to the pointer
|
|
560
|
+
// now that its rest state sits in the same tint family as an inactive hover.
|
|
534
561
|
NavLink: NavLink.extend({
|
|
535
562
|
vars: () => ({
|
|
536
563
|
root: {
|
|
537
|
-
'--nl-bg': 'var(--vx-
|
|
564
|
+
'--nl-bg': 'color-mix(in srgb, var(--vx-ink) 9%, transparent)',
|
|
538
565
|
'--nl-color': 'var(--vx-ink)',
|
|
539
|
-
'--nl-hover': 'color-mix(in srgb, var(--vx-ink)
|
|
566
|
+
'--nl-hover': 'color-mix(in srgb, var(--vx-ink) 13%, transparent)',
|
|
540
567
|
},
|
|
541
568
|
children: {},
|
|
542
569
|
}),
|
|
@@ -570,7 +597,7 @@ function buildTheme(data: PaletteData, options: BuildThemeOptions = {}): Mantine
|
|
|
570
597
|
label: { fontSize: VX.text.md, lineHeight: 'var(--vx-space-row-line-height)' },
|
|
571
598
|
},
|
|
572
599
|
}),
|
|
573
|
-
// Field idiom (docs/DESIGN-SPEC.md §5): field surface + `shadow-
|
|
600
|
+
// Field idiom (docs/DESIGN-SPEC.md §5): field surface + `shadow-raised` depth + faint placeholder,
|
|
574
601
|
// accent border + subtle accent ring on focus — see controls.module.css. The two slots carry
|
|
575
602
|
// different halves of it: the `wrapper` is where Mantine declares the `--input-*` vars, the
|
|
576
603
|
// `input` is the box that actually paints the surface (bg + radius) and therefore the only
|
|
@@ -625,16 +652,19 @@ function buildTheme(data: PaletteData, options: BuildThemeOptions = {}): Mantine
|
|
|
625
652
|
PillsInput: PillsInput.extend({
|
|
626
653
|
classNames: { wrapper: controlsClasses.inputWrapper, input: controlsClasses.input },
|
|
627
654
|
}),
|
|
628
|
-
// Button / ActionIcon
|
|
629
|
-
//
|
|
630
|
-
//
|
|
655
|
+
// Button / ActionIcon: depth for EVERY variant that owns a box (docs/DESIGN-SPEC.md §5's
|
|
656
|
+
// Button idiom; doctrine inversion #1) — `default`/`filled`/`light` on the shared
|
|
657
|
+
// `shadow-raised`, `outline` on ring-free `shadow-ctrl`, `subtle` flat at rest. Emphasis stays
|
|
658
|
+
// in fill weight,
|
|
659
|
+
// never in z-height. Background for `default` already resolves through the shared
|
|
631
660
|
// `--mantine-color-default` var (→ `--vx-surface-panel`, see cssVariablesResolver below) and
|
|
632
|
-
//
|
|
661
|
+
// its border through `--mantine-color-default-border` (→ transparent, same resolver) — neither
|
|
633
662
|
// component had a `.extend()` block at all before this, which is why they never picked up
|
|
634
|
-
// either.
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
//
|
|
663
|
+
// either. Everything else — the per-variant depth and the disabled/reduced-motion grounding —
|
|
664
|
+
// lives in controls.module.css, where the variant scoping
|
|
665
|
+
// is an attribute selector: Mantine declares no `box-shadow` on either component at any
|
|
666
|
+
// specificity, so none of it needs a specificity fight. Nothing about depth is expressible in
|
|
667
|
+
// the flat `styles` objects here, which is the whole reason that file exists.
|
|
638
668
|
//
|
|
639
669
|
// Density coupling: mirrors Input's `--input-height` override above — Mantine's OWN
|
|
640
670
|
// `--button-height-md`/`--ai-size` (via `--ai-size-md`) are global, non-retunable statics
|
|
@@ -679,7 +709,8 @@ function buildTheme(data: PaletteData, options: BuildThemeOptions = {}): Mantine
|
|
|
679
709
|
// real attribute selectors ((0,2,0)) — Mantine's own rule wraps the same attribute in `:where()`
|
|
680
710
|
// (0 specificity, landing at (0,1,0) total), so no `html[…]` prefix fight is needed here either.
|
|
681
711
|
// Neither component sets a background of its own, so panel bg is added alongside the shadow —
|
|
682
|
-
// the same panel-bg +
|
|
712
|
+
// the same panel-bg + depth + no-border triad as Card/Paper — on `shadow-raised`, the shared
|
|
713
|
+
// CONTROL depth, not Card/Paper's `shadow-card`: a selection card is something you click.
|
|
683
714
|
CheckboxCard: CheckboxCard.extend({
|
|
684
715
|
classNames: { card: controlsClasses.checkboxCardRoot },
|
|
685
716
|
}),
|
|
@@ -1099,8 +1130,8 @@ function buildCssVariablesResolver(data: PaletteData): CSSVariablesResolver {
|
|
|
1099
1130
|
'--mantine-color-body': `var(--vx-surface-bg, ${SURFACE.bg.light})`, // page background
|
|
1100
1131
|
'--mantine-color-default': `var(--vx-surface-panel, ${SURFACE.panel.light})`, // cards / default controls
|
|
1101
1132
|
'--mantine-color-default-hover': `var(--vx-surface-elevated, ${SURFACE.elevated.light})`,
|
|
1102
|
-
// THE central lever for doctrine inversion #1 on CONTROLS (docs/DESIGN-SPEC.md §8): depth is
|
|
1103
|
-
//
|
|
1133
|
+
// THE central lever for doctrine inversion #1 on CONTROLS (docs/DESIGN-SPEC.md §8): depth is a
|
|
1134
|
+
// shadow, never a hairline, for every `variant="default"` control — not just Card/Paper.
|
|
1104
1135
|
// `defaultVariantColorsResolver`'s `variant === 'default'` branch reads this ONE var for every
|
|
1105
1136
|
// component that uses it (Button, ActionIcon, Chip, CheckboxCard, RadioCard, …), so pointing it
|
|
1106
1137
|
// at `transparent` kills the stock 1px hairline everywhere at once. `transparent`, not `border:
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* Active nav row = heavier weight
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* selectors live here. React Router's NavLink marks the active item via `aria-current` rather than
|
|
7
|
-
* the `active` prop's `data-active`, so both states are matched.
|
|
2
|
+
* Active nav row = heavier weight over the ink tint `--nl-bg` carries (set in theme/index.ts,
|
|
3
|
+
* applied unconditionally by Mantine's own `[data-active]` CSS). NO depth: an active row is a
|
|
4
|
+
* SELECTED ROW, not a raised control — it deliberately reads as a stronger version of the hover
|
|
5
|
+
* tint rather than as a button, so the row never competes with the real filled controls around it.
|
|
8
6
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* Applied at the THEME level via `classNames` so it reaches EVERY NavLink render path — including a
|
|
8
|
+
* consumer's router `<Link>` mounted through `renderNavLink`, which a shell-scoped CSS module never
|
|
9
|
+
* sees. Mantine's `styles` prop only takes flat inline properties (a nested `&[data-active]` there
|
|
10
|
+
* throws a React warning), so the state selectors live here. React Router's NavLink marks the
|
|
11
|
+
* active item via `aria-current` rather than the `active` prop's `data-active`, so both states are
|
|
12
|
+
* matched.
|
|
12
13
|
*/
|
|
13
14
|
.root[data-active],
|
|
14
15
|
.root[aria-current='page'] {
|
|
15
16
|
font-weight: 600;
|
|
16
|
-
box-shadow: var(--vx-shadow-card);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/*
|
|
@@ -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
|
|
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
|
|
21
|
-
* `*.module.css`, or a `boxShadow` object property — literal, `VX.shadowCard
|
|
22
|
-
* template-literal composite of
|
|
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
|
|
38
|
-
* point.
|
|
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)
|
|
120
|
-
*
|
|
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:
|
|
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: ".
|
|
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
|
-
//
|
|
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
|
-
|
|
331
|
-
|
|
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 /
|
|
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
|
|
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 ' +
|
package/src/tokens/index.ts
CHANGED
|
@@ -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]),
|
package/src/tokens/palette.ts
CHANGED
|
@@ -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
|
package/src/vite.ts
CHANGED
|
@@ -13,8 +13,13 @@
|
|
|
13
13
|
*
|
|
14
14
|
* NEVER reference `import.meta.env` here — this file is invoked inside the consumer's vite config
|
|
15
15
|
* at config-evaluation time (plain Node), where only `process.env` exists.
|
|
16
|
+
*
|
|
17
|
+
* It also carries the enforcement notice (`warnIfUnenforced`): this is the one basalt seam that runs
|
|
18
|
+
* on every dev start and every build of a consumer app, so it is the only place that can tell an app
|
|
19
|
+
* it installed the components and skipped `basalt-ui init`.
|
|
16
20
|
*/
|
|
17
|
-
import {
|
|
21
|
+
import { existsSync } from 'node:fs'
|
|
22
|
+
import { dirname, resolve } from 'node:path'
|
|
18
23
|
import { searchForWorkspaceRoot } from 'vite'
|
|
19
24
|
import type { HtmlTagDescriptor, Plugin, PluginOption, UserConfig } from 'vite'
|
|
20
25
|
import { SURFACE } from './tokens/palette'
|
|
@@ -46,6 +51,55 @@ export type BasaltViteOptions = {
|
|
|
46
51
|
basaltSrc?: string
|
|
47
52
|
/** App version surfaced via the `__APP_VERSION__` define. Defaults to `0.0.0`. */
|
|
48
53
|
version?: string
|
|
54
|
+
/**
|
|
55
|
+
* Print a one-time notice when `basalt-ui init` has never been run in this project (no
|
|
56
|
+
* `.basalt/manifest.json` found at or above the cwd) — see `warnIfUnenforced`. Default `true`.
|
|
57
|
+
* Set `false` to adopt the components without the toolchain and stop being told about it.
|
|
58
|
+
*/
|
|
59
|
+
enforcementNotice?: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** How many directories up from `cwd` to look for `.basalt/manifest.json`. Three covers the common
|
|
63
|
+
* shapes: config at the app root, an app in `web/` or `apps/<name>/` under a repo root. */
|
|
64
|
+
const UNENFORCED_SEARCH_DEPTH = 3
|
|
65
|
+
|
|
66
|
+
/** Set once the notice has been printed, so a watch-mode restart doesn't reprint it every reload. */
|
|
67
|
+
let unenforcedNoticePrinted = false
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Print one notice when basalt-ui is installed as a component library and nothing else.
|
|
71
|
+
*
|
|
72
|
+
* `basalt-ui init` is what places the oxlint preset, the theme-guard wiring, the lefthook stub and
|
|
73
|
+
* the agent rules. Skipping it leaves a consumer with the components and NONE of the enforcement —
|
|
74
|
+
* and, critically, no signal that this is the case. The failure is silent and cumulative: every
|
|
75
|
+
* hand-rolled `<Card withBorder radius="md" padding="lg">` renders perfectly, so the app drifts a
|
|
76
|
+
* card idiom at a time until someone looks at a screenshot and asks why the design system did
|
|
77
|
+
* nothing. The first real consumer reached seven such cards across six files that way.
|
|
78
|
+
*
|
|
79
|
+
* This is the only seam basalt owns that runs on every dev start and every build, which makes it
|
|
80
|
+
* the only place that can say so at the time it is still cheap to fix. It stays a notice, never an
|
|
81
|
+
* error: declining to adopt the toolchain is a legitimate choice, and a build that fails because a
|
|
82
|
+
* lint preset is absent would be a worse bug than the one this prevents.
|
|
83
|
+
*/
|
|
84
|
+
function warnIfUnenforced(cwd: string): void {
|
|
85
|
+
if (unenforcedNoticePrinted) return
|
|
86
|
+
|
|
87
|
+
let dir = cwd
|
|
88
|
+
for (let i = 0; i <= UNENFORCED_SEARCH_DEPTH; i++) {
|
|
89
|
+
if (existsSync(resolve(dir, '.basalt/manifest.json'))) return
|
|
90
|
+
const parent = dirname(dir)
|
|
91
|
+
if (parent === dir) break
|
|
92
|
+
dir = parent
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
unenforcedNoticePrinted = true
|
|
96
|
+
// oxlint-disable-next-line no-console -- a build-time notice has no other channel
|
|
97
|
+
console.warn(
|
|
98
|
+
'\n[basalt-ui] Installed, but `basalt-ui init` has never run here — no oxlint preset, no theme\n' +
|
|
99
|
+
' guard, no agent rules. You have the components and none of the enforcement.\n' +
|
|
100
|
+
' Fix: `bunx basalt-ui init`, then add `oxlint . && basalt-ui check-theme` as your\n' +
|
|
101
|
+
' lint script. Silence: pass `enforcementNotice: false` to basaltViteConfig().\n',
|
|
102
|
+
)
|
|
49
103
|
}
|
|
50
104
|
|
|
51
105
|
export function basaltViteConfig(opts: BasaltViteOptions): UserConfig {
|
|
@@ -55,6 +109,13 @@ export function basaltViteConfig(opts: BasaltViteOptions): UserConfig {
|
|
|
55
109
|
// on without editing its vite config. process.env (not import.meta.env) — this runs in Node.
|
|
56
110
|
const basaltSrc = opts.basaltSrc ?? process.env['BASALT_LOCAL']
|
|
57
111
|
|
|
112
|
+
// Skipped under basaltSrc/BASALT_LOCAL: that path means basalt-ui itself is being developed from
|
|
113
|
+
// a sibling checkout (the playground, or a consumer debugging against local source), where the
|
|
114
|
+
// enforcement config lives in basalt's own repo and a manifest is not expected.
|
|
115
|
+
if (opts.enforcementNotice !== false && basaltSrc === undefined) {
|
|
116
|
+
warnIfUnenforced(process.cwd())
|
|
117
|
+
}
|
|
118
|
+
|
|
58
119
|
const config: UserConfig = {
|
|
59
120
|
define: {
|
|
60
121
|
__APP_VERSION__: JSON.stringify(process.env['BUILD_VERSION'] ?? version),
|