zabi-components 7.0.2 → 8.0.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 (174) hide show
  1. package/README.md +46 -3
  2. package/THEMING.md +255 -15
  3. package/dist/atoms/ActionPanel.svelte +12 -7
  4. package/dist/atoms/ActionPanel.svelte.d.ts +2 -0
  5. package/dist/atoms/Badge.svelte +93 -56
  6. package/dist/atoms/Badge.svelte.d.ts +15 -3
  7. package/dist/atoms/Button.svelte +49 -49
  8. package/dist/atoms/Button.svelte.d.ts +3 -0
  9. package/dist/atoms/Card.svelte +31 -28
  10. package/dist/atoms/Card.svelte.d.ts +5 -2
  11. package/dist/atoms/CardContent.svelte +10 -2
  12. package/dist/atoms/CardContent.svelte.d.ts +2 -0
  13. package/dist/atoms/CardFooter.svelte +10 -2
  14. package/dist/atoms/CardFooter.svelte.d.ts +2 -0
  15. package/dist/atoms/CardHeader.svelte +10 -2
  16. package/dist/atoms/CardHeader.svelte.d.ts +2 -0
  17. package/dist/atoms/Checkbox.svelte +4 -0
  18. package/dist/atoms/Checkbox.svelte.d.ts +2 -0
  19. package/dist/atoms/CodeBlock.svelte +15 -7
  20. package/dist/atoms/CodeBlock.svelte.d.ts +9 -3
  21. package/dist/atoms/ColorPicker.svelte +19 -9
  22. package/dist/atoms/ColorPicker.svelte.d.ts +3 -1
  23. package/dist/atoms/Container.svelte +2 -3
  24. package/dist/atoms/Divider.svelte +4 -4
  25. package/dist/atoms/FeatureCard.svelte +4 -3
  26. package/dist/atoms/Heading.svelte +56 -27
  27. package/dist/atoms/Heading.svelte.d.ts +17 -4
  28. package/dist/atoms/IconButton.svelte +30 -37
  29. package/dist/atoms/Input.svelte +26 -34
  30. package/dist/atoms/List.svelte +10 -2
  31. package/dist/atoms/List.svelte.d.ts +2 -0
  32. package/dist/atoms/ListItem.svelte +2 -2
  33. package/dist/atoms/ListItemLeading.svelte +4 -2
  34. package/dist/atoms/ListItemLeading.svelte.d.ts +1 -1
  35. package/dist/atoms/OptimizedImage.svelte +11 -2
  36. package/dist/atoms/OptimizedImage.svelte.d.ts +2 -0
  37. package/dist/atoms/Progress.svelte +4 -1
  38. package/dist/atoms/Progress.svelte.d.ts +2 -0
  39. package/dist/atoms/Radio.svelte +4 -0
  40. package/dist/atoms/Radio.svelte.d.ts +2 -0
  41. package/dist/atoms/Select.svelte +65 -67
  42. package/dist/atoms/Select.svelte.d.ts +4 -0
  43. package/dist/atoms/SelectionControl.svelte +5 -1
  44. package/dist/atoms/SelectionControl.svelte.d.ts +2 -0
  45. package/dist/atoms/Skeleton.svelte +5 -3
  46. package/dist/atoms/Table.svelte +2 -1
  47. package/dist/atoms/Text.svelte +34 -5
  48. package/dist/atoms/Text.svelte.d.ts +7 -1
  49. package/dist/atoms/Textarea.svelte +7 -6
  50. package/dist/atoms/ThemeToggle.svelte +17 -14
  51. package/dist/atoms/ThemeToggle.svelte.d.ts +2 -0
  52. package/dist/atoms/Toast.svelte +10 -8
  53. package/dist/atoms/Toggle.svelte +21 -13
  54. package/dist/atoms/Toggle.svelte.d.ts +7 -0
  55. package/dist/atoms/Tooltip.svelte +80 -22
  56. package/dist/atoms/Tooltip.svelte.d.ts +15 -0
  57. package/dist/components/index.d.ts +3 -1
  58. package/dist/components/index.d.ts.map +1 -1
  59. package/dist/components/organisms/index.d.ts +1 -0
  60. package/dist/components/organisms/index.d.ts.map +1 -1
  61. package/dist/components/util/cn.d.ts +8 -0
  62. package/dist/components/util/cn.d.ts.map +1 -0
  63. package/dist/components/util/ssr-safe.d.ts +4 -4
  64. package/dist/components/util/ssr-safe.d.ts.map +1 -1
  65. package/dist/index.d.ts +3 -1
  66. package/dist/index.js +3 -6
  67. package/dist/molecules/Alert.svelte +43 -64
  68. package/dist/molecules/Alert.svelte.d.ts +7 -1
  69. package/dist/molecules/ComponentDemo.svelte +10 -2
  70. package/dist/molecules/ComponentDemo.svelte.d.ts +2 -0
  71. package/dist/molecules/ContactForm.svelte +12 -2
  72. package/dist/molecules/ContactForm.svelte.d.ts +2 -0
  73. package/dist/molecules/Dropdown.svelte +40 -11
  74. package/dist/molecules/Dropdown.svelte.d.ts +4 -0
  75. package/dist/molecules/EmptyState.svelte +2 -1
  76. package/dist/molecules/Form.svelte +25 -14
  77. package/dist/molecules/Form.svelte.d.ts +13 -5
  78. package/dist/molecules/FormField.svelte +2 -1
  79. package/dist/molecules/Header.svelte +13 -3
  80. package/dist/molecules/Header.svelte.d.ts +2 -0
  81. package/dist/molecules/ImageUpload.svelte +54 -15
  82. package/dist/molecules/ImageUpload.svelte.d.ts +16 -5
  83. package/dist/molecules/Modal.svelte +110 -31
  84. package/dist/molecules/Modal.svelte.d.ts +9 -6
  85. package/dist/molecules/NavigationMenu.svelte +15 -3
  86. package/dist/molecules/NavigationMenu.svelte.d.ts +4 -0
  87. package/dist/molecules/NavigationMenuContent.svelte +36 -38
  88. package/dist/molecules/NavigationMenuContent.svelte.d.ts +2 -0
  89. package/dist/molecules/NavigationMenuItem.svelte +10 -2
  90. package/dist/molecules/NavigationMenuItem.svelte.d.ts +2 -0
  91. package/dist/molecules/NavigationMenuLink.svelte +10 -2
  92. package/dist/molecules/NavigationMenuLink.svelte.d.ts +2 -0
  93. package/dist/molecules/NavigationMenuList.svelte +10 -2
  94. package/dist/molecules/NavigationMenuList.svelte.d.ts +2 -0
  95. package/dist/molecules/NavigationMenuTrigger.svelte +16 -3
  96. package/dist/molecules/NavigationMenuTrigger.svelte.d.ts +2 -0
  97. package/dist/molecules/Page.svelte +8 -2
  98. package/dist/molecules/Page.svelte.d.ts +2 -0
  99. package/dist/molecules/PropsTable.svelte +6 -3
  100. package/dist/molecules/PropsTable.svelte.d.ts +2 -0
  101. package/dist/molecules/RadioGroup.svelte +5 -1
  102. package/dist/molecules/RadioGroup.svelte.d.ts +2 -0
  103. package/dist/molecules/Section.svelte +12 -4
  104. package/dist/molecules/Section.svelte.d.ts +3 -1
  105. package/dist/molecules/SidebarBrandHeader.svelte +13 -4
  106. package/dist/molecules/SidebarBrandHeader.svelte.d.ts +2 -0
  107. package/dist/molecules/SidebarFooter.svelte +12 -4
  108. package/dist/molecules/SidebarFooter.svelte.d.ts +2 -0
  109. package/dist/molecules/SidebarNavSection.svelte +12 -4
  110. package/dist/molecules/SidebarNavSection.svelte.d.ts +2 -0
  111. package/dist/molecules/SlideUp.svelte +85 -21
  112. package/dist/molecules/SlideUp.svelte.d.ts +5 -4
  113. package/dist/molecules/Tabs.svelte +25 -19
  114. package/dist/molecules/Tabs.svelte.d.ts +8 -4
  115. package/dist/molecules/Toaster.svelte +3 -2
  116. package/dist/molecules/ToasterToast.svelte +41 -10
  117. package/dist/molecules/ToasterToast.svelte.d.ts +2 -0
  118. package/dist/organisms/SidebarAccountPanel.svelte +4 -0
  119. package/dist/organisms/SidebarAccountPanel.svelte.d.ts +2 -0
  120. package/dist/organisms/SidebarNavigation.svelte +102 -59
  121. package/dist/organisms/SidebarNavigation.svelte.d.ts +2 -0
  122. package/dist/organisms/SidebarPanel.svelte +19 -11
  123. package/dist/organisms/SidebarPanel.svelte.d.ts +2 -0
  124. package/dist/organisms/SidebarShell.svelte +112 -0
  125. package/dist/organisms/SidebarShell.svelte.d.ts +52 -0
  126. package/dist/organisms/TopNavbar.svelte +88 -17
  127. package/dist/organisms/TopNavbar.svelte.d.ts +2 -0
  128. package/dist/organisms/index.d.ts +1 -0
  129. package/dist/organisms/index.js +1 -0
  130. package/dist/routes/lib/variant-utils.d.ts +26 -0
  131. package/dist/routes/lib/{variant-utils.ts → variant-utils.js} +15 -46
  132. package/dist/types/events.js +1 -0
  133. package/dist/types/index.js +3 -0
  134. package/dist/util/cn.d.ts +7 -0
  135. package/dist/util/cn.js +48 -0
  136. package/dist/util/ssr-safe.d.ts +4 -4
  137. package/dist/zabi-components-colors.css +2 -2
  138. package/dist/zabi-components-theme-dark-only.css +139 -92
  139. package/dist/zabi-components-theme-dark.css +139 -92
  140. package/dist/zabi-components-theme-only.css +268 -122
  141. package/dist/zabi-components-theme.css +268 -122
  142. package/dist/zabi-components.css +831 -883
  143. package/docs/lucide-icons.md +3 -1
  144. package/package.json +19 -24
  145. package/dist/lib/layout-width-tokens.d.ts +0 -17
  146. package/dist/lib/layout-width-tokens.d.ts.map +0 -1
  147. package/dist/lib/showcase/component-docs/Button.d.ts +0 -3
  148. package/dist/lib/showcase/component-docs/Button.d.ts.map +0 -1
  149. package/dist/lib/showcase/component-docs/Input.d.ts +0 -3
  150. package/dist/lib/showcase/component-docs/Input.d.ts.map +0 -1
  151. package/dist/lib/showcase/component-docs/List.d.ts +0 -3
  152. package/dist/lib/showcase/component-docs/List.d.ts.map +0 -1
  153. package/dist/lib/showcase/component-docs/Modal.d.ts +0 -3
  154. package/dist/lib/showcase/component-docs/Modal.d.ts.map +0 -1
  155. package/dist/lib/showcase/component-docs/Radio.d.ts +0 -3
  156. package/dist/lib/showcase/component-docs/Radio.d.ts.map +0 -1
  157. package/dist/lib/showcase/component-docs/SidebarNavigation.d.ts +0 -3
  158. package/dist/lib/showcase/component-docs/SidebarNavigation.d.ts.map +0 -1
  159. package/dist/lib/showcase/component-docs/Skeleton.d.ts +0 -3
  160. package/dist/lib/showcase/component-docs/Skeleton.d.ts.map +0 -1
  161. package/dist/lib/showcase/component-docs/_shared.d.ts +0 -12
  162. package/dist/lib/showcase/component-docs/_shared.d.ts.map +0 -1
  163. package/dist/lib/showcase/component-docs/index.d.ts +0 -4
  164. package/dist/lib/showcase/component-docs/index.d.ts.map +0 -1
  165. package/dist/lib/showcase/components-catalog.d.ts +0 -3
  166. package/dist/lib/showcase/components-catalog.d.ts.map +0 -1
  167. package/dist/lib/showcase/components-showcase-constants.d.ts +0 -89
  168. package/dist/lib/showcase/components-showcase-constants.d.ts.map +0 -1
  169. package/dist/lib/showcase/docs-sidebar-helpers.d.ts +0 -15
  170. package/dist/lib/showcase/docs-sidebar-helpers.d.ts.map +0 -1
  171. package/dist/routes/lib/focus-utils.d.ts +0 -12
  172. package/dist/routes/lib/focus-utils.ts +0 -110
  173. package/dist/routes/lib/ssr-safe.ts +0 -58
  174. package/scripts/fix-lucide-svelte-icon-dts.js +0 -35
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Svelte **5** UI primitives (runes, `onclick` / `oninput`, …), Tailwind **v4** design tokens, SSR-safe. Requires **Svelte ≥ 5.43.8**.
4
4
 
5
+ Docs: **[zabi-components.vercel.app](https://zabi-components.vercel.app/)**
6
+
5
7
  ---
6
8
 
7
9
  ## Quick start
@@ -12,7 +14,7 @@ Svelte **5** UI primitives (runes, `onclick` / `oninput`, …), Tailwind **v4**
12
14
  npm install zabi-components
13
15
  ```
14
16
 
15
- Ensure `svelte@^5.43.8` is installed — SvelteKit apps usually already match; add `@sveltejs/kit@^2` only for Kit. Optional: `react@^18` if you use `zabi-components/react`.
17
+ Ensure `svelte@^5.43.8` is installed — SvelteKit apps usually already match; add `@sveltejs/kit@^2` only for Kit. The package is Svelte-only (the unmaintained `zabi-components/react` export was removed in 8.0.0).
16
18
 
17
19
  **2. Global CSS** (e.g. SvelteKit `src/app.css` — adjust for your bundler entry)
18
20
 
@@ -100,6 +102,24 @@ Stick to **documented package subpaths** from `package.json` `exports`. Do not r
100
102
  </NavigationMenu>
101
103
  ```
102
104
 
105
+ Some components ship both ways. `SidebarNavigation` is the data-driven one — one
106
+ component, ~39 props, excellent when your sidebar is shaped like that one.
107
+ `SidebarShell` is the same chrome with the regions left open, for when it isn't:
108
+
109
+ ```svelte
110
+ <SidebarShell mode="collapsed">
111
+ {#snippet header({ collapsed })}
112
+ <SidebarBrandHeader {collapsed} brandName="Zabi" />
113
+ {/snippet}
114
+
115
+ <SidebarNavSection title="Main" sectionKey="main">…</SidebarNavSection>
116
+
117
+ {#snippet footer({ collapsed, insetX })}
118
+ <SidebarFooter {collapsed} class={insetX} />
119
+ {/snippet}
120
+ </SidebarShell>
121
+ ```
122
+
103
123
  Wrong mental model: treating composable roots as optional — see **Common pitfalls**.
104
124
 
105
125
  ---
@@ -162,10 +182,21 @@ Components use **DOM-style props** (`onclick`, `oninput`), not legacy `on:click`
162
182
 
163
183
  | Token / class | Role |
164
184
  |---------------|------|
165
- | `surface-1` / `surface-2` | Layered backgrounds — prefer over raw neutrals so light/dark stay aligned. |
185
+ | `bg-surface-base` · `raised` · `elevated` · `overlay` | The four surface levels. Anything floating above content uses `overlay`. In dark mode each level is the one below it with more light on it — shadows don't read on a dark page. |
186
+ | `surface-1` / `surface-2` | Compatibility aliases for `raised` / `elevated`. Prefer the named levels in new code. |
187
+ | `shadow-sm` / `shadow-lg` | Elevation is **two steps**: raised, and floating. `shadow-none` is the explicit absence of one. `shadow-md`, `shadow-xl` and a bare `shadow` fail the build. |
188
+ | `rounded-control` · `container` · `overlay` · `pill` | Radius is chosen by **role**, never by size. A large button is a bigger box with the same corner as a small one. |
189
+ | `hover:bg-surface-hover` / `active:bg-surface-active` | Interaction fills. Surface-relative on purpose — a fixed ramp step can land on exactly the colour it sits on. |
166
190
  | `.focus-ring` / `focus-ring--nav` | Keyboard focus rings — don’t replace with `outline-none` unless you substitute an equivalent visible focus style. |
167
191
 
168
- Details: [THEME.md](./THEME.md).
192
+ Spacing rides a **4px grid**: `gap-*`, `space-*` and padding land on whole steps.
193
+ Half-steps survive only as optical nudges, and only on margins (`mt-0.5` on an
194
+ icon beside a first line of text).
195
+
196
+ `Heading` and `Text` share **one** type scale, so `<Text size="md">` sits on the
197
+ same line box as an `h6` and `size="lg"` as an `h5`.
198
+
199
+ Details: [THEME.md](./THEME.md) · [THEMING.md](./THEMING.md).
169
200
 
170
201
  ---
171
202
 
@@ -186,6 +217,16 @@ import Button from "some-path/node_modules/zabi-components/dist/atoms/Button.sve
186
217
 
187
218
  Load theme CSS (Quick start). Unstyled components usually mean missing `@import "zabi-components/theme-only"` (and dark overrides if you use `.dark`).
188
219
 
220
+ `class` is the public prop on every component and is merged **last**, through
221
+ [tailwind-merge](https://github.com/dcastil/tailwind-merge), so your utility
222
+ wins outright rather than racing the component's own:
223
+
224
+ ```svelte
225
+ <Card class="rounded-pill" /> <!-- rounded-container is dropped, not fought -->
226
+ ```
227
+
228
+ `className` still works everywhere as a deprecated alias.
229
+
189
230
  **Library code (contributors)**
190
231
 
191
232
  ```ts
@@ -232,6 +273,8 @@ import type { Foo } from "../types/variants.js";
232
273
  |-------|---------|---------|
233
274
  | Unit / component | `npm run test` | Vitest + Testing Library — logic and regressions |
234
275
  | Interaction | `npm run test:e2e` | Playwright — overlays, focus, keyboard flows |
276
+ | Types & structure | `npm run check` | `svelte-check`, import-path and layout-width rules, plus `check:design` |
277
+ | Design system | `npm run check:design` | Ramp lightness, WCAG AA on every rendered pair, interaction fills that differ from their surface, control geometry, and token violations (raw palette classes, off-scale shadows, half-step spacing) |
235
278
 
236
279
  ---
237
280
 
package/THEMING.md CHANGED
@@ -116,7 +116,8 @@ Dark mapping rule reference:
116
116
 
117
117
  ### Change Primary Button Color
118
118
 
119
- The primary button uses `--color-action-primary` which defaults to `brand-800`. To change it:
119
+ The primary button uses `--color-action-primary`, which defaults to `brand-600`
120
+ (4.86:1 against a white label). To change it:
120
121
 
121
122
  ```css
122
123
  @theme {
@@ -135,11 +136,15 @@ The brand scale is used throughout the system:
135
136
  - **brand-50 to brand-100**: Subtle backgrounds, hover states
136
137
  - **brand-300 to brand-400**: Light accents, disabled states
137
138
  - **brand-500**: Focus rings, medium emphasis
138
- - **brand-600**: Primary color, links
139
- - **brand-700**: Link hover, primary hover
140
- - **brand-800**: **Primary buttons** (`action-primary`)
141
- - **brand-900**: Primary button hover
142
- - **brand-950**: Darkest brand shade
139
+ - **brand-600**: **Primary buttons** (`action-primary`)
140
+ - **brand-700**: Primary hover, links
141
+ - **brand-800**: Primary active, link hover
142
+ - **brand-900 / 950**: Darkest brand shades
143
+
144
+ Hover and active always move *toward* the dark end in light mode. The `.dark`
145
+ mirror flips that automatically, so "more pressed" means "more contrast against
146
+ the page" in both themes. Never point `-active` at a step on the far side of the
147
+ ramp — that is how the pressed primary label ended up at 1.66:1.
143
148
 
144
149
  ### Dark Mode
145
150
 
@@ -162,15 +167,11 @@ Dark mode action colors are handled in `src/app.css` in the `.dark` block (lines
162
167
  - `base-600` (light mode) → `base-400` (dark mode) automatically
163
168
 
164
169
  ### Danger Actions
165
- - **Has explicit dark mode overrides** (lines 399-404) because red colors aren't automatically inverted:
166
- ```css
167
- .dark {
168
- --color-action-danger: theme(colors.red.500);
169
- --color-action-danger-hover: theme(colors.red.600);
170
- --color-action-danger-active: theme(colors.red.700);
171
- /* ... */
172
- }
173
- ```
170
+ - **No re-pointing needed.** Danger resolves through `--color-error-*`, which
171
+ mirrors like every other ramp, so dark danger is the same step of the same
172
+ ramp as light danger. The `.dark` block restates the aliases only so that
173
+ `zabi-components/theme-dark-only` remains a complete standalone import
174
+ (`validate-theme.js` enforces that).
174
175
 
175
176
  ### In Generated Files
176
177
 
@@ -179,6 +180,245 @@ Dark mode action colors are handled in `src/app.css` in the `.dark` block (lines
179
180
  - **`dist/zabi-components-theme-only.css`**: Only has light mode (@theme block), no dark mode
180
181
  - **`dist/zabi-components-theme.css`**: Only has light mode (@theme block), no dark mode
181
182
 
183
+ ## 🎨 Calibrated Colour Ramps
184
+
185
+ Every chromatic ramp (`brand`, `citron`, `pine`, `iris`, `warning`, `error`) is
186
+ **generated against one shared lightness curve**, so `<ramp>-600` means the same
187
+ perceptual lightness in every ramp. That is what lets the semantic families read
188
+ as one family instead of six unrelated colours.
189
+
190
+ | step | 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950 |
191
+ |---|---|---|---|---|---|---|---|---|---|---|---|
192
+ | CIE L\* | 97 | 94 | 88 | 80 | 70 | 58 | **47** | 38 | 29 | 20 | 12 |
193
+
194
+ Step **600** is the solid-fill step: dark enough to clear 4.5:1 against white,
195
+ light enough to still read as a colour. Every semantic token points at it.
196
+
197
+ - **Source of truth:** `tokens/chromatic-scales.js` — hue, peak chroma, the
198
+ chroma envelope, and the target curve.
199
+ - **Regenerate:** `node scripts/generate-ramps.js` (runs inside `npm run sync:tokens`).
200
+ - **Verify:** `node scripts/check-ramp-lightness.js` — fails if any step drifts
201
+ more than ±1.5 L\* off the curve, or if two adjacent steps differ by more than
202
+ 15 L\* (a cliff rather than a ramp).
203
+
204
+ To rebrand, edit `hue` / `peakChroma` for a ramp in `tokens/chromatic-scales.js`
205
+ and re-run the generator. **Do not hand-edit `--zabi-<ramp>-*` in `src/app.css`** —
206
+ the generator overwrites them, in both the `@theme` block and the `.dark` mirror.
207
+
208
+ The `base` (neutral) ramp is deliberately **not** on this curve: it is wider on
209
+ purpose because it also drives text, borders and the surface levels. Semantic
210
+ `neutral` aliases the base step closest to the chromatic 600s.
211
+
212
+ ## 🧩 Semantic Families
213
+
214
+ Each family exposes the same roles, built from the same steps:
215
+
216
+ | token | step | use |
217
+ |---|---|---|
218
+ | `--color-<family>` | 600 | solid fill — badge `emphasis="solid"`, progress bars |
219
+ | `--color-<family>-weak` | 700 | pressed / emphasis |
220
+ | `--color-<family>-medium` | 800 | strongest fill |
221
+ | `--color-<family>-strong` | 900 | darkest |
222
+ | `--color-<family>-subtle` | 100 | tinted fill — badges, alerts |
223
+ | `--color-<family>-border` | 200 | tinted edge |
224
+ | `--color-<family>-text` | 700 | text on a subtle fill or a page surface |
225
+
226
+ Families: `success`, `warning`, `error`, `info`, `energetic`, `neutral`.
227
+
228
+ Prefer the **subtle** trio (`-subtle` fill + `-border` edge + `-text` label) for
229
+ anything informational. Solid fills are for the one element on a screen that has
230
+ to shout.
231
+
232
+ ## 📐 Control Geometry
233
+
234
+ One height scale is shared by **every** form control, so a Button, Input, Select
235
+ and IconButton of the same size line up in a row:
236
+
237
+ | size | height | token |
238
+ |---|---|---|
239
+ | `sm` | 32px | `--control-height-sm` |
240
+ | `md` | 40px | `--control-height-md` |
241
+ | `lg` | 48px | `--control-height-lg` |
242
+
243
+ Enforced by `scripts/check-control-geometry.js`.
244
+
245
+ ## 🔲 Border Radius
246
+
247
+ Four radii, chosen by **role**, never by size. A large button is a bigger box
248
+ with the same corner as a small one.
249
+
250
+ | token | value | use |
251
+ |---|---|---|
252
+ | `--radius-control` | 8px | buttons, inputs, selects, toggles |
253
+ | `--radius-container` | 12px | cards, alerts, panels, list items, code blocks |
254
+ | `--radius-overlay` | 16px | modals, sheets, menus, popovers, toasts |
255
+ | `--radius-pill` | full | badges, avatars, status dots |
256
+
257
+ `--radius-sm/md/lg/xl` remain as legacy aliases for consumer overrides, but
258
+ components must not use them — `check-control-geometry.js` fails the build if a
259
+ component reaches for a t-shirt radius.
260
+
261
+ ## ✋ Interaction Fills
262
+
263
+ Hover and active fills are **surface-relative alpha tints**, not fixed ramp steps:
264
+
265
+ ```css
266
+ /* light */
267
+ --color-surface-hover: rgba(9, 9, 11, 0.06);
268
+ --color-surface-active: rgba(9, 9, 11, 0.11);
269
+ /* dark */
270
+ --color-surface-hover: rgba(250, 250, 250, 0.08);
271
+ --color-surface-active: rgba(250, 250, 250, 0.14);
272
+ ```
273
+
274
+ A fixed step can coincide exactly with the surface it sits on — `base-100` in
275
+ dark mode *is* `--color-surface-base`, which is how ghost-button hover became
276
+ invisible. `check-token-violations.js` now fails on `hover:bg-base-*` /
277
+ `active:bg-base-*` in component source.
278
+
279
+ Use `hover:bg-surface-hover` / `active:bg-surface-active` for quiet controls, and
280
+ the `action-*-hover` / `action-*-active` tokens for filled ones.
281
+
282
+ ## ♿ Contrast
283
+
284
+ `scripts/check-contrast.js` resolves every fill/foreground pair a component can
285
+ render — through the same token chain the CSS uses — in **both** themes, and
286
+ fails below WCAG AA. Run it after re-pointing any token:
287
+
288
+ ```bash
289
+ npm run check:contrast # or: npm run check:design (all four guards)
290
+ ```
291
+
292
+ ## 🧱 Surface Elevation Levels
293
+
294
+ Every surface uses one of four semantic levels. Use these tokens instead of raw `base-*` steps for backgrounds:
295
+
296
+ | Level | Token / utility | Use for | Light | Dark (OKLCH L) |
297
+ |---|---|---|---|---|
298
+ | Base | `--color-surface-base` · `bg-surface-base` | Page / app shell (the darkest level in dark mode) | `base-100` | `#18181b` · 21 |
299
+ | Raised | `--color-surface-raised` · `bg-surface-raised` | Cards, panels, sidebars | `#ffffff` | `#262629` · 27 |
300
+ | Elevated | `--color-surface-elevated` · `bg-surface-elevated` | Nested cards, hover and active fills | `base-50` | `#35353a` · 33 |
301
+ | Overlay | `--color-surface-overlay` · `bg-surface-overlay` | Modals, sheets, dropdown/select menus, navigation panels, toasts | `#ffffff` | `#44444c` · 39 |
302
+
303
+ Supporting tokens:
304
+ - `--color-surface-overlay-hover` (`bg-surface-overlay-hover`): row and icon-button hover **inside** an overlay. It is lighter than the overlay in dark mode.
305
+ - `--color-border-overlay` (`border-border-overlay`): 1px edge on overlays. Transparent in light mode, visible in dark mode.
306
+
307
+ **The dark levels are generated, not hand-picked.** Each one is `#fafafa` — the
308
+ same light as the dark hover tint `--color-surface-hover` — washed over the page
309
+ at 6.4% / 13.1% / 19.7%, so every level is literally the level below it with more
310
+ light on it. The ladder lives in `tokens/surface-ladder.js`; edit the alphas
311
+ there and run `npm run sync:tokens`, never the hex in `app.css`.
312
+
313
+ The composite is baked to an opaque hex at build time rather than shipped as
314
+ `rgba()`. Flat colours keep the static toolchain working — `check-contrast.js`
315
+ can resolve every AA pair against a surface, `check-surface-elevation.js` can
316
+ measure the steps — and an opaque overlay means a modal never lets the page bleed
317
+ through it. The trade: nesting past the four levels is not automatic. A card
318
+ inside a card does not self-lighten, it names the next level up.
319
+
320
+ **Why dark mode steps lightness instead of using shadows:** a drop shadow simulates light blocked by a raised object, which reads on a light page. On a dark page the shadow is as dark as the background, so the signal disappears. In dark mode each level is therefore **lighter** than the one below it, by +6 OKLCH lightness points on the neutral base hue. Light mode keeps white surfaces and uses shadows for elevation.
321
+
322
+ **Rules:**
323
+ - Anything that floats above content must use a lighter level than what it floats over. Floating components (Modal, SlideUp, Dropdown/Select menu, NavigationMenu panel, Toaster/Toast) use `bg-surface-overlay`.
324
+ - Hover fills on overlays use `bg-surface-overlay-hover`, not `bg-base-*`, which would go darker in dark mode.
325
+ - Existing tokens remain as aliases: `background` → base, `card` / `surface-1` → raised, `surface-2` / `card-hover` → elevated, `surface-3` → overlay (dark), `card-elevated` → overlay (light) / elevated (dark).
326
+
327
+ **Enforced by** `scripts/check-surface-elevation.js`, which `validate-theme.js` runs during `npm run build:css`. It fails when:
328
+ - dark levels aren't strictly increasing;
329
+ - any step is outside 5–8 OKLCH L points;
330
+ - overlay hover or tooltip fills are darker than the overlay;
331
+ - a floating component paints a surface class below overlay.
332
+
333
+ To retune the dark levels, edit the four `--color-surface-*` values in the `.dark` block of `src/app.css` and run `node scripts/check-surface-elevation.js`.
334
+
335
+ > The light surface tokens must stay **below** the `/* Background Colors */` marker in `@theme`. `scripts/sync-theme-tokens.js` regenerates everything between the base-scale aliases and that marker.
336
+
337
+ ## 🎛️ Overriding a Component's Classes
338
+
339
+ `class` is the public prop on every component and is merged **last**, so a
340
+ call-site utility wins. That promise needs `cn()` (`src/components/util/cn.ts`,
341
+ tailwind-merge) to be true: `rounded-control` and `rounded-container` are
342
+ equal-specificity utilities, so plain concatenation leaves the winner to
343
+ whichever one Tailwind emits later in the stylesheet, not to the caller.
344
+
345
+ ```svelte
346
+ <Card class="rounded-pill" /> <!-- rounded-container is dropped, not fought -->
347
+ ```
348
+
349
+ Most semantic utilities work with stock tailwind-merge — `bg-card` vs
350
+ `bg-surface-overlay`, `text-headline` vs `text-description` all resolve. The
351
+ role-based radii are the exception and are declared explicitly in `cn.ts`,
352
+ because `control`/`container`/`overlay`/`pill` are role names rather than scale
353
+ values. **Add any future custom scale to that config**, or tailwind-merge keeps
354
+ both classes and stylesheet order silently decides again.
355
+
356
+ Covered by `tests/class-merge.test.ts`.
357
+
358
+ ## 🌑 Shadow Scale
359
+
360
+ Elevation in light mode is **two steps**, not a ramp. A shadow says "this is
361
+ above the page" or "this floats over it" — there is no third meaning, and an
362
+ in-between value just makes two neighbouring surfaces look accidentally
363
+ different.
364
+
365
+ | Utility | Reads as | Use for |
366
+ |---|---|---|
367
+ | `shadow-sm` | Raised | Cards, tables, sidebars, toggle knobs, colour-picker thumbs |
368
+ | `shadow-lg` | Floating | Modals, sheets, dropdown/select menus, toasts, popovers |
369
+
370
+ `shadow-none` is the explicit *absence* of a shadow (outlined and flat cards,
371
+ disabled buttons), not a third step.
372
+
373
+ Rules:
374
+ - A raised element that becomes floating on interaction may go `sm` → `lg` on
375
+ hover. Something already at `lg` does not deepen further — it changes its
376
+ background instead.
377
+ - `shadow-md`, `shadow-xl` and a bare `shadow` are build failures. The bare
378
+ `shadow` utility is the sneaky one: it resolves to a Tailwind default that
379
+ `app.css` never defines, so no token controls it.
380
+ - Shadows carry elevation in **light mode only**. In dark mode the surface
381
+ levels above do the work — see Surface Elevation Levels.
382
+
383
+ Enforced by `scripts/check-token-violations.js`.
384
+
385
+ ## 📏 Spacing Rhythm
386
+
387
+ Layout spacing rides a **4px grid**. Half-steps (`gap-1.5`, `px-2.5`, `py-2.5`)
388
+ set up a second rhythm competing with the first: a 6px gap beside an 8px gap is
389
+ a 2px wobble nobody chose, and it accumulates down a dense form.
390
+
391
+ - `gap-*`, `space-x/y-*` and every padding utility must land on a whole step.
392
+ - **Margins are exempt**, because they carry optical nudges. `mt-0.5` on an
393
+ icon sitting beside a first line of text is an alignment correction, not
394
+ rhythm — Alert, ListItem, ToasterToast and the radio control all rely on it.
395
+ Keep those to 0.5 and keep them on margins so the distinction stays visible.
396
+ - Icon sizes are their own scale (`size-3.5` = 14px, `size-4`, `size-5`), not
397
+ spacing, and are unaffected.
398
+
399
+ Enforced by `scripts/check-token-violations.js`.
400
+
401
+ ## 🔠 Type Scale
402
+
403
+ `Heading` and `Text` sit on **one** ramp rather than each carrying its own, so a
404
+ heading and the copy beneath it share line boxes instead of nearly matching.
405
+
406
+ | Step | `Heading` | `Text` |
407
+ |---|---|---|
408
+ | 36px | `level={1}` | — |
409
+ | 30px | `level={2}` | — |
410
+ | 24px | `level={3}` | — |
411
+ | 20px | `level={4}` | — |
412
+ | 18px | `level={5}` | `size="lg"` |
413
+ | 16px | `level={6}` | `size="md"` (default) |
414
+ | 14px | — | `size="sm"` |
415
+ | 12px | — | `size="xs"` |
416
+
417
+ Headings tighten tracking as they grow and are `semibold`/`bold`; `Text` takes a
418
+ `weight` prop (`normal` · `medium` · `semibold` · `bold`) so body copy can carry
419
+ emphasis without being promoted to a heading. `tone="label"` defaults to
420
+ `medium`; every other tone defaults to `normal`.
421
+
182
422
  ## 📁 File Structure
183
423
 
184
424
  ```
@@ -2,6 +2,7 @@
2
2
  import Badge from "./Badge.svelte";
3
3
  import type { ExtendedSemanticVariant } from "../types/variants.js";
4
4
 
5
+ import { cn } from "../util/cn.js";
5
6
  interface Props {
6
7
  title: string;
7
8
  description: string;
@@ -13,6 +14,8 @@
13
14
  badgeVariant?: ExtendedSemanticVariant;
14
15
  /** Overrides the default accessible name for the clickable region. */
15
16
  ariaLabel?: string;
17
+ class?: string;
18
+ /** @deprecated use `class`. */
16
19
  className?: string;
17
20
  target?: HTMLAnchorElement["target"];
18
21
  rel?: string;
@@ -28,12 +31,17 @@
28
31
  badgeText,
29
32
  badgeVariant = "neutral",
30
33
  ariaLabel,
31
- className = "",
34
+ class: classAttr = "",
35
+ className: legacyClass = "",
32
36
  target,
33
37
  rel,
34
38
  ...restProps
35
39
  }: Props = $props();
36
40
 
41
+ /** `class` is the public prop; `className` is a deprecated alias.
42
+ * Both are merged here so existing call sites keep working. */
43
+ const className = $derived(`${classAttr} ${legacyClass}`.trim());
44
+
37
45
  const isDisabled = $derived(disabled || loading);
38
46
 
39
47
  const computedAriaLabel = $derived(
@@ -41,16 +49,13 @@
41
49
  );
42
50
 
43
51
  const baseClasses =
44
- "group focus-ring focus-ring--muted block rounded-2xl border border-base-200 bg-base-0 p-6 text-left transition-all duration-200 hover:border-headline/30 hover:bg-base-50 active:scale-[0.99] active:bg-base-100";
52
+ "group focus-ring focus-ring--muted block rounded-container border border-border bg-card p-6 text-left transition-colors duration-150 hover:border-border-medium hover:bg-surface-hover active:scale-[0.99] active:bg-surface-active";
45
53
 
46
54
  const disabledClasses =
47
- "opacity-50 cursor-not-allowed pointer-events-none hover:border-base-200 hover:bg-base-0 active:scale-100";
55
+ "opacity-50 cursor-not-allowed pointer-events-none hover:border-border hover:bg-card active:scale-100";
48
56
 
49
57
  const panelClasses = $derived(
50
- [baseClasses, isDisabled ? disabledClasses : "", className]
51
- .filter(Boolean)
52
- .join(" ")
53
- .trim(),
58
+ cn(baseClasses, isDisabled ? disabledClasses : "", className),
54
59
  );
55
60
 
56
61
  function handleKeydown(event: KeyboardEvent) {
@@ -10,6 +10,8 @@ interface Props {
10
10
  badgeVariant?: ExtendedSemanticVariant;
11
11
  /** Overrides the default accessible name for the clickable region. */
12
12
  ariaLabel?: string;
13
+ class?: string;
14
+ /** @deprecated use `class`. */
13
15
  className?: string;
14
16
  target?: HTMLAnchorElement["target"];
15
17
  rel?: string;
@@ -1,82 +1,119 @@
1
1
  <script lang="ts">
2
- import { Check, AlertTriangle, X, Info } from "@lucide/svelte";
2
+ import type { Snippet } from "svelte";
3
+ import type { HTMLAttributes } from "svelte/elements";
4
+ import { Check, TriangleAlert, X, Info, Zap } from "@lucide/svelte";
5
+ import { cn } from "../util/cn.js";
3
6
  import type {
4
7
  ExtendedSemanticVariant,
5
8
  SizeVariant,
6
9
  } from "../types/variants.js";
7
10
 
8
- interface Props {
11
+ /**
12
+ * `subtle` is the default because a page full of saturated fills is noise —
13
+ * a badge is a label, not a button. `solid` is there for the one badge on
14
+ * a screen that has to shout.
15
+ */
16
+ type BadgeEmphasis = "subtle" | "solid";
17
+
18
+ type Props = Omit<HTMLAttributes<HTMLSpanElement>, "class"> & {
9
19
  variant?: ExtendedSemanticVariant;
10
20
  size?: SizeVariant;
11
- text: string;
21
+ emphasis?: BadgeEmphasis;
22
+ /** Label text. Ignored when `children` is provided. */
23
+ text?: string;
12
24
  showIcon?: boolean;
13
- }
25
+ class?: string;
26
+ children?: Snippet;
27
+ };
14
28
 
15
29
  let {
16
30
  variant = "default",
17
31
  size = "md",
32
+ emphasis = "subtle",
18
33
  text = "",
19
34
  showIcon = false,
35
+ class: className = "",
36
+ children,
37
+ ...restProps
20
38
  }: Props = $props();
21
39
 
22
- const classes = $derived(() => {
23
- const baseClasses =
24
- "inline-flex items-center font-medium border rounded-xs";
25
-
26
- const sizeClass =
27
- size === "sm"
28
- ? "px-2 py-0.5 text-xs"
29
- : size === "lg"
30
- ? "px-4 py-2 text-base"
31
- : "px-3 py-1 text-sm";
32
-
33
- const variantClass =
34
- variant === "success"
35
- ? "bg-success border-success text-card"
36
- : variant === "warning"
37
- ? "bg-warning border-warning text-card"
38
- : variant === "error"
39
- ? "bg-error border-error text-card"
40
- : variant === "info"
41
- ? "bg-info border-info text-card"
42
- : variant === "neutral"
43
- ? "bg-neutral border-neutral text-card"
44
- : variant === "energetic"
45
- ? "bg-energetic border-energetic text-card"
46
- : "bg-secondary border-secondary text-card";
47
-
48
- return `${baseClasses} ${sizeClass} ${variantClass}`.trim();
40
+ const sizeClass = $derived.by(() => {
41
+ if (size === "sm") return { box: "h-5 px-2 gap-1 text-xs", icon: 12 };
42
+ if (size === "lg") return { box: "h-7 px-3 gap-2 text-sm", icon: 16 };
43
+ return { box: "h-6 px-2 gap-1 text-xs", icon: 14 };
49
44
  });
50
45
 
51
- const iconSize = $derived(() => {
52
- return size === "sm" ? 12 : size === "lg" ? 20 : 16;
46
+ /**
47
+ * Both emphases are built from the SAME step of each family's ramp, so
48
+ * every variant reads at the same weight. Subtle is 100/200/800 (8.2:1),
49
+ * solid is 600 with its `-text` foreground (4.9:1). Before the ramps were
50
+ * calibrated, a solid warning badge sat at 2.15:1 and energetic at 1.97:1.
51
+ */
52
+ const variantClass = $derived.by(() => {
53
+ const solid = emphasis === "solid";
54
+ switch (variant) {
55
+ case "success":
56
+ return solid
57
+ ? "bg-success text-card border-transparent"
58
+ : "bg-success-subtle text-success-text border-success-border";
59
+ case "warning":
60
+ return solid
61
+ ? "bg-warning text-card border-transparent"
62
+ : "bg-warning-subtle text-warning-text border-warning-border";
63
+ case "error":
64
+ return solid
65
+ ? "bg-error text-card border-transparent"
66
+ : "bg-error-subtle text-error-text border-error-border";
67
+ case "info":
68
+ return solid
69
+ ? "bg-info text-card border-transparent"
70
+ : "bg-info-subtle text-info-text border-info-border";
71
+ case "energetic":
72
+ return solid
73
+ ? "bg-energetic text-card border-transparent"
74
+ : "bg-energetic-subtle text-energetic-text border-energetic-border";
75
+ case "neutral":
76
+ case "default":
77
+ default:
78
+ return solid
79
+ ? "bg-neutral text-card border-transparent"
80
+ : "bg-neutral-subtle text-neutral-text border-neutral-border";
81
+ }
53
82
  });
54
83
 
55
- const iconSpacingClass = $derived(() => {
56
- return size === "sm" ? "mr-1" : size === "lg" ? "mr-2" : "mr-1.5";
84
+ const badgeClasses = $derived(
85
+ cn(`inline-flex items-center justify-center whitespace-nowrap rounded-pill border font-medium ${sizeClass.box} ${variantClass} ${className}`),
86
+ );
87
+
88
+ /** One outline icon family, so the variants don't look like a rummage. */
89
+ const Icon = $derived.by(() => {
90
+ switch (variant) {
91
+ case "success":
92
+ return Check;
93
+ case "warning":
94
+ return TriangleAlert;
95
+ case "error":
96
+ return X;
97
+ case "energetic":
98
+ return Zap;
99
+ default:
100
+ return Info;
101
+ }
57
102
  });
103
+
104
+ const hasLabel = $derived(Boolean(children) || Boolean(text));
58
105
  </script>
59
106
 
60
- <span class={classes()}>
61
- {#if showIcon}
62
- {#if variant === "success"}
63
- <Check size={iconSize()} class="{iconSpacingClass()} text-card" />
64
- {:else if variant === "warning"}
65
- <AlertTriangle
66
- size={iconSize()}
67
- class="{iconSpacingClass()} text-card"
68
- />
69
- {:else if variant === "error"}
70
- <X size={iconSize()} class="{iconSpacingClass()} text-card" />
71
- {:else if variant === "info"}
72
- <Info size={iconSize()} class="{iconSpacingClass()} text-card" />
73
- {:else if variant === "neutral"}
74
- <Info size={iconSize()} class="{iconSpacingClass()} text-card" />
75
- {:else if variant === "energetic"}
76
- <Info size={iconSize()} class="{iconSpacingClass()} text-card" />
107
+ {#if hasLabel}
108
+ <span class={badgeClasses} {...restProps}>
109
+ {#if showIcon}
110
+ {@const BadgeIcon = Icon}
111
+ <BadgeIcon size={sizeClass.icon} class="shrink-0" aria-hidden="true" />
112
+ {/if}
113
+ {#if children}
114
+ {@render children()}
77
115
  {:else}
78
- <Info size={iconSize()} class="{iconSpacingClass()} text-card" />
116
+ {text}
79
117
  {/if}
80
- {/if}
81
- {text}
82
- </span>
118
+ </span>
119
+ {/if}
@@ -1,10 +1,22 @@
1
+ import type { Snippet } from "svelte";
2
+ import type { HTMLAttributes } from "svelte/elements";
1
3
  import type { ExtendedSemanticVariant, SizeVariant } from "../types/variants.js";
2
- interface Props {
4
+ /**
5
+ * `subtle` is the default because a page full of saturated fills is noise —
6
+ * a badge is a label, not a button. `solid` is there for the one badge on
7
+ * a screen that has to shout.
8
+ */
9
+ type BadgeEmphasis = "subtle" | "solid";
10
+ type Props = Omit<HTMLAttributes<HTMLSpanElement>, "class"> & {
3
11
  variant?: ExtendedSemanticVariant;
4
12
  size?: SizeVariant;
5
- text: string;
13
+ emphasis?: BadgeEmphasis;
14
+ /** Label text. Ignored when `children` is provided. */
15
+ text?: string;
6
16
  showIcon?: boolean;
7
- }
17
+ class?: string;
18
+ children?: Snippet;
19
+ };
8
20
  declare const Badge: import("svelte").Component<Props, {}, "">;
9
21
  type Badge = ReturnType<typeof Badge>;
10
22
  export default Badge;