@spaethtech/svelte-ui 0.18.1-dev.93.9244176 → 0.19.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/CLAUDE.md CHANGED
@@ -41,12 +41,25 @@ the shared `variant` (color/style) and `size` (sm/md/lg) axes.
41
41
  - `docs/themes.md` - CSS variables and theming guide
42
42
  - `docs/paradigm.md` - Development paradigm and workflow
43
43
 
44
- **Consumer-facing surfaces that ship in the npm package** (via the `files` whitelist — `dist` + these):
45
- `CLAUDE.md`, `docs/`, and two skills — **`.claude/skills/svelte-ui`** (how to USE the library) and
46
- **`.claude/skills/themes`** (theming). Consumers symlink those two skills into their own
47
- `.claude/skills/` so their Claude Code agents can invoke them. Keep both accurate when the component
48
- set / axes / token model change. The other skills (`demo`, `playwright-cli`) are internal-only and are
49
- NOT shipped.
44
+ **Consumer-facing surfaces.** The npm package ships (via the `files` whitelist) `dist`, `CLAUDE.md`
45
+ and `docs/` only. The two **consumer skills** are no longer bundled in the tarball — they are
46
+ **published to the private `spaethtech/claude-plugins` marketplace** as the **`svelte-ui`** plugin, so
47
+ a consumer installs them with `claude plugin install svelte-ui@spaethtech-plugins` (then restarts the
48
+ session) instead of symlinking. They arrive namespaced: **`svelte-ui:usage`** (how to USE the library —
49
+ source: `.claude/skills/svelte-ui/`) and **`svelte-ui:themes`** (theming — source:
50
+ `.claude/skills/themes/`). Keep both accurate when the component set / axes / token model change, then
51
+ **republish** (see "Publishing skills" below). The other skills (`demo`, `playwright-cli`) are
52
+ internal-only and are NOT published.
53
+
54
+ **Publishing skills (after editing `.claude/skills/svelte-ui` or `.claude/skills/themes`).** This
55
+ repo is a marketplace publishing project — its `.claude-plugin/plugin.json` (the card) +
56
+ `.claude-plugin/skills.json` (maps `svelte-ui`→`usage`, `themes`→`themes`) drive
57
+ `~/claude-plugins/bin/skills.sh`. A skill edit only reaches consumers after a **version bump +
58
+ republish**: `~/claude-plugins/bin/skills.sh bump [patch|minor|major] --project .`, commit + push this
59
+ repo, then `~/claude-plugins/bin/skills.sh publish --project .` (dry-run first). Consumers update by
60
+ version, so a republish without a bump is refused. `minor` for a real capability change, `major` only
61
+ if a published skill is renamed/removed (breaks invoke-by-name). See
62
+ `plugins/marketplace/skills/publishing/SKILL.md` in the marketplace for the full contract.
50
63
 
51
64
  ### Tech Stack
52
65
 
package/docs/themes.md CHANGED
@@ -172,7 +172,7 @@ tint tokens are re-defined per theme scope; see the themes skill.
172
172
  This page documents the token _surface_. For the authoring side — swapping palette/sizing, tinting
173
173
  by variant (`--ui-accent` + `.ui-accent`), scoping/inverse surfaces, the custom-property freeze
174
174
  rule, the self-reference trap, and bridging to another design system (DaisyUI, shadcn, …) — see the
175
- themes skill: [`.claude/skills/themes/SKILL.md`](../.claude/skills/themes/SKILL.md).
175
+ **`svelte-ui:themes`** skill (install with `claude plugin install svelte-ui@spaethtech-plugins`).
176
176
 
177
177
  > **Any scope that overrides `--ui-color-*` must also restate `--ui-color-neutral`** (and re-derive
178
178
  > `--ui-border-color`). Neutral borders/surfaces derive from `--ui-color-neutral`, which — by the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaethtech/svelte-ui",
3
- "version": "0.18.1-dev.93.9244176",
3
+ "version": "0.19.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/spaethtech/svelte-ui.git"
@@ -35,9 +35,7 @@
35
35
  "!dist/**/*.test.*",
36
36
  "!dist/**/*.spec.*",
37
37
  "CLAUDE.md",
38
- "docs",
39
- ".claude/skills/themes",
40
- ".claude/skills/svelte-ui"
38
+ "docs"
41
39
  ],
42
40
  "sideEffects": [
43
41
  "**/*.css"
@@ -1,309 +0,0 @@
1
- ---
2
- name: svelte-ui
3
- description: How to build UIs with the @spaethtech/svelte-ui component library in a CONSUMER project — one-time setup (theme.css + Tailwind @source + unplugin-icons), the import + MDI-icon-snippet patterns, the shared variant/size axes, the composition standard (use library components, never raw <button>/<select>/<input>), and the component catalog. Use whenever adding or editing UI that uses @spaethtech/svelte-ui components. For palette/token/theming/inverse-surface work, use the companion `themes` skill instead.
4
- ---
5
-
6
- # Using @spaethtech/svelte-ui
7
-
8
- A **Svelte 5 (runes)** component library styled with **Tailwind CSS v4** and **MDI icons**. Every
9
- component is theme-aware via CSS variables and most consume the shared `variant` + `size` axes.
10
- Import from the package name — the **`@spaethtech/svelte-ui`** barrel, or a **per-component subpath**
11
- (`@spaethtech/svelte-ui/button`) — never a raw `dist/` path.
12
-
13
- > Depth lives in the package's shipped docs (`node_modules/@spaethtech/svelte-ui/docs/*.md`) and this
14
- > repo's `CLAUDE.md`. Theming (tokens, dark mode, inverse surfaces, brand bridging) → the **`themes`**
15
- > skill. This skill is the usage mental-model + patterns; read the docs for full per-component APIs.
16
-
17
- ## One-time setup (per consumer project)
18
-
19
- Peer deps (not bundled): `svelte@^5`, `tailwindcss@^4`, `unplugin-icons@^23`, `@iconify-json/mdi@^1`.
20
-
21
- **1. Vite — add the icon resolver** (`vite.config.ts`), because the package ships bare `~icons/mdi/*`
22
- imports that resolve at the consumer's build time:
23
-
24
- ```ts
25
- import Icons from "unplugin-icons/vite";
26
- export default defineConfig({
27
- plugins: [/* sveltekit(), tailwindcss(), */ Icons({ compiler: "svelte" })],
28
- });
29
- ```
30
-
31
- **2. CSS** (`app.css`, Tailwind v4) — import the theme and scan the dist so Tailwind emits the classes
32
- the components use:
33
-
34
- ```css
35
- @import "tailwindcss";
36
- @import "@spaethtech/svelte-ui/theme.css";
37
- @source "../node_modules/@spaethtech/svelte-ui/dist"; /* path relative to this file */
38
- ```
39
-
40
- > **Missing borders/styles after `npm update`?** Two causes: (1) the `@source` line above is absent or
41
- > its path is wrong, so Tailwind never emits the components' (arbitrary) classes — fix the glob. (2) A
42
- > version bumped a class string (e.g. 0.17 rewrote the neutral border classes), and Tailwind's build
43
- > **cached** the old scan — restart the dev server / clear the Vite+Tailwind cache and rebuild. If a
44
- > border resolves to the wrong colour (not missing), it's a **theme** issue — see the `themes` skill's
45
- > "restate `--ui-color-neutral` per scope" note.
46
-
47
- ## Import & basic use
48
-
49
- ```svelte
50
- <script>
51
- import { Button, Input, Select } from "@spaethtech/svelte-ui";
52
- let name = $state("");
53
- </script>
54
-
55
- <Input bind:value={name} placeholder="Name" />
56
- <Button text="Save" variant="primary" onclick={() => save(name)} />
57
- ```
58
-
59
- ### Barrel vs. per-component subpaths (dev cold-start / memory)
60
-
61
- Two import styles, both resolving to the same source (identical output, identical HMR-into-lib):
62
-
63
- - **Barrel** — `import { Button, Input } from "@spaethtech/svelte-ui"`. Convenient, but importing
64
- *anything* from the barrel pulls *all* 71 component `.svelte` files into the dev compile graph (Vite
65
- doesn't tree-shake a barrel in `dev`), so the dev server compiles the whole library on cold start.
66
- Prod builds tree-shake normally, so this is a dev-time cost only.
67
- - **Per-component subpath** — `import Button from "@spaethtech/svelte-ui/button"`. Kebab-case of the
68
- component name (`DataTable` → `/data-table`, `SideBarMenu` → `/side-bar-menu`, `DatePicker` →
69
- `/date-picker`; multi-export folders keep their group, e.g. `Avatar`/`AvatarGroup` at `/avatar`).
70
- Only the components you actually import get compiled. **On a memory-constrained dev host, prefer
71
- subpaths exclusively** — it's the single biggest lever on svelte-ui's cold-start footprint.
72
-
73
- Non-component runtime **values** have their own component-free entries so they don't drag the barrel
74
- in either: **`@spaethtech/svelte-ui/utils`** (`toast`, `anchored`/`tooltip`, `setUiI18n`/`uiI18n`,
75
- `responsiveClasses`, …) and **`@spaethtech/svelte-ui/data`** (`createGrid`, query AST). **Types** are
76
- free from anywhere — `import type { Variant } from "@spaethtech/svelte-ui"` is erased at compile time
77
- and pulls no components, so type-only imports may stay on the barrel.
78
-
79
- ```svelte
80
- <script>
81
- import Button from "@spaethtech/svelte-ui/button";
82
- import DataTable from "@spaethtech/svelte-ui/data-table";
83
- import { toast } from "@spaethtech/svelte-ui/utils";
84
- import { createGrid } from "@spaethtech/svelte-ui/data";
85
- import type { Variant } from "@spaethtech/svelte-ui"; // type-only ⇒ free, no compile
86
- </script>
87
- ```
88
-
89
- ## Localization (i18n) — opt-in
90
-
91
- Components render their own microcopy (aria-labels, titles, "per page", "No results", month names…)
92
- through a message registry with **English defaults**. Do nothing → identical English output. To
93
- localize, call **`setUiI18n`** ONCE near the app root (a layout); partial `messages` merge over the
94
- defaults, so translate a subset and the rest stays English. `locale` also feeds the date components'
95
- `Intl.*` formatting (falls back to `navigator.language` when unset; a per-instance `locale` prop still
96
- overrides).
97
-
98
- ```svelte
99
- <script>
100
- import { setUiI18n } from "@spaethtech/svelte-ui";
101
- import { getLocale } from "$lib/paraglide/runtime";
102
- import { m } from "$lib/paraglide/messages";
103
- setUiI18n({
104
- locale: () => getLocale(), // getter ⇒ reactive; or a plain string
105
- messages: () => ({
106
- dismiss: m.ui_dismiss(),
107
- selectAll: m.ui_select_all(),
108
- rowRange: ({ start, end, total }) => m.ui_row_range({ start, end, total }), // "1–10 of 247"
109
- removeFile: ({ name }) => m.ui_remove_file({ name }),
110
- // …any subset of `UiMessages`
111
- }),
112
- });
113
- </script>
114
- ```
115
-
116
- Parameterized entries are functions (`rowRange`, `removeFile`) — 1:1 with Paraglide's `m.key(params)`.
117
- Values may be plain or getters (getters make components re-render on locale change; plain is fine for
118
- per-navigation locale). Full key list: the `UiMessages` type; `defaultUiMessages` is the English source.
119
- Strings already exposed as PROPS (`Select placeholder`, `EmptyState title`, `CommandPalette empty`…) you
120
- localize directly on the component. **Authoring rule (this repo):** every new user-facing string a
121
- component renders must go through `uiI18n().messages` + `defaultUiMessages`, never a bare literal.
122
-
123
- ## The two shared axes (mirror each other)
124
-
125
- - **`variant`** — color/style: `primary` `secondary` `danger` `info` `success` `warning` `ghost`
126
- `plain` (`neutral` on some components). `plain` = borderless/transparent, for dense icon controls.
127
- - **`size`** — `sm` (24px) · `md` (32px, **default**) · `lg` (40px). Consistent height across every
128
- sizeable component, so a `Button`, `Input`, and `Select` at the same `size` line up.
129
-
130
- Both accept a responsive map too, e.g. `size={{ base: "sm", md: "lg" }}`.
131
-
132
- ## Field chrome (label / help / error)
133
-
134
- Every field-shaped control — `Input`, `Select`, `TextArea`, `Checkbox`, `Toggle`, and the specialized
135
- inputs — renders its own label + help/error frame from a shared set of props. **Don't hand-wrap fields
136
- in `<label>` + hint spans** — use these:
137
-
138
- - **`label`** (string) — rendered **above** the control, left-aligned. Uniform for *every* control
139
- (checkbox/toggle labels sit above too), so a row of mixed controls at the same `size` lines up
140
- pixel-perfect. For any other placement, omit `label` and bring your own `<label>`.
141
- - **`aside`** (Snippet) — right-aligned in the label row: a char count, status badge, link, button.
142
- - **`error`** (string | Snippet) — inline error row; also flips the error border + `aria-invalid`.
143
- String is rendered as `{@html}`.
144
- - **`description`** (string | Snippet) — muted help row below the error (they coexist). String is
145
- `{@html}` — **author-controlled markup only**, never untrusted input (use the Snippet form for that).
146
- - **`required`** — appends `*` to the label + sets `aria-required`.
147
- - **`id`** — auto-generated if omitted; wires `<label for>` + `aria-describedby`.
148
-
149
- `label`/aria wiring is automatic. Omit all of them and the field renders bare, exactly as a plain
150
- control.
151
-
152
- ```svelte
153
- <Input
154
- label="Slug"
155
- description="Lowercase, hyphens only."
156
- error={slugTaken ? "That slug is already taken." : null}
157
- bind:value={slug}
158
- />
159
-
160
- <!-- Mixed controls in a grid all align on the same label row + control baseline: -->
161
- <div class="grid grid-cols-4 gap-4 items-start">
162
- <Input label="Name" bind:value={name} />
163
- <Select label="Role" {options} bind:value={role} />
164
- <Toggle label="Notifications" bind:checked={notify} />
165
- <Checkbox label="Agree" bind:checked={agree} required />
166
- </div>
167
- ```
168
-
169
- **Validation vs. error:** the built-in `validate={(v) => true | "message"}` (on Input/TextArea)
170
- surfaces through the **native validity tooltip** (shown on form submit); the controlled **`error`**
171
- prop is the inline row. Both flip the field's error border.
172
-
173
- **Groups:** for a set of radios/checkboxes/toggles under one legend, wrap them in **`FieldGroup`**
174
- (same `label`/`description`/`error`/`required` vocab on a `<fieldset>`, plus `columns` — a number or
175
- responsive map; default 1 = stacked, higher lays items across equal auto-placed columns — and `gap`,
176
- a number or `{ x, y }`). For non-field content, **`Grid`** is the same auto-placement as a plain
177
- primitive (`columns` + `gap`, any children). Inside a
178
- group, item labels render **inline** (right of the control). For a radio group, `bind:value` on the
179
- `FieldGroup` (+ optional `name`) and the child `<Radio>`s share it automatically — no `bind:group`:
180
-
181
- ```svelte
182
- <FieldGroup label="Delivery" bind:value={method} name="method">
183
- <Radio value="email" label="Email" />
184
- <Radio value="sms" label="SMS" />
185
- </FieldGroup>
186
- ```
187
-
188
- ## Icons — no `<Icon>` component
189
-
190
- Import an MDI icon component from `~icons/mdi/*` and render it into a component's **`icon` snippet**;
191
- the library sizes and colors it to match `size`/`variant`:
192
-
193
- ```svelte
194
- <script>
195
- import { Button } from "@spaethtech/svelte-ui";
196
- import IconHome from "~icons/mdi/home";
197
- </script>
198
-
199
- <Button title="Home" variant="plain">{#snippet icon()}<IconHome />{/snippet}</Button>
200
- ```
201
-
202
- ## Composition standard (important)
203
-
204
- Build UI out of these components — **never** raw `<button>` / `<select>` / `<input>` / `<textarea>`
205
- for things the library covers. A raw interactive element where a library component fits is a bug: you
206
- lose the theme, the axes, focus states, and a11y. Compound pieces are already composed this way
207
- (e.g. DataTable's controls are `<Button variant="plain">` + `<Select size="sm">`).
208
-
209
- ## Toasts
210
-
211
- Mount `<Toaster />` once at the app root, then push from anywhere with `toast.success(...)` /
212
- `toast.error(...)` / etc. — no prop plumbing:
213
-
214
- ```svelte
215
- <script>
216
- import { Toaster, toast } from "@spaethtech/svelte-ui";
217
- </script>
218
- <Toaster />
219
- <Button text="Go" onclick={() => toast.success("Saved")} />
220
- ```
221
-
222
- ## Component catalog
223
-
224
- All from `@spaethtech/svelte-ui` (see the shipped `docs/components.md` + `docs/usage.md` for props +
225
- examples):
226
-
227
- - **Form:** `Button` `ButtonGroup` `Input` `Select` `Combobox` `Slider` `List` `TextArea` `Checkbox`
228
- `Toggle` `Radio` `Rating` (display `average`/`count`, OR `interactive` + `bind:value` star picker
229
- with `allowHalf`, click/hover/arrow-keys) · **`Combobox`** (free-text autocomplete — arbitrary value + suggestions,
230
- client `options` or async `suggest`; vs `Select` which is a closed choice. Composes Input + Popup.) ·
231
- **`TokenInput`** (multi-value entry — Enter/comma adds a removable `Chip` token, Backspace-on-empty
232
- removes; `bind:values`, `max`, `allowDuplicates`; FieldChrome.) · **`FileUpload`** (drag-drop + click
233
- dropzone; `bind:files`, `accept`/`multiple`/`maxSize`, image thumbnails, `progress` map → `Progress`
234
- bar; FieldChrome.) · **`Slider`** (draggable number/range; `range` dual-thumb, `min`/`max`/`step`,
235
- `showValue`/`format`; FieldChrome + `variant`/`size`; keyboard + `role="slider"`) · **`FieldGroup`**
236
- (fieldset wrapper for radio/checkbox/toggle sets) · **`ButtonGroup`**
237
- (joined row/`col` of `<Button>`s as one unit — an action toolbar or a **controlled** segmented
238
- selector; `items` of `ButtonGroupItem` {`value`,`text`,`icon`,`variant`,`disabled`,`loading`,`href`/
239
- `target`,`menu`,`menuOnly`,`onclick`} or a presentational default slot; `select`
240
- `'none'|'single'|'multi'` with `bind:value`/`bind:values`; selected = variant-swap to filled,
241
- unselected = `ghost`; the group owns the frame. An item `menu` (`MenuItem[]`) makes a **split
242
- button** (action + caret); add `menuOnly` for a caret-only **menu-trigger** cell (e.g. an
243
- `[action][▾]` pair = a plain item + a `menuOnly` item). This replaces the old `ButtonDropdown`. Not
244
- a form field — for legend/`name`/submit use `FieldGroup`; for view switching use `TabStrip`.)
245
- - **Specialized inputs:** `PasswordInput` `EmailInput` `SearchInput` `NumberInput` (formatting +
246
- `percent`/`stepper`/`clamp`/`liveFormat`) `PhoneInput` (stores E.164; dep-free, inject
247
- `parse`/`format` for per-country)
248
- - **Date / time:** `DatePicker` `Calendar` `TimePicker` `TimeSpinner` `TimeRangeInput` `DateTimeInput`.
249
- For a start–end range use **`DatePicker range`** (`bind:value` = `{ start, end }`) — it composes
250
- `Calendar mode="range"`.
251
- - **Data:** `DataTable` `Query` — driven by the headless layer at **`@spaethtech/svelte-ui/data`**
252
- (query-language parser/AST via `parseQuery`, `createGrid`, `DataGrid<T>`, `DataSet`). For a
253
- **client-held array**, use **`localFetch(rows, columns, opts?)`** as the grid's `fetch` — it parses
254
- the query, filters via **`matchesRow(row, ast)`** (the in-memory AST evaluator: cmp/and/or/not/
255
- match/in/range/search, honouring column `id`/`searchable`/`sortKey`), then sorts + paginates. Only a
256
- remote/DB backend needs to compile the AST itself; do NOT hand-roll a "strip `$` + substring" filter.
257
- `createGrid` also takes **`selectableRow: (row) => boolean`** — rows returning `false` are locked out
258
- of selection (disabled checkbox, excluded from select-all, never in `grid.selected`), so
259
- `bulkActions(selected)` never sees them; don't filter locked ids in the handler.
260
- · **`Pagination`** (standalone
261
- pager styled like the DataTable footer: tinted band, per-page `Select`, range total, and a
262
- `First·Prev·[page]/N·Next·Last` stepper whose indicator is an editable `NumberInput` quick-jump;
263
- `bind:page`/`bind:perPage`, `total`, `perPageOptions`, `showEdges`/`showTotal`, `borderless`;
264
- `variant` tints band+hover only; built from `Button`+`Select`+`NumberInput`).
265
- - **Overlays:** `Dialog` (pure modal shell — BYO content; `width`/`height` take any CSS value;
266
- `onShow(ctx)`/`onHide(ctx)` control focus — set `ctx.autoFocus` to a selector/element/`false`,
267
- `ctx.restoreFocus` likewise) · `ConfirmDialog` (title + message + Confirm/Cancel, built on Dialog)
268
- · **`Drawer`** (edge-anchored sliding panel / "sheet"; `side` left/right/top/bottom, `size` CSS
269
- length, `bind:open`, `dismissible`/`backdrop`; same modal mechanics as Dialog — a pure shell, put a
270
- Card inside) · `Popup` `Menu` · **`ContextMenu`** (right-click menu at the pointer; `items` MenuItem[]; on `Menu`) · **`CommandPalette`** (⌘K/Ctrl+K fuzzy command launcher; `bind:open`, `commands` Command[] {`label`,`keywords`,`group`,`icon`,`shortcut`,`disabled`,`onrun`}, global `hotkey` default `"mod+k"`; ↑/↓/Enter/Esc; on `Input`+`Kbd`) · `tooltip` (a Svelte `use:` action) · `anchored` (positioning engine)
271
- - **Navigation:** **`Tree`** (hierarchical expandable tree — `items` of `TreeNode`; `bind:expanded`/`selected`; roving-tabindex keyboard; ARIA `tree`) · **`Breadcrumbs`** (trail of `BreadcrumbItem` {`label`,`href`,`onclick`,`icon`}; last =
272
- current page; `maxItems` collapses the middle to a `⋯` Menu; `separator` slot) · `TabStrip` (tabs;
273
- `size`/`variant`, `gap`/`pad` bordered spacer cells, `height` for a
274
- fixed-height bar with bottom-aligned tabs, `activeSurface` to flow the active tab into the content
275
- below) · **`SideBarMenu`** (data-driven vertical nav: `items`/`bottomItems` of `SideBarMenuItem`
276
- {`icon`,`label`,`link`,`badge`,`children`,`onclick`,`popout`,`reload`,`trailing` snippet,`activeMatch`};
277
- responsive `modes` map + `iconHoverExpand`; `variant` tints only hover/active; `size`; `side`;
278
- `interactMode` `'hover'|'click'` popouts; `currentPath` active-match (per-item `activeMatch` for
279
- query-keyed/exact rules); `bind:open`/`bind:api`/`bind:activeMode`; `publishWidthVar` → CSS width var;
280
- popouts expose `data-sbm-popout`/`data-open`)
281
- - **Feedback:** `Alert` `Banner` `Badge` `Toaster` + `toast` · **`Kbd`** (keyboard keycap; `text`/`keys` combo) · **`Spinner`** (indeterminate loading
282
- ring; `variant`/`size`, omit `variant` to inherit `currentColor`. `Button` has a `loading` prop that
283
- renders one in place of its icon and blocks interaction.) · **`Progress`** (horizontal bar;
284
- determinate `value`/`max` or `indeterminate` sweep; `variant`/`size` = 4/8/12px; `label`/`showValue`.)
285
- · **`Skeleton`** (neutral loading placeholder; `shape` text/rect/circle, `lines`, `width`/`height`,
286
- `animate` shimmer. No `variant` — it's absent-content, not a themed surface. Compose to sketch a card.)
287
- - **Layout:** `Card` `CardHeader` `CardBody` `CardFooter` · **`Grid`** (auto-placed equal-cell grid;
288
- `columns` + `gap`) · **`ScrollArea`** (themed thin-scrollbar container; `orientation`, `maxHeight`, `variant` thumb) · **`Splitter`** (two resizable panes + draggable divider; `bind:size` %, `orientation`, keyboard) · **`Carousel`** (sliding viewport; `items` + `slide` snippet, `bind:index`, `autoplay`, controls + dots) · **`Stat`** (metric/KPI card — `label`/`value`/`delta` up-down + `icon`; on `Card`) · **`Timeline`** (vertical event list — `items` of `TimelineItem` {`title`,`time`,`description`,`icon`,`variant`}) · **`Divider`** (thin `role="separator"` rule; `orientation` h/v, optional inline
289
- `label`+`labelAlign`; `--ui-border-color`)
290
- - **Flow:** **`Stepper`** (linear multi-step indicator — `steps` of `StepItem` {`label`,`description`,
291
- `icon`}, `bind:current`, `orientation` h/v, `onstep` clickable; done/current/upcoming markers +
292
- connectors. Panels + Next/Back are the consumer's.)
293
- - **Disclosure:** **`Disclosure`** (one expand/collapse section; `title`/`header`, `bind:open`, panel
294
- stays mounted so state is preserved) · **`Accordion`** (coordinated group — `multiple` single/multi,
295
- `bind:value`; children are `<Disclosure value="…">`, no per-item `bind:open`)
296
- - **Display / theme:** **`EmptyState`** (zero-data placeholder — icon + title + description + `action`) · **`Chip`** (interactive pill — `selected` toggle fill, `removable` ×/`onremove`,
297
- `onclick`/`href`, `icon`; the interactive counterpart to display-only `Badge`) · **`Avatar`** (image →
298
- `name` initials → `icon` → glyph fallback; `size`, `variant` fill, `shape`, `status` dot) +
299
- **`AvatarGroup`** (overlapped stack; `items`+`max` → `+N`) ·
300
- `NotesEditor` · `ThemeSelector` (Auto/Light/Dark Select) · `ThemeToggle`
301
- (compact ☀/☾ icon button, same persisted state)
302
- - **Types / utils:** `Variant` `Size` `TabDefinition` `MenuItem` `ButtonGroupItem` `ButtonGroupSelect`
303
- (types), `responsiveClasses` / `resolveScalar`, `BREAKPOINT_PX`.
304
-
305
- ## Adopting updates
306
-
307
- The lib is versioned on npm. Pin a caret range (`^x.y.z`) and `npm update` for fixes; a `dev` dist-tag
308
- tracks the newest `main` build (`npm install @spaethtech/svelte-ui@dev`) for testing unreleased work.
309
- Pre-1.0, a **minor** bump can be breaking — check the package's `CHANGELOG.md` before bumping the range.
@@ -1,357 +0,0 @@
1
- ---
2
- name: themes
3
- description: How svelte-ui theming works and how to build/consume a theme — the --ui-* token surface, the --ui-accent interactive-tint system + variants, light/dark application, scoped "inverse" surfaces, the custom-property freeze rule, and bridging to another design system. Use when swapping palette/sizing, tinting a surface by variant, putting components on an inverse (dark-on-light / light-on-dark) surface, or bridging svelte-ui to DaisyUI/shadcn/brand tokens.
4
- ---
5
-
6
- # Theming svelte-ui
7
-
8
- svelte-ui themes via **plain CSS custom properties** — no Sass, no JS theme object, no
9
- compile step. Every component reads a small set of **`--ui-*`** tokens; redefine those and
10
- every component follows. Two built-in themes, **light** (default) and **dark**, follow the
11
- system preference and can be forced with the `.theme-light` / `.theme-dark` classes.
12
-
13
- Authoritative source: [`src/lib/theme.css`](../../src/lib/theme.css). Everything below reflects
14
- that file — if they ever disagree, the file wins and this skill is stale.
15
-
16
- ## The token surface — everything is `--ui-*`
17
-
18
- There are **no unprefixed tokens** and **no component-scoped tokens**. Components consume the
19
- shared vars directly. (The old `--color-*` palette, `--ui-padding*`/`--ui-border` density, and
20
- per-component `--dt-*`/`--card-*` namespaces have all been removed — don't reintroduce them.)
21
-
22
- ### Palette — `--ui-color-*`
23
-
24
- ```
25
- --ui-color-background --ui-color-text --ui-color-neutral
26
- --ui-color-primary --ui-color-secondary
27
- --ui-color-success --ui-color-warning
28
- --ui-color-error --ui-color-info
29
- ```
30
-
31
- These are the only palette colours a component references. The `variant` axis
32
- (`types/variants.ts` → `variantToken`) maps each `Variant` to one of these (`danger`→`error`; the two
33
- non-accent variants `neutral` and `ghost` → **`--ui-color-neutral`**). Canonical order:
34
- `neutral · primary · secondary · success · info · warning · danger · ghost`.
35
-
36
- **`neutral` is the default/undefined variant** — every sizeable/themeable component defaults to
37
- `variant="neutral"`, so out of the box the whole library is **monochrome**; a colour appears only when
38
- you set an explicit `variant`. **`--ui-color-neutral`** is the themeable neutral base: it **defaults to
39
- `var(--ui-color-text)`** (so it flips opposite `--ui-color-background` per theme, and a border mixed
40
- toward `transparent` always reads against the background), but a theme can override it **independently
41
- of body text** to recolour every neutral border/surface at once. Every neutral border/divider — and
42
- `--ui-border-color` — derives from it, so it's the single knob for "the colour of all neutral borders".
43
-
44
- - `ghost` — borderless, transparent, hover-tint only (the minimal control). For the box-shaped
45
- **fields** (Input/Select/TextArea) this also drives the **field background**: `ghost` → transparent,
46
- every other variant → `--ui-color-background` (so a field reads as a field on any surface).
47
- - `neutral` — the _uncoloured_, **default** variant (mixes from `--ui-color-neutral`, which defaults to
48
- `--ui-color-text`): **solid** in solid contexts (Button/Toggle/Banner → black-on-white /
49
- white-on-black), a **tint** in soft/bordered contexts (Badge/Alert/Input/Card frame).
50
- - (`plain` was removed — it's now `ghost`. Old bordered-`ghost` → `neutral`.)
51
-
52
- Field **help/error text** uses no new token: the muted `description` row is an inline
53
- `color-mix(in srgb, var(--ui-color-text) ~65%, transparent)`, and the `error` row uses
54
- `--ui-color-error`.
55
-
56
- ### Interactive-surface tints — `--ui-accent` + hover/surface/active
57
-
58
- One tint language for every hover / section / pressed surface, three steps of the SAME mix:
59
-
60
- ```
61
- --ui-color-hover 4% row / option / ghost-button hover (lightest)
62
- --ui-color-surface 8% header / footer / section bands (a notch darker)
63
- --ui-color-active 12% pressed / held (strongest)
64
- ```
65
-
66
- Each is `color-mix(in srgb, var(--ui-accent) N%, transparent)`. The percentages are
67
- **theme-independent** (identical in light and dark); only the accent colour flips. `--ui-accent`
68
- **defaults to `--ui-color-text`**, so out of the box these are neutral grey tints. Point
69
- `--ui-accent` at a semantic colour and all three follow — that is how a **variant tints a whole
70
- surface** (see the accent system below).
71
-
72
- ### Tinted-surface scale — `--ui-tint-*`
73
-
74
- A variant-_tinted_ surface (a Badge chip, a Card frame, a `filled` Alert) mixes the component's OWN
75
- variant token at shared strengths, so every tinted surface is calculated identically:
76
-
77
- ```
78
- --ui-tint-soft 12% soft resting fill (Badge fill, Alert `filled`)
79
- --ui-tint-divider 30% soft internal divider (CardHeader/CardFooter separators; the default
80
- NEUTRAL divider is `--ui-border-color`)
81
- --ui-tint-border 50% bordered surface, REST (Badge/Alert/Banner ring · Card/DataTable/Input/
82
- TextArea frame · Button outline)
83
- --ui-tint-border-hover 80% bordered field, HOVER (Input/TextArea/Card/DataTable/Button — the
84
- stronger step on hover/focus)
85
- --ui-tint-strong 80% foreground text (Badge)
86
- ```
87
-
88
- e.g. `color-mix(in srgb, var(<variant-token>) var(--ui-tint-soft), transparent)`. A `primary` badge is
89
- primary@these; a `neutral` (default) one is **`--ui-color-neutral`**@these. Theme-independent (a theme
90
- MAY override them to tune the whole tint language). **Every bordered surface uses ONE calc** —
91
- `--ui-tint-border` at rest, `--ui-tint-border-hover` on hover/focus, from the variant token — with **no
92
- `secondary` special case**. A default (neutral) frame is therefore `--ui-color-neutral` @ these; a
93
- default divider is `--ui-color-neutral` @ `--ui-tint-divider` (= `--ui-border-color`). Internal
94
- **dividers** use the softer `--ui-tint-divider` (30%) tier, likewise one calc per variant — so a
95
- divider always reads lighter than the frame at any variant. `--ui-border-color-strong`/`-strong-hover`
96
- have been **REMOVED** (migrate to `color-mix(in srgb, var(--ui-color-neutral) var(--ui-tint-border),
97
- transparent)`, hover → `--ui-tint-border-hover`). (Distinct from the interactive tints above, which mix
98
- from `--ui-accent`.)
99
-
100
- ### The `borderless` prop (border-colour swap, never a border-width change)
101
-
102
- Every surface/framed component (`Button`, `Badge`, `Banner`, `Alert`, `Card`, `Input`, `DataTable`)
103
- takes a **`borderless`** boolean. The border is **always 1px** — only its _colour_ moves, so toggling
104
- it can't shift the box by a pixel:
105
-
106
- - `borderless={true}` → border-colour = the component's **own background** (ring hidden, space kept).
107
- - `borderless={false}` → border-colour = the **standard tint-border** (`--ui-tint-border` at rest,
108
- `--ui-tint-border-hover` on hover/focus for the interactive fields) — the same calc for every surface.
109
-
110
- Defaults differ by component (fill-y things default `true`: Button/Badge/Banner; framed things default
111
- `false`: Alert/Card/Input/DataTable). One consequence worth knowing: a **`ghost` Button with
112
- `borderless={false}`** is the **outline button** (transparent fill + a `--ui-color-text` tint edge) —
113
- there is deliberately no separate `outline`/`ghost-bordered` variant. Toggle/Checkbox intentionally do
114
- NOT take `borderless`.
115
-
116
- ### Border / radius — `--ui-border-*`
117
-
118
- ```
119
- --ui-border-radius corner radius — ONE fixed value for every rectangular surface; NOT
120
- scaled by `size`, no -sm/-lg (pills use rounded-full)
121
- --ui-border-color soft internal divider (row separators, section splits) — NEUTRAL:
122
- `--ui-color-neutral` @ `--ui-tint-divider` (30%). Repeated per theme
123
- scope so it re-resolves per theme + survives @theme tree-shaking.
124
- (--ui-border-color-strong/-strong-hover were REMOVED — use the
125
- `--ui-tint-border` / `--ui-tint-border-hover` scheme instead)
126
- --ui-color-neutral the neutral/undefined-variant base colour; defaults to
127
- `var(--ui-color-text)`. Override to recolour ALL neutral borders +
128
- surfaces at once, independent of body text + the variant colours.
129
- ```
130
-
131
- **One hue, two weights.** All *neutral* borders share the `--ui-color-neutral` hue; only the tint
132
- weight differs by role — **divider** `--ui-tint-divider` (30%, `--ui-border-color`) vs **frame**
133
- `--ui-tint-border` (50%). A `variant`-set border tints from that variant's token instead (a `secondary`
134
- Card = cyan border) — that's intended; the neutral default is what's monochrome.
135
-
136
- ### Density / text / icon — the `size` axis
137
-
138
- ```
139
- --ui-height-sm / --ui-height / --ui-height-lg control square heights (sm/md/lg)
140
- --ui-height-touch explicit 44px touch target (not a size tier)
141
- --ui-text-sm / --ui-text / --ui-text-lg per-size text
142
- --ui-icon-sm / --ui-icon / --ui-icon-lg per-size inline icons
143
- ```
144
-
145
- The `sm/md/lg` triplets are tied to the `size` prop on every sized component — override one
146
- tier and every component on that tier shifts. (Per-component _padding/gap_ is NOT a token; it's
147
- a `Record<Size,string>` Tailwind class map inside each component.)
148
-
149
- ### Breakpoints (in `@theme`)
150
-
151
- Narrow-mobile tiers `4xs` 320 / `3xs` 360 / `2xs` 420 / `xs` 480 and ultrawide `3xl` 1920, on
152
- top of Tailwind's defaults — so `DataTable` column placements can target the full range.
153
-
154
- ## The accent / variant system
155
-
156
- `--ui-color-hover/surface/active` mix from `--ui-accent`. To tint a surface, override
157
- `--ui-accent` — **but you must also RE-MIX** the three tokens where you override it, via the
158
- `.ui-accent` class:
159
-
160
- ```html
161
- <!-- a DataTable, but any surface works the same -->
162
- <div class="ui-accent" style="--ui-accent: var(--ui-color-primary)">…</div>
163
- ```
164
-
165
- `.ui-accent` (defined in `theme.css`) redefines hover/surface/active from the local
166
- `--ui-accent`. Setting `--ui-accent` **alone is not enough** — see the freeze rule below for
167
- why. The reference consumer is **`<DataTable variant="…">`**: its root always carries
168
- `.ui-accent` and, when a `variant` is passed, sets `--ui-accent: var(<variantToken>)`, tinting
169
- row hover + header/footer bands with that colour. No variant → neutral grey.
170
-
171
- ### Implementing `variant` tinting in a component (author recipe)
172
-
173
- Every themed component does this the **same** way (DataTable / List / Menu / CardBody / TabStrip).
174
- Follow it exactly — do **not** invent a new mechanism:
175
-
176
- 1. Resolve the variant to its colour token and build an inline-`style` string that sets the shared
177
- `--ui-accent` — **only for a real accent** (greyscale variants resolve to `--ui-color-text`, the
178
- default, so leave `--ui-accent` unset):
179
- ```svelte
180
- const accentVar = $derived(variant ? `--ui-accent: var(${variantToken[variant]});` : '');
181
- ```
182
- 2. Put `.ui-accent` on the component root and apply the style: `class="ui-accent …" style={accentVar}`.
183
- 3. Everywhere you need the variant colour, read **`var(--ui-accent)`** from a **static** Tailwind
184
- class — the accent tints (`--ui-color-surface/hover/active`) automatically; for text/borders read
185
- `var(--ui-accent)` directly, e.g. `[color:var(--ui-accent)]`,
186
- `[border-color:color-mix(in_srgb,var(--ui-accent)_var(--ui-tint-border),transparent)]`.
187
-
188
- **Two hard rules — both are silent failures, not `svelte-check` errors:**
189
-
190
- - **NEVER a per-component custom property.** No `--ui-tab-border`, `--dt-*`, `--card-*`, etc. The
191
- variant colour lives in the shared `--ui-accent`; components only ever _read_ it. (See "Extending
192
- upstream" — lift a hard-coded value into a shared `--ui-*` token, never a component namespace.)
193
- - **NEVER build a Tailwind class by interpolating a token/value at runtime.** Tailwind only emits
194
- arbitrary-property CSS for class strings it finds **literally in source**. A runtime-built
195
- `` `[--ui-accent:var(${token})]` `` or `` `[border-color:${x}]` `` is never scanned → the class
196
- applies at runtime but **no CSS backs it**, so it silently does nothing (every variant falls back to
197
- neutral). Two consequences: dynamic custom-prop values go through inline **`style`** (step 1), and
198
- repeated `color-mix(…var(--ui-accent)…)` classes are written out **in full**, per state, not
199
- factored into a `const` and interpolated.
200
-
201
- ## Applying a theme
202
-
203
- ### Light / dark, and the switcher rule
204
-
205
- `.theme-light` / `.theme-dark` are set on **`<html>` (documentElement)** — matching the
206
- pre-hydration script in `app.html` and the system `@media (prefers-color-scheme)` fallback. A
207
- theme switcher **must toggle the class on `documentElement`, never `<body>`** (`ThemeSelector`
208
- does this). Putting it on `<body>` leaves `<html>` on the system path and freezes the
209
- accent-derived tints to the wrong theme (see below).
210
-
211
- ### Global palette override (whole app)
212
-
213
- Set `--ui-color-*` at `:root` after importing the theme:
214
-
215
- ```css
216
- @import "tailwindcss";
217
- @import "svelte-ui/theme.css";
218
- :root {
219
- --ui-color-primary: #ff6600;
220
- --ui-color-background: #0d1117;
221
- --ui-color-text: #e6edf3;
222
- --ui-color-neutral: var(--ui-color-text); /* ≥0.17 — restate so neutral borders track your text */
223
- }
224
- ```
225
-
226
- A bare `:root` override like this works because `--ui-color-neutral` (declared at `:root` in
227
- `theme.css`) re-resolves on `:root`. But the moment you override the palette in a **more specific
228
- scope** (`.theme-*`, a branding class, an inverse-surface subtree), you MUST restate
229
- `--ui-color-neutral` + `--ui-border-color` in that scope too (freeze rule).
230
-
231
- ### Scoped override / **inverse surfaces** (a subtree)
232
-
233
- Because the theme classes are ordinary CSS, apply one to **any container** to re-theme just
234
- that subtree. This is the blessed pattern for an **inverse surface** — e.g. a dark header on a
235
- light page, where component text/icons would otherwise be dark-on-dark and invisible:
236
-
237
- ```svelte
238
- <header class="theme-dark bg-black">
239
- <Button variant="primary">…</Button>
240
- <!-- text/icons resolve LIGHT → visible -->
241
- <ThemeSelector />
242
- </header>
243
- ```
244
-
245
- Every svelte-ui component inside inherits the subtree's tokens (text flips to light, hover/
246
- surface/active/accent all re-mix for the dark surface). **Prefer this over a per-component
247
- `inverse` prop** — one class covers the whole subtree, composes at any depth, and adds no API
248
- surface. For a single control, put the class on its nearest wrapper. This only works correctly
249
- because the tint tokens are defined **per theme scope** (the freeze rule) — without that,
250
- `.theme-dark` on a subtree would leave hover/surface frozen to `:root`.
251
-
252
- ## The custom-property freeze rule (the mechanic behind everything above)
253
-
254
- A custom property whose value contains `var(...)` is resolved **at the element where it is
255
- DEFINED**, then inherited already-resolved. A descendant that overrides the referenced variable
256
- does **not** retroactively re-resolve it. Verified: `--m: color-mix(var(--acc)…)` at `:root`
257
- with `--acc` overridden on a child stays the `:root` colour; redefining `--m` in the child's
258
- scope picks up the child's `--acc`.
259
-
260
- Two consequences baked into `theme.css`:
261
-
262
- 1. **Tints AND neutral-derived tokens are repeated in EVERY theme scope** (`:root`, `@media
263
- (prefers-color-scheme:dark) :root`, `.theme-light`, `.theme-dark`) rather than defined once at
264
- `:root` — so they re-resolve against whichever theme (and `--ui-accent`) is in scope. This
265
- covers `--ui-color-hover/surface/active` **and** `--ui-color-neutral` + `--ui-border-color`
266
- (both derive from `--ui-color-text`). This is why scoped themes and the switcher-on-`<html>`
267
- rule work, why a switcher on `<body>` breaks them, and why **any custom scope that overrides the
268
- palette must also restate `--ui-color-neutral`/`--ui-border-color`** (see Building a custom theme).
269
- 2. **Overriding `--ui-accent` requires the `.ui-accent` re-mix class** — the override alone
270
- can't re-tint tokens computed higher up.
271
-
272
- Also: these tints are **plain `:root`/class CSS, not `@theme`**. Tailwind v4 only emits an
273
- `@theme` variable that backs a recognised utility; it never looks inside arbitrary classes like
274
- `[background-color:var(--ui-color-hover)]`, so an `@theme` home would get them tree-shaken out
275
- of the final bundle (the symptom: hover works off the SSR critical CSS during load, then dies).
276
-
277
- ## The self-reference trap
278
-
279
- CSS silently invalidates a custom property that resolves to a `var()` of **itself**:
280
-
281
- ```css
282
- /* WRONG — becomes guaranteed-invalid/unset; components reading it lose their colour */
283
- .theme-x {
284
- --ui-color-success: var(--ui-color-success);
285
- }
286
- ```
287
-
288
- To keep an inherited value, **omit the line entirely** — `--ui-*` already inherits from the
289
- outer scope. Only list a token when you want a _different_ value. If the outer scope doesn't
290
- define it at all, either import `svelte-ui/theme.css` globally so `:root` has the full palette,
291
- or give a literal value in the wrapper.
292
-
293
- ## Building a custom theme / bridging another design system
294
-
295
- The common case isn't picking colours from scratch — it's **aliasing svelte-ui's `--ui-*`
296
- tokens to a palette already loaded in the host** (DaisyUI, shadcn, MUI, brand vars). Because
297
- custom properties resolve at use-time, the host system's cascade applies automatically.
298
-
299
- ```css
300
- /* scoped bridge onto a host's tokens */
301
- .theme-myapp {
302
- --ui-color-background: var(--color-base-100); /* host token */
303
- --ui-color-text: var(--color-base-content);
304
- /* REQUIRED in ANY scope that overrides the palette: the neutral base + the divider it derives
305
- * do NOT re-resolve across scopes (freeze rule), so restate them here — otherwise neutral
306
- * borders/surfaces freeze to `:root`'s colour (wrong hue, or invisible on a dark brand). */
307
- --ui-color-neutral: var(--ui-color-text);
308
- --ui-border-color: color-mix(in srgb, var(--ui-color-neutral) var(--ui-tint-divider), transparent);
309
- /* only list what differs — the rest inherit; NEVER self-reference (see the trap) */
310
- --ui-height-sm: 1.75rem;
311
- --ui-height: 2.25rem;
312
- --ui-height-lg: 2.75rem; /* match host control heights */
313
- }
314
- ```
315
-
316
- > **Upgrading an existing theme to ≥ 0.17:** `--ui-color-neutral` is new (it's what all *neutral*
317
- > borders/surfaces derive from). A pre-0.17 custom theme scope defines the palette but not this token,
318
- > so after upgrading, neutral borders (Card/Input frames, Menu, DataTable/SideBarMenu edges…) render
319
- > with the wrong colour or vanish. **Add `--ui-color-neutral` (and re-derive `--ui-border-color`) to
320
- > every scope where you override `--ui-color-*`.**
321
-
322
- Apply globally at `:root` (svelte-ui _is_ the design system) or scoped on a wrapper subtree
323
- (svelte-ui opted into per route/component while the rest of the app uses the host system).
324
-
325
- ## Extending upstream instead of forking
326
-
327
- If a component hard-codes something you need to theme, **lift it into a shared `--ui-*` token
328
- in `theme.css`** with the current literal as the default (zero visual diff), then reference the
329
- token from the component via arbitrary-value syntax (`[background-color:var(--ui-color-surface)]`).
330
- Do **not** add a new component-scoped namespace — the `--dt-*`-style per-component tokens were
331
- removed in favour of the shared surface language (`--ui-color-hover/surface/active`, `--ui-accent`).
332
- Verify light + dark before/after.
333
-
334
- ## Consumer note (Shearwood Creek)
335
-
336
- SC consumes these `--ui-*` tokens **directly** — there is no `--color-*`/`--border-*` alias
337
- layer (it was removed). Per-tenant branding overrides `--ui-color-primary`/`--ui-color-secondary`
338
- at `html.theme-light,html.theme-dark` (see SC `$lib/branding.ts`); neutrals and semantic colours
339
- stay theme-controlled. **≥ 0.17:** those branding scopes must also set
340
- `--ui-color-neutral: var(--ui-color-text);` (and re-derive `--ui-border-color`) — the freeze rule
341
- means the scope's `--ui-color-text` override won't otherwise reach the neutral borders, so they'd
342
- freeze to `:root` (wrong hue / invisible on a dark tenant).
343
-
344
- ## Authoring checklist
345
-
346
- 1. Scope: global (`:root`) or wrapped (`.theme-myname` / an inverse-surface container).
347
- 2. Palette: only list `--ui-color-*` that differ; inherit the rest (no self-reference).
348
- 3. Density: override `--ui-height*` only if controls must differ; most themes inherit.
349
- 4. Accent/variant: to tint a surface, set `--ui-accent` **and** add `.ui-accent`.
350
- 5. Light + dark: test both (`prefers-color-scheme`) if you don't pin values.
351
- 6. Hit a hard-code? Extend `theme.css` + the component with a shared token first.
352
-
353
- ## Reference
354
-
355
- - Source of truth — [`src/lib/theme.css`](../../src/lib/theme.css)
356
- - Variant → colour map — [`src/lib/types/variants.ts`](../../src/lib/types/variants.ts)
357
- - Public token docs — [`docs/themes.md`](../../docs/themes.md)