basalt-ui 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +19 -0
  3. package/agent/rules/basalt-mantine.md +80 -40
  4. package/agent/rules/basalt-tokens.md +3 -1
  5. package/agent/skills/basalt-design/SKILL.md +24 -7
  6. package/agent/templates/CLAUDE-block.md.tpl +3 -2
  7. package/agent/templates/DESIGN.md.tpl +2 -1
  8. package/configs/oxlint-plugin.js +75 -1
  9. package/configs/oxlint.json +1 -0
  10. package/dist/cli/index.d.ts +9 -0
  11. package/dist/cli/index.d.ts.map +1 -1
  12. package/dist/cli/index.js +2 -0
  13. package/dist/dashboard/index.d.ts +1 -1
  14. package/dist/dashboard/index.d.ts.map +1 -1
  15. package/dist/dashboard/stat-card.d.ts +8 -1
  16. package/dist/dashboard/stat-card.d.ts.map +1 -1
  17. package/dist/dashboard/stat-card.js +42 -4
  18. package/dist/guard/index.d.ts +7 -1
  19. package/dist/guard/index.d.ts.map +1 -1
  20. package/dist/guard/index.js +16 -1
  21. package/dist/guard/types.d.ts +18 -3
  22. package/dist/guard/types.d.ts.map +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/surfaces.d.ts +2 -2
  26. package/dist/surfaces.d.ts.map +1 -1
  27. package/dist/surfaces.js +8 -2
  28. package/dist/theme/controls.module.css +5 -3
  29. package/dist/vite.d.ts +6 -0
  30. package/dist/vite.d.ts.map +1 -1
  31. package/dist/vite.js +21 -1
  32. package/llms.txt +3 -3
  33. package/package.json +1 -1
  34. package/src/cli/index.ts +11 -0
  35. package/src/dashboard/index.ts +1 -1
  36. package/src/dashboard/stat-card.test.tsx +81 -0
  37. package/src/dashboard/stat-card.tsx +75 -2
  38. package/src/guard/check-source.test.ts +64 -1
  39. package/src/guard/index.ts +42 -3
  40. package/src/guard/types.ts +18 -2
  41. package/src/index.ts +1 -0
  42. package/src/surfaces.ts +8 -2
  43. package/src/theme/contrast.test.ts +18 -1
  44. package/src/theme/controls.module.css +5 -3
  45. package/src/vite.ts +62 -1
package/AGENTS.md CHANGED
@@ -8,7 +8,7 @@ Toolchain: Bun runtime, oxlint + oxfmt, conventional commits (empty scope), `mas
8
8
 
9
9
  | Subpath | Layer | Purpose |
10
10
  | --------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
11
- | `basalt-ui` | mantine-coupled | BasaltProvider, createBasaltTheme, BasaltShell + sidebar/mobile-nav/breadcrumbs, NavCountBadge, ThemeToggle, ThreadWorkspace + thread-chat components, dashboard composites (DeltaBadge, StatCard, EmptyState, SettingsSection/SettingsRow/DangerZone) |
11
+ | `basalt-ui` | mantine-coupled | BasaltProvider, createBasaltTheme, BasaltShell + sidebar/mobile-nav/breadcrumbs, NavCountBadge, ThemeToggle, ThreadWorkspace + thread-chat components, dashboard composites (DeltaBadge, StatCard with threshold tone, EmptyState, SettingsSection/SettingsRow/DangerZone) |
12
12
  | `basalt-ui/charts` | headless | visx chart primitives, sparklines, hooks, and token re-exports (Mantine-free) |
13
13
  | `basalt-ui/tokens` | headless | VX token refs, buildPaletteCss, defineSeries, seriesTokens, groupTokens, alpha (Mantine-free) |
14
14
  | `basalt-ui/theme-lab` | mantine-coupled | ThemeLabControls, applyOverrides, COLOR_GROUPS — a low-level inspector for the non-derived structural tokens; identity/color tuning lives in DeriveControls |
package/CHANGELOG.md CHANGED
@@ -1,3 +1,22 @@
1
+ # [1.8.0](https://github.com/jkrumm/basalt-ui/compare/v1.7.0...v1.8.0) (2026-08-02)
2
+
3
+
4
+ ### Features
5
+
6
+ * give the stat card's threshold rail a good tone ([49e795f](https://github.com/jkrumm/basalt-ui/commit/49e795f2f0da85334953bf6280ca108c730d532c))
7
+
8
+ # [1.7.0](https://github.com/jkrumm/basalt-ui/compare/v1.6.0...v1.7.0) (2026-08-02)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * correct the shipped depth and active-nav doctrine in the agent rules ([38e0f39](https://github.com/jkrumm/basalt-ui/commit/38e0f396012064aaee0ca7b252dabea3c281d5e3))
14
+
15
+
16
+ ### Features
17
+
18
+ * close the three gaps that let a consumer fork the card idiom ([d83988b](https://github.com/jkrumm/basalt-ui/commit/d83988b12c38287c42b3c3148493c614380e27b9))
19
+
1
20
  # [1.6.0](https://github.com/jkrumm/basalt-ui/compare/v1.5.0...v1.6.0) (2026-07-31)
2
21
 
3
22
 
@@ -55,11 +55,13 @@ Restraint is the #1 lever — neutrals carry the surface, the accent only points
55
55
  labels, links, primary buttons, focus rings, and the leader bar in meters. It does **NOT** appear
56
56
  on borders, large fills, every icon, or secondary/routine buttons. Don't flood blue — "ink earns
57
57
  its color."
58
- - **Active nav = panel bg + `shadow-card`** + an **accent-colored icon** + weight-600 ink text.
59
- Inactive = muted text, faint icon; hover = ink-6% tint. This is baked into the theme's NavLink
60
- defaults (`--nl-*` vars) and holds for _every_ render path, including a consumer's router `<Link>`
61
- passed via `renderNavLink`. Child items indent with a 1px `divider` left border; active child =
62
- accent text, weight 600.
58
+ - **Active nav = an ink-9% tint** + an **accent-colored icon** + weight-600 ink text — **no panel
59
+ fill and no shadow**. It is a _selected row_, not a raised control; a panel-filled, shadowed row
60
+ reads as a button sitting in the nav rather than as the current location. Inactive = muted text,
61
+ faint icon, hover ink-6%; an active row hovers one step further to ink-13%. This is baked into
62
+ the theme's NavLink defaults (`--nl-*` vars) and holds for _every_ render path, including a
63
+ consumer's router `<Link>` passed via `renderNavLink`. Child items indent with a 1px `divider`
64
+ left border; active child = accent text, weight 600.
63
65
  - **Buttons**: the primary action = filled accent (`variant="filled"`), **exactly one per view**.
64
66
  Every other/secondary action = `variant="default"` (neutral). Do **not** use a colored
65
67
  `variant="light"` for routine actions.
@@ -67,7 +69,7 @@ Restraint is the #1 lever — neutrals carry the surface, the accent only points
67
69
  `color="teal"` (vivid turquoise) or a saturated emerald. Status (`red`/`green`/`orange`/`yellow`)
68
70
  is for signal only, kept muted — never raw Material/AntD.
69
71
  - **Dark mode** is cool-neutral zinc (`#27272a`-family panels on a darker `#18181b`-mixed page) —
70
- not steel-blue, not pure black. Lift elevation via `shadow-card`'s whisper shadow + inset ring;
72
+ not steel-blue, not pure black. Lift elevation via the depth tokens' whisper shadow + inset ring;
71
73
  the accent uses its lighter shade (`#8ec5ff`) on dark to avoid glow/bleed **as INK** — links,
72
74
  active-nav icons, chart lines, focus ring. A FILLED control is the opposite case: it keeps the
73
75
  deep `#0077bd` fill and a white label in both schemes, because a light fill cannot carry white
@@ -75,7 +77,8 @@ Restraint is the #1 lever — neutrals carry the surface, the accent only points
75
77
  `--vx-accent`.
76
78
  - **Light mode** is never pure-white page + pure-black text (harsh halation). Page is a
77
79
  slightly-darker zinc mix (`#ececee`-ish) than the panel (`#f4f4f5`) so cards lift subtly off it;
78
- text = near-black ink (`#262626`). Depth comes from `shadow-card` (whisper shadow + 1px ring), not
80
+ text = near-black ink (`#262626`). Depth comes from a whisper shadow with a 1px ring baked in
81
+ (`shadow-card` for panels, `shadow-raised` for controls — see "Elevation, density & shape"), not
79
82
  a plain hairline border.
80
83
 
81
84
  ## Bridging Mantine ↔ charts
@@ -143,12 +146,23 @@ section divider, not card depth) are both fine.
143
146
  consumer code must compose Mantine layout/surface primitives — `Box`, `Flex`, `Grid`,
144
147
  `SimpleGrid`, `Stack`, `Group`, `Paper`, `Card` — instead of raw `<div>`/`<span>` with inline
145
148
  `style`. Raw HTML with inline layout/surface styling defeats the token system.
146
- - **Mechanical enforcement.** `basalt-ui check-theme` adds five surface guard kinds (each a config knob,
149
+ - **Mechanical enforcement.** `basalt-ui check-theme` adds six surface guard kinds (each a config knob,
147
150
  default ON; `theme-allow` line-comment escape): `off-system-surface-var` (raw ramp-step vars),
148
- `card-with-border` (`withBorder` on a `Card`/`Paper`), `raw-html-layout` (raw `<div>`/`<span>` with
149
- inline layout/surface styling), `inline-spacing` (inline spacing literals), `inline-display`
150
- (inline `display` literals). The Mantine-free `src/charts/**` is the only place raw `<div>` is
151
- allowed — and it must still use `VX.*` tokens.
151
+ `card-with-border` (`withBorder` on a `Card`/`Paper`), `mantine-shade-index` (a shade-pinned color —
152
+ see below), `raw-html-layout` (raw `<div>`/`<span>` with inline layout/surface styling),
153
+ `inline-spacing` (inline spacing literals), `inline-display` (inline `display` literals). The
154
+ Mantine-free `src/charts/**` is the only place raw `<div>` is allowed — and it must still use `VX.*`
155
+ tokens. `oxlint` adds `basalt/card-inset`, which catches the other half of the same card drift: an
156
+ explicit `p`/`padding`/`radius` on a `Card`/`Paper` instead of the xs/sm inset and the theme radius.
157
+
158
+ **Never pin a shade index on a color.** `c="yellow.7"`, `bg="blue.4"`, `var(--mantine-color-red-6)` —
159
+ each names one fixed swatch that is identical in light and dark, so a shade legible on the dark page
160
+ is the one that fails contrast on the light one. Two correct forms: `VX.status.*` /
161
+ `--vx-status-warn|bad|good` for a verdict color (emitted per scheme, contrast guaranteed by the
162
+ derivation), or the bare hue name (`c="red"`, `color="green"`) to let the theme pick the step. The
163
+ `mantine-shade-index` guard kind catches the pinned form; `gray`/`dark` steps in `var()` belong to
164
+ `off-system-surface-var` instead. Note the distinction from `off-identity-accent`, which polices
165
+ _which hue_ you reach for and permits the bare status names on purpose — this kind polices the index.
152
166
 
153
167
  ## Scroll regions
154
168
 
@@ -190,34 +204,60 @@ shape doctrine lives here; the spacing/radius/type **tokens** are in basalt-toke
190
204
  radius token (`radius="md"` / `var(--vx-radius-card)`) + `VX.shadowCard` + `VX.surface.*`.
191
205
  Mechanically enforced by `basalt-ui check-theme`'s `raw-surface` guard. Outer spacing comes from the
192
206
  parent `Stack`/`SimpleGrid` gap, not an intrinsic card margin.
193
- - **Depth = `shadow-card`, not a plain hairline** (see `docs/DESIGN-SPEC.md` in the basalt-ui repo,
194
- doctrine inversion #1 — this supersedes the old "never a drop shadow" rule). Elevation tiers: 0 flat (no shadow — body,
195
- page bg); 1 surface (`shadow-card` on `canvas` — cards, panels, chart cards); 2 elevated (same
196
- `shadow-card`, `surface-elevated` bg — tooltips, lifted cards); 3 focus (2px primary outline —
197
- focused control). `Card`/`Paper` default to the `shadow-card` shadow (no `withBorder`), and the
198
- Mantine-free `ChartCard` matches them (`VX.shadowCard` + `--vx-radius-card` radius). The same
199
- inversion applies to every **`variant="default"` control surface**, not just Card/Paper: Button,
200
- ActionIcon, and the field idiom (Input/TextInput/…) all render panel/field bg + `shadow-card`
201
- with a _transparent_ 1px border box (never `border: none` — the box stays so focus/error can
202
- recolor it with no layout shift). `--mantine-color-default-border` is pinned to `transparent` in
203
- `cssVariablesResolver`, which is the ONE lever that kills the stock hairline for every
204
- default-variant component at once (`defaultVariantColorsResolver`'s `variant === 'default'`
205
- branch reads that single var); the shadow itself is added per-component in
206
- `controls.module.css`, since Mantine never declares one for `default`. Layout dividers (header
207
- bottom border, sidebar section separators) still use plain borders — only card/control depth
208
- moved to shadow. (Genuinely floating elements — modals, popovers, menus — get elevation from
209
- Mantine's own shadow scale, not from the card token.) `CheckboxCard`/`RadioCard` (`withBorder`,
210
- their own default) and `Chip` (its own default, internally `variant="filled"` — Chip has no
211
- literal `"default"`) get the SAME triad — panel bg + `shadow-card`, border dropped — via
212
- dedicated rules in `controls.module.css`; `PillsInput` reuses the Input field idiom's `classNames`
213
- wiring directly (it renders `InputBase` under its own `__staticSelector`, so the base `Input`
214
- theme entry never reached it). A dedicated test, `src/theme/border-coverage.test.ts`, mechanically
215
- enumerates every `@mantine/core` component whose shipped CSS declares a border and asserts it's
216
- either a themed `baseTheme.components` key or a reasoned `BORDER_ALLOWLIST` entry — this is what
217
- catches the NEXT unthemed bordered component (Button/ActionIcon shipped with no `.extend()` block
218
- at all, which no consumer-source guard can ever see).
219
- - **The shadow-card ring must land on the box that carries the surface's `border-radius`.**
220
- `shadow-card`/`shadow-ctrl` bakes a 1px ring into the shadow value itself, and the ring is drawn
207
+ - **Depth = a whisper shadow with a 1px ring baked in, not a plain hairline** (see
208
+ `docs/DESIGN-SPEC.md` in the basalt-ui repo, doctrine inversion #1 — this supersedes the old
209
+ "never a drop shadow" rule). **Three depth tiers, split control-vs-panel-vs-floating:**
210
+ - **`shadow-card` — panels.** A surface that HOLDS content: `Card`, `Paper`, `Notification`,
211
+ `ChartCard`, `SettingsSection`. Its light-mode ring is an **outset** 1px hairline; dark is a
212
+ deeper drop + an inset 4%-white rim.
213
+ - **`shadow-raised` — interactive controls.** Anything you click or type into: Button,
214
+ ActionIcon, the field idiom (Input/TextInput/PillsInput/…), the sidebar search trigger, header
215
+ icon buttons, `Chip`, `CheckboxCard`/`RadioCard`, the agent composer. **Never an outset ring** —
216
+ dark is drop + an inset 8%-white rim, light is drop only. That is what lets ONE value ride a
217
+ saturated fill, a 13% tint and a neutral panel alike: an outset ring paints in the color of
218
+ whatever it is drawn over, so `shadow-card`'s pale hairline would read as a grey outline around
219
+ a colored button, while an inset edge is drawn over the control's own background. The
220
+ light/dark asymmetry is deliberate — a light-mode edge would have to be a _darkening_, which
221
+ reads as a border (uniform) or a double bottom rule (bottom-only); both were tried and
222
+ rejected. Don't tidy the two schemes into one shape.
223
+ - **`shadow-overlay` — detached floating surfaces.** Tooltip, Popover, Menu, Combobox, Modal,
224
+ Drawer — they sit _above_ the page rather than on it. Resolved in `theme/index.ts`; not
225
+ Mantine's own shadow scale.
226
+
227
+ Focus is a fourth tier and **layers**: a 2px accent ring drawn OVER the resting depth, never
228
+ replacing it.
229
+
230
+ **The dividing line is control-vs-panel, not component-by-component.** Two controls sitting side
231
+ by side must never land on different tokens — a `size="md"` Input beside a `size="md"` Button
232
+ (pinned to the same height by `--vx-space-control-height`), or the header's search trigger and
233
+ icon button. Closing exactly that split is why `shadow-raised` exists.
234
+
235
+ **Emphasis is fill weight, not depth.** Every Button/ActionIcon variant that owns a box takes the
236
+ SAME resting depth: `default` / `filled` / `light` → `shadow-raised` unmodified; `outline` →
237
+ ring-free `shadow-ctrl` (its real 1px accent border already is its edge); `subtle` → **flat in
238
+ both states** (a text affordance, not an object — its hover is a neutral ink-6% tint and nothing
239
+ more). **Depth is static — no variant moves or changes depth on hover or press.** A hover lift
240
+ and a materializing `subtle` shadow were both tried and reverted; don't reintroduce either.
241
+ Disabled is flat and grounded. Reduced motion drops the transition, never the resting depth.
242
+
243
+ Mechanics: every default-variant control renders its bg + depth with a _transparent_ 1px border
244
+ box (never `border: none` — the box stays so focus/error can recolor it with no layout shift).
245
+ `--mantine-color-default-border` is pinned to `transparent` in `cssVariablesResolver`, the ONE
246
+ lever that kills the stock hairline for every default-variant component at once
247
+ (`defaultVariantColorsResolver`'s `variant === 'default'` branch reads that single var); the
248
+ shadow itself is added per-component in `controls.module.css`, since Mantine never declares one
249
+ for `default`. `PillsInput` reuses the Input field idiom's `classNames` wiring directly (it
250
+ renders `InputBase` under its own `__staticSelector`, so the base `Input` theme entry never
251
+ reached it). Layout dividers (header bottom border, sidebar section separators) still use plain
252
+ borders — only card/control depth moved to shadow. A dedicated test,
253
+ `src/theme/border-coverage.test.ts`, mechanically enumerates every `@mantine/core` component
254
+ whose shipped CSS declares a border and asserts it's either a themed `baseTheme.components` key
255
+ or a reasoned `BORDER_ALLOWLIST` entry — this is what catches the NEXT unthemed bordered
256
+ component (Button/ActionIcon shipped with no `.extend()` block at all, which no consumer-source
257
+ guard can ever see).
258
+
259
+ - **The ring must land on the box that carries the surface's `border-radius`.**
260
+ `shadow-card`/`shadow-raised` bakes a 1px ring into the shadow value itself, and the ring is drawn
221
261
  by the shadowed box's OWN corners — it never reads correctly against a different element's radius
222
262
  (the Input wrapper bug: a `position: relative` box with `border-radius: 0` drew a square ring
223
263
  around the 8px-rounded `<input>` inside it). Background usually co-locates with the radius since
@@ -13,7 +13,9 @@ basalt-ui ships one color/type/spacing/radius identity shared by the Mantine chr
13
13
  charts. The **identity is modern zinc** (see `docs/DESIGN-SPEC.md` in the basalt-ui repo) —
14
14
  **cool-neutral zinc surfaces**
15
15
  (Tailwind zinc family) on both light and dark, low-contrast panel lift on a slightly darker page,
16
- depth via a whisper shadow + 1px ring (`shadow-card`) rather than a hairline border, carrying
16
+ depth via a whisper shadow + 1px ring (`shadow-card` on panels, `shadow-raised` on interactive
17
+ controls, `shadow-overlay` on floating surfaces — see basalt-mantine.md) rather than a hairline
18
+ border, carrying
17
19
  **one saturated sky-blue accent**, split by ROLE: as INK (links, active-nav icon, chart
18
20
  lines, focus ring) it is `#0077bd` light / `#8ec5ff` dark; as a FILLED SURFACE it is
19
21
  `#0077bd` in BOTH schemes with a WHITE label (`--vx-accent-fill`, never `--vx-accent`). Neutrals do ~90% of the
@@ -19,7 +19,7 @@ one that exists. Lower layers fill gaps; they never override a higher layer.
19
19
  2. **Shipped `basalt-*` rules** (`.claude/rules/basalt-{tokens,charts,mantine,state}.md`) — the
20
20
  universal law AND its enforcement: the Basalt modern-zinc palette + saturated sky accent,
21
21
  one-hue-per-metric, neutral structure, gradient defaults, the three-tier token contract, the
22
- shadow-card elevation/density doctrine.
22
+ elevation/density doctrine.
23
23
  3. **This skill** — only the loop and the judgment, where the above are silent.
24
24
 
25
25
  If a consumer `DESIGN.md` does not exist yet, the app has not been scaffolded — run `/basalt-app`
@@ -93,8 +93,9 @@ links, primary buttons, focus rings, and the leader bar in meters. It stays off:
93
93
  borders, large fills, every icon, secondary buttons. "Ink earns its color" — neutral does the
94
94
  structure, the accent only points.
95
95
 
96
- - **Active nav = panel bg + `shadow-card` + accent-colored icon + weight-600 ink text**, never a
97
- full accent fill. The theme bakes this into `NavLink` for every render path — don't re-color it.
96
+ - **Active nav = an ink-9% tint + accent-colored icon + weight-600 ink text** — no panel fill, no
97
+ shadow, and never a full accent fill. It's a selected row, not a raised control. The theme bakes
98
+ this into `NavLink` for every render path — don't re-color it.
98
99
  - **Buttons:** exactly one filled-accent primary CTA per view; every other action is
99
100
  `variant="default"` (neutral). Avoid colored `variant="light"` for routine actions.
100
101
  - **Status:** positive deltas `color="green"` (forest), never `teal`/turquoise/saturated emerald.
@@ -115,15 +116,31 @@ structure, the accent only points.
115
116
  inline-override a surface's `border`/`borderRadius`/`boxShadow`/`backgroundColor` — use the
116
117
  radius token + `VX.surface.*` / the shadow-card token. Mechanically enforced by
117
118
  `basalt-ui check-theme`'s `raw-surface` guard.
119
+ - **Depth splits control-vs-panel-vs-floating** — three tokens, not one. Panels holding content
120
+ (Card/Paper/Notification/ChartCard) take `shadow-card`; anything you click or type into (Button,
121
+ ActionIcon, inputs, chips, the search trigger) takes `shadow-raised`, whose ring is INSET so one
122
+ value rides a fill, a tint and a panel alike; detached floating surfaces (Tooltip/Popover/Menu/
123
+ Modal/Drawer) take `shadow-overlay`. Depth is static — nothing lifts or gains a shadow on hover,
124
+ and emphasis is carried by fill weight, never z-height. Full doctrine in the `basalt-mantine`
125
+ rule.
118
126
  - **Strict surfaces & primitives.** The theme runs a strict surface system: Mantine components read
119
127
  raw ramp steps directly, so `cssVariablesResolver` **collapses the ramp steps onto the
120
- `--vx-surface-*` tokens** — one `shadow-card` treatment, one card bg, one radius across every
128
+ `--vx-surface-*` tokens** — one depth token per tier, one card bg, one radius across every
121
129
  component (AppShell, Table, Input, Divider, Tabs, Popover, Accordion, cards). Consumer code must
122
130
  **use Mantine layout primitives** (`Box`/`Flex`/`Grid`/`SimpleGrid`/`Stack`/`Group`/`Paper`/`Card`),
123
- not raw `<div>`/`<span>` with inline `style`. `check-theme` adds four guard kinds to enforce this
131
+ not raw `<div>`/`<span>` with inline `style`. `check-theme` adds five guard kinds to enforce this
124
132
  (default ON, `theme-allow` escape): `off-system-surface-var` (raw ramp-step vars),
125
- `raw-html-layout`, `inline-spacing`, `inline-display`. Only the Mantine-free `src/charts/**` may
126
- use raw `<div>` (still with `VX.*` tokens).
133
+ `mantine-shade-index` (a shade-pinned color — `c="yellow.7"` is one fixed swatch in both schemes;
134
+ use `VX.status.*` or the bare hue name), `raw-html-layout`, `inline-spacing`, `inline-display`.
135
+ Only the Mantine-free `src/charts/**` may use raw `<div>` (still with `VX.*` tokens).
136
+ - **Reach for the shipped composite before wrapping one.** A `StatCard` carrying a threshold verdict
137
+ takes `tone="good" | "warn" | "bad"` (an accent rail down its leading edge). Omitting `tone` is not
138
+ the same as `"good"` — omitted means "fine, or nothing measured" and stays untinted, so `"good"` is
139
+ a positive assertion you opt into for a value that earned it (`0 min` downtime), never a default.
140
+ A chart goes in `ChartCard`. Wrapping
141
+ a shipped card in a hand-positioned `Box` to add a mark it already has is how an app grows a second
142
+ card idiom — one the guard cannot recognize as a card, and therefore cannot police. If a composite
143
+ genuinely can't express what you need, that is a gap to report, not to route around.
127
144
 
128
145
  **Anti-slop checklist:** no pure `#000`/`#fff`; one accent locked page-wide; neutral does the
129
146
  structure, accent only points; don't flood blue; hierarchy via scale/weight/contrast/space, not
@@ -31,8 +31,9 @@ defer to DESIGN.md.
31
31
  dark-first professional surface — not a showcase. Ignore `/frontend-design`'s push toward a "BOLD
32
32
  aesthetic direction", gradient meshes, noise/grain, and dramatic motion. Here: the shipped
33
33
  three-font system (Nunito Sans body, Hubot Sans condensed headings, JetBrains Mono for every
34
- numeral/micro-label), depth via `shadow-card` (a whisper shadow + 1px ring, never a decorative drop
35
- shadow), neutral zinc-by-default with the single saturated accent spent only when earned (trend /
34
+ numeral/micro-label), depth via a whisper shadow + 1px ring (`shadow-card` on panels,
35
+ `shadow-raised` on controls — never a decorative drop shadow, never a hover lift), neutral
36
+ zinc-by-default with the single saturated accent spent only when earned (trend /
36
37
  signal / categorical separation). Restraint **is** the identity.
37
38
 
38
39
  <!-- basalt:end -->
@@ -21,7 +21,8 @@ law, the law wins.
21
21
  ## Identity
22
22
 
23
23
  {{APP_NAME}} inherits the basalt-ui identity verbatim: modern zinc surfaces, one earned saturated
24
- sky-blue accent, `shadow-card` elevation, dense-by-default spacing, and the three-font system. The
24
+ sky-blue accent, the `shadow-card` / `shadow-raised` / `shadow-overlay` depth split,
25
+ dense-by-default spacing, and the three-font system. The
25
26
  law itself — every hex, role split, and enforcement rule — lives in the `basalt-tokens` and
26
27
  `basalt-mantine` rules (`.claude/rules/basalt-{tokens,mantine}.md`) and `docs/DESIGN-SPEC.md` in
27
28
  the basalt-ui repo; it is **not** restated here. Confirm or restate any intentional identity shift
@@ -81,6 +81,27 @@ function isNumericLiteral(node) {
81
81
  return node !== null && node.type === 'Literal' && typeof node.value === 'number'
82
82
  }
83
83
 
84
+ /**
85
+ * An absolute CSS length written as a STRING — `'10px'`, `"1.5rem"`, `'0.75em'`.
86
+ *
87
+ * Mantine's `size` / `fz` props accept a scale token (`"xs"`…`"xl"`) OR any CSS length, and the
88
+ * second form is the one that leaves the token system. A string is not a numeric literal, so every
89
+ * `size="10px"` in an app slipped past the numeric-only check these two rules used to be.
90
+ *
91
+ * `em`/relative ratios are included on `size` (a `2em` icon is still an off-scale dimension) but
92
+ * the message names the right escape for each rule, so the two are not merged into one check.
93
+ */
94
+ const CSS_LENGTH_STRING = /^-?\d*\.?\d+(?:px|rem|em)$/
95
+
96
+ function isCssLengthString(node) {
97
+ return (
98
+ node !== null &&
99
+ node.type === 'Literal' &&
100
+ typeof node.value === 'string' &&
101
+ CSS_LENGTH_STRING.test(node.value.trim())
102
+ )
103
+ }
104
+
84
105
  /** Unwraps a JSXExpressionContainer to its inner expression; passes any other node through. */
85
106
  function unwrapExpressionContainer(node) {
86
107
  return node !== null && node.type === 'JSXExpressionContainer' ? node.expression : node
@@ -126,7 +147,59 @@ const noRawFontSize = {
126
147
  },
127
148
  }
128
149
 
129
- // ── Rule 2 — card-inset ─────────────────────────────────────────────────────────────────────────
150
+ // ── Rule 2 — raw-size-literal ───────────────────────────────────────────────────────────────────
151
+
152
+ const RAW_SIZE_LITERAL_MESSAGE =
153
+ 'Raw CSS length on a size prop — use the scale ("xs".."xl"), or fz={VX.text.*} when the type ' +
154
+ 'scale has no step for it. (basalt/raw-size-literal)'
155
+
156
+ /** The size-ish JSX props a CSS-length string can leave the token system through. */
157
+ const SIZE_ATTRS = new Set(['size', 'fz', 'fontSize'])
158
+
159
+ /**
160
+ * A CSS length written as a STRING on a size prop — `<Text size="10px">`, `<ThemeIcon size="2rem">`,
161
+ * `<Text fz="0.8rem">`. This is the hole `no-raw-font-size` left open: it only ever tested for a
162
+ * NUMERIC literal, so every string form walked straight past it.
163
+ *
164
+ * Its own rule id rather than a widening of `no-raw-font-size`, for two reasons. It covers `size`,
165
+ * which is not always a font size — on `Text` it is, on `ThemeIcon`/`ActionIcon` it is a box
166
+ * dimension — so one shared id would mean a consumer silencing the icon case also silences every
167
+ * off-scale font size, the bundled-rule mistake the three boundary rules were split apart to avoid.
168
+ * And a separate id can carry its own severity: this rule ships `warn` in the consumer preset for
169
+ * one minor (the grace-minor doctrine in the package CLAUDE.md — it rejects code that previously
170
+ * passed, and majors are banned here, so a consumer has no semver channel warning them). Widening
171
+ * `no-raw-font-size` in place would have promoted it to `error` on upgrade with no such runway.
172
+ *
173
+ * Numeric `size={32}` is deliberately NOT flagged. It is the documented Mantine idiom for icon
174
+ * dimensions and flagging it would fire on nearly every icon in an app for no design-system gain —
175
+ * the token system has an opinion about type scale and spacing, not about how many pixels wide one
176
+ * glyph box is.
177
+ */
178
+ const rawSizeLiteral = {
179
+ meta: {
180
+ type: 'suggestion',
181
+ docs: {
182
+ description: 'Disallow raw CSS-length strings on size/fz/fontSize props.',
183
+ },
184
+ schema: [],
185
+ },
186
+ create(context) {
187
+ if (getFilename(context).includes('/src/tokens/')) return {}
188
+
189
+ return {
190
+ JSXAttribute(node) {
191
+ const name = node.name?.name
192
+ if (typeof name !== 'string' || !SIZE_ATTRS.has(name)) return
193
+ const value = unwrapExpressionContainer(node.value)
194
+ if (!isCssLengthString(value)) return
195
+ if (hasThemeAllow(context, node)) return
196
+ context.report({ node, message: RAW_SIZE_LITERAL_MESSAGE })
197
+ },
198
+ }
199
+ },
200
+ }
201
+
202
+ // ── Rule 3 — card-inset ─────────────────────────────────────────────────────────────────────────
130
203
 
131
204
  const CARD_TAGS = new Set(['Card', 'Paper'])
132
205
 
@@ -456,6 +529,7 @@ export default {
456
529
  meta: { name: 'basalt' },
457
530
  rules: {
458
531
  'no-raw-font-size': noRawFontSize,
532
+ 'raw-size-literal': rawSizeLiteral,
459
533
  'card-inset': cardInset,
460
534
  'chart-in-raw-surface': chartInRawSurface,
461
535
  'raw-scroll-container': rawScrollContainer,
@@ -17,6 +17,7 @@
17
17
  "import/no-duplicates": "error",
18
18
  "import/no-unassigned-import": "off",
19
19
  "basalt/no-raw-font-size": "error",
20
+ "basalt/raw-size-literal": "warn",
20
21
  "basalt/card-inset": "error",
21
22
  "basalt/chart-in-raw-surface": "error",
22
23
  "basalt/raw-scroll-container": "off",
@@ -41,6 +41,15 @@ export type BasaltConfig = {
41
41
  * surface tokens. Default: `true` (ON). Set `false` to disable the `off-system-surface-var` check.
42
42
  */
43
43
  offSystemSurfaceVar?: boolean;
44
+ /**
45
+ * Flag a SHADE-PINNED Mantine color — `c="yellow.7"`, `bg="blue.4"`,
46
+ * `var(--mantine-color-red-6)`. A pinned step is one fixed swatch in BOTH color schemes, so it
47
+ * cannot stay legible in either; route a verdict color through `VX.status.*` / `--vx-status-*`,
48
+ * or drop the index (`c="red"`) and let the theme resolve the shade per scheme. `gray-*`/`dark-*`
49
+ * in `var()` form belong to `off-system-surface-var` instead. Default: `true` (ON). Set `false`
50
+ * to disable the `mantine-shade-index` check.
51
+ */
52
+ mantineShadeIndex?: boolean;
44
53
  /**
45
54
  * Flag raw lowercase JSX layout/surface elements (`div`/`span`/`section`/…) carrying an inline
46
55
  * `style={{}}` with a layout/surface property — steer to a Mantine layout primitive
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":"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"}
package/dist/cli/index.js CHANGED
@@ -109,6 +109,7 @@ function checkTheme(cwd = process.cwd()) {
109
109
  spacingSteps: cfg.spacingSteps ?? DEFAULT_GUARD_CONFIG.spacingSteps,
110
110
  rawRadius: cfg.rawRadius ?? DEFAULT_GUARD_CONFIG.rawRadius,
111
111
  forbiddenAccents: cfg.forbiddenAccents ?? DEFAULT_GUARD_CONFIG.forbiddenAccents,
112
+ mantineShadeIndex: cfg.mantineShadeIndex ?? DEFAULT_GUARD_CONFIG.mantineShadeIndex,
112
113
  rawSurface: cfg.rawSurface ?? DEFAULT_GUARD_CONFIG.rawSurface,
113
114
  cardWithBorder: cfg.cardWithBorder ?? DEFAULT_GUARD_CONFIG.cardWithBorder,
114
115
  offSystemSurfaceVar: cfg.offSystemSurfaceVar ?? DEFAULT_GUARD_CONFIG.offSystemSurfaceVar,
@@ -962,6 +963,7 @@ async function guardHook(cwd = process.cwd()) {
962
963
  spacingSteps: cfg.spacingSteps ?? DEFAULT_GUARD_CONFIG.spacingSteps,
963
964
  rawRadius: cfg.rawRadius ?? DEFAULT_GUARD_CONFIG.rawRadius,
964
965
  forbiddenAccents: cfg.forbiddenAccents ?? DEFAULT_GUARD_CONFIG.forbiddenAccents,
966
+ mantineShadeIndex: cfg.mantineShadeIndex ?? DEFAULT_GUARD_CONFIG.mantineShadeIndex,
965
967
  rawSurface: cfg.rawSurface ?? DEFAULT_GUARD_CONFIG.rawSurface,
966
968
  cardWithBorder: cfg.cardWithBorder ?? DEFAULT_GUARD_CONFIG.cardWithBorder,
967
969
  offSystemSurfaceVar: cfg.offSystemSurfaceVar ?? DEFAULT_GUARD_CONFIG.offSystemSurfaceVar,
@@ -1,5 +1,5 @@
1
1
  export { DeltaBadge, type DeltaBadgeProps } from './delta-badge.js';
2
- export { StatCard, type StatCardProps } from './stat-card.js';
2
+ export { StatCard, type StatCardProps, type StatCardTone } from './stat-card.js';
3
3
  export { EmptyState, type EmptyStateProps } from './empty-state.js';
4
4
  export { SettingsSection, type SettingsSectionProps, SettingsRow, type SettingsRowProps, DangerZone, type DangerZoneProps, } from './settings-section.js';
5
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/dashboard/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,eAAe,CAAA;AAChE,OAAO,EAAE,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAA;AAC1D,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,eAAe,CAAA;AAChE,OAAO,EACL,eAAe,EACf,KAAK,oBAAoB,EACzB,WAAW,EACX,KAAK,gBAAgB,EACrB,UAAU,EACV,KAAK,eAAe,GACrB,MAAM,oBAAoB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/dashboard/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,eAAe,CAAA;AAChE,OAAO,EAAE,QAAQ,EAAE,KAAK,aAAa,EAAE,KAAK,YAAY,EAAE,MAAM,aAAa,CAAA;AAC7E,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,eAAe,CAAA;AAChE,OAAO,EACL,eAAe,EACf,KAAK,oBAAoB,EACzB,WAAW,EACX,KAAK,gBAAgB,EACrB,UAAU,EACV,KAAK,eAAe,GACrB,MAAM,oBAAoB,CAAA"}
@@ -1,4 +1,6 @@
1
1
  import type { ReactNode } from 'react';
2
+ /** A threshold verdict on the card's value. `undefined` is never tinted — see the module docblock. */
3
+ export type StatCardTone = 'good' | 'warn' | 'bad';
2
4
  export type StatCardProps = {
3
5
  /** Mono uppercase micro-label (docs/DESIGN-SPEC.md §3: 12.5px, tracking 0.06em, faint). */
4
6
  label: string;
@@ -13,6 +15,11 @@ export type StatCardProps = {
13
15
  sparkline?: ReactNode;
14
16
  /** Optional header-right slot (e.g. a ghost "..." menu trigger) — consumer-owned. */
15
17
  menu?: ReactNode;
18
+ /** Threshold verdict — draws an accent rail down the card's leading edge and announces itself to
19
+ * assistive tech. Omitting it is NOT `'good'`: omitted covers a reading that is fine AND one that
20
+ * is absent, and stays untinted so a card with nothing measured can never render green. Pass
21
+ * `'good'` only to assert a measured value that has earned the verdict. */
22
+ tone?: StatCardTone;
16
23
  };
17
- export declare function StatCard({ label, value, delta, deltaPeriod, sparkline, menu }: StatCardProps): import("react").JSX.Element;
24
+ export declare function StatCard({ label, value, delta, deltaPeriod, sparkline, menu, tone, }: StatCardProps): import("react").JSX.Element;
18
25
  //# sourceMappingURL=stat-card.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"stat-card.d.ts","sourceRoot":"","sources":["../../src/dashboard/stat-card.tsx"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAItC,MAAM,MAAM,aAAa,GAAG;IAC1B,2FAA2F;IAC3F,KAAK,EAAE,MAAM,CAAA;IACb,oEAAoE;IACpE,KAAK,EAAE,MAAM,CAAA;IACb,oFAAoF;IACpF,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,oGAAoG;IACpG,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;kGAC8F;IAC9F,SAAS,CAAC,EAAE,SAAS,CAAA;IACrB,qFAAqF;IACrF,IAAI,CAAC,EAAE,SAAS,CAAA;CACjB,CAAA;AAED,wBAAgB,QAAQ,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,IAAI,EAAE,EAAE,aAAa,+BA8D5F"}
1
+ {"version":3,"file":"stat-card.d.ts","sourceRoot":"","sources":["../../src/dashboard/stat-card.tsx"],"names":[],"mappings":"AA0CA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAItC,sGAAsG;AACtG,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,MAAM,GAAG,KAAK,CAAA;AAYlD,MAAM,MAAM,aAAa,GAAG;IAC1B,2FAA2F;IAC3F,KAAK,EAAE,MAAM,CAAA;IACb,oEAAoE;IACpE,KAAK,EAAE,MAAM,CAAA;IACb,oFAAoF;IACpF,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,oGAAoG;IACpG,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;kGAC8F;IAC9F,SAAS,CAAC,EAAE,SAAS,CAAA;IACrB,qFAAqF;IACrF,IAAI,CAAC,EAAE,SAAS,CAAA;IAChB;;;+EAG2E;IAC3E,IAAI,CAAC,EAAE,YAAY,CAAA;CACpB,CAAA;AAED,wBAAgB,QAAQ,CAAC,EACvB,KAAK,EACL,KAAK,EACL,KAAK,EACL,WAAW,EACX,SAAS,EACT,IAAI,EACJ,IAAI,GACL,EAAE,aAAa,+BAyFf"}
@@ -1,8 +1,22 @@
1
- import { jsx, jsxs } from "react/jsx-runtime";
2
- import { Card, Group } from "@mantine/core";
1
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
+ import { Box, Card, Group, VisuallyHidden } from "@mantine/core";
3
3
  import { DeltaBadge } from "./delta-badge.js";
4
4
  import { VX } from "../tokens/index.js";
5
- function StatCard({ label, value, delta, deltaPeriod, sparkline, menu }) {
5
+ const TONE_RAIL_WIDTH = 3;
6
+ const TONE_LABEL = {
7
+ good: "Within the good threshold",
8
+ warn: "Past the warning threshold",
9
+ bad: "Past the severe threshold"
10
+ };
11
+ function StatCard({
12
+ label,
13
+ value,
14
+ delta,
15
+ deltaPeriod,
16
+ sparkline,
17
+ menu,
18
+ tone
19
+ }) {
6
20
  return /* @__PURE__ */ jsxs(
7
21
  Card,
8
22
  {
@@ -19,9 +33,33 @@ function StatCard({ label, value, delta, deltaPeriod, sparkline, menu }) {
19
33
  // theme-allow: Card root as a flex column — Mantine Card takes no layout props
20
34
  flexDirection: "column",
21
35
  gap: "var(--vx-space-stat-card-gap)",
22
- overflow: "hidden"
36
+ overflow: "hidden",
37
+ // Anchors the tone rail. Set unconditionally so a card's stacking context does not change
38
+ // depending on whether it happens to have crossed a threshold this render.
39
+ position: "relative"
23
40
  },
41
+ "data-tone": tone,
24
42
  children: [
43
+ tone !== void 0 && /* @__PURE__ */ jsxs(Fragment, { children: [
44
+ /* @__PURE__ */ jsx(VisuallyHidden, { children: TONE_LABEL[tone] }),
45
+ /* @__PURE__ */ jsx(
46
+ Box,
47
+ {
48
+ "aria-hidden": "true",
49
+ style: {
50
+ position: "absolute",
51
+ insetBlock: 0,
52
+ insetInlineStart: 0,
53
+ width: TONE_RAIL_WIDTH,
54
+ // `StatCardTone`'s members are `VX.status` keys by construction, so the rail reads the
55
+ // per-scheme `--vx-status-*` solid directly — never a hex, so it re-resolves in both
56
+ // schemes and under a consumer's `derive` retune. Contrast against
57
+ // `--vx-surface-panel` is measured in `theme/contrast.test.ts`.
58
+ background: VX.status[tone]
59
+ }
60
+ }
61
+ )
62
+ ] }),
25
63
  /* @__PURE__ */ jsxs(Group, { justify: "space-between", align: "flex-start", wrap: "nowrap", gap: "xs", children: [
26
64
  /* @__PURE__ */ jsx(
27
65
  "span",
@@ -9,7 +9,7 @@ export type { Finding, GuardConfig, GuardKind, GuardSeverity };
9
9
  /** Shared default config — CLI and tests import this to avoid duplication. */
10
10
  export declare const DEFAULT_GUARD_CONFIG: GuardConfig;
11
11
  /**
12
- * The closed registry of all 19 guard kinds. The triad test asserts
12
+ * The closed registry of all 20 guard kinds. The triad test asserts
13
13
  * `surface.guardKinds ⊆ keyof GUARD_RULES` at runtime.
14
14
  *
15
15
  * raw-surface, raw-html-layout, and sub-16-input-font are handled inline in checkSource
@@ -51,6 +51,12 @@ export declare const GUARD_RULES: {
51
51
  readonly appliesTo: (relPath: string) => boolean;
52
52
  readonly message: "For an off-identity accent use blue/gray or a status hue (red/green/orange/yellow).";
53
53
  };
54
+ readonly 'mantine-shade-index': {
55
+ readonly kind: "mantine-shade-index";
56
+ readonly pattern: RegExp;
57
+ readonly enabled: (cfg: GuardConfig) => boolean;
58
+ readonly message: "Shade-pinned Mantine color — one fixed swatch in BOTH color schemes, so it cannot stay legible in either. Use VX.status.* / --vx-status-* for a verdict color, or the bare hue name (c=\"red\") to let the theme resolve the shade per scheme.";
59
+ };
54
60
  readonly 'raw-spacing': {
55
61
  readonly kind: "raw-spacing";
56
62
  readonly pattern: (cfg: GuardConfig) => RegExp;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/guard/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAE7E,YAAY,EAAE,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,aAAa,EAAE,CAAA;AAsO9D,8EAA8E;AAC9E,eAAO,MAAM,oBAAoB,EAAE,WAmBlC,CAAA;AAgTD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;gCA0BL,WAAW;;;;;;gCAaX,WAAW;;;;;;;gCAaX,WAAW;;;;;;;gCAQX,WAAW;;;;;;gCAOX,WAAW;;;;;;;gCASX,WAAW;;;;;;gCAOX,WAAW;;;;;;;gCASX,WAAW;;;;;;gCAMX,WAAW;;;;;;;gCASX,WAAW;;;;;;;gCAUX,WAAW;;;;;;;gCAUX,WAAW;;;;;;;gCASX,WAAW;;;;;;;gCASX,WAAW;;;;;;;gCAUX,WAAW;;;;CAOmB,CAAA;AAUjD;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,WAAW,GAAG,OAAO,EAAE,CAiPtF"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/guard/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAE7E,YAAY,EAAE,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,aAAa,EAAE,CAAA;AAoP9D,8EAA8E;AAC9E,eAAO,MAAM,oBAAoB,EAAE,WAoBlC,CAAA;AAuTD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;gCA0BL,WAAW;;;;;;;gCAcX,WAAW;;;;;gCAMX,WAAW;;;;;;;gCAaX,WAAW;;;;;;;gCAQX,WAAW;;;;;;gCAOX,WAAW;;;;;;;gCASX,WAAW;;;;;;gCAOX,WAAW;;;;;;;gCASX,WAAW;;;;;;gCAMX,WAAW;;;;;;;gCASX,WAAW;;;;;;;gCAUX,WAAW;;;;;;;gCAUX,WAAW;;;;;;;gCASX,WAAW;;;;;;;gCASX,WAAW;;;;;;;gCAUX,WAAW;;;;CAOmB,CAAA;AAUjD;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,WAAW,GAAG,OAAO,EAAE,CA2PtF"}