@dorsk/tsumikit 0.82.2 → 0.82.3

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/README.md CHANGED
@@ -1,1348 +1,123 @@
1
1
  # @dorsk/tsumikit
2
2
 
3
- A minimal, **dependency-free** UI kit for Svelte 5 + pure CSS. Token-driven
4
- atoms and molecules with theming, font-scaling, a color-blind-safe theme and
5
- mobile/a11y baked in. No CDNs, no runtime UI dependencies — just Svelte 5 as a
6
- peer.
3
+ Svelte 5 UI components styled with plain CSS variables: 31 themes, WCAG AA contrast, no runtime dependencies.
7
4
 
8
- ## Design
5
+ [![npm](https://img.shields.io/npm/v/@dorsk/tsumikit)](https://www.npmjs.com/package/@dorsk/tsumikit)
6
+ [![CI](https://github.com/DorskFR/tsumikit/actions/workflows/ci.yml/badge.svg)](https://github.com/DorskFR/tsumikit/actions/workflows/ci.yml)
7
+ [![svelte](https://img.shields.io/npm/dependency-version/@dorsk/tsumikit/peer/svelte)](https://svelte.dev)
8
+ [![license](https://img.shields.io/npm/l/@dorsk/tsumikit)](LICENSE)
9
9
 
10
- Each layer reaches only one layer down:
10
+ <picture>
11
+ <source media="(prefers-color-scheme: dark)" srcset="docs/assets/hero-dark.png">
12
+ <img alt="tsumikit showcase page: breadcrumb, page header with actions, four metric cards, a settings form with inputs, selects, checkboxes, switches, slider, radio group and segmented control, and a panel of button variants, sizes, tones, split buttons and a menu" src="docs/assets/hero-light.png" width="900">
13
+ </picture>
11
14
 
12
- ```
13
- variables.css → every color/space/radius/font/size is a var(--…). No
14
- component hard-codes a hex or a pixel.
15
- ↓
16
- atoms → the only place raw <button>/<input>/<select>/<h*>/text lives.
17
- ↓
18
- molecules → compose or specialize atoms (never reimplement a primitive).
19
- ↓
20
- your app → assemble molecules + layout.
21
- ```
22
-
23
- **Rules:** primitives live in atoms only · specialize, don't reimplement · no
24
- hard-coded values · override by specificity, never by forking · props (incl.
25
- `aria-*`) spread down onto the underlying element.
26
-
27
- **Live demo:** https://dorskfr.github.io/tsumikit
15
+ Every colour, space, radius and size is a CSS custom property: a theme is one block of variables, a component is restyled with `style="--btn-bg: …"`. Interactive parts use `<dialog>`, the Popover API and native form controls; a design lint and [design principles](principles/README.md) ship in the package.
28
16
 
29
- ### Principles
17
+ [Live demo with every component and prop](https://dorskfr.github.io/tsumikit)
30
18
 
31
- The design principles the kit serves, one markdown file each, live in
32
- [`principles/`](principles/README.md) and ship in the package
33
- (`node_modules/@dorsk/tsumikit/principles/`). Each gives the principle, its
34
- source, do / don't, what tsumikit provides for it, and a check to run on a
35
- screen. The index ends with a short checklist for designing a screen; point
36
- design reviews and coding agents at it. The showcase renders them at
37
- [`/principles`](https://dorskfr.github.io/tsumikit/principles).
19
+ ## Install
38
20
 
39
- ## Develop
40
-
41
- ```bash
42
- npm install
43
- npm run dev # showcase at the printed URL (+ /shell for the AppShell demo)
44
- npm run lint # biome (ts/js)
45
- npm run check # svelte-check
46
- npm run build # prerendered static site in /build
47
- npm run package # build the publishable library into /dist (+ publint)
21
+ ```sh
22
+ npm i @dorsk/tsumikit
48
23
  ```
49
24
 
50
- Git hooks (Biome on commit, `svelte-check` on push) are installed by lefthook
51
- via the `prepare` script. The library is published to npm from a release tag
52
- via GitHub Actions using npm **trusted publishing** (OIDC — no token secret).
53
-
54
- ## Use in your own project
25
+ Peer: `svelte@^5.0.0`. Inter font bundled.
55
26
 
56
- ```ts
57
- import '@dorsk/tsumikit/styles/app.css'; // once, at the app root
58
- import { Button, Field, Input, Modal, ThemePicker } from '@dorsk/tsumikit';
59
- ```
27
+ ## Quick start
60
28
 
61
29
  ```svelte
62
- <Field label="Name" for="name">
30
+ <script lang="ts">
31
+ import '@dorsk/tsumikit/styles/app.css';
32
+ import { Button, Field, Input, Toaster, toasts } from '@dorsk/tsumikit';
33
+
34
+ let name = $state('');
35
+ </script>
36
+
37
+ <Field label="Workspace name" for="name">
63
38
  <Input id="name" bind:value={name} />
64
39
  </Field>
65
- <Button variant="primary" onclick={save}>Save</Button>
40
+ <Button variant="primary" onclick={() => toasts.ok(`Saved ${name}`)}>Save</Button>
41
+ <Toaster />
66
42
  ```
67
43
 
44
+ Import the stylesheet once at the app root. Pressing Save shows an `ok` toast.
45
+
68
46
  ### Start a new project
69
47
 
70
- Copy [`examples/starter`](examples/starter) and change `"@dorsk/tsumikit": "file:../.."` to a
71
- version. It is the baseline every app should start from: the kit stylesheet (tokens, themes,
72
- Inter), `AppShell`, a `ThemePicker`, a `Toaster`, an `+error.svelte` that says what happened in
73
- plain words (404 included), a list page with URL-driven filters (`Tabs href` + `urlState`),
74
- `LoadMore`, empty and filtered-empty states, delete with `toasts.undo()` instead of a confirm
75
- dialog, and `tsumikit-lint` on every commit via lefthook.
48
+ Copy [`examples/starter`](examples/starter) and replace `"file:../.."` with a version. Contents: [examples/starter/README.md](examples/starter/README.md).
76
49
 
77
- Upgrading from 0.81 or older: [docs/MIGRATION-0.82.md](docs/MIGRATION-0.82.md) lists what looks
78
- different and how to revert each change.
50
+ Upgrading from 0.81 or older: [docs/MIGRATION-0.82.md](docs/MIGRATION-0.82.md).
79
51
 
80
52
  ## Theming
81
53
 
82
- - 31 themes ship (light, highcontrast, gruvboxlight, solarizedlight,
83
- everforestlight, rosepinedawn, latte, nordlight, tokyoday, kanagawalotus,
84
- sepia, dark, **colorblind** — Okabe-Ito —, mocha, dracula, nord, tokyonight,
85
- gruvbox, solarized, rosepine, onedark, everforest, monokai, amoled), plus a
86
- **dim** tier between the bright and dark extremes: stone, fog, sage (light)
87
- and slate, moss, espresso, zenburn (dark).
88
- - Every built-in clears WCAG AA on `bg`, `bg-elev` and `surface`: text 7:1,
89
- muted 4.5:1, every accent/status hue 4.5:1, `accent-ink` on `accent` 4.5:1,
90
- and `--c-border-strong` (the outline of every control) 3:1.
91
- - Three text levels: `--text` for values and titles, `--text-muted` for labels
92
- and secondary text (4.5:1), `--text-faint` only for placeholders, disabled
93
- and decorative text (3:1, and muted is at least 1.35x faint so the step is
94
- visible).
95
- - The accent is never a status colour: every theme keeps `--c-accent` at least
96
- OKLab deltaE 0.08 away from `--c-green`, `--c-amber` and `--c-red`, so the
97
- primary action never reads as "success".
98
- `tests/theme-contrast.mjs` holds the floors; the test suite fails on any
99
- breach.
100
- - `<ThemePicker />` and `<FontScalePicker />` wire the stores to the UI. Theme
101
- is persisted to `localStorage` and applied with no flash (head snippet in
102
- `app.html`) and updates the mobile `<meta name="theme-color">`.
103
- - The store keeps a **preference**, not just an id: one remembered light theme,
104
- one remembered dark theme, and which of the two is pinned — or `auto`, which
105
- follows `prefers-color-scheme` and repaints when the system flips.
106
- `theme.choose(id | 'auto')`, `theme.mode`, `theme.pref`, `theme.resolved`,
107
- `theme.hydrate(pref)` (replay a server-side preference) and
108
- `theme.onchange = (pref) => …` (mirror it back). `theme.set(id)` and
109
- `theme.current` are unchanged. `<ThemePicker auto />` adds the "Auto" row;
110
- its labels (`autoLabel`, `autoHelp`, `lightLabel`, `darkLabel`) are props.
111
- - **Storage format (0.47):** `localStorage['tsumikit-theme']` now holds
112
- `{"mode","light","dark"}` instead of a bare theme id. A bare id left by an
113
- older version still loads — it pins its own slot — but a downgrade will not
114
- read the new blob and falls back to the default theme.
115
-
116
- ### Typeface
117
-
118
- `styles/app.css` bundles [Inter](https://rsms.me/inter/) (variable, latin subset, OFL-1.1, licence in
119
- `styles/fonts/`) through `styles/font.css` and lists it first in `--font-sans`. Counts, stat values,
120
- eyebrow labels and shortcut hints (`--font-numeric`, `--font-label`) are sans by default; only code
121
- (`CodeBlock`, `Kbd`, `GitRef`, `Text variant="code"`) stays monospace.
122
-
123
- To opt out, import `tokens.css` / `themes.css` without `font.css`, or override `--font-sans`.
124
- Reading text is capped at `--measure` (68ch): `Prose` always, `Text` with `measure`; set
125
- `--measure: none` on a container to lift it.
126
-
127
- ### Stylesheets
128
-
129
- `@dorsk/tsumikit/styles/app.css` is a shell over five files you can import
130
- individually, in this order:
131
-
132
- ```css
133
- @import '@dorsk/tsumikit/styles/tokens.css';
134
- @import '@dorsk/tsumikit/styles/themes.css'; /* optional */
135
- @import '@dorsk/tsumikit/styles/reset.css';
136
- @import '@dorsk/tsumikit/styles/utilities.css';
137
- @import '@dorsk/tsumikit/styles/syntax.css';
138
- @import './brand.css';
139
- ```
140
-
141
- | export | contents |
142
- | --- | --- |
143
- | `styles/tokens.css` | `:root` only — every token the kit reads (`--c-*` palette, `--bg`/`--text`/`--accent` aliases, type scale, spacing, radii, shadows, fonts, `--control-height`, …). Theme-less. |
144
- | `styles/themes.css` | the built-in `[data-theme="id"]` blocks, one per `THEMES` entry. |
145
- | `styles/reset.css` | reset + element defaults, global reduced-motion / forced-colors / prefers-contrast rules. |
146
- | `styles/utilities.css` | `.container`, `.stack`, `.row`, `.sr-only`, icon sizing, … |
147
- | `styles/syntax.css` | highlight.js / Prism class → `--syn-*` mapping. |
148
-
149
- `styles/variables.css` = `tokens.css` + `themes.css` (kept for compatibility).
150
- Nothing is wrapped in `@layer`: kit rules are unlayered so component-scoped
151
- styles cascade exactly as before, and a consumer stylesheet imported **after**
152
- the kit wins on source order at equal specificity. Do not put brand overrides
153
- inside a `@layer` of your own — layered rules lose to the kit's unlayered ones.
154
-
155
- ### Your own theme, without vendoring
54
+ 31 themes, all tested for WCAG AA contrast. `<ThemePicker />` persists the choice in `localStorage`; `auto` follows `prefers-color-scheme`.
156
55
 
157
56
  ```ts
158
- // +layout.ts (or any module that runs before first render)
159
57
  import { theme } from '@dorsk/tsumikit';
160
- theme.register({ id: 'kusaritoi', label: 'Kusaritoi', icon: '鎖', themeColor: '#2a2a2a', mode: 'dark' });
161
- theme.setDefault('kusaritoi');
58
+ theme.register({ id: 'acme', label: 'Acme', mode: 'dark' });
59
+ theme.setDefault('acme');
162
60
  ```
163
61
 
164
62
  ```css
165
- /* brand.css — imported after the kit stylesheet(s) */
166
- @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono&display=swap');
167
- :root { --font-mono: 'JetBrains Mono', ui-monospace, monospace; }
168
-
169
- [data-theme='kusaritoi'] {
170
- color-scheme: dark;
171
- --c-bg: #2a2a2a; --c-bg-elev: #323232; --c-bg-elev-2: #3a3a3a;
172
- --c-surface: #323232; --c-border: #484848; --c-border-strong: #585858;
173
- --c-text: #e8e8e8; --c-text-muted: #b0b0b0; --c-text-faint: #808080;
174
- --c-accent: #5ac8c8; --c-accent-ink: #0d1f1f; --c-accent-dim: #3a8a8a;
175
- --c-blue: #5a9fd4; --c-amber: #d9a543; --c-red: #e06060;
176
- --c-green: #6abf69; --c-violet: #b48ef0; --c-gold: #d9a543;
177
- --c-teal: #5ac8c8;
178
- --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
179
- --shadow-md: 0 6px 20px rgba(0, 0, 0, 0.5);
180
- --shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.6);
181
- --mach-bg-sl: 45% 24%; --mach-fg-sl: 70% 82%; --mach-border-sl: 45% 40%;
63
+ /* brand.css, imported after the kit stylesheet */
64
+ [data-theme='acme'] {
65
+ --c-bg: #2a2a2a;
66
+ --c-accent: #5ac8c8;
67
+ /* …the rest of the 19 --c-* palette tokens, shadows and --mach-* pairs */
182
68
  }
183
69
  ```
184
70
 
185
- Contract for a `[data-theme='x']` block: all nineteen `--c-*` palette tokens,
186
- `--shadow-sm|md|lg`, `--mach-bg-sl|fg-sl|border-sl`, and `color-scheme: light`
187
- for a light theme (`:root` defaults to dark, so dark themes may omit it).
188
- `--md-code-bg` is optional (derives from `--c-blue`). Everything else (`--bg`,
189
- `--text`, `--syn-*`, …) is an alias in `tokens.css` and follows automatically.
190
-
191
- - `theme.register(def | def[])` appends to `theme.all` (built-ins first); an
192
- entry with a built-in `id` replaces that built-in. `icon` and `themeColor` are
193
- optional. `ThemePicker` reads `theme.all`, so registered themes appear at once.
194
- - `theme.setDefault(id)` is the theme used when `localStorage` holds nothing
195
- valid — set the same id as `data-theme` in your `app.html` so the no-flash
196
- snippet and the store agree. A persisted id that only becomes valid after
197
- `register()` is picked up as soon as it is registered.
198
- - Only the palette or only the fonts? Import `tokens.css` + your `brand.css`
199
- and skip `themes.css`; or import the full `app.css` and override `:root`
200
- tokens after it. Never copy the kit files.
201
- - Adding a built-in theme to the kit = one entry in `THEMES`
202
- (`stores/theme.svelte.ts`) + one block in `styles/themes.css`.
203
-
204
- ### Styling a component from outside
205
-
206
- A Svelte consumer's **scoped** CSS cannot reach a child component's markup — the
207
- scoping attribute is only added to elements the consumer itself renders. `class`
208
- passthrough therefore only helps for layout hooks (margin, flex, grid placement).
209
- CSS custom properties *do* cross the component boundary, so they are the
210
- sanctioned escape hatch for everything else.
211
-
212
- - **Naming:** `--<prefix>-<axis>`, where `<prefix>` is the component's short
213
- prefix (`btn`, `badge`, `txt`, `select`, `input`, `textarea`, `card`, `md`, …)
214
- and `<axis>` is one of `bg`, `fg`, `border`, `size`, `radius`, plus a few
215
- component-specific ones (`--btn-tone`, `--badge-max-width`, `--gauge-w`).
216
- `Text` uses `txt` because `--text*` is already the theme's foreground token.
217
- - **Setting them:** per instance via `style`, or on any ancestor for a subtree.
218
-
219
71
  ```svelte
220
72
  <Button style="--btn-bg: var(--c-violet); --btn-fg: #fff; --btn-radius: 0">Ship</Button>
221
-
222
- <div class="danger-zone">
223
- <Button>Delete</Button> <!-- inherits the block below -->
224
- </div>
225
-
226
- <style>
227
- .danger-zone { --btn-border: var(--danger); --btn-fg: var(--danger); }
228
- </style>
229
73
  ```
230
74
 
231
- - Every property is read as `var(--x, <default>)`, so leaving it unset renders
232
- exactly as before.
233
- - **`:global()` into kit internals is unsupported.** Class names like `.btn`,
234
- `.badge`, `.select-wrap` or `.textarea` are private and change without a major
235
- bump. If an axis you need is missing, open an issue — do not reach in.
236
-
237
- | component | published properties |
238
- | --- | --- |
239
- | `Button` | `--btn-bg`, `--btn-fg`, `--btn-border`, `--btn-size`, `--btn-radius`, `--btn-tone`, `--btn-on`, `--btn-box`, `--btn-count-bg`, `--btn-count-fg`, `--btn-count-size` |
240
- | `Badge` | `--badge-bg`, `--badge-fg`, `--badge-border`, `--badge-radius`, `--badge-tone`, `--badge-max-width`, `--badge-fs`, `--badge-fw`, `--badge-min-size` |
241
- | `Text` | `--txt-fg`, `--txt-size` |
242
- | `Tabs` | `--tab-max-width` |
243
- | `Select` | `--select-bg`, `--select-fg`, `--select-border`, `--select-size`, `--select-radius` |
244
- | `Input` | `--input-bg`, `--input-fg`, `--input-border`, `--input-size`, `--input-radius` |
245
- | `Textarea` | `--textarea-bg`, `--textarea-fg`, `--textarea-border`, `--textarea-size`, `--textarea-radius` |
246
- | `Card` | `--card-pad`, `--card-gap`, `--card-border-style` |
247
- | `Divider` | `--divider-color`, `--divider-spacing` |
248
- | `Gauge` | `--gauge-w`, `--gauge-h`, `--gauge-fill` |
249
- | `MasterDetail` | `--md-list-w`, `--md-gap`, `--md-divider` |
250
- | `NavBar` | `--navbar-max`, `--navbar-center-w`, `--navbar-center-raise` |
251
- | `Callout` | `--callout-tone` |
252
- | `Fieldset` | `--fieldset-pad`, `--fieldset-border` |
253
- | `GitRef` | `--git-ref-tone`, `--git-ref-max-width` |
254
- | `EmptyState` | `--empty-tone` |
255
- | `Combobox` | `--cb-bg`, `--cb-border`, `--cb-radius`, `--cb-active-bg`, `--cb-min-width`, `--cb-max-height` |
256
- | `Toggle` | `--toggle-accent`, `--toggle-size` |
257
- | `Popover` | `--pop-trigger-bg`, `--pop-trigger-fg`, `--pop-trigger-border`, `--pop-trigger-radius`, `--pop-trigger-size`, `--pop-trigger-pad`, `--pop-box`, `--pop-count-bg`, `--pop-count-fg`, `--pop-count-size` |
258
- | `SortableList` | `--sortable-row-h` |
259
- | `RadialMenu` | `--radial-radius`, `--radial-petal-size`, `--radial-fab-size` |
260
-
261
- `tests/css-custom-property-contract.test.js` reads this table and fails if a
262
- listed property is not actually read by its component, so the docs cannot drift.
263
-
264
- `Popover`'s hooks paint the **trigger**; hover states keep their own feedback
265
- colours. `--pop-box: auto` releases the square floor, so `pill` plus
266
- `--pop-trigger-pad` reshapes the default trigger into a chip of any height. `--pop-trigger-border` needs the chromed trigger — `bare` means no ring
267
- by definition. `Badge`'s `--badge-min-size` pairs with `numeric` (already centred
268
- and tabular) to turn a count badge into a square/circular counter.
269
-
270
- A `triggerClass` on `Popover` (or `class` on the pickers that wrap it) is a hook
271
- for a **globally loaded** stylesheet only. Your component's *scoped* CSS cannot
272
- reach it: Svelte's scoping attribute is added to elements your own template
273
- renders, and the trigger belongs to `Popover`, so `.toolbar .my-trigger` compiles
274
- to `.toolbar.svelte-x .my-trigger.svelte-x` and matches nothing. Use `style` and
275
- the properties above instead.
276
-
277
- ### Anchoring to a component
278
-
279
- Components that render a single addressable element forward rest props onto it,
280
- so `data-journey`, `data-testid` and analytics attributes land where you expect.
281
- `Popover` puts them on its **trigger** (so `Menu`, `ThemePicker` and
282
- `FontScalePicker` forward through to it), `Tabs` on its root — with a per-tab
283
- `attrs` bag on each `TabItem` — `FilterInput` on its root, and `FilterSearchBar`
284
- forwards onto that. `Menu` items take an `attrs` bag. Components that forward
285
- into another component take `AnchorAttributes` (`data-*` only), which cannot
286
- collide with the child's own props; the rest take full `HTMLAttributes`. The
287
- component's own `role`, `aria-*`, `class` and keyboard wiring are applied after
288
- the spread and always win, so an anchor can never break behaviour; `class` in a
289
- rest/`attrs` bag is therefore dropped — use `class` / `triggerClass`.
290
-
291
- Never target the private `data-tsu="…"` markers: they are internal, unguarded by
292
- any test, and change without a major.
75
+ Theme contract, looks, stylesheets and typeface: [docs/theming.md](docs/theming.md). Per-component custom properties and test ids: [docs/styling.md](docs/styling.md).
293
76
 
294
77
  ## Components
295
78
 
296
- **Atoms:** Text, Heading, Button, Input (`icon` inset leading glyph,
297
- `clearable` + `onclear`, `shape="pill"`, `width` fixed + `flex: none`,
298
- `onenter(value)`; the bare `<input>` DOM is unchanged unless `icon`/`clearable`
299
- is used), Textarea, Select (`options` array with per-option `icon`/`emoji`/`hint`/`disabled`;
300
- the trigger overlays the selected option's glyph + muted hint on the native control), Switch, Checkbox (`size`
301
- sm/md/lg as on `Switch`, or `--cb-box` for the box alone),
302
- Slider (`ticks` draws a dot per step for a segmented slider), Progress, Gauge (vertical consumption cell, `variant` continuous/segments,
303
- threshold tones via `warnAt`/`dangerAt`, `corner` snippet), Artwork (lazy cover image with seeded gradient + initials
304
- fallback, `aspect`, `status` overlay, `srcset`/`sizes` passed to the `<img>`), Avatar (grapheme-safe initial or `glyph`, hue hashed from
305
- `seed`/`name` or a fixed `tone`, `size` xs–lg or px, `shape` circle/square, `decorative`, corner `status`), Card (`tone` tints the surface for inline banners), Badge
306
- (`tone` semantic palette (the shared `Tone`, `success` = `ok`, plus `muted`/`violet`) or `color` for any CSS colour, `size` xs/sm/md (sm/xs on `--fs-xs`), `dot`,
307
- `icon`, `numeric`, `truncate`, `variant="text"`, `removable`/`onremove` or any
308
- trailing action via `actionIcon`/`actionLabel`/`onaction`, `removed` mutes and
309
- strikes the pill for a restorable entry; all tints derive from
310
- `--badge-tone`), Dot (`ring` dark halo over artwork), Swatch (colour chip:
311
- `color` any CSS colour, `size` sm/md/lg, `shape` circle/square, `label` → role=img
312
- else aria-hidden, `interactive` renders a button with `onclick` + `aria-pressed`
313
- from `selected`), Divider (standalone
314
- horizontal/vertical hairline, `tone`, `spacing`, optional centred label,
315
- `decorative` to drop the `separator` role), Link (`tone`, `underline`
316
- always/hover/none, default hover for navigation and titles; pass `always` inside prose, `align`), Icon (open registry — pass a `children` snippet for
317
- any custom SVG).
318
-
319
- **Molecules:** Field (`grow`), IconButton (`filled` forwards to `Icon`, for a
320
- filled pin/star toggle), SelectButton, Toggle, OptionButton (`value` +
321
- `description` secondary text wired via `aria-describedby`, `onfocuschange(value)` on
322
- focus/hover to drive a preview pane), Modal,
323
- Popover, RadialMenu (a FAB that fans `Menu` items out as petals on an arc, see below), Menu (items take a free-form trailing `tag` + `tagTone`, or a `tag` snippet,
324
- and an `attrs` object for `data-*`/test ids on the row; a checkable `pressed` item keeps
325
- its icon leading and shows its check trailing, or uses the leading check slot when it has
326
- no icon; selecting closes the menu unless `closeOnSelect={false}` or the item sets `keepOpen`;
327
- rest props land on the trigger, `count`/`countMax` paint its corner badge and `panelStyle`
328
- sizes the panel; an item with a `control` snippet renders as a non-button row hosting a native
329
- `<select>` or nested Popover trigger, keeping the icon column, row height and ↑/↓ navigation;
330
- labels may repeat, and an optional `id` keeps a row's identity when the list reorders),
331
- Tabs (omit `panel` for a strip-only tablist of document tabs: `closable` + `onclose(id)`
332
- close through the ✕, Delete and middle-click, per-tab `leading` snippet, `title`,
333
- trailing `actions` snippet; `--tab-max-width` truncates long labels),
334
- Breadcrumb (`maxItems` collapses the middle, `size` sm/md, `wrap={false}` keeps one
335
- line with the current crumb ellipsised, `collapseCurrent` keeps only the ancestors),
336
- RadioGroup (per-option `description` in every variant, announced via
337
- `aria-describedby`; `onfocuschange(value)` fires once per newly focused/hovered option;
338
- `variant="rows"`: bordered rows, per-option `note`,
339
- `action(option)` trailing control that never toggles, `below(option)` inline panel),
340
- Tooltip, Disclosure (single collapsible: `header` snippet, bindable `open` or `open` + `onchange` for
341
- controlled mode, `chevron` start/end/false, `size="compact"` for strip rows, native button with
342
- `aria-expanded`/`aria-controls` over a `region` panel), Accordion (a stack of Disclosures; items take a
343
- `title` string or a `summary` snippet that receives the `item` it heads, per-item `open`/`onchange`,
344
- `multiple=false` keeps one open, `variant="plain"` drops the outer border, `size` forwards to every item), CopyButton, FileButton, InputGroup (a field with inset
345
- leading/trailing controls fused into one bordered unit, see below),
346
- Dropzone, CodeBlock, Callout, EmptyState, ConfirmModal, Pagination, Toaster,
347
- ThemePicker (popover grid of 2×2 palette swatches: bg · surface · text · accent per theme),
348
- EmojiPicker (popover with a searchable EN+FR glyph catalogue, grouped tabs, roving-tabindex grid; pass `groups` to swap the catalogue), FontScalePicker (popover with a stepped slider across the five text sizes), SoundSettings (popover with the sound switch, volume and preview; see [Sound](#sound-opt-in)), SectionHeader, KeyValue, LoadMore,
349
- GitRef (branch chip + PR link tinted by state + `+N −N` diff; `collapse`
350
- auto/never/glyph degrades to icons inside a narrow `.cq` container),
351
- CapBar (consumption track with a draggable, keyboard-steppable cap handle that
352
- shows a `N%` bubble while it is moved; `oninput` live, `onchange` on commit;
353
- `marker`/`markerLabel`/`markerTone` draw a pace rule, `cap: null` plus
354
- `clearAtMax`/`uncappedLabel` express "no cap", `layout`/`stackBelow` put the
355
- readout above a full-width track, `capLabel`/`capValueText` localise the ARIA
356
- strings),
357
- StatusBar (thin `role="status"` row: `tone` idle/busy/ok/warn/error dot or spinner, `label` or
358
- `children`, `trailing`), ChatBubble (`timestamp`, `footer` snippet, `markdown` body rendered
359
- through `renderMarkdown` into `Prose` when there are no `children`),
360
- Composer (`toolbarStart`/`toolbarEnd` snippets move attach + send into a row under the field),
361
- Drawer (side-panel `<dialog>`: `side`, `width` clamped to the viewport, full-screen
362
- under 48rem; `nav` page column that turns into a horizontal strip on narrow
363
- screens, or `navMobile`; sticky `footer`; Escape / scrim / close button all close),
364
- Timestamp (`mode` date/time/datetime/relative/iso/`short-iso` for a locale-independent `YYYY-MM-DD`; `utc`; click for a details popover), WorkingDir
365
- (fish-style path chip: ancestors abbreviate left to right as the slot narrows,
366
- then the leaf ellipsises down to `minLeaf`; `full` skips the fit, `copy` makes it
367
- a copy-on-click button; candidates via the `workingDirCandidates` helper).
368
- Fieldset (bordered zone, legend rides the border; `droppable` makes it an HTML5
369
- drop target with `accepts`/`ondrop`/`dropHint` — keyboard alternative is the consumer's job),
370
- SortableList (reorderable list: grip drag on Pointer Events, keyboard grab, ↑/↓ arrows, see below),
371
- Combobox (suggestion listbox for a field you render as `children` — an Input for
372
- plain autocomplete or a Textarea for `@`/`#` mentions; the kit sets the field's
373
- combobox ARIA and `aria-activedescendant`, floats the `role="listbox"` in the top
374
- layer under the field or under the caret via `anchor="caret"`, and handles
375
- ArrowUp/Down, Enter/Tab (`selectOn`), Escape and blur; `open`/`index` are
376
- bindable, `onselect`/`onclose(reason)` report picks; `findTrigger`/`applyTrigger`
377
- are exported for trigger-char detection and token replacement).
378
-
379
- **Organisms:** ConversationPanel / ConversationFeed (chat column + sticky
380
- feed, see below), DataTable (generic `<T>`, typed columns + cell snippets; `headerSnippets[key]`
381
- replaces a header cell (a select-all checkbox);
382
- `layout="fixed"` makes column widths authoritative, `Column.truncate` /
383
- `nowrap` / `hideBelow="sm|md|lg"` (container-query on the table's own box),
384
- `hideHeader` clips the header but keeps it for assistive tech, `rowTone(row)`
385
- paints a left accent bar + `data-tone`, `rowClass(row)`, `rowActions` snippet
386
- for a hover/focus-revealed trailing cell (always visible on touch; under
387
- `layout="fixed"` its column is `--dt-actions-w`, default 3.5rem),
388
- `stickyOffset` for the sticky header's `top` (it sticks within the table's own
389
- scroller, so give the table a bounded height), `size="sm"`, `loading`,
390
- `onloadmore` footer button, `empty` as string or snippet; `data-part`
391
- hooks on head/row/cell; `responsive="stack"` turns rows into cards below
392
- `stackBelow` (48rem, measured on the table's own box) using `Column.role`
393
- `title | detail | meta | actions | hidden` — detail cells get a `data-label`
394
- prefix, `Column.stackLabel: false` drops it so metas join on one `·` line
395
- and details share lines, `--dt-meta-font` restyles meta values (e.g. `var(--font-sans)`; a
396
- literal `inherit` falls back to mono), stacked tables use `table-layout: fixed`,
397
- `stackActions="end"` closes every card with its actions, cells that render
398
- nothing get `data-empty` and are hidden and skipped by the `·` separators, the `<table>`
399
- stays a table for assistive tech), FilterSearchBar /
400
- FilterInput (`font="sans"` for a plain search box, `size="sm"` compact bar on `--control-height-compact`, text
401
- floored at 16px under `pointer: coarse` so iOS does not zoom on focus,
402
- `shape="pill"`, `surface`, `hotkey="/"` focuses the input from anywhere
403
- outside an editable element, `showHotkey` renders the `<kbd>` hint, `grow`;
404
- `key="cwd"` puts FilterInput in single-key mode — `value` is the bare value
405
- instead of a `key:"value"` query, no key prefix in the box, the `placeholder`
406
- shows while empty, and completion still runs through that field's provider).
407
-
408
- **Layouts:** AppShell (responsive header/sidebar/main/footer — persistent
409
- sidebar on desktop, overlay drawer on mobile, optionally resizable;
410
- `layout="sidebar-full"` runs the sidebar the full height with the header over
411
- the content column only, `stickySidebar` pins it to the viewport, and
412
- `sidebarPadding="none" | "sm" | "md"` sets the aside gutter — the header and its
413
- children are `min-width: 0` so a wide title/actions row can't widen the grid on
414
- mobile; the mobile drawer's state is `bind:open` with `onOpenChange(open)`, and
415
- `closeOnNavigate` (default `true`) closes it when a plain same-origin link inside
416
- it is clicked — a router can also close it, e.g. SvelteKit
417
- `afterNavigate(() => (navOpen = false))` with `<AppShell bind:open={navOpen}>`
418
- to cover `goto` and back/forward), NavBar (horizontal route bar — `<nav>` + `<a aria-current="page">`,
419
- icon/emoji + label + count badge; `placement="bar"` pins it to the bottom edge
420
- over `--z-nav` with `--safe-bottom` padding, `orientation` stacks or inlines the
421
- glyph, `maxWidth` centres the row; items without `href` are buttons that set a
422
- bindable `value` / `onchange`, in a `tablist` when none are links; `center`
423
- reserves a notch the items split around, for a raised FAB; `variant="floating"`
424
- insets it as an elevated rounded card), NavItem
425
- (collapses to an icon rail when the sidebar is narrow; `icon` from the
426
- registry, `iconPath` / `iconChildren` for custom glyphs, `activeStyle="bar"`
427
- for the inset-bar active look), MasterDetail (list + detail columns that
428
- become two pages — one pane at a time with a sticky 44px back header, keeping the
429
- list's scroll and focus — below
430
- their own `breakpoint`, so `selected` can be driven from the URL; `gap` spaces
431
- the columns and `divider="none"` drops the seam, both also settable as the
432
- `--md-gap` / `--md-divider` custom properties), Container, Stack
433
- (vertical), Cluster (wrapping row; `stackAt="xs|sm|md|lg"` makes it its own
434
- query container and stacks children full-width below 18/30/40/48rem — phone
435
- action rows without a viewport query), AutoGrid (intrinsically responsive
436
- columns — no media/container query needed; a child marked
437
- `data-span="2|3|4|full"` claims that many tracks, so a primary action can sit
438
- at 2 of 4 columns and take the whole row once the columns halve).
439
-
440
- ### Container
441
-
442
- `size` caps the column (`--content-max` by default; `size="none"` fills the parent),
443
- `gutter` overrides the `--sp-4` inline padding (safe-area insets still win),
444
- `align="start"` drops the centering. `fullWidth` is a *viewport* breakout for
445
- edge-to-edge sections — inside AppShell's main column use `size="none"` instead;
446
- `inset="<left> <right>"` keeps a `fullWidth` container clear of docked panels.
447
- Children can bleed to the column edge with
448
- `margin-inline: calc(-1 * var(--container-gutter))`.
449
-
450
- ### ResizablePanel
451
-
452
- Inline by default: `panel` shares the row with `children` on its `side`, drag the
453
- edge or use the arrow keys/Home/End on the separator, collapse it with the edge
454
- chevron; `width`/`minWidth`/`maxWidth` (px numbers or CSS lengths such as
455
- `'12rem'`) and `widthKey` persist the width. `mode="overlay"` turns it into a
456
- fixed non-modal drawer (`role="dialog"`) on the viewport edge: `bind:open`,
457
- `onclose` fires on Escape, scrim click or the edge control, `scrim={false}` drops
458
- the dim backdrop, `fullWidthBelow="960px"` makes the drawer span the viewport (no
459
- handle, no scrim) on small screens, and `clampToViewport` (default true) keeps a
460
- stored width inside a shrunken window. `children` is optional in overlay mode.
461
- `onresizestart` / `onresize(width)` / `onresizeend(width)` report the drag: start
462
- fires on pointer-down, `onresize` once per animation frame (and per keyboard
463
- step), `onresizeend` with the settled width on release — enough to suspend
464
- expensive panel work only while the width is in flux. A keyboard step commits
465
- without a start.
466
-
467
- ```svelte
468
- <ResizablePanel mode="overlay" side="right" label="Conversation" bind:open
469
- width={720} minWidth={360} maxWidth="90vw" widthKey="conv-w" fullWidthBelow="960px">
470
- {#snippet panel()}…{/snippet}
471
- </ResizablePanel>
472
- ```
473
-
474
- `Scrim` (`onclose`, `hideBelow`, `z`, `label`) is the drawer's backdrop atom,
475
- exported for custom overlays: click or Escape (document-level) calls `onclose`.
476
- `resizeHandle` is the shared drag action behind every grip: `use:resizeHandle={{
477
- side, min, max, step, onwidth, oncommit, onreset, onactive, measure }}` gives
478
- any element pointer-capture + rAF-coalesced dragging, arrow/Home/End keys and
479
- double-click reset; the width defaults to the element's parent box.
480
-
481
- ### Stacked distribution + legend
482
-
483
- `SegmentedProgress mode="stacked"` turns the bar into one shared track whose slice
484
- widths follow `value` (not `max`), fully filled, no gaps; a zero value collapses to
485
- nothing. Pass a top-level `max` to show the remainder as empty track. `legend`
486
- renders a dot + label + count per segment (`true`/`'below'` or `'inline'`), or takes
487
- a snippet for custom rendering. In this mode the bar is `role="img"`, labelled from
488
- the segments. `gap` (px or CSS length, default `2`) applies in segments mode, and
489
- `tone: 'ok'` is an alias of `'success'`.
490
-
491
- ```svelte
492
- <SegmentedProgress
493
- mode="stacked"
494
- label="Analysis"
495
- max={analyzedCount}
496
- legend
497
- segments={[
498
- { value: 412, max: 0, tone: 'ok', label: 'conforming' },
499
- { value: 12, max: 0, tone: 'warn', label: 'nonconforming' },
500
- { value: 0, max: 0, tone: 'danger', label: 'blocked' }
501
- ]}
502
- />
503
- ```
504
-
505
- ### Tinted surfaces & Callout
506
-
507
- `Card` takes `tone="neutral" | "ok" | "warn" | "danger" | "info"` (default
508
- `neutral`, unchanged look) to tint its border and wash its background with the
509
- semantic hue. `Callout` builds on it for inline messages: leading glyph (auto
510
- per tone, or `icon`), optional `title`, body, right-aligned `actions` snippet,
511
- `dismissible` + `ondismiss`, and `busy` to show a Spinner while work runs. It
512
- is a live region — `role="status"`, or `role="alert"` when `tone="danger"`.
513
-
514
- ```svelte
515
- <Callout tone="danger" title="Search failed" dismissible ondismiss={clear}>
516
- The provider returned 503.
517
- {#snippet actions()}<Button size="sm" onclick={retry}>Retry</Button>{/snippet}
518
- </Callout>
519
- ```
79
+ Layers: `tokens.css`, atoms (the only raw `<button>`/`<input>`/`<select>`), molecules, layouts. Each reaches one layer down ([docs/design.md](docs/design.md)). Props, `aria-*` included, spread onto the underlying element.
520
80
 
521
- ### Modal, ConfirmModal & Pagination
81
+ | Layer | Components |
82
+ |---|---|
83
+ | [Atoms](docs/components.md#atoms) | Text, Heading, Button, Input, Textarea, Select, Switch, Checkbox, Slider, Gauge, Avatar, Card, Badge, Link, Icon |
84
+ | [Molecules](docs/components.md#molecules) | Field, Modal, Popover, Menu, Tabs, Combobox, Disclosure, Accordion, Toaster, ThemePicker, Drawer, SplitButton, SortableList |
85
+ | [Organisms](docs/components.md#organisms) | DataTable, ConversationPanel, ConversationFeed, FilterSearchBar |
86
+ | [Layouts](docs/components.md#layouts) | AppShell, NavBar, MasterDetail, Container, Stack, Cluster, AutoGrid |
87
+ | [Mobile and media](docs/components.md#mobile-tabbar-swiperow-pulltorefresh) | TabBar, SwipeRow, PullToRefresh, SeekSlider, VolumeSlider |
88
+ | [Stores and helpers](docs/components.md#stores-actions-and-helpers) | `theme`, `toasts`, `fontScale`, `autoresize`, `copyToClipboard` |
522
89
 
523
- `Modal` opens on mount by default; pass `bind:open` instead to keep it mounted
524
- and drive `showModal()`/`close()` from state (closing writes `open = false`).
525
- `tone="danger" | "warn" | "info"` adds a title glyph and a 3px top border;
526
- `busy` makes the body inert, shows a spinner by the title and disables Escape,
527
- backdrop and the close button. The `footer` snippet is a right-aligned flex row
528
- (`justify-content: flex-end; gap: var(--sp-2)`); `footerFill` stretches its
529
- children instead, for a footer that is one layout wrapper rather than a short
530
- row of buttons.
90
+ | Guide | Covers |
91
+ |---|---|
92
+ | [Patterns](docs/patterns.md) | loading, undo over confirm, URL state, breakpoints |
93
+ | [Layout and sizing](docs/layout.md) | container queries, rem sizing, control heights, 44px hit areas |
94
+ | [Guardrails](docs/guardrails.md) | `tsumikit-lint` CLI, dev-mode warnings |
95
+ | [Sound](docs/sound.md) | opt-in UI sounds |
96
+ | [Accessibility](docs/accessibility.md) | ARIA patterns, platform features |
97
+ | [Design principles](principles/README.md) | 30 principles, each with a check |
531
98
 
532
- `ConfirmModal` wraps it as a yes/no dialog: `title`, `message` (or `children`),
533
- `confirmLabel`/`cancelLabel`, `tone="primary" | "danger" | "warn"`, `busy`,
534
- `onconfirm`, `oncancel`. An async `onconfirm` keeps the dialog busy until it
535
- settles, closes only on success and shows a rejection's message (`role="alert"`)
536
- under the body. `Button` accepts `tone="danger"` as an alias of
537
- `variant="danger"` so the tone axis works alone.
538
-
539
- ```svelte
540
- <ConfirmModal bind:open title="Delete library?" tone="danger" confirmLabel="Delete"
541
- onconfirm={() => api.deleteLibrary(id)} />
542
- ```
543
-
544
- `Pagination` renders `<nav aria-label>` with prev/next IconButtons, numbered
545
- pages (`aria-current="page"`), ellipses and an optional `showRange` readout.
546
- Page mode: `bind:page` + `pageCount`. Offset mode: `bind:offset` + `limit` +
547
- `total` (page and count are derived, `offset` is written back). `onchange(page)`,
548
- `siblings` (1), `showEdges` (true), `size="sm" | "md"`, `label`. Under 24rem of
549
- container width it collapses to prev / "3 / 12" / next.
550
-
551
- ```svelte
552
- <Pagination bind:offset limit={20} total={412} showRange onchange={load} />
553
- ```
554
-
555
- ### SectionHeader, Card header/footer, KeyValue, LoadMore
556
-
557
- `SectionHeader` is the one "title + meta + right-aligned actions" row:
558
- `title` (or `label`), `level` 1–4 (default 2), `size`, `subtitle`, `icon`,
559
- `count` (faint tabular figure after the title), `tone`, `hue` (0–360 swatch
560
- chip), `uppercase` (eyebrow group label), `divider`, `sticky` (pins at
561
- `--sticky-offset` / `--header-h` and publishes `--section-header-h` on its
562
- parent), `collapsible` + `bind:open` (title becomes a disclosure button with
563
- `aria-expanded`; `children` render beneath while open), `actions` snippet.
564
- `variant="group"` is the non-wrapping list-group row: `lead` snippet (dot /
565
- badge) · title · `count` · flexible 1px rule · `actions` (sort menu, eye toggle).
566
-
567
- `Card` gains `header` / `footer` snippets rendered outside the padded body
568
- behind a divider, `title` / `subtitle` / `actions` sugar that renders a
569
- `SectionHeader` in the header slot, and `gap` to stack children as a flex
570
- column. A Card without any of these renders exactly as before.
571
-
572
- `KeyValue` renders `rows: { label, value: string | number | Snippet, mono?,
573
- tone?, hint? }[]` as a semantic `<dl>` grid; `columns` 1 | 2, `dense`, `align`
574
- start | end. `LoadMore` is the tri-state list footer: `state` idle | loading |
575
- error | done, `onload`, `label`, `loadingLabel`, `errorLabel`, `retryLabel`,
576
- `doneLabel`, `pill` for the compact "load older" chip.
577
-
578
- ```svelte
579
- <Card title="Recent sessions" subtitle="last 24h">
580
- {#snippet actions()}<Link href="/sessions">View all</Link>{/snippet}
581
- <KeyValue rows={[{ label: 'Running', value: 3, tone: 'ok' }, { label: 'Host', value: 'sakura', mono: true }]} />
582
- {#snippet footer()}<LoadMore state={more} onload={loadMore} />{/snippet}
583
- </Card>
584
-
585
- <SectionHeader label="Blocked" count={4} uppercase hue={12} collapsible bind:open>
586
- …group rows…
587
- </SectionHeader>
588
- ```
589
-
590
- ### InputGroup
591
-
592
- One visual unit: `[leading] field [trailing]` with a single border, radius,
593
- background and focus ring. The field (`Input` or `Textarea`, optionally wrapped
594
- by the consumer) keeps its own border and spans the whole group width; the
595
- `leading`/`trailing` snippets are overlaid inside it and the field pads its
596
- text past them. This is a hard constraint, not a look: Firefox Android zooms to
597
- the focused element's rect, so a field laid out beside its buttons zooms the
598
- page. Adornment widths are measured with a `ResizeObserver` into
599
- `--ig-leading-w`/`--ig-trailing-w`, so a trailing label that changes
600
- (`Send (42s)`) never runs under the text.
601
-
602
- The adornments sit flush inside the field's padding as ghost/primary controls,
603
- no dividers: pick a control one step below the group (`size="sm"`/`box="sm"` in
604
- an `md` group, `box="xs"` in `sm`, `box="lg"` in `lg`). `align="end"` (default)
605
- pins them to the bottom edge as a textarea grows. A square control (`box`,
606
- `iconOnly`) drops its fixed box and stretches square to the slot height, so its
607
- glyph centres on the field; `align="center"` is for a
608
- single-line `Input`. `size` sm/md/lg, `disabled` and `error` flow to the field
609
- through context (a `size`/`invalid`/`disabled` set on the field still wins).
610
- `Textarea` keeps `autoresize`, `maxHeight` and `resize="top"` (the grip is
611
- clipped to the space between the adornments). Once a `Textarea` needs more
612
- than one row (content, `rows`, or a dragged floor; a wrapping placeholder does
613
- not count, it stays on one clipped line) the adornments move into a
614
- bar under the text: every line then runs the full width with the normal
615
- padding, the textarea's box (and its scrollbar) ends above the controls, and
616
- the bar continues the field's border, the controls still flush against its
617
- bottom and side edges. The textarea measures that in the
618
- single-line layout, so the switch cannot flip back and forth; the single-line
619
- look is untouched. Tab order is leading → field → trailing; the group shows
620
- the focus ring around the whole unit.
621
-
622
- ```svelte
623
- <InputGroup>
624
- {#snippet leading()}<FileButton iconOnly label="Attach" variant="ghost" box="sm" {onfiles} />{/snippet}
625
- <Textarea bind:value autoresize rows={1} resize="top" maxHeight="40vh" aria-label="Message" />
626
- {#snippet trailing()}<Button variant="primary" size="sm" onclick={send}>Send</Button>{/snippet}
627
- </InputGroup>
628
- ```
629
-
630
- `Composer` is built on it: attachments above, `FileButton` leading, the send
631
- button trailing, `resize="top"` opt-in.
632
-
633
- ### AttachmentList
634
-
635
- `AttachmentList` renders `files` (`File` or `{ name, size?, type?, url? }`)
636
- as removable mono chips with the formatted size. It sets `container-type:
637
- inline-size` on itself: at 30rem or narrower every file collapses to a square
638
- tile (a file glyph, or the image's thumbnail — an object URL for a `File`,
639
- `url` for a plain attachment). Tapping a tile opens a `Popover` with the full
640
- wrapping name, the size and a Remove button, so four screenshots take one row
641
- on a phone instead of four. `tiles` forces the tile mode (`false` forces
642
- chips); `removeLabel` names the popover button. Both variants are in the DOM
643
- and the container query displays one; the hidden one is `display: none`, so
644
- it is neither focusable nor announced. `Composer` inherits the behaviour.
645
- Chips show a small thumbnail before an image's name and clip it in the middle
646
- (the last 12 characters stay, so look-alike screenshot names remain telling).
647
- `numbered` shows each file's 1-based position on the chip and the tile,
648
- matching `[#N]` markers written in a prompt. `onopen(index)` makes the chip name a button and adds a
649
- larger preview (images) plus an `openLabel` ("Open") action to the tile
650
- popover; Remove stays separate.
651
-
652
- ```svelte
653
- <AttachmentList files={attachments} onremove={(i) => (attachments = attachments.toSpliced(i, 1))} />
654
- <AttachmentList tiles files={[{ name: 'cover.png', size: 1024, type: 'image/png', url }]} />
655
- <AttachmentList numbered files={attachments} onopen={(i) => preview(attachments[i])} />
656
- ```
657
-
658
- ### ConversationPanel, ConversationFeed, StatusBar
659
-
660
- `ConversationPanel` is the chat column: `header`, the feed as `children`
661
- (flexes), `status`, `composer`, filling its parent with token borders between
662
- the rows. `ConversationFeed` (generic `<T>`) takes `items`, `key(item)` and an
663
- `item(item, index)` snippet; `empty` renders when there is nothing. It sticks
664
- to the bottom while the user is within 80px of it (`follow`, default true),
665
- holds the viewport still when older items are prepended, and shows a
666
- `jumpLabel` pill ("Jump to latest") when new items land while scrolled up.
667
- `loadMore` idle | loading | error | done renders a `LoadMore` pill at the top
668
- that calls `onloadmore`; `scrollToBottom(behavior?)` is exported on the
669
- instance. `StatusBar` is the thin live row under the feed: `tone` idle |
670
- busy (Spinner) | ok | warn | error, `label` or `children`, `trailing`.
671
-
672
- `renderMarkdown(src, { tables?, autolink?, highlight? })` turns chat markdown
673
- into HTML for `Prose`: everything is escaped first, then headings, emphasis,
674
- inline/fenced code, lists, blockquotes, rules, GFM tables and links with safe
675
- schemes only (`javascript:`/`data:` stay text). `highlight(code, lang)` is an
676
- optional hook returning safe HTML for fenced blocks; `stripAnsi` and
677
- `escapeHtml` are exported too. `ChatBubble` takes `markdown` directly, plus
678
- `timestamp` (after the role pill) and a `footer` snippet. `Composer` gains
679
- `toolbarStart` / `toolbarEnd`: either one moves attach and send into a row
680
- under the field, start slot left, end slot then send right.
681
-
682
- ```svelte
683
- <ConversationPanel>
684
- {#snippet header()}<Text weight="medium">Session</Text>{/snippet}
685
- <ConversationFeed bind:this={feed} items={msgs} key={(m) => m.id} loadMore={more} onloadmore={loadOlder}>
686
- {#snippet item(m)}
687
- <ChatBubble role={m.role} timestamp={m.at} markdown={m.text}>
688
- {#snippet footer()}<span>{m.ms}ms</span>{/snippet}
689
- </ChatBubble>
690
- {/snippet}
691
- </ConversationFeed>
692
- {#snippet status()}<StatusBar tone={busy ? 'busy' : 'ok'} label={busy ? 'Working…' : 'Listening'} />{/snippet}
693
- {#snippet composer()}
694
- <Composer onsubmit={send}>
695
- {#snippet toolbarStart()}<Toggle pressed={pick} onclick={togglePick}>⌖ Pick</Toggle>{/snippet}
696
- </Composer>
697
- {/snippet}
698
- </ConversationPanel>
699
- ```
700
-
701
- ### Artwork
702
-
703
- `Artwork` is the one cover/thumbnail tile: `src` + `alt` (required) render a
704
- lazy, async-decoded `<img>`; a missing or failing source swaps to a gradient
705
- seeded from `seed` (default `alt`) with `fallback` initials | icon | none.
706
- `aspect` ('1/1', '2/3', '16/9' or any ratio), `size` (width), `radius`
707
- sm | md | lg | pill, `fit` cover | contain, `hover` for tappable tiles,
708
- `status` snippet overlaid bottom-right, `onerror`. `artworkGradient(seed)`,
709
- `artworkHue(seed)` and `initials(text)` are exported for non-component use.
710
-
711
- ```svelte
712
- <Artwork src={album.cover} alt={album.title} aspect="1/1" size="9rem" hover>
713
- {#snippet status()}<Dot status="active" ring />{/snippet}
714
- </Artwork>
715
- ```
716
-
717
- ### Avatar
718
-
719
- `Avatar` is the initials/glyph identity mark for people and accounts. `name`
720
- gives the initial (first grapheme, so emoji and flags stay whole) and the
721
- `aria-label`; `glyph` shows an emoji or character instead. The fill hue is
722
- hashed from `seed` (default `name`) — the same hash as Artwork — or set with
723
- `hue`; `tone` accent | neutral | none swaps the hash for a fixed palette (`none`
724
- is for a bare emoji). Hashed fills mix the theme's `--mach-*-sl` pairs with the
725
- surface and ink tokens at ratios that clear 4.5:1 at every hue on every bundled
726
- theme. `size` xs | sm | md | lg (1.25–2.5rem) or a pixel number, `shape` circle |
727
- square, `decorative` drops it from the accessibility tree when the row already
728
- names the person (otherwise `role="img"`), `status` overlays a Dot on the
729
- top-right corner, ringed with `--avatar-status-ring` (default `--bg-elevated`).
730
- `--avatar-bg` / `--avatar-fg` override the colours from outside;
731
- `avatarInitial(name)` and `avatarHue(seed)` are exported.
732
-
733
- ```svelte
734
- <Avatar name={user.name} size={26} decorative />
735
- <Avatar name="work" glyph="🐙" shape="square" size={16} tone="none" />
736
- <Avatar name={me} tone="accent" size="sm">
737
- {#snippet status()}<Dot status="dead" />{/snippet}
738
- </Avatar>
739
- ```
740
-
741
- ### GlyphStack
742
-
743
- `GlyphStack` lays out up to four small glyphs (a pin, a status dot, an entity
744
- tag, an avatar) as an inline run, and folds them into one 2×2 grid of 1rem
745
- cells with a 1px gap when the row is tight — the four slots plus three gaps
746
- a phone-width title cannot spare. `stack` never | always | auto (default);
747
- `auto` measures the nearest query container (any `.cq`, an AppShell region,
748
- or the ancestor whose `container-name` is `container`) and folds below
749
- `stackBelow` (default `30rem`, like the `.cq-*` utilities). Pass `items` as
750
- `{ inline, stacked? }` pairs (snippets or strings) so a glyph can swap to a
751
- compact variant in the grid — a full `Badge` inline, a tinted initial tile
752
- stacked — or render free-form `children`, where each direct child is a cell.
753
- The grid is visual only: DOM order, tab order and reading order are those of
754
- the inline run. `--glyph-stack-gap` sets the inline gap (default `--sp-1`).
755
-
756
- Folded cells are 1rem — hard to hit on touch. `expand` none | tap | slide
757
- (default `none`, `items` only, active while stacked) makes a press open the
758
- four glyphs as big tiles in a top-layer panel over the stack. `tap` opens it
759
- on click and keeps it open, so the star can be clicked, a status dot hovered
760
- and a badge read (dismiss by pressing outside or Escape); keyboard activation
761
- still goes straight to the cell. `slide` opens it on press: drag toward a
762
- corner to arm that tile and release to fire it — the item's `action`, else a
763
- click on the tile's first control. The panel closes after firing unless that
764
- opened a nested popover; a release in place, or on a tile with nothing to
765
- fire, leaves it open as in `tap`. An item's `expanded` snippet replaces
766
- `inline` in its tile; `--glyph-stack-tile` (default `3rem`) and
767
- `--glyph-stack-zoom` (default `1.75`) size the tiles; `expandLabel` names the
768
- panel.
769
-
770
- ```svelte
771
- <GlyphStack stackBelow="34rem" items={[
772
- { inline: pin },
773
- { inline: dot },
774
- { inline: machine, stacked: machineTile },
775
- { inline: avatar }
776
- ]} />
777
-
778
- <GlyphStack stack="always" expand="slide" items={[
779
- { inline: pin, action: togglePin },
780
- { inline: dot },
781
- { inline: machine, stacked: machineTile },
782
- { inline: avatar }
783
- ]} />
784
- ```
785
-
786
- ### RadialMenu
787
-
788
- `RadialMenu` is a round FAB that opens its `items` (the same `MenuItem` type
789
- `Menu` takes: `label`, `icon`, `onselect`, `danger`, `disabled`, `pressed`,
790
- `keepOpen`, `attrs`) as round icon-over-label petals on an arc around it, over
791
- a `Scrim` (`scrim={false}` drops it; a click outside still closes). `arc`
792
- `{ from, to }` is in degrees, 0 = right, counter-clockwise; the default
793
- `{ from: 200, to: -20 }` is an upward fan. Petals sit at equal angles at
794
- `radius` (default `--radial-radius`, else `8.5rem`) and are `petalSize` wide
795
- (default `--radial-petal-size`, else `3.5rem`, never under `--touch-target`).
796
- The panel lives in the top layer, so it escapes `overflow: hidden` parents and
797
- a fixed bottom bar; the radius shrinks, and then the fan shifts, to keep every
798
- petal inside the viewport and its safe-area insets. `status` renders a line
799
- centred above the arc.
800
-
801
- The default trigger is a `--radial-fab-size` (3.75rem) circle with a 2px ring
802
- in `tone` (default `accent`) that swaps `icon` for `closeIcon` while open and
803
- shows an optional `caption` (string or snippet); `trigger` replaces its content
804
- and receives `{ open }`. `open` is bindable, with `onopen` / `onclose`; rest
805
- props land on the trigger. Tap toggles; press-and-slide (press the FAB, drag
806
- over a petal, release) fires the petal under the finger, sharing the gesture
807
- helper with `GlyphStack expand="slide"`. The trigger carries `aria-haspopup`,
808
- `aria-expanded` and `aria-controls`; petals are `menuitem`s with a roving
809
- tabindex. Opening from the keyboard focuses the first enabled petal; ←/→ walk
810
- the arc in screen order, ↓/↑ in item order, Home/End jump to the ends,
811
- Enter/Space fire and close, Escape or Tab close and return focus to the
812
- trigger. Under `prefers-reduced-motion` petals appear without the staggered
813
- scale-in.
814
-
815
- ```svelte
816
- <RadialMenu label="Bot" {items} caption="● bot" tone="ok">
817
- {#snippet status()}● Armed · explore · in world{/snippet}
818
- </RadialMenu>
819
- ```
820
-
821
- ### SplitButton
822
-
823
- `SplitButton` fuses a primary action with a narrow caret that opens a `Menu`:
824
- one outer radius, square inner corners, a 1px divider, two tab stops. The
825
- primary takes `onclick`, `type` (submit inside a form) and `loading`; the caret
826
- takes `label` (its accessible name), `items`, `placement` and the `Menu`
827
- panel props (`tag`, `panelClass`, `panelStyle`, `gap`, `open`, `onopen`,
828
- `onclose`). `primaryProps` sets attributes on the primary segment itself
829
- (`title`, `aria-describedby`, `data-*` — a disabled-with-reason tooltip), while
830
- rest props stay on the root box; the props SplitButton owns are applied after it,
831
- so the fused chrome cannot be clobbered.
832
- `variant` (default | primary | danger), `size` and `control` apply to both
833
- segments and match `Button` heights; `disabled` turns off the whole control,
834
- `menuDisabled` only the caret. `caret="half"` narrows the caret to half the
835
- control height so it reads as secondary. ArrowDown on the primary opens the menu.
836
-
837
- ```svelte
838
- <SplitButton variant="primary" label="Send options" {items} onclick={send}>Send</SplitButton>
839
- ```
99
+ ## Limitations
840
100
 
841
- ### SortableList
101
+ - Svelte 5 only.
102
+ - No bundled syntax highlighter: `CodeBlock` takes a `highlight` callback ([details](docs/components.md#syntax-highlighting-codeblock)).
103
+ - Internal class names (`.btn`) and `data-tsu` markers are private and may change in any release. Style through the published custom properties, not `:global()`.
104
+ - Pre-1.0: minor releases can change defaults. Migration notes are in [`docs/`](docs/).
842
105
 
843
- `SortableList` reorders a list the consumer owns. Pass `items` and a `key`, render
844
- each row's content with the `row` snippet; the kit draws the grip, the ordinal
845
- (`numbered`) and the ↑/↓ arrows (`arrows`, on by default) around it. `onreorder(keys,
846
- { key, from, to })` fires on a drop or an arrow press with the full new key order.
847
- The list shows that order at once and keeps it until `items` changes; if the
848
- consumer never adopts it, it falls back to `items` after `revertAfter` ms
849
- (3000). `canMove(key, to)` vetoes a target; `locked(item)` removes the grip and
850
- disables the arrows. `selected` (bindable) + `onselect` turn the row body into a
851
- button with an accent tint and a 2px inset bar on the selected row; put row
852
- actions in the `trailing` snippet so they stay outside that button.
853
-
854
- Drag runs on Pointer Events, so it works on touch. The grip has `touch-action:
855
- none`; `handle="row"` drags from anywhere on the row, and touch then starts
856
- after a 250 ms long-press so the list still scrolls. The lifted row casts
857
- `--shadow-md` over a dashed slot, and the nearest scroll container scrolls at
858
- its edges. On the keyboard the grip is a "Reorder {name}" button: Space/Enter
859
- grabs, ↑/↓ moves, Home/End jumps, Space/Enter drops, Escape restores.
860
- Alt+↑/↓ moves the focused row in one step. A polite live region announces each
861
- step; override any string through `messages` (defaults in
862
- `SORTABLE_LIST_MESSAGES`). `size` is `sm` (1.875rem rows) or `md` (2.25rem);
863
- `--sortable-row-h` overrides both.
864
-
865
- ```svelte
866
- <SortableList
867
- items={rares}
868
- key={(r) => r.id}
869
- name={(r) => r.name}
870
- label="Rare hunt order"
871
- numbered
872
- size="sm"
873
- bind:selected
874
- onreorder={(keys) => send({ rare_order: keys })}
875
- >
876
- {#snippet row(r)}<span>{r.name}</span><span>{r.dist}</span>{/snippet}
877
- </SortableList>
878
- ```
879
-
880
- ## Patterns
881
-
882
- ### Calm loading
883
-
884
- Every busy indicator in the kit waits 200 ms before appearing and then stays at least 500 ms, so a 60 ms save never flashes a spinner and a slow one never flickers. `Button loading` and `EmptyState loading` do this internally; `aria-busy` and the click block still follow `loading` at once, only the visual is calmed. Tune or disable per instance with `loadingDelay` / `loadingMin` (`0` = immediate). For your own indicators:
885
-
886
- ```svelte
887
- <script>
888
- import { pending } from '@dorsk/tsumikit';
889
- let saving = $state(false);
890
- const p = pending(() => saving, { delay: 200, min: 500 });
891
- </script>
892
- {#if p.visible}<Spinner />{/if}
893
- ```
894
-
895
- ### Undo over confirm
896
-
897
- Prefer an optimistic change with an Undo toast to a confirmation dialog when success is likely. `toasts.undo` applies at once, offers Undo while the toast is visible, commits when it goes away (timeout, click, or `pagehide`), and reverts with a plain-words error toast if the commit fails:
898
-
899
- ```ts
900
- toasts.undo({
901
- message: 'Message deleted',
902
- apply: () => (items = items.filter((m) => m.id !== id)),
903
- revert: () => (items = [...items, removed]),
904
- commit: () => api.delete(id),
905
- });
906
- ```
907
-
908
- Keep `ConfirmModal` for irreversible or bulk actions (purging data, deleting many rows, anything that cannot be reverted locally).
909
-
910
- ### URL state
911
-
912
- Put tab, filter and page state in the URL so it survives a refresh and can be shared. `urlState` is a framework-neutral rune for one search param; Tabs, SegmentedControl and Pagination accept `href` and then render real links (middle-click opens in a new tab) with `aria-current`.
913
-
914
- ```svelte
915
- <script lang="ts">
916
- import { Tabs, Pagination, urlState } from '@dorsk/tsumikit';
917
- const tab = urlState('tab', { default: 'overview' });
918
- const page = urlState('page', { default: 1, parse: Number, serialize: String, push: true });
919
- </script>
920
-
921
- <Tabs {tabs} bind:value={tab.value} href={(id) => tab.href(id)} />
922
- <Pagination bind:page={page.value} pageCount={12} href={(p) => page.href(p)} />
923
- ```
924
-
925
- `urlState(key, { default, parse?, serialize?, push? })` reads the param (or `default`), writes with `history.replaceState` (`pushState` when `push`), follows back/forward, and holds `default` without a `window` (SSR-safe). A value that serializes like the default removes the param. `href(value)` builds the link for the current page with that value. Per-item `href` on `TabItem`/`SegmentOption` overrides the function. `isModifiedClick(e)` is exported for custom links that must leave ctrl/meta/shift/middle clicks to the browser.
926
-
927
- ### Backdrop
928
-
929
- `Backdrop` is the page-level hero: a full-bleed `Artwork` (so a missing or failed
930
- `src` shows the seeded placeholder and the picture fades in lazily) that melts into
931
- `--bg` along its bottom edge, with `children` sitting on a scrim mixed from `--bg`
932
- so titles and actions read over any picture. `height` takes any CSS length
933
- (default `min(45svh, 22rem)`), `fade="none"` keeps a hard edge, `blur` softens the
934
- picture, `dim` tints it toward `--bg`. `seed`, `fallback`, `icon`, `fit`, `srcset`,
935
- `sizes`, `onerror` are passed straight to Artwork; everything else (incl. `aria-*`)
936
- spreads onto the root. Under `prefers-reduced-transparency` the scrim becomes
937
- opaque and the blur is dropped; `prefers-contrast: more` also uses the opaque scrim.
938
-
939
- ```svelte
940
- <Backdrop as="header" src={album.cover} alt="" seed={album.title} height="18rem">
941
- <Heading level={1}>{album.title}</Heading>
942
- <Text tone="muted">{album.artist}</Text>
943
- <Button variant="primary">Play</Button>
944
- </Backdrop>
945
- ```
946
-
947
- ### Masked spans while typing (Textarea `segments`)
948
-
949
- `Textarea` (and `Composer`, which forwards it) accepts `segments`, a function that splits the
950
- current value into spans for a mirror overlay rendered behind a text-transparent native textarea.
951
- Each segment's `text` is rendered verbatim so glyph widths match 1:1; `mask: true` keeps the span's
952
- layout but hides its glyphs under a token fill (`--textarea-mask` overrides it); `class` is added
953
- to the span. The texts must concatenate back to the value. Caret, selection, IME composition, undo,
954
- autoresize and scrolling stay native. The overlay is `aria-hidden`: assistive tech and the form
955
- still get the raw value. Without `segments` the Textarea renders exactly as before.
956
-
957
- ```svelte
958
- <script lang="ts">
959
- import { Textarea } from '@dorsk/tsumikit';
960
- const TAG = /(<redacted>)([\s\S]*?)(<\/redacted>)/g;
961
- function segments(text: string) {
962
- const out = [];
963
- let last = 0;
964
- for (const m of text.matchAll(TAG)) {
965
- if (m.index > last) out.push({ text: text.slice(last, m.index) });
966
- out.push({ text: m[1], class: 'tag' }, { text: m[2], mask: true }, { text: m[3], class: 'tag' });
967
- last = m.index + m[0].length;
968
- }
969
- if (last < text.length) out.push({ text: text.slice(last) });
970
- return out;
971
- }
972
- </script>
973
-
974
- <Textarea bind:value autoresize {segments} aria-label="Message" />
975
- ```
976
-
977
- ### Breakpoints
978
-
979
- One list for every media query: `sm` 40rem (640px), `md` 48rem (768px), `lg`
980
- 64rem (1024px); max-width queries use the complement (`39.999rem`,
981
- `47.999rem`, `63.999rem`). CSS custom properties cannot drive `@media`, so the
982
- list is exported for JS (`breakpoints`, `minWidth(bp)`, `maxWidth(bp)`) with a
983
- reactive, SSR-safe `mq.sm` / `mq.md` / `mq.lg` and `media(query)`.
984
- `tsumikit-lint` enforces the list in CSS. Inside a pane, prefer container
985
- queries (below).
986
-
987
- ## Guardrails
988
-
989
- ### Design lint (`tsumikit-lint`)
990
-
991
- The package ships a dependency-free CLI that flags drift from the kit's rules in your `.svelte` and `.css` files (it only needs node and the `svelte` peer).
106
+ ## Development
992
107
 
993
108
  ```sh
994
- npx tsumikit-lint # lints ./src
995
- npx tsumikit-lint src lib/styles/app.css
996
- npx tsumikit-lint --json src # CI-friendly
997
- npx tsumikit-lint --rule spacing-scale,breakpoints src
998
- npx tsumikit-lint --disable no-raw-control src
999
- ```
1000
-
1001
- Output is `file:line:col rule message` plus a per-rule summary; exit code is 1 when anything is reported, 2 on usage errors.
1002
-
1003
- | Rule | Flags |
1004
- | --- | --- |
1005
- | `no-raw-color` | hex / rgb / hsl literals outside `:root` / `[data-theme]` blocks (custom-property definitions and `var()`-composed colors are fine) |
1006
- | `spacing-scale` | px / rem in padding, margin, gap, inset and top/right/bottom/left that is not a `--sp-*` token; `0` and `1px` hairlines allowed; suggests the token |
1007
- | `no-raw-control` | raw `<button>`, `<input>`, `<select>`, `<textarea>` instead of the atoms |
1008
- | `one-primary` | more than one `variant="primary"` that can render at the same time (if/else branches are exclusive, snippets are separate) |
1009
- | `no-raw-font` | `font-family` other than `var(--font-*)`; `--font-mono` outside code-ish elements (use `--font-numeric` / `--font-label`) |
1010
- | `breakpoints` | media-query widths other than 40rem/640px, 48rem/768px, 64rem/1024px (and their max-width complements like 39.999rem / 639.98px) |
1011
- | `motion` | `transition` / `@keyframes` on properties other than transform, opacity, color, background-color, box-shadow, filter (and other paint-only properties); `transition: all` |
1012
- | `img-alt` | `<img>` without `alt` |
1013
-
1014
- Suppress a single line with a comment on the line above (or at the end of the line):
1015
-
1016
- ```css
1017
- /* tsumikit-lint-ignore spacing-scale */
1018
- padding: 3px;
1019
- ```
1020
- ```svelte
1021
- <!-- tsumikit-lint-ignore no-raw-control -->
1022
- <button type="button" class="chip">…</button>
1023
- ```
1024
-
1025
- Omit the rule id to ignore every rule on that line. In app code, read the breakpoint list from `breakpoints` in the main entry.
1026
-
1027
- lefthook:
1028
-
1029
- ```yaml
1030
- pre-commit:
1031
- commands:
1032
- tsumikit-lint:
1033
- glob: '*.{svelte,css}'
1034
- run: npx tsumikit-lint {staged_files}
1035
- ```
1036
-
1037
- ### Dev-mode design warnings
1038
-
1039
- In development, `AppShell` starts a DOM audit (`src/lib/dev-audit.ts`) that console.warns once per issue, prefixed `[tsumikit]`, with the offending element:
1040
-
1041
- - more than one visible primary `Button` in the same `main` or dialog (modal and page are counted separately);
1042
- - `Button`/`IconButton` without an accessible name;
1043
- - on coarse pointers, interactive elements whose hit box is under `--touch-target` (kit controls are accepted at `--touch-target-compact`; controls carrying the kit's touch slab are not flagged; opt out per element with `.hit-compact`);
1044
- - text over an `Artwork`/`img` without a `Scrim` (or any opaque background).
1045
-
1046
- Without `AppShell`: `import { startDesignAudit } from '@dorsk/tsumikit/dev-audit'; startDesignAudit();`. The audit is guarded by `import.meta.env.DEV`, so production bundles contain none of it.
1047
-
1048
- ## Container queries
1049
-
1050
- AppShell's `main` and `sidebar` are query containers (`container-name: main` /
1051
- `sidebar`), so components respond to **their box's** width, not the viewport.
1052
- Use the `.cq-*` utilities (`.cq-hide`, `.cq-stack`, `.cq-truncate`,
1053
- `.cq-hide-xs`) on children of any `.cq` container — e.g. wrap a button's label in
1054
- `.cq-hide` and it becomes icon-only when its column is tight. `NavItem` uses the
1055
- `sidebar` container to drop labels below ~8rem; `AppShell resizableSidebar` lets
1056
- you drag the sidebar down to that icon rail (width persisted).
1057
-
1058
- **Stores:** `theme`, `toasts`, `fontScale` (opt-in). **Actions:** `autoresize`.
1059
-
1060
- `toasts.show(message, { tone, duration, action })` plus the `ok`/`error`/`info`
1061
- shorthands (tones `neutral|ok|error|info`; `danger` aliases `error`). An `action`
1062
- (`{ label, run }`) renders a button; the toast shows a loading state while `run`
1063
- settles, then dismisses, and action toasts stay 7s instead of 4s (`duration: 0` is
1064
- sticky). `Toaster` renders in the browser top layer (`popover="manual"`), so toasts
1065
- paint above an open `Modal`; `--toast-max-width` (28rem) caps the stack.
1066
- **Helpers:** `copyToClipboard(text)` — async Clipboard API with an
1067
- insecure-context fallback; returns whether it succeeded.
1068
-
1069
- ## Sizing & zoom
1070
-
1071
- The kit is **`rem`-based and never resets the root font-size**, so the user's
1072
- browser/OS font-size preference and browser zoom scale everything
1073
- proportionally with no code. That's the recommended path for magnification.
1074
-
1075
- `fontScale` / `<FontScalePicker>` is an **opt-in** extra (drives `--fs-scale`,
1076
- text tokens only) for reading-dense apps that want larger body text while
1077
- keeping chrome compact. It isn't wired into AppShell or any default.
1078
-
1079
- ### Mobile: TabBar, SwipeRow, PullToRefresh
1080
-
1081
- `TabBar` is a phone bottom tab bar: `items: { id, label, icon?, href?, badge? }[]`,
1082
- `bind:value` (the selected `id`), `onselect(id, item)`. Items without `href` are a
1083
- `tablist` with roving focus (←/→/Home/End); items with `href` are links with
1084
- `aria-current="page"`. It is fixed to the bottom with `env(safe-area-inset-bottom)`
1085
- (`placement="inline"` flows with the page) and, while mounted as a bar, publishes its
1086
- height as `--tabbar-h` on `<html>`, so content clears it with
1087
- `padding-bottom: var(--tabbar-h)`. Targets are at least 48px; numeric badges cap at `99+`.
1088
-
1089
- `SwipeRow` wraps a list row with one action per side: `leadingAction` on a rightward
1090
- swipe, `trailingAction` on a leftward one, each `{ label, icon?, tone?, onaction }`
1091
- (or `leading` / `trailing` snippets receiving the action and an `armed` flag).
1092
- Releasing past `threshold` (0.35 of the width) fires `onaction`. Drags that are more
1093
- vertical than horizontal are left to the scroller (`touch-action: pan-y`). The same
1094
- actions are visually hidden buttons for keyboard and screen-reader users.
1095
-
1096
- `PullToRefresh` is a scroll container with pull-down-to-refresh for touch and pen.
1097
- `onrefresh` returns a promise; the Spinner holds and the container is `aria-busy`
1098
- with `label` ("Refreshing…") announced politely until it settles. It starts only at
1099
- `scrollTop === 0`, fires once past `threshold` (72px), and contains overscroll so the
1100
- browser's own pull does not fight it. Keep a visible refresh control for mouse users.
1101
-
1102
- ### Player: SeekSlider, VolumeSlider, TimeDisplay
1103
-
1104
- `SeekSlider` is a media scrubber: `bind:value` (seconds), `max` (duration;
1105
- `null`/`Infinity`/`0` = live, track disabled), `buffered` (`[start, end][]` drawn
1106
- as a muted fill), `step` (5 s arrows), `bigStep` (10 % PageUp/Down), `size="sm" | "md"`,
1107
- `formatTime`, `previewSnippet(time)` for a thumbnail in the hover tooltip. `oninput`
1108
- fires while dragging and never seeks; `onchange` fires once per release or keyboard
1109
- step, so seek the player only there. `onscrubstart`/`onscrubend` bracket a drag. The
1110
- hit slab is `--touch-target-compact` tall on coarse pointers while the track stays
1111
- 3–4 px, and horizontal scrubbing never scrolls the page. `aria-valuetext` reads
1112
- "1:02 of 23:45".
1113
-
1114
- `VolumeSlider` pairs a mute toggle (glyph follows the level) with a short slider:
1115
- `bind:value` (0–1), `bind:muted`, `orientation="vertical"`, `step` (5 %, also the
1116
- wheel notch), `onchange`/`oninput`/`onmutechange`. Changing the level unmutes.
1117
- `hideOnTouch` (default) hides the slider, not the mute button, on touch screens.
1118
-
1119
- `TimeDisplay` renders "1:02 / 23:45" in tabular numbers; clicking toggles
1120
- `bind:remaining`. `formatMediaTime`, `describeMediaTime` and `isLiveDuration` are
1121
- exported for custom readouts.
1122
-
1123
- AppShell exposes `--shell-header-h` (the header plus the top safe-area inset) for
1124
- sticky offsets under `viewport-fit=cover`.
1125
-
1126
- ### Toolbar control heights
1127
-
1128
- `size="sm"` is the shared compact-toolbar contract for `Button`, `Popover`
1129
- triggers, and `SegmentedControl`. Each renders an exact
1130
- `--control-height-compact` outer box, so mixed controls share a vertical center:
1131
-
1132
- ```svelte
1133
- <SegmentedControl size="sm" {options} bind:value />
1134
- <Popover size="sm" variant="default" label="More actions">
1135
- {#snippet trigger()}More{/snippet}
1136
- <!-- panel content -->
1137
- </Popover>
1138
- <Button size="sm">Apply</Button>
1139
- ```
1140
-
1141
- `Popover` owns that single semantic trigger button; pass `variant`, `tone`,
1142
- `size`, `control`, `block`, and `disabled` directly instead of rendering a
1143
- `Button` inside its `trigger` snippet. Omitting these props preserves the
1144
- original ghost icon-button default. Use `control` on `Button` or `Popover` when
1145
- the roomier shared `--control-height` composer contract is required.
1146
-
1147
- `variant="toggle"` renders the trigger as a `Toggle` chip — the component
1148
- itself, not a copy of its chrome — so a popover trigger sitting next to a
1149
- `Toggle` in a toolbar shares one box. `size` (`sm` default, `md`), `pill`,
1150
- `pressed` and `tone` behave as they do on `Toggle`; a `Toggle`'s height comes
1151
- from `--toggle-size`, never from its content, so an emoji, an `Icon` and text
1152
- all leave the chip the same height.
1153
-
1154
- ```svelte
1155
- <Toggle pressed={auto} onclick={toggleAuto}><Icon name="zap" size={12} /> Auto</Toggle>
1156
- <Popover label="Pins" variant="toggle" pressed={pinned.length > 0} count={pinned.length}>
1157
- {#snippet trigger()}Pins{/snippet}
1158
- {#snippet children()}…{/snippet}
1159
- </Popover>
1160
- ```
1161
-
1162
- `Toolbar` implements the keyboard contract its `role="toolbar"` announces: the
1163
- bar is a **single tab stop**, and `←`/`→` (plus `Home`/`End`) move between the
1164
- controls that are actually visible — collapsed `data-overflow` children drop out
1165
- of the ring and the `…` trigger is its last stop. Controls that own the arrow
1166
- keys themselves (text inputs, `select`, sliders) keep them. Pass
1167
- `roving={false}` to opt out; that drops the role along with the promise, leaving
1168
- every child an ordinary tab stop.
1169
-
1170
- Button and Popover share the same semantic tones. For a confirmed positive
1171
- action, `tone="success"` gives neutral controls a success tint; combine it with
1172
- `variant="primary"` for a filled success action without consumer CSS.
1173
-
1174
- `count` on `Button`, `IconButton`, `Popover` and `Menu` (their trigger) paints a corner
1175
- count pill (unread, selected, queued) on any size, variant or `box`. It hides at
1176
- `0`/`undefined`, displays `99+` past `countMax`, and the exact number joins the
1177
- accessible name (`"Inbox, 120"`) whether that comes from `aria-label`, `label`
1178
- or the button's text. The pill is accent on accent-ink, inverted on `primary`;
1179
- retint one with `--btn-count-bg` / `--btn-count-fg` (`--pop-count-*` on a
1180
- Popover). `formatCount(n, max)` / `hasCount(n)` are exported for a matching
1181
- counter elsewhere.
1182
-
1183
- ```svelte
1184
- <IconButton icon="bell" label="Notifications" count={unread} />
1185
- <Button variant="primary" count={selected.length} countMax={999}>Apply</Button>
1186
- <Popover label="Filters" count={active}>{#snippet trigger()}<Icon name="filter" />{/snippet}…</Popover>
1187
- ```
1188
-
1189
- `layout="stacked"` turns a `Button` into a hotbar slot: `glyph` (a registry
1190
- icon name, any text such as `■`, or a snippet) over the label, with heights
1191
- following `size` (sm 2.5rem, md 3rem, lg 3.625rem). `glyph` also leads an
1192
- inline button. `shortcut` paints a faint mono key hint in the top-left corner,
1193
- opposite `count`, and sets `aria-keyshortcuts`; the app still binds the key.
1194
- `tone="danger"` gives a stacked slot a danger border and ink, `pressed` sets
1195
- `aria-pressed` and an accent ring, and coarse pointers get the touch hit slab.
1196
-
1197
- ```svelte
1198
- <Button layout="stacked" size="sm" glyph="■" shortcut="1" tone="danger" onclick={disarm}>Disarm</Button>
1199
- <Button layout="stacked" size="sm" glyph="search" shortcut="2">Go zone</Button>
1200
- ```
1201
-
1202
- ### Square boxes & touch targets
1203
-
1204
- Icon-ish controls share one square scale, `box="xs" | "sm" | "md" | "lg"` =
1205
- `--box-xs/sm/md/lg` (1.5rem / `--control-height-compact` / 2.25rem /
1206
- `--control-height-default`): `IconButton`, `Button`, `SelectButton`, `Popover`
1207
- (trigger), `CopyButton` (hides its label) and `FileButton` (implies `iconOnly`).
1208
- A boxed control is always square, padding-less and `flex: none`, so a header
1209
- row of IconButton + SelectButton + Popover lines up with no per-app overrides.
1210
- `md` is the classic 2.25rem icon button and the default; `chip` is an outlined
1211
- `lg`. `Button square` instead takes its side from the height contract in force
1212
- (`size` tier, or `--control-height` with `control`), for a square text glyph in
1213
- a toolbar. `IconButton glyphSize` sets the exact glyph size for SVG *and* text
1214
- glyphs (`size` stays the SVG px, emoji ×1.35).
1215
-
1216
- On `(pointer: coarse)` every icon-only/square control (and `Popover`'s default
1217
- trigger) carries an invisible `::after` slab that extends its hit area to
1218
- `--touch-target` (44px, WCAG 2.5.8) without moving layout; fine pointers are
1219
- untouched. The slab is the centred target with its inline-*end* half cut off:
1220
- 44px tall, and the box plus half the deficit wide (34px for a 24px icon).
1221
- Inline-end overflow would count towards the scrollable overflow of every scroll
1222
- ancestor, so a button near the right edge of an `overflow-y: auto` container
1223
- would make it scroll sideways; in a row that half lies under the next sibling's
1224
- slab anyway. Growing the whole deficit towards the start instead would let each
1225
- slab cover the previous sibling's centre, so taps would land one icon to the
1226
- right.
1227
- `hitArea="compact"` opts a dense-table row out. `Button
1228
- collapseLabel="mobile" | "container"` hides `<span data-label>` children below
1229
- 40rem viewport / 30rem container, drops to icon padding, and names the button
1230
- from the hidden text unless `aria-label` is set:
1231
-
1232
- ```svelte
1233
- <Button collapseLabel="container"><Icon name="search" /><span data-label>Search</span></Button>
1234
- <Cluster stackAt="md"><Button grow>Cancel</Button><Button grow variant="primary">Save</Button></Cluster>
1235
- ```
1236
-
1237
- Compact text controls (`Button size="sm"`, `Toggle`, `Switch`, `Link`) take
1238
- the same shape, sized by `--touch-slab`: at least 8px past the block edges and
1239
- the inline start, and at least `--touch-target-compact` (40px) tall, with no
1240
- inline-end overflow. `SegmentedControl` items grow on the block axis and by
1241
- half the gap sideways, which stays inside the group's padding, so neighbours
1242
- never overlap. `Input size="sm"` cannot host a pseudo-element and grows to 40px
1243
- instead, as should any control that clips its own overflow:
1244
- `min-height: var(--touch-target-compact)`.
1245
- `Checkbox`, `Button variant="link"` and the `DataTable` sort button take the same
1246
- slab; inside a DataTable it is clipped at the table's top edge. A `SegmentedControl`
1247
- wider than its container switches to the `scroll` layout until it fits again
1248
- (`wrap` wraps instead), and `block` segments ellipsise their labels.
1249
-
1250
- `--control-height-*` and `--box-*` are **required tokens**: a consumer that
1251
- forks `variables.css` must keep them (Button `control`, Select `sm`, every `box`)
1252
- or those controls render with unresolved sizes.
1253
-
1254
- ## Sound (opt-in)
1255
-
1256
- UI sound effects are **off by default** and nothing is loaded while they stay off:
1257
- no audio code, no `AudioContext`, no listeners. The user opts in with
1258
- `<SoundSettings />` (a speaker popover with an on/off switch, a volume slider and a
1259
- preview button; `size="sm"` for toolbars) or your own UI bound to the `sound` store.
1260
- The choice persists in `localStorage` under `tsumikit-sound`.
1261
-
1262
- ```svelte
1263
- <script>
1264
- import { Button, SoundSettings, sound } from '@dorsk/tsumikit';
1265
- </script>
1266
-
1267
- <SoundSettings size="sm" />
1268
- <Button sound={false}>Silent button</Button>
1269
- <Button onclick={() => sound.play('success')}>Play success</Button>
109
+ git clone https://github.com/DorskFR/tsumikit && cd tsumikit
110
+ npm install
111
+ npm run dev # showcase at the printed URL (+ /shell for the AppShell demo)
112
+ npm test # contract tests (node --test)
113
+ npm run lint # biome
114
+ npm run check # svelte-check
115
+ npm run build # prerendered static site in /build
116
+ npm run package # publishable library in /dist (+ publint)
1270
117
  ```
1271
118
 
1272
- - **Palette**: `click` (buttons, selections, menu items), `toggle-on` / `toggle-off`
1273
- (Switch, Checkbox, Toggle), `open` / `close` (Modal, Drawer, Popover, Menu,
1274
- Accordion…), `success` / `error` (ok and error toasts, CopyButton), `tick` (slider
1275
- steps, pull-to-refresh threshold). Every sound is a short soft synth tone, under
1276
- 120 ms for interactions and under 160 ms for `success` / `error`; nothing is hover
1277
- driven.
1278
- - **Per instance**: every hooked component takes `sound={false}` to stay silent.
1279
- - **Your events**: `sound.play('success' | 'error' | …)` goes through the same gates
1280
- (enabled, volume, hidden tab, user activation), so an app can fire feedback on its
1281
- own saves and failures.
1282
- - **Reduced motion**: with `prefers-reduced-motion: reduce` only `success` and
1283
- `error` play; decorative sounds are muted. `sound.muted` tells why nothing plays
1284
- (`'off' | 'reduced-motion' | null`) and `SoundSettings` shows it as a hint.
1285
- - **Weight**: the synth is a separate lazy chunk of about 0.7 KB gzipped, imported
1286
- on the first play (or when the setting is turned on, so a gesture can unlock audio
1287
- on Safari). What components import is a ~0.3 KB gate plus the store.
1288
- - Calls within one frame coalesce (the highest priority wins: error > success >
1289
- open/close > toggle > click > tick) and the same sound plays at most once per 50 ms.
1290
-
1291
- ## Built on the platform
1292
-
1293
- Interactive components lean on modern web features rather than reimplementing
1294
- them in JS — less code, better a11y, fewer edge cases:
1295
-
1296
- - **`<dialog>`** for Modal — top-layer rendering, real focus trap, inert
1297
- background, focus restore and `::backdrop`, all from the browser.
1298
- - **Popover API** (`popover` / `popovertarget`) for Popover & Menu — top layer
1299
- (no z-index races), light-dismiss and Escape handled natively; we add only
1300
- smart placement (anchors to the trigger, flips into the viewport).
1301
- - **Native form controls** under Checkbox / RadioGroup / Select / Switch — real
1302
- keyboard, form participation and a11y; only the visuals are tokenised.
1303
- - **`color-scheme`** per theme so native widgets/scrollbars match; **`@media
1304
- (forced-colors)`** (Windows High Contrast) and **`prefers-contrast`** support;
1305
- **`prefers-reduced-motion`** disables animation globally.
1306
- - **Intrinsic responsive layout**: `<AutoGrid>` (auto-fit + `minmax`) and `.cq`
1307
- (container queries) adapt to available space, not just viewport breakpoints.
1308
-
1309
- ## Syntax highlighting (CodeBlock)
1310
-
1311
- The kit ships **no** highlighter — that keeps it zero-dep and avoids shipping a
1312
- big grammar bundle to every consumer. `CodeBlock` renders the chrome (language
1313
- label, copy, line numbers, wrap, scroll) and takes code three ways: plain
1314
- `code`, a `highlight={(code, lang) => htmlString}` callback, or pre-rendered
1315
- `html`. Pick a highlighter per app:
1316
-
1317
- - **highlight.js** — class-based output (`hljs-*`). Recommended for this kit:
1318
- those classes are already mapped to the `--syn-*` theme tokens (in `app.css`),
1319
- so highlighted code re-themes with every theme for free. Import only the
1320
- languages you need to keep it lean.
1321
- - **Prism** — also class-based (`token.*`), mapped the same way. Similar fit.
1322
- - **Shiki** — VS Code-grade accuracy, but it emits *inline colors* from a fixed
1323
- theme, so it won't follow your themes out of the box. Use its
1324
- `css-variables` theme and map `--shiki-*` to your `--syn-*` tokens if you want
1325
- it themed. Best when you want exact editor fidelity over theme-following.
1326
-
1327
- ```svelte
1328
- <script>
1329
- import hljs from 'highlight.js/lib/core';
1330
- import ts from 'highlight.js/lib/languages/typescript';
1331
- hljs.registerLanguage('typescript', ts);
1332
- const hl = (code, lang) => hljs.highlight(code, { language: lang ?? 'typescript' }).value;
1333
- </script>
1334
- <CodeBlock {code} lang="typescript" highlight={hl} showLineNumbers />
1335
- ```
119
+ Hooks (lefthook, via `prepare`): Biome on commit, `svelte-check` on push. A `v*` tag publishes to npm through GitHub Actions trusted publishing.
1336
120
 
1337
- ## Accessibility baseline
121
+ ## License
1338
122
 
1339
- - Every interactive atom spreads `...rest`, so `id`, `aria-*`, `title`,
1340
- `disabled` and native events pass through.
1341
- - Visible `:focus-visible` rings; ARIA patterns implemented for switch, menu
1342
- (`role=menu` + roving focus), tabs (`tablist` + arrow keys), radiogroup,
1343
- toolbar (`role=toolbar` + roving tabindex), dialog; polite live region for
1344
- toasts; `.sr-only`.
1345
- - Mobile-first: one `min-width: 640px` breakpoint, bottom-sheet→centered-modal,
1346
- safe-area insets, 16px-min inputs (no iOS zoom).
1347
- - A verified color-blind-safe theme (Okabe-Ito); meaning never relies on hue
1348
- alone (icons + text accompany every status color).
123
+ [MIT](LICENSE)