@dorsk/tsumikit 0.82.1 → 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 +73 -1268
- package/dist/components/atoms/Textarea.svelte +101 -0
- package/dist/components/atoms/Textarea.svelte.d.ts +10 -0
- package/dist/components/layouts/ResizablePanel.svelte +1 -1
- package/dist/components/molecules/Composer.svelte +5 -1
- package/dist/components/molecules/Composer.svelte.d.ts +3 -0
- package/dist/index.d.ts +1 -0
- package/dist/styles/app.css +1 -1
- package/dist/styles/syntax.css +1 -1
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -1,1318 +1,123 @@
|
|
|
1
1
|
# @dorsk/tsumikit
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/@dorsk/tsumikit)
|
|
6
|
+
[](https://github.com/DorskFR/tsumikit/actions/workflows/ci.yml)
|
|
7
|
+
[](https://svelte.dev)
|
|
8
|
+
[](LICENSE)
|
|
9
9
|
|
|
10
|
-
|
|
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
|
-
|
|
17
|
+
[Live demo with every component and prop](https://dorskfr.github.io/tsumikit)
|
|
30
18
|
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
25
|
+
Peer: `svelte@^5.0.0`. Inter font bundled.
|
|
53
26
|
|
|
54
|
-
##
|
|
55
|
-
|
|
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
|
-
<
|
|
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={
|
|
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
|
|
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)
|
|
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
|
-
|
|
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: '
|
|
161
|
-
theme.setDefault('
|
|
58
|
+
theme.register({ id: 'acme', label: 'Acme', mode: 'dark' });
|
|
59
|
+
theme.setDefault('acme');
|
|
162
60
|
```
|
|
163
61
|
|
|
164
62
|
```css
|
|
165
|
-
/* brand.css
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
441
80
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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` |
|
|
449
89
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
```
|
|
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 |
|
|
520
98
|
|
|
521
|
-
|
|
99
|
+
## Limitations
|
|
522
100
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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.
|
|
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/).
|
|
531
105
|
|
|
532
|
-
|
|
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
|
-
```
|
|
840
|
-
|
|
841
|
-
### SortableList
|
|
842
|
-
|
|
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
|
-
### Breakpoints
|
|
948
|
-
|
|
949
|
-
One list for every media query: `sm` 40rem (640px), `md` 48rem (768px), `lg`
|
|
950
|
-
64rem (1024px); max-width queries use the complement (`39.999rem`,
|
|
951
|
-
`47.999rem`, `63.999rem`). CSS custom properties cannot drive `@media`, so the
|
|
952
|
-
list is exported for JS (`breakpoints`, `minWidth(bp)`, `maxWidth(bp)`) with a
|
|
953
|
-
reactive, SSR-safe `mq.sm` / `mq.md` / `mq.lg` and `media(query)`.
|
|
954
|
-
`tsumikit-lint` enforces the list in CSS. Inside a pane, prefer container
|
|
955
|
-
queries (below).
|
|
956
|
-
|
|
957
|
-
## Guardrails
|
|
958
|
-
|
|
959
|
-
### Design lint (`tsumikit-lint`)
|
|
960
|
-
|
|
961
|
-
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
|
|
962
107
|
|
|
963
108
|
```sh
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
| Rule | Flags |
|
|
974
|
-
| --- | --- |
|
|
975
|
-
| `no-raw-color` | hex / rgb / hsl literals outside `:root` / `[data-theme]` blocks (custom-property definitions and `var()`-composed colors are fine) |
|
|
976
|
-
| `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 |
|
|
977
|
-
| `no-raw-control` | raw `<button>`, `<input>`, `<select>`, `<textarea>` instead of the atoms |
|
|
978
|
-
| `one-primary` | more than one `variant="primary"` that can render at the same time (if/else branches are exclusive, snippets are separate) |
|
|
979
|
-
| `no-raw-font` | `font-family` other than `var(--font-*)`; `--font-mono` outside code-ish elements (use `--font-numeric` / `--font-label`) |
|
|
980
|
-
| `breakpoints` | media-query widths other than 40rem/640px, 48rem/768px, 64rem/1024px (and their max-width complements like 39.999rem / 639.98px) |
|
|
981
|
-
| `motion` | `transition` / `@keyframes` on properties other than transform, opacity, color, background-color, box-shadow, filter (and other paint-only properties); `transition: all` |
|
|
982
|
-
| `img-alt` | `<img>` without `alt` |
|
|
983
|
-
|
|
984
|
-
Suppress a single line with a comment on the line above (or at the end of the line):
|
|
985
|
-
|
|
986
|
-
```css
|
|
987
|
-
/* tsumikit-lint-ignore spacing-scale */
|
|
988
|
-
padding: 3px;
|
|
989
|
-
```
|
|
990
|
-
```svelte
|
|
991
|
-
<!-- tsumikit-lint-ignore no-raw-control -->
|
|
992
|
-
<button type="button" class="chip">…</button>
|
|
993
|
-
```
|
|
994
|
-
|
|
995
|
-
Omit the rule id to ignore every rule on that line. In app code, read the breakpoint list from `breakpoints` in the main entry.
|
|
996
|
-
|
|
997
|
-
lefthook:
|
|
998
|
-
|
|
999
|
-
```yaml
|
|
1000
|
-
pre-commit:
|
|
1001
|
-
commands:
|
|
1002
|
-
tsumikit-lint:
|
|
1003
|
-
glob: '*.{svelte,css}'
|
|
1004
|
-
run: npx tsumikit-lint {staged_files}
|
|
1005
|
-
```
|
|
1006
|
-
|
|
1007
|
-
### Dev-mode design warnings
|
|
1008
|
-
|
|
1009
|
-
In development, `AppShell` starts a DOM audit (`src/lib/dev-audit.ts`) that console.warns once per issue, prefixed `[tsumikit]`, with the offending element:
|
|
1010
|
-
|
|
1011
|
-
- more than one visible primary `Button` in the same `main` or dialog (modal and page are counted separately);
|
|
1012
|
-
- `Button`/`IconButton` without an accessible name;
|
|
1013
|
-
- 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`);
|
|
1014
|
-
- text over an `Artwork`/`img` without a `Scrim` (or any opaque background).
|
|
1015
|
-
|
|
1016
|
-
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.
|
|
1017
|
-
|
|
1018
|
-
## Container queries
|
|
1019
|
-
|
|
1020
|
-
AppShell's `main` and `sidebar` are query containers (`container-name: main` /
|
|
1021
|
-
`sidebar`), so components respond to **their box's** width, not the viewport.
|
|
1022
|
-
Use the `.cq-*` utilities (`.cq-hide`, `.cq-stack`, `.cq-truncate`,
|
|
1023
|
-
`.cq-hide-xs`) on children of any `.cq` container — e.g. wrap a button's label in
|
|
1024
|
-
`.cq-hide` and it becomes icon-only when its column is tight. `NavItem` uses the
|
|
1025
|
-
`sidebar` container to drop labels below ~8rem; `AppShell resizableSidebar` lets
|
|
1026
|
-
you drag the sidebar down to that icon rail (width persisted).
|
|
1027
|
-
|
|
1028
|
-
**Stores:** `theme`, `toasts`, `fontScale` (opt-in). **Actions:** `autoresize`.
|
|
1029
|
-
|
|
1030
|
-
`toasts.show(message, { tone, duration, action })` plus the `ok`/`error`/`info`
|
|
1031
|
-
shorthands (tones `neutral|ok|error|info`; `danger` aliases `error`). An `action`
|
|
1032
|
-
(`{ label, run }`) renders a button; the toast shows a loading state while `run`
|
|
1033
|
-
settles, then dismisses, and action toasts stay 7s instead of 4s (`duration: 0` is
|
|
1034
|
-
sticky). `Toaster` renders in the browser top layer (`popover="manual"`), so toasts
|
|
1035
|
-
paint above an open `Modal`; `--toast-max-width` (28rem) caps the stack.
|
|
1036
|
-
**Helpers:** `copyToClipboard(text)` — async Clipboard API with an
|
|
1037
|
-
insecure-context fallback; returns whether it succeeded.
|
|
1038
|
-
|
|
1039
|
-
## Sizing & zoom
|
|
1040
|
-
|
|
1041
|
-
The kit is **`rem`-based and never resets the root font-size**, so the user's
|
|
1042
|
-
browser/OS font-size preference and browser zoom scale everything
|
|
1043
|
-
proportionally with no code. That's the recommended path for magnification.
|
|
1044
|
-
|
|
1045
|
-
`fontScale` / `<FontScalePicker>` is an **opt-in** extra (drives `--fs-scale`,
|
|
1046
|
-
text tokens only) for reading-dense apps that want larger body text while
|
|
1047
|
-
keeping chrome compact. It isn't wired into AppShell or any default.
|
|
1048
|
-
|
|
1049
|
-
### Mobile: TabBar, SwipeRow, PullToRefresh
|
|
1050
|
-
|
|
1051
|
-
`TabBar` is a phone bottom tab bar: `items: { id, label, icon?, href?, badge? }[]`,
|
|
1052
|
-
`bind:value` (the selected `id`), `onselect(id, item)`. Items without `href` are a
|
|
1053
|
-
`tablist` with roving focus (←/→/Home/End); items with `href` are links with
|
|
1054
|
-
`aria-current="page"`. It is fixed to the bottom with `env(safe-area-inset-bottom)`
|
|
1055
|
-
(`placement="inline"` flows with the page) and, while mounted as a bar, publishes its
|
|
1056
|
-
height as `--tabbar-h` on `<html>`, so content clears it with
|
|
1057
|
-
`padding-bottom: var(--tabbar-h)`. Targets are at least 48px; numeric badges cap at `99+`.
|
|
1058
|
-
|
|
1059
|
-
`SwipeRow` wraps a list row with one action per side: `leadingAction` on a rightward
|
|
1060
|
-
swipe, `trailingAction` on a leftward one, each `{ label, icon?, tone?, onaction }`
|
|
1061
|
-
(or `leading` / `trailing` snippets receiving the action and an `armed` flag).
|
|
1062
|
-
Releasing past `threshold` (0.35 of the width) fires `onaction`. Drags that are more
|
|
1063
|
-
vertical than horizontal are left to the scroller (`touch-action: pan-y`). The same
|
|
1064
|
-
actions are visually hidden buttons for keyboard and screen-reader users.
|
|
1065
|
-
|
|
1066
|
-
`PullToRefresh` is a scroll container with pull-down-to-refresh for touch and pen.
|
|
1067
|
-
`onrefresh` returns a promise; the Spinner holds and the container is `aria-busy`
|
|
1068
|
-
with `label` ("Refreshing…") announced politely until it settles. It starts only at
|
|
1069
|
-
`scrollTop === 0`, fires once past `threshold` (72px), and contains overscroll so the
|
|
1070
|
-
browser's own pull does not fight it. Keep a visible refresh control for mouse users.
|
|
1071
|
-
|
|
1072
|
-
### Player: SeekSlider, VolumeSlider, TimeDisplay
|
|
1073
|
-
|
|
1074
|
-
`SeekSlider` is a media scrubber: `bind:value` (seconds), `max` (duration;
|
|
1075
|
-
`null`/`Infinity`/`0` = live, track disabled), `buffered` (`[start, end][]` drawn
|
|
1076
|
-
as a muted fill), `step` (5 s arrows), `bigStep` (10 % PageUp/Down), `size="sm" | "md"`,
|
|
1077
|
-
`formatTime`, `previewSnippet(time)` for a thumbnail in the hover tooltip. `oninput`
|
|
1078
|
-
fires while dragging and never seeks; `onchange` fires once per release or keyboard
|
|
1079
|
-
step, so seek the player only there. `onscrubstart`/`onscrubend` bracket a drag. The
|
|
1080
|
-
hit slab is `--touch-target-compact` tall on coarse pointers while the track stays
|
|
1081
|
-
3–4 px, and horizontal scrubbing never scrolls the page. `aria-valuetext` reads
|
|
1082
|
-
"1:02 of 23:45".
|
|
1083
|
-
|
|
1084
|
-
`VolumeSlider` pairs a mute toggle (glyph follows the level) with a short slider:
|
|
1085
|
-
`bind:value` (0–1), `bind:muted`, `orientation="vertical"`, `step` (5 %, also the
|
|
1086
|
-
wheel notch), `onchange`/`oninput`/`onmutechange`. Changing the level unmutes.
|
|
1087
|
-
`hideOnTouch` (default) hides the slider, not the mute button, on touch screens.
|
|
1088
|
-
|
|
1089
|
-
`TimeDisplay` renders "1:02 / 23:45" in tabular numbers; clicking toggles
|
|
1090
|
-
`bind:remaining`. `formatMediaTime`, `describeMediaTime` and `isLiveDuration` are
|
|
1091
|
-
exported for custom readouts.
|
|
1092
|
-
|
|
1093
|
-
AppShell exposes `--shell-header-h` (the header plus the top safe-area inset) for
|
|
1094
|
-
sticky offsets under `viewport-fit=cover`.
|
|
1095
|
-
|
|
1096
|
-
### Toolbar control heights
|
|
1097
|
-
|
|
1098
|
-
`size="sm"` is the shared compact-toolbar contract for `Button`, `Popover`
|
|
1099
|
-
triggers, and `SegmentedControl`. Each renders an exact
|
|
1100
|
-
`--control-height-compact` outer box, so mixed controls share a vertical center:
|
|
1101
|
-
|
|
1102
|
-
```svelte
|
|
1103
|
-
<SegmentedControl size="sm" {options} bind:value />
|
|
1104
|
-
<Popover size="sm" variant="default" label="More actions">
|
|
1105
|
-
{#snippet trigger()}More{/snippet}
|
|
1106
|
-
<!-- panel content -->
|
|
1107
|
-
</Popover>
|
|
1108
|
-
<Button size="sm">Apply</Button>
|
|
1109
|
-
```
|
|
1110
|
-
|
|
1111
|
-
`Popover` owns that single semantic trigger button; pass `variant`, `tone`,
|
|
1112
|
-
`size`, `control`, `block`, and `disabled` directly instead of rendering a
|
|
1113
|
-
`Button` inside its `trigger` snippet. Omitting these props preserves the
|
|
1114
|
-
original ghost icon-button default. Use `control` on `Button` or `Popover` when
|
|
1115
|
-
the roomier shared `--control-height` composer contract is required.
|
|
1116
|
-
|
|
1117
|
-
`variant="toggle"` renders the trigger as a `Toggle` chip — the component
|
|
1118
|
-
itself, not a copy of its chrome — so a popover trigger sitting next to a
|
|
1119
|
-
`Toggle` in a toolbar shares one box. `size` (`sm` default, `md`), `pill`,
|
|
1120
|
-
`pressed` and `tone` behave as they do on `Toggle`; a `Toggle`'s height comes
|
|
1121
|
-
from `--toggle-size`, never from its content, so an emoji, an `Icon` and text
|
|
1122
|
-
all leave the chip the same height.
|
|
1123
|
-
|
|
1124
|
-
```svelte
|
|
1125
|
-
<Toggle pressed={auto} onclick={toggleAuto}><Icon name="zap" size={12} /> Auto</Toggle>
|
|
1126
|
-
<Popover label="Pins" variant="toggle" pressed={pinned.length > 0} count={pinned.length}>
|
|
1127
|
-
{#snippet trigger()}Pins{/snippet}
|
|
1128
|
-
{#snippet children()}…{/snippet}
|
|
1129
|
-
</Popover>
|
|
1130
|
-
```
|
|
1131
|
-
|
|
1132
|
-
`Toolbar` implements the keyboard contract its `role="toolbar"` announces: the
|
|
1133
|
-
bar is a **single tab stop**, and `←`/`→` (plus `Home`/`End`) move between the
|
|
1134
|
-
controls that are actually visible — collapsed `data-overflow` children drop out
|
|
1135
|
-
of the ring and the `…` trigger is its last stop. Controls that own the arrow
|
|
1136
|
-
keys themselves (text inputs, `select`, sliders) keep them. Pass
|
|
1137
|
-
`roving={false}` to opt out; that drops the role along with the promise, leaving
|
|
1138
|
-
every child an ordinary tab stop.
|
|
1139
|
-
|
|
1140
|
-
Button and Popover share the same semantic tones. For a confirmed positive
|
|
1141
|
-
action, `tone="success"` gives neutral controls a success tint; combine it with
|
|
1142
|
-
`variant="primary"` for a filled success action without consumer CSS.
|
|
1143
|
-
|
|
1144
|
-
`count` on `Button`, `IconButton`, `Popover` and `Menu` (their trigger) paints a corner
|
|
1145
|
-
count pill (unread, selected, queued) on any size, variant or `box`. It hides at
|
|
1146
|
-
`0`/`undefined`, displays `99+` past `countMax`, and the exact number joins the
|
|
1147
|
-
accessible name (`"Inbox, 120"`) whether that comes from `aria-label`, `label`
|
|
1148
|
-
or the button's text. The pill is accent on accent-ink, inverted on `primary`;
|
|
1149
|
-
retint one with `--btn-count-bg` / `--btn-count-fg` (`--pop-count-*` on a
|
|
1150
|
-
Popover). `formatCount(n, max)` / `hasCount(n)` are exported for a matching
|
|
1151
|
-
counter elsewhere.
|
|
1152
|
-
|
|
1153
|
-
```svelte
|
|
1154
|
-
<IconButton icon="bell" label="Notifications" count={unread} />
|
|
1155
|
-
<Button variant="primary" count={selected.length} countMax={999}>Apply</Button>
|
|
1156
|
-
<Popover label="Filters" count={active}>{#snippet trigger()}<Icon name="filter" />{/snippet}…</Popover>
|
|
1157
|
-
```
|
|
1158
|
-
|
|
1159
|
-
`layout="stacked"` turns a `Button` into a hotbar slot: `glyph` (a registry
|
|
1160
|
-
icon name, any text such as `■`, or a snippet) over the label, with heights
|
|
1161
|
-
following `size` (sm 2.5rem, md 3rem, lg 3.625rem). `glyph` also leads an
|
|
1162
|
-
inline button. `shortcut` paints a faint mono key hint in the top-left corner,
|
|
1163
|
-
opposite `count`, and sets `aria-keyshortcuts`; the app still binds the key.
|
|
1164
|
-
`tone="danger"` gives a stacked slot a danger border and ink, `pressed` sets
|
|
1165
|
-
`aria-pressed` and an accent ring, and coarse pointers get the touch hit slab.
|
|
1166
|
-
|
|
1167
|
-
```svelte
|
|
1168
|
-
<Button layout="stacked" size="sm" glyph="■" shortcut="1" tone="danger" onclick={disarm}>Disarm</Button>
|
|
1169
|
-
<Button layout="stacked" size="sm" glyph="search" shortcut="2">Go zone</Button>
|
|
1170
|
-
```
|
|
1171
|
-
|
|
1172
|
-
### Square boxes & touch targets
|
|
1173
|
-
|
|
1174
|
-
Icon-ish controls share one square scale, `box="xs" | "sm" | "md" | "lg"` =
|
|
1175
|
-
`--box-xs/sm/md/lg` (1.5rem / `--control-height-compact` / 2.25rem /
|
|
1176
|
-
`--control-height-default`): `IconButton`, `Button`, `SelectButton`, `Popover`
|
|
1177
|
-
(trigger), `CopyButton` (hides its label) and `FileButton` (implies `iconOnly`).
|
|
1178
|
-
A boxed control is always square, padding-less and `flex: none`, so a header
|
|
1179
|
-
row of IconButton + SelectButton + Popover lines up with no per-app overrides.
|
|
1180
|
-
`md` is the classic 2.25rem icon button and the default; `chip` is an outlined
|
|
1181
|
-
`lg`. `Button square` instead takes its side from the height contract in force
|
|
1182
|
-
(`size` tier, or `--control-height` with `control`), for a square text glyph in
|
|
1183
|
-
a toolbar. `IconButton glyphSize` sets the exact glyph size for SVG *and* text
|
|
1184
|
-
glyphs (`size` stays the SVG px, emoji ×1.35).
|
|
1185
|
-
|
|
1186
|
-
On `(pointer: coarse)` every icon-only/square control (and `Popover`'s default
|
|
1187
|
-
trigger) carries an invisible `::after` slab that extends its hit area to
|
|
1188
|
-
`--touch-target` (44px, WCAG 2.5.8) without moving layout; fine pointers are
|
|
1189
|
-
untouched. The slab is the centred target with its inline-*end* half cut off:
|
|
1190
|
-
44px tall, and the box plus half the deficit wide (34px for a 24px icon).
|
|
1191
|
-
Inline-end overflow would count towards the scrollable overflow of every scroll
|
|
1192
|
-
ancestor, so a button near the right edge of an `overflow-y: auto` container
|
|
1193
|
-
would make it scroll sideways; in a row that half lies under the next sibling's
|
|
1194
|
-
slab anyway. Growing the whole deficit towards the start instead would let each
|
|
1195
|
-
slab cover the previous sibling's centre, so taps would land one icon to the
|
|
1196
|
-
right.
|
|
1197
|
-
`hitArea="compact"` opts a dense-table row out. `Button
|
|
1198
|
-
collapseLabel="mobile" | "container"` hides `<span data-label>` children below
|
|
1199
|
-
40rem viewport / 30rem container, drops to icon padding, and names the button
|
|
1200
|
-
from the hidden text unless `aria-label` is set:
|
|
1201
|
-
|
|
1202
|
-
```svelte
|
|
1203
|
-
<Button collapseLabel="container"><Icon name="search" /><span data-label>Search</span></Button>
|
|
1204
|
-
<Cluster stackAt="md"><Button grow>Cancel</Button><Button grow variant="primary">Save</Button></Cluster>
|
|
1205
|
-
```
|
|
1206
|
-
|
|
1207
|
-
Compact text controls (`Button size="sm"`, `Toggle`, `Switch`, `Link`) take
|
|
1208
|
-
the same shape, sized by `--touch-slab`: at least 8px past the block edges and
|
|
1209
|
-
the inline start, and at least `--touch-target-compact` (40px) tall, with no
|
|
1210
|
-
inline-end overflow. `SegmentedControl` items grow on the block axis and by
|
|
1211
|
-
half the gap sideways, which stays inside the group's padding, so neighbours
|
|
1212
|
-
never overlap. `Input size="sm"` cannot host a pseudo-element and grows to 40px
|
|
1213
|
-
instead, as should any control that clips its own overflow:
|
|
1214
|
-
`min-height: var(--touch-target-compact)`.
|
|
1215
|
-
`Checkbox`, `Button variant="link"` and the `DataTable` sort button take the same
|
|
1216
|
-
slab; inside a DataTable it is clipped at the table's top edge. A `SegmentedControl`
|
|
1217
|
-
wider than its container switches to the `scroll` layout until it fits again
|
|
1218
|
-
(`wrap` wraps instead), and `block` segments ellipsise their labels.
|
|
1219
|
-
|
|
1220
|
-
`--control-height-*` and `--box-*` are **required tokens**: a consumer that
|
|
1221
|
-
forks `variables.css` must keep them (Button `control`, Select `sm`, every `box`)
|
|
1222
|
-
or those controls render with unresolved sizes.
|
|
1223
|
-
|
|
1224
|
-
## Sound (opt-in)
|
|
1225
|
-
|
|
1226
|
-
UI sound effects are **off by default** and nothing is loaded while they stay off:
|
|
1227
|
-
no audio code, no `AudioContext`, no listeners. The user opts in with
|
|
1228
|
-
`<SoundSettings />` (a speaker popover with an on/off switch, a volume slider and a
|
|
1229
|
-
preview button; `size="sm"` for toolbars) or your own UI bound to the `sound` store.
|
|
1230
|
-
The choice persists in `localStorage` under `tsumikit-sound`.
|
|
1231
|
-
|
|
1232
|
-
```svelte
|
|
1233
|
-
<script>
|
|
1234
|
-
import { Button, SoundSettings, sound } from '@dorsk/tsumikit';
|
|
1235
|
-
</script>
|
|
1236
|
-
|
|
1237
|
-
<SoundSettings size="sm" />
|
|
1238
|
-
<Button sound={false}>Silent button</Button>
|
|
1239
|
-
<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)
|
|
1240
117
|
```
|
|
1241
118
|
|
|
1242
|
-
|
|
1243
|
-
(Switch, Checkbox, Toggle), `open` / `close` (Modal, Drawer, Popover, Menu,
|
|
1244
|
-
Accordion…), `success` / `error` (ok and error toasts, CopyButton), `tick` (slider
|
|
1245
|
-
steps, pull-to-refresh threshold). Every sound is a short soft synth tone, under
|
|
1246
|
-
120 ms for interactions and under 160 ms for `success` / `error`; nothing is hover
|
|
1247
|
-
driven.
|
|
1248
|
-
- **Per instance**: every hooked component takes `sound={false}` to stay silent.
|
|
1249
|
-
- **Your events**: `sound.play('success' | 'error' | …)` goes through the same gates
|
|
1250
|
-
(enabled, volume, hidden tab, user activation), so an app can fire feedback on its
|
|
1251
|
-
own saves and failures.
|
|
1252
|
-
- **Reduced motion**: with `prefers-reduced-motion: reduce` only `success` and
|
|
1253
|
-
`error` play; decorative sounds are muted. `sound.muted` tells why nothing plays
|
|
1254
|
-
(`'off' | 'reduced-motion' | null`) and `SoundSettings` shows it as a hint.
|
|
1255
|
-
- **Weight**: the synth is a separate lazy chunk of about 0.7 KB gzipped, imported
|
|
1256
|
-
on the first play (or when the setting is turned on, so a gesture can unlock audio
|
|
1257
|
-
on Safari). What components import is a ~0.3 KB gate plus the store.
|
|
1258
|
-
- Calls within one frame coalesce (the highest priority wins: error > success >
|
|
1259
|
-
open/close > toggle > click > tick) and the same sound plays at most once per 50 ms.
|
|
1260
|
-
|
|
1261
|
-
## Built on the platform
|
|
1262
|
-
|
|
1263
|
-
Interactive components lean on modern web features rather than reimplementing
|
|
1264
|
-
them in JS — less code, better a11y, fewer edge cases:
|
|
1265
|
-
|
|
1266
|
-
- **`<dialog>`** for Modal — top-layer rendering, real focus trap, inert
|
|
1267
|
-
background, focus restore and `::backdrop`, all from the browser.
|
|
1268
|
-
- **Popover API** (`popover` / `popovertarget`) for Popover & Menu — top layer
|
|
1269
|
-
(no z-index races), light-dismiss and Escape handled natively; we add only
|
|
1270
|
-
smart placement (anchors to the trigger, flips into the viewport).
|
|
1271
|
-
- **Native form controls** under Checkbox / RadioGroup / Select / Switch — real
|
|
1272
|
-
keyboard, form participation and a11y; only the visuals are tokenised.
|
|
1273
|
-
- **`color-scheme`** per theme so native widgets/scrollbars match; **`@media
|
|
1274
|
-
(forced-colors)`** (Windows High Contrast) and **`prefers-contrast`** support;
|
|
1275
|
-
**`prefers-reduced-motion`** disables animation globally.
|
|
1276
|
-
- **Intrinsic responsive layout**: `<AutoGrid>` (auto-fit + `minmax`) and `.cq`
|
|
1277
|
-
(container queries) adapt to available space, not just viewport breakpoints.
|
|
1278
|
-
|
|
1279
|
-
## Syntax highlighting (CodeBlock)
|
|
1280
|
-
|
|
1281
|
-
The kit ships **no** highlighter — that keeps it zero-dep and avoids shipping a
|
|
1282
|
-
big grammar bundle to every consumer. `CodeBlock` renders the chrome (language
|
|
1283
|
-
label, copy, line numbers, wrap, scroll) and takes code three ways: plain
|
|
1284
|
-
`code`, a `highlight={(code, lang) => htmlString}` callback, or pre-rendered
|
|
1285
|
-
`html`. Pick a highlighter per app:
|
|
1286
|
-
|
|
1287
|
-
- **highlight.js** — class-based output (`hljs-*`). Recommended for this kit:
|
|
1288
|
-
those classes are already mapped to the `--syn-*` theme tokens (in `app.css`),
|
|
1289
|
-
so highlighted code re-themes with every theme for free. Import only the
|
|
1290
|
-
languages you need to keep it lean.
|
|
1291
|
-
- **Prism** — also class-based (`token.*`), mapped the same way. Similar fit.
|
|
1292
|
-
- **Shiki** — VS Code-grade accuracy, but it emits *inline colors* from a fixed
|
|
1293
|
-
theme, so it won't follow your themes out of the box. Use its
|
|
1294
|
-
`css-variables` theme and map `--shiki-*` to your `--syn-*` tokens if you want
|
|
1295
|
-
it themed. Best when you want exact editor fidelity over theme-following.
|
|
1296
|
-
|
|
1297
|
-
```svelte
|
|
1298
|
-
<script>
|
|
1299
|
-
import hljs from 'highlight.js/lib/core';
|
|
1300
|
-
import ts from 'highlight.js/lib/languages/typescript';
|
|
1301
|
-
hljs.registerLanguage('typescript', ts);
|
|
1302
|
-
const hl = (code, lang) => hljs.highlight(code, { language: lang ?? 'typescript' }).value;
|
|
1303
|
-
</script>
|
|
1304
|
-
<CodeBlock {code} lang="typescript" highlight={hl} showLineNumbers />
|
|
1305
|
-
```
|
|
119
|
+
Hooks (lefthook, via `prepare`): Biome on commit, `svelte-check` on push. A `v*` tag publishes to npm through GitHub Actions trusted publishing.
|
|
1306
120
|
|
|
1307
|
-
##
|
|
121
|
+
## License
|
|
1308
122
|
|
|
1309
|
-
|
|
1310
|
-
`disabled` and native events pass through.
|
|
1311
|
-
- Visible `:focus-visible` rings; ARIA patterns implemented for switch, menu
|
|
1312
|
-
(`role=menu` + roving focus), tabs (`tablist` + arrow keys), radiogroup,
|
|
1313
|
-
toolbar (`role=toolbar` + roving tabindex), dialog; polite live region for
|
|
1314
|
-
toasts; `.sr-only`.
|
|
1315
|
-
- Mobile-first: one `min-width: 640px` breakpoint, bottom-sheet→centered-modal,
|
|
1316
|
-
safe-area insets, 16px-min inputs (no iOS zoom).
|
|
1317
|
-
- A verified color-blind-safe theme (Okabe-Ito); meaning never relies on hue
|
|
1318
|
-
alone (icons + text accompany every status color).
|
|
123
|
+
[MIT](LICENSE)
|