@dorsk/tsumikit 0.49.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -154,6 +154,60 @@ for a light theme (`:root` defaults to dark, so dark themes may omit it).
154
154
  - Adding a built-in theme to the kit = one entry in `THEMES`
155
155
  (`stores/theme.svelte.ts`) + one block in `styles/themes.css`.
156
156
 
157
+ ### Styling a component from outside
158
+
159
+ A Svelte consumer's **scoped** CSS cannot reach a child component's markup — the
160
+ scoping attribute is only added to elements the consumer itself renders. `class`
161
+ passthrough therefore only helps for layout hooks (margin, flex, grid placement).
162
+ CSS custom properties *do* cross the component boundary, so they are the
163
+ sanctioned escape hatch for everything else.
164
+
165
+ - **Naming:** `--<prefix>-<axis>`, where `<prefix>` is the component's short
166
+ prefix (`btn`, `badge`, `txt`, `select`, `input`, `textarea`, `card`, `md`, …)
167
+ and `<axis>` is one of `bg`, `fg`, `border`, `size`, `radius`, plus a few
168
+ component-specific ones (`--btn-tone`, `--badge-max-width`, `--gauge-w`).
169
+ `Text` uses `txt` because `--text*` is already the theme's foreground token.
170
+ - **Setting them:** per instance via `style`, or on any ancestor for a subtree.
171
+
172
+ ```svelte
173
+ <Button style="--btn-bg: var(--c-violet); --btn-fg: #fff; --btn-radius: 0">Ship</Button>
174
+
175
+ <div class="danger-zone">
176
+ <Button>Delete</Button> <!-- inherits the block below -->
177
+ </div>
178
+
179
+ <style>
180
+ .danger-zone { --btn-border: var(--danger); --btn-fg: var(--danger); }
181
+ </style>
182
+ ```
183
+
184
+ - Every property is read as `var(--x, <default>)`, so leaving it unset renders
185
+ exactly as before.
186
+ - **`:global()` into kit internals is unsupported.** Class names like `.btn`,
187
+ `.badge`, `.select-wrap` or `.textarea` are private and change without a major
188
+ bump. If an axis you need is missing, open an issue — do not reach in.
189
+
190
+ | component | published properties |
191
+ | --- | --- |
192
+ | `Button` | `--btn-bg`, `--btn-fg`, `--btn-border`, `--btn-size`, `--btn-radius`, `--btn-tone`, `--btn-on`, `--btn-box` |
193
+ | `Badge` | `--badge-bg`, `--badge-fg`, `--badge-border`, `--badge-radius`, `--badge-tone`, `--badge-max-width` |
194
+ | `Text` | `--txt-fg`, `--txt-size` |
195
+ | `Select` | `--select-bg`, `--select-fg`, `--select-border`, `--select-size`, `--select-radius` |
196
+ | `Input` | `--input-bg`, `--input-fg`, `--input-border`, `--input-size`, `--input-radius` |
197
+ | `Textarea` | `--textarea-bg`, `--textarea-fg`, `--textarea-border`, `--textarea-size`, `--textarea-radius` |
198
+ | `Card` | `--card-pad`, `--card-gap`, `--card-border-style` |
199
+ | `Divider` | `--divider-color`, `--divider-spacing` |
200
+ | `Gauge` | `--gauge-w`, `--gauge-h`, `--gauge-fill` |
201
+ | `MasterDetail` | `--md-list-w`, `--md-gap`, `--md-divider` |
202
+ | `NavBar` | `--navbar-max` |
203
+ | `Callout` | `--callout-tone` |
204
+ | `Fieldset` | `--fieldset-pad`, `--fieldset-border` |
205
+ | `GitRef` | `--git-ref-tone`, `--git-ref-max-width` |
206
+ | `EmptyState` | `--empty-tone` |
207
+
208
+ `tests/css-custom-property-contract.test.js` reads this table and fails if a
209
+ listed property is not actually read by its component, so the docs cannot drift.
210
+
157
211
  ## Components
158
212
 
159
213
  **Atoms:** Text, Heading, Button, Input (`icon` inset leading glyph,
@@ -166,7 +220,9 @@ threshold tones via `warnAt`/`dangerAt`, `corner` snippet), Artwork (lazy cover
166
220
  fallback, `aspect`, `status` overlay), Card (`tone` tints the surface for inline banners), Badge
167
221
  (`tone` semantic palette or `color` for any CSS colour, `size` xs/sm/md, `dot`,
168
222
  `icon`, `numeric`, `truncate`, `variant="text"`; all tints derive from
169
- `--badge-tone`), Dot (`ring` dark halo over artwork), Link (`tone`, `underline`
223
+ `--badge-tone`), Dot (`ring` dark halo over artwork), Divider (standalone
224
+ horizontal/vertical hairline, `tone`, `spacing`, optional centred label,
225
+ `decorative` to drop the `separator` role), Link (`tone`, `underline`
170
226
  always/hover/none, `align`), Icon (open registry — pass a `children` snippet for
171
227
  any custom SVG).
172
228
 
@@ -219,12 +275,17 @@ sidebar on desktop, overlay drawer on mobile, optionally resizable;
219
275
  the content column only, `stickySidebar` pins it to the viewport, and
220
276
  `sidebarPadding="none" | "sm" | "md"` sets the aside gutter — the header and its
221
277
  children are `min-width: 0` so a wide title/actions row can't widen the grid on
222
- mobile), NavItem
278
+ mobile), NavBar (horizontal route bar — `<nav>` + `<a aria-current="page">`,
279
+ icon/emoji + label + count badge; `placement="bar"` pins it to the bottom edge
280
+ over `--z-nav` with `--safe-bottom` padding, `orientation` stacks or inlines the
281
+ glyph, `maxWidth` centres the row), NavItem
223
282
  (collapses to an icon rail when the sidebar is narrow; `icon` from the
224
283
  registry, `iconPath` / `iconChildren` for custom glyphs, `activeStyle="bar"`
225
284
  for the inset-bar active look), MasterDetail (list + detail columns that
226
285
  become two pages — one pane at a time with a sticky 44px back header — below
227
- their own `breakpoint`, so `selected` can be driven from the URL), Container, Stack
286
+ their own `breakpoint`, so `selected` can be driven from the URL; `gap` spaces
287
+ the columns and `divider="none"` drops the seam, both also settable as the
288
+ `--md-gap` / `--md-divider` custom properties), Container, Stack
228
289
  (vertical), Cluster (wrapping row; `stackAt="xs|sm|md|lg"` makes it its own
229
290
  query container and stacks children full-width below 18/30/40/48rem — phone
230
291
  action rows without a viewport query), AutoGrid (intrinsically responsive
@@ -166,13 +166,13 @@
166
166
  align-items: center;
167
167
  gap: var(--sp-1);
168
168
  padding: 0.15rem var(--sp-2);
169
- border-radius: var(--r-pill);
169
+ border-radius: var(--badge-radius, var(--r-pill));
170
170
  font-size: var(--fs-xs);
171
171
  font-weight: var(--fw-medium);
172
172
  line-height: 1.4;
173
- background: var(--bg-elevated-2);
174
- color: var(--text-muted);
175
- border: 1px solid var(--border);
173
+ background: var(--badge-bg, var(--bg-elevated-2));
174
+ color: var(--badge-fg, var(--text-muted));
175
+ border: 1px solid var(--badge-border, var(--border));
176
176
  white-space: nowrap;
177
177
  max-width: var(--badge-max-width, 100%);
178
178
  }
@@ -188,9 +188,9 @@
188
188
  padding: 0.05rem var(--sp-2);
189
189
  }
190
190
  .toned {
191
- color: var(--badge-tone);
192
- border-color: color-mix(in srgb, var(--badge-tone) 40%, transparent);
193
- background: color-mix(in srgb, var(--badge-tone) 12%, transparent);
191
+ color: var(--badge-fg, var(--badge-tone));
192
+ border-color: var(--badge-border, color-mix(in srgb, var(--badge-tone) 40%, transparent));
193
+ background: var(--badge-bg, color-mix(in srgb, var(--badge-tone) 12%, transparent));
194
194
  }
195
195
  .text {
196
196
  padding: 0;
@@ -165,11 +165,11 @@
165
165
  justify-content: center;
166
166
  gap: var(--sp-2);
167
167
  padding: var(--sp-2) var(--sp-4);
168
- min-height: var(--control-height-default);
169
- border: 1px solid var(--border-strong);
170
- border-radius: var(--r-md);
171
- background: var(--surface);
172
- color: var(--text);
168
+ min-height: var(--btn-size, var(--control-height-default));
169
+ border: 1px solid var(--btn-border, var(--border-strong));
170
+ border-radius: var(--btn-radius, var(--r-md));
171
+ background: var(--btn-bg, var(--surface));
172
+ color: var(--btn-fg, var(--text));
173
173
  font-weight: var(--fw-medium);
174
174
  font-size: var(--fs-sm);
175
175
  line-height: 1;
@@ -188,9 +188,9 @@
188
188
  cursor: not-allowed;
189
189
  }
190
190
  .btn-primary {
191
- background: var(--accent);
192
- border-color: var(--accent);
193
- color: var(--text-on-accent);
191
+ background: var(--btn-bg, var(--accent));
192
+ border-color: var(--btn-border, var(--accent));
193
+ color: var(--btn-fg, var(--text-on-accent));
194
194
  font-weight: var(--fw-semibold);
195
195
  }
196
196
  .btn-primary:hover:not(:disabled) {
@@ -205,21 +205,21 @@
205
205
  border-color: var(--danger);
206
206
  }
207
207
  .btn-ghost {
208
- background: transparent;
209
- border-color: transparent;
208
+ background: var(--btn-bg, transparent);
209
+ border-color: var(--btn-border, transparent);
210
210
  }
211
211
  .btn-ghost:hover:not(:disabled) {
212
212
  background: var(--bg-elevated-2);
213
213
  border-color: transparent;
214
214
  }
215
215
  .btn-sm {
216
- height: var(--control-height-compact);
217
- min-height: var(--control-height-compact);
216
+ height: var(--btn-size, var(--control-height-compact));
217
+ min-height: var(--btn-size, var(--control-height-compact));
218
218
  padding: var(--sp-1) var(--sp-3);
219
219
  font-size: var(--fs-xs);
220
220
  }
221
221
  .btn-lg {
222
- min-height: var(--control-height-large);
222
+ min-height: var(--btn-size, var(--control-height-large));
223
223
  padding: var(--sp-3) var(--sp-5);
224
224
  font-size: var(--fs-base);
225
225
  }
@@ -298,13 +298,13 @@
298
298
  align-items: center;
299
299
  justify-content: center;
300
300
  gap: var(--sp-2);
301
- height: var(--control-height);
302
- min-height: var(--control-height);
301
+ height: var(--btn-size, var(--control-height));
302
+ min-height: var(--btn-size, var(--control-height));
303
303
  padding: 0 var(--sp-3);
304
- border: 1px solid var(--border-strong);
305
- border-radius: var(--r-md);
306
- background: var(--surface);
307
- color: var(--text);
304
+ border: 1px solid var(--btn-border, var(--border-strong));
305
+ border-radius: var(--btn-radius, var(--r-md));
306
+ background: var(--btn-bg, var(--surface));
307
+ color: var(--btn-fg, var(--text));
308
308
  font-weight: var(--fw-medium);
309
309
  font-size: var(--fs-sm);
310
310
  line-height: 1;
@@ -326,9 +326,9 @@
326
326
  the primary action with the neutral --surface; restore the accent fill when
327
327
  both are present. */
328
328
  .btn-control.btn-primary {
329
- background: var(--accent);
330
- border-color: var(--accent);
331
- color: var(--text-on-accent);
329
+ background: var(--btn-bg, var(--accent));
330
+ border-color: var(--btn-border, var(--accent));
331
+ color: var(--btn-fg, var(--text-on-accent));
332
332
  }
333
333
  .btn-control.btn-primary:hover:not(:disabled) {
334
334
  border-color: var(--accent);
@@ -389,7 +389,7 @@
389
389
  align-self: flex-start;
390
390
  }
391
391
  .btn-pill {
392
- border-radius: var(--r-pill);
392
+ border-radius: var(--btn-radius, var(--r-pill));
393
393
  }
394
394
  /* Link variant: no box, inherits the surrounding text size. Placed after the
395
395
  size rules so their min-height doesn't reapply; a tone wins over --link. */
@@ -419,23 +419,23 @@
419
419
  }
420
420
  /* `square`: side = the height contract in force (size tier or control). */
421
421
  .btn-square {
422
- width: var(--control-height-default);
423
- min-width: var(--control-height-default);
422
+ width: var(--btn-size, var(--control-height-default));
423
+ min-width: var(--btn-size, var(--control-height-default));
424
424
  padding: 0;
425
425
  flex: none;
426
426
  }
427
427
  .btn-square.btn-sm {
428
- width: var(--control-height-compact);
429
- min-width: var(--control-height-compact);
428
+ width: var(--btn-size, var(--control-height-compact));
429
+ min-width: var(--btn-size, var(--control-height-compact));
430
430
  }
431
431
  .btn-square.btn-lg {
432
- width: var(--control-height-large);
433
- min-width: var(--control-height-large);
434
- height: var(--control-height-large);
432
+ width: var(--btn-size, var(--control-height-large));
433
+ min-width: var(--btn-size, var(--control-height-large));
434
+ height: var(--btn-size, var(--control-height-large));
435
435
  }
436
436
  .btn-square.btn-control {
437
- width: var(--control-height);
438
- min-width: var(--control-height);
437
+ width: var(--btn-size, var(--control-height));
438
+ min-width: var(--btn-size, var(--control-height));
439
439
  }
440
440
  /* `box`: explicit square scale, wins over icon/chip/square/size sizing. */
441
441
  .btn-box {
@@ -0,0 +1,107 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+ // Standalone hairline between two siblings. Horizontal by default; the
5
+ // vertical form stretches to the flex parent's cross size. With `children`
6
+ // the rule splits around a centred label ("or", "3 older messages") — a
7
+ // labelled divider is never decorative, so it keeps role="separator".
8
+ type Own = {
9
+ orientation?: 'horizontal' | 'vertical';
10
+ /** Margin along the axis (any CSS length). */
11
+ spacing?: string;
12
+ tone?: 'default' | 'strong' | 'faint';
13
+ /** Centred label turning the rule into rule + text. */
14
+ children?: Snippet;
15
+ /** Purely visual (aria-hidden) rather than a semantic separator. */
16
+ decorative?: boolean;
17
+ class?: string;
18
+ };
19
+ let {
20
+ orientation = 'horizontal',
21
+ spacing,
22
+ tone = 'default',
23
+ children,
24
+ decorative = false,
25
+ class: klass = '',
26
+ ...rest
27
+ }: Omit<HTMLAttributes<HTMLDivElement>, keyof Own> & Own = $props();
28
+
29
+ const vertical = $derived(orientation === 'vertical');
30
+ const semantic = $derived(!decorative || !!children);
31
+ </script>
32
+
33
+ <div
34
+ class="divider {orientation} tone-{tone} {klass}"
35
+ class:labelled={!!children}
36
+ data-tsu="Divider"
37
+ role={semantic ? 'separator' : undefined}
38
+ aria-orientation={semantic ? orientation : undefined}
39
+ aria-hidden={semantic ? undefined : 'true'}
40
+ style:--divider-spacing={spacing}
41
+ {...rest}
42
+ >
43
+ {#if children}
44
+ <span class="divider-label">{@render children()}</span>
45
+ {/if}
46
+ </div>
47
+
48
+ <style>
49
+ .divider {
50
+ --divider-color: var(--border);
51
+ --divider-spacing: var(--sp-3);
52
+ flex: none;
53
+ border: 0;
54
+ }
55
+ .tone-strong {
56
+ --divider-color: var(--border-strong);
57
+ }
58
+ .tone-faint {
59
+ --divider-color: color-mix(in srgb, var(--border) 45%, transparent);
60
+ }
61
+ .horizontal {
62
+ width: 100%;
63
+ height: 1px;
64
+ background: var(--divider-color);
65
+ margin-block: var(--divider-spacing);
66
+ }
67
+ .vertical {
68
+ align-self: stretch;
69
+ width: 1px;
70
+ min-height: 1em;
71
+ background: var(--divider-color);
72
+ margin-inline: var(--divider-spacing);
73
+ }
74
+ .horizontal.labelled,
75
+ .vertical.labelled {
76
+ display: flex;
77
+ align-items: center;
78
+ gap: var(--sp-2);
79
+ background: none;
80
+ }
81
+ .horizontal.labelled {
82
+ height: auto;
83
+ }
84
+ .vertical.labelled {
85
+ flex-direction: column;
86
+ width: auto;
87
+ }
88
+ .horizontal.labelled::before,
89
+ .horizontal.labelled::after {
90
+ content: '';
91
+ flex: 1;
92
+ height: 1px;
93
+ background: var(--divider-color);
94
+ }
95
+ .vertical.labelled::before,
96
+ .vertical.labelled::after {
97
+ content: '';
98
+ flex: 1;
99
+ width: 1px;
100
+ background: var(--divider-color);
101
+ }
102
+ .divider-label {
103
+ color: var(--text-muted);
104
+ font-size: var(--fs-sm);
105
+ white-space: nowrap;
106
+ }
107
+ </style>
@@ -0,0 +1,17 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ type Own = {
4
+ orientation?: 'horizontal' | 'vertical';
5
+ /** Margin along the axis (any CSS length). */
6
+ spacing?: string;
7
+ tone?: 'default' | 'strong' | 'faint';
8
+ /** Centred label turning the rule into rule + text. */
9
+ children?: Snippet;
10
+ /** Purely visual (aria-hidden) rather than a semantic separator. */
11
+ decorative?: boolean;
12
+ class?: string;
13
+ };
14
+ type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, keyof Own> & Own;
15
+ declare const Divider: import("svelte").Component<$$ComponentProps, {}, "">;
16
+ type Divider = ReturnType<typeof Divider>;
17
+ export default Divider;
@@ -130,13 +130,13 @@
130
130
  <style>
131
131
  .input {
132
132
  width: 100%;
133
- min-height: var(--control-height-default);
133
+ min-height: var(--input-size, var(--control-height-default));
134
134
  padding: var(--sp-2) var(--sp-3);
135
135
  line-height: var(--lh-tight);
136
- background: var(--bg);
137
- border: 1px solid var(--border-strong);
138
- border-radius: var(--r-md);
139
- color: var(--text);
136
+ background: var(--input-bg, var(--bg));
137
+ border: 1px solid var(--input-border, var(--border-strong));
138
+ border-radius: var(--input-radius, var(--r-md));
139
+ color: var(--input-fg, var(--text));
140
140
  transition: border-color 0.12s var(--ease);
141
141
  }
142
142
  .input:focus {
@@ -148,12 +148,12 @@
148
148
  outline-offset: var(--focus-ring-offset);
149
149
  }
150
150
  .input-sm {
151
- min-height: var(--control-height-compact);
151
+ min-height: var(--input-size, var(--control-height-compact));
152
152
  padding: var(--sp-1) var(--sp-2);
153
153
  font-size: var(--fs-sm);
154
154
  }
155
155
  .input-lg {
156
- min-height: var(--control-height-large);
156
+ min-height: var(--input-size, var(--control-height-large));
157
157
  padding: var(--sp-3) var(--sp-4);
158
158
  font-size: var(--fs-base);
159
159
  }
@@ -166,7 +166,7 @@
166
166
  flex: none;
167
167
  }
168
168
  .input-pill {
169
- border-radius: var(--r-pill);
169
+ border-radius: var(--input-radius, var(--r-pill));
170
170
  padding-inline: var(--sp-4);
171
171
  }
172
172
  .input[aria-invalid='true'] {
@@ -205,13 +205,13 @@
205
205
  }
206
206
  .select {
207
207
  width: 100%;
208
- min-height: var(--control-height-default);
208
+ min-height: var(--select-size, var(--control-height-default));
209
209
  padding: var(--sp-2) var(--sp-3);
210
210
  line-height: var(--lh-tight);
211
- background: var(--bg);
212
- border: 1px solid var(--border-strong);
213
- border-radius: var(--r-md);
214
- color: var(--text);
211
+ background: var(--select-bg, var(--bg));
212
+ border: 1px solid var(--select-border, var(--border-strong));
213
+ border-radius: var(--select-radius, var(--r-md));
214
+ color: var(--select-fg, var(--text));
215
215
  transition: border-color 0.12s var(--ease);
216
216
  }
217
217
  /* Default variant: drop the OS chevron, reserve room for our own. */
@@ -226,7 +226,7 @@
226
226
  }
227
227
  /* Compact inline form for dense headers/toolbars. */
228
228
  .select.select-lg {
229
- min-height: var(--control-height-large);
229
+ min-height: var(--select-size, var(--control-height-large));
230
230
  padding: var(--sp-3) var(--sp-4);
231
231
  font-size: var(--fs-base);
232
232
  }
@@ -240,15 +240,15 @@
240
240
  /* Toolbar contract: size="sm" shares the compact control height so it lines up
241
241
  with Button size="sm", Popover size="sm" and SegmentedControl size="sm". */
242
242
  .select.select-sm {
243
- height: var(--control-height-compact);
243
+ height: var(--select-size, var(--control-height-compact));
244
244
  }
245
245
  .select.w-auto {
246
246
  width: auto;
247
247
  }
248
248
  .select.embedded {
249
- background: var(--bg-elevated-2);
249
+ background: var(--select-bg, var(--bg-elevated-2));
250
250
  border: none;
251
- border-radius: var(--r-sm);
251
+ border-radius: var(--select-radius, var(--r-sm));
252
252
  }
253
253
  .select:focus {
254
254
  outline: none;
@@ -281,7 +281,7 @@
281
281
  gap: var(--sp-2);
282
282
  padding: 0 var(--sp-3);
283
283
  padding-right: calc(var(--sp-3) + 1.25rem);
284
- color: var(--text);
284
+ color: var(--select-fg, var(--text));
285
285
  pointer-events: none;
286
286
  overflow: hidden;
287
287
  }
@@ -95,29 +95,31 @@
95
95
  /* Base: inherit everything — a bare <Text> renders like a plain span. */
96
96
  .text {
97
97
  margin: 0;
98
+ color: var(--txt-fg, inherit);
99
+ font-size: var(--txt-size, inherit);
98
100
  }
99
101
  /* Variants (presets) — listed before tone/weight/size so those override. */
100
102
  .v-body {
101
- font-size: var(--fs-base);
103
+ font-size: var(--txt-size, var(--fs-base));
102
104
  line-height: var(--lh-normal);
103
- color: var(--text);
105
+ color: var(--txt-fg, var(--text));
104
106
  }
105
107
  .v-label {
106
- font-size: var(--fs-sm);
108
+ font-size: var(--txt-size, var(--fs-sm));
107
109
  font-weight: var(--fw-medium);
108
- color: var(--text-muted);
110
+ color: var(--txt-fg, var(--text-muted));
109
111
  }
110
112
  .v-caption {
111
- font-size: var(--fs-xs);
112
- color: var(--text-faint);
113
+ font-size: var(--txt-size, var(--fs-xs));
114
+ color: var(--txt-fg, var(--text-faint));
113
115
  }
114
116
  .v-code {
115
117
  font-family: var(--font-mono);
116
- font-size: 0.92em;
118
+ font-size: var(--txt-size, 0.92em);
117
119
  }
118
120
  .v-eyebrow {
119
- font-size: var(--fs-xs);
120
- color: var(--text-muted);
121
+ font-size: var(--txt-size, var(--fs-xs));
122
+ color: var(--txt-fg, var(--text-muted));
121
123
  font-weight: var(--fw-medium);
122
124
  text-transform: uppercase;
123
125
  letter-spacing: 0.04em;
@@ -127,28 +129,28 @@
127
129
  font-family: var(--font-mono);
128
130
  }
129
131
  .tone-default {
130
- color: var(--text);
132
+ color: var(--txt-fg, var(--text));
131
133
  }
132
134
  .tone-muted {
133
- color: var(--text-muted);
135
+ color: var(--txt-fg, var(--text-muted));
134
136
  }
135
137
  .tone-faint {
136
- color: var(--text-faint);
138
+ color: var(--txt-fg, var(--text-faint));
137
139
  }
138
140
  .tone-success {
139
- color: var(--ok);
141
+ color: var(--txt-fg, var(--ok));
140
142
  }
141
143
  .tone-warn {
142
- color: var(--warn);
144
+ color: var(--txt-fg, var(--warn));
143
145
  }
144
146
  .tone-danger {
145
- color: var(--danger);
147
+ color: var(--txt-fg, var(--danger));
146
148
  }
147
149
  .tone-info {
148
- color: var(--info);
150
+ color: var(--txt-fg, var(--info));
149
151
  }
150
152
  .tone-accent {
151
- color: var(--accent);
153
+ color: var(--txt-fg, var(--accent));
152
154
  }
153
155
  /* Weight — overrides variant weight. */
154
156
  .fw-normal {
@@ -165,46 +167,46 @@
165
167
  }
166
168
  /* Size — overrides variant size (listed last so it wins). */
167
169
  .fs-xs {
168
- font-size: var(--fs-xs);
170
+ font-size: var(--txt-size, var(--fs-xs));
169
171
  }
170
172
  .fs-sm {
171
- font-size: var(--fs-sm);
173
+ font-size: var(--txt-size, var(--fs-sm));
172
174
  }
173
175
  .fs-base {
174
- font-size: var(--fs-base);
176
+ font-size: var(--txt-size, var(--fs-base));
175
177
  }
176
178
  .fs-md {
177
- font-size: var(--fs-md);
179
+ font-size: var(--txt-size, var(--fs-md));
178
180
  }
179
181
  .fs-lg {
180
- font-size: var(--fs-lg);
182
+ font-size: var(--txt-size, var(--fs-lg));
181
183
  }
182
184
  .fs-xl {
183
- font-size: var(--fs-xl);
185
+ font-size: var(--txt-size, var(--fs-xl));
184
186
  }
185
187
  .fs-2xl {
186
- font-size: var(--fs-2xl);
188
+ font-size: var(--txt-size, var(--fs-2xl));
187
189
  }
188
190
  .noscale.fs-xs {
189
- font-size: 12px;
191
+ font-size: var(--txt-size, 12px);
190
192
  }
191
193
  .noscale.fs-sm {
192
- font-size: 13px;
194
+ font-size: var(--txt-size, 13px);
193
195
  }
194
196
  .noscale.fs-base {
195
- font-size: 15px;
197
+ font-size: var(--txt-size, 15px);
196
198
  }
197
199
  .noscale.fs-md {
198
- font-size: 16px;
200
+ font-size: var(--txt-size, 16px);
199
201
  }
200
202
  .noscale.fs-lg {
201
- font-size: 18px;
203
+ font-size: var(--txt-size, 18px);
202
204
  }
203
205
  .noscale.fs-xl {
204
- font-size: 22px;
206
+ font-size: var(--txt-size, 22px);
205
207
  }
206
208
  .noscale.fs-2xl {
207
- font-size: 28px;
209
+ font-size: var(--txt-size, 28px);
208
210
  }
209
211
  .italic {
210
212
  font-style: italic;
@@ -223,16 +223,16 @@
223
223
  --sp-3 top+bottom would overshoot it and a `rows={1}` textarea would
224
224
  render taller than the buttons it sits beside. */
225
225
  padding: var(--sp-2) var(--sp-3);
226
- background: var(--bg);
227
- border: 1px solid var(--border-strong);
228
- border-radius: var(--r-md);
229
- color: var(--text);
226
+ background: var(--textarea-bg, var(--bg));
227
+ border: 1px solid var(--textarea-border, var(--border-strong));
228
+ border-radius: var(--textarea-radius, var(--r-md));
229
+ color: var(--textarea-fg, var(--text));
230
230
  transition: border-color 0.12s var(--ease);
231
231
  /* Custom handle replaces the native grip; never show the native one. */
232
232
  resize: none;
233
233
  /* Match the single-row height of Button/Input so a `rows={1}` textarea
234
234
  lines up with them; the native `rows` attribute grows it from here. */
235
- min-height: 2.5rem;
235
+ min-height: var(--textarea-size, 2.5rem);
236
236
  line-height: var(--lh-tight);
237
237
  font-family: inherit;
238
238
  }
@@ -250,12 +250,12 @@
250
250
  .textarea-sm {
251
251
  padding: var(--sp-1) var(--sp-2);
252
252
  font-size: var(--fs-sm);
253
- min-height: 2rem;
253
+ min-height: var(--textarea-size, 2rem);
254
254
  }
255
255
  .textarea-lg {
256
256
  padding: var(--sp-3) var(--sp-4);
257
257
  font-size: var(--fs-base);
258
- min-height: var(--control-height-large);
258
+ min-height: var(--textarea-size, var(--control-height-large));
259
259
  }
260
260
  .textarea[aria-invalid='true'],
261
261
  .textarea[aria-invalid='true']:focus {
@@ -15,6 +15,8 @@
15
15
  empty,
16
16
  listWidth = '17rem',
17
17
  breakpoint = '48rem',
18
+ gap = '0',
19
+ divider = 'border',
18
20
  selected = false,
19
21
  onback,
20
22
  backLabel = 'Back',
@@ -32,6 +34,10 @@
32
34
  listWidth?: string;
33
35
  /** Own-width threshold (px/em/rem) below which the panes become two pages. */
34
36
  breakpoint?: string;
37
+ /** Space between the two columns (any CSS length). */
38
+ gap?: string;
39
+ /** Seam between the panes; `--md-divider` overrides it with any border shorthand. */
40
+ divider?: 'border' | 'none';
35
41
  selected?: boolean;
36
42
  onback?: () => void;
37
43
  backLabel?: string;
@@ -72,7 +78,9 @@
72
78
  class="md {klass}"
73
79
  class:mobile
74
80
  class:selected
75
- style="--md-list-w: {listWidth};{style ? ` ${style}` : ''}"
81
+ style="--md-list-w: {listWidth}; --md-gap: {gap}; --md-divider: {divider === 'none'
82
+ ? 'none'
83
+ : '1px solid var(--border)'};{style ? ` ${style}` : ''}"
76
84
  data-tsu="MasterDetail"
77
85
  {...rest}
78
86
  >
@@ -113,6 +121,7 @@
113
121
  .md {
114
122
  display: grid;
115
123
  grid-template-columns: var(--md-list-w) minmax(0, 1fr);
124
+ gap: var(--md-gap, 0);
116
125
  min-width: 0;
117
126
  min-height: 0;
118
127
  height: 100%;
@@ -127,7 +136,7 @@
127
136
  -webkit-overflow-scrolling: touch;
128
137
  }
129
138
  .md-list {
130
- border-right: 1px solid var(--border);
139
+ border-right: var(--md-divider, 1px solid var(--border));
131
140
  }
132
141
  .md-detail {
133
142
  display: flex;
@@ -183,6 +192,7 @@
183
192
 
184
193
  .md.mobile {
185
194
  grid-template-columns: minmax(0, 1fr);
195
+ gap: 0;
186
196
  }
187
197
  .md.mobile .md-list {
188
198
  border-right: 0;
@@ -9,6 +9,10 @@ type $$ComponentProps = {
9
9
  listWidth?: string;
10
10
  /** Own-width threshold (px/em/rem) below which the panes become two pages. */
11
11
  breakpoint?: string;
12
+ /** Space between the two columns (any CSS length). */
13
+ gap?: string;
14
+ /** Seam between the panes; `--md-divider` overrides it with any border shorthand. */
15
+ divider?: 'border' | 'none';
12
16
  selected?: boolean;
13
17
  onback?: () => void;
14
18
  backLabel?: string;
@@ -0,0 +1,170 @@
1
+ <script lang="ts">
2
+ // Horizontal route navigation: a `<nav>` landmark of `<a>` links, one marked
3
+ // `aria-current="page"`. `placement="bar"` pins it to the bottom edge over
4
+ // `--z-nav` and pads for the iOS home indicator with `--safe-bottom`;
5
+ // `placement="inline"` is a plain row for a header. `orientation` stacks the
6
+ // icon over the label (phone bar) or sits it beside (header).
7
+ import Icon from '../atoms/Icon.svelte';
8
+ import type { IconName } from '../atoms/Icon.svelte';
9
+
10
+ export interface NavBarItem {
11
+ href: string;
12
+ label: string;
13
+ icon?: IconName;
14
+ /** Rendered instead of `icon` when set. */
15
+ emoji?: string;
16
+ /** Count or short text pinned to the glyph. 0 / '' render nothing. */
17
+ badge?: number | string;
18
+ current?: boolean;
19
+ }
20
+
21
+ let {
22
+ items,
23
+ label,
24
+ placement = 'inline',
25
+ orientation = 'stacked',
26
+ maxWidth,
27
+ class: klass = '',
28
+ style: styleProp = '',
29
+ ...rest
30
+ }: {
31
+ items: NavBarItem[];
32
+ /** Accessible name of the `<nav>` landmark. */
33
+ label: string;
34
+ /** 'bar' = fixed to the bottom edge with safe-area padding. */
35
+ placement?: 'bar' | 'inline';
36
+ /** Icon over label vs icon beside label. */
37
+ orientation?: 'stacked' | 'inline';
38
+ /** Caps and centres the row of links. */
39
+ maxWidth?: string;
40
+ class?: string;
41
+ style?: string;
42
+ [key: string]: unknown;
43
+ } = $props();
44
+
45
+ const hasBadge = (badge: NavBarItem['badge']) =>
46
+ badge !== undefined && badge !== null && badge !== '' && badge !== 0;
47
+ </script>
48
+
49
+ <nav
50
+ data-tsu="NavBar"
51
+ aria-label={label}
52
+ class="navbar {klass}"
53
+ class:bar={placement === 'bar'}
54
+ class:stacked={orientation === 'stacked'}
55
+ style="{maxWidth ? `--navbar-max: ${maxWidth};` : ''}{styleProp}"
56
+ {...rest}
57
+ >
58
+ <div class="navbar-inner">
59
+ {#each items as item (item.href)}
60
+ <a
61
+ href={item.href}
62
+ class="navbar-link"
63
+ class:current={item.current}
64
+ aria-current={item.current ? 'page' : undefined}
65
+ >
66
+ <span class="navbar-glyph">
67
+ {#if item.emoji}
68
+ <span class="navbar-emoji" aria-hidden="true">{item.emoji}</span>
69
+ {:else if item.icon}
70
+ <Icon name={item.icon} size={20} />
71
+ {/if}
72
+ {#if hasBadge(item.badge)}
73
+ <span class="navbar-badge">{item.badge}</span>
74
+ {/if}
75
+ </span>
76
+ <span class="navbar-label">{item.label}</span>
77
+ </a>
78
+ {/each}
79
+ </div>
80
+ </nav>
81
+
82
+ <style>
83
+ .navbar {
84
+ --navbar-max: none;
85
+ min-width: 0;
86
+ }
87
+ .navbar-inner {
88
+ display: flex;
89
+ align-items: stretch;
90
+ justify-content: space-around;
91
+ gap: var(--sp-1);
92
+ min-width: 0;
93
+ max-width: var(--navbar-max);
94
+ margin-inline: auto;
95
+ min-height: var(--nav-h);
96
+ }
97
+ .bar {
98
+ position: fixed;
99
+ inset-inline: 0;
100
+ bottom: 0;
101
+ z-index: var(--z-nav);
102
+ padding-bottom: var(--safe-bottom);
103
+ padding-inline: max(var(--safe-left), var(--sp-1)) max(var(--safe-right), var(--sp-1));
104
+ background: color-mix(in srgb, var(--bg-elevated) 88%, transparent);
105
+ backdrop-filter: blur(12px);
106
+ border-top: 1px solid var(--border);
107
+ }
108
+ .navbar-link {
109
+ position: relative;
110
+ display: flex;
111
+ flex: 1 1 0;
112
+ min-width: 0;
113
+ flex-direction: row;
114
+ align-items: center;
115
+ justify-content: center;
116
+ gap: var(--sp-2);
117
+ padding: var(--sp-2);
118
+ border-radius: var(--r-md);
119
+ color: var(--text-muted);
120
+ font-size: var(--fs-sm);
121
+ font-weight: var(--fw-medium);
122
+ text-decoration: none;
123
+ transition:
124
+ background 0.12s var(--ease),
125
+ color 0.12s var(--ease);
126
+ }
127
+ .stacked .navbar-link {
128
+ flex-direction: column;
129
+ gap: 2px;
130
+ font-size: var(--fs-xs);
131
+ }
132
+ .navbar-link:hover {
133
+ background: var(--bg-elevated-2);
134
+ color: var(--text);
135
+ text-decoration: none;
136
+ }
137
+ .navbar-link.current {
138
+ color: var(--accent);
139
+ background: color-mix(in srgb, var(--accent) 12%, transparent);
140
+ }
141
+ .navbar-glyph {
142
+ position: relative;
143
+ display: inline-flex;
144
+ flex: none;
145
+ align-items: center;
146
+ }
147
+ .navbar-emoji {
148
+ font-size: 1.15em;
149
+ line-height: 1;
150
+ }
151
+ .navbar-label {
152
+ min-width: 0;
153
+ overflow: hidden;
154
+ text-overflow: ellipsis;
155
+ white-space: nowrap;
156
+ }
157
+ .navbar-badge {
158
+ position: absolute;
159
+ top: -0.35rem;
160
+ left: calc(100% - 0.35rem);
161
+ padding-inline: 0.25rem;
162
+ min-width: 1rem;
163
+ border-radius: var(--r-pill);
164
+ background: var(--accent);
165
+ color: var(--text-on-accent);
166
+ font-size: var(--fs-xs);
167
+ line-height: 1rem;
168
+ text-align: center;
169
+ }
170
+ </style>
@@ -0,0 +1,28 @@
1
+ import type { IconName } from '../atoms/Icon.svelte';
2
+ export interface NavBarItem {
3
+ href: string;
4
+ label: string;
5
+ icon?: IconName;
6
+ /** Rendered instead of `icon` when set. */
7
+ emoji?: string;
8
+ /** Count or short text pinned to the glyph. 0 / '' render nothing. */
9
+ badge?: number | string;
10
+ current?: boolean;
11
+ }
12
+ type $$ComponentProps = {
13
+ items: NavBarItem[];
14
+ /** Accessible name of the `<nav>` landmark. */
15
+ label: string;
16
+ /** 'bar' = fixed to the bottom edge with safe-area padding. */
17
+ placement?: 'bar' | 'inline';
18
+ /** Icon over label vs icon beside label. */
19
+ orientation?: 'stacked' | 'inline';
20
+ /** Caps and centres the row of links. */
21
+ maxWidth?: string;
22
+ class?: string;
23
+ style?: string;
24
+ [key: string]: unknown;
25
+ };
26
+ declare const NavBar: import("svelte").Component<$$ComponentProps, {}, "">;
27
+ type NavBar = ReturnType<typeof NavBar>;
28
+ export default NavBar;
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export { default as Badge } from './components/atoms/Badge.svelte';
6
6
  export { default as Button } from './components/atoms/Button.svelte';
7
7
  export { default as Card } from './components/atoms/Card.svelte';
8
8
  export { default as Checkbox } from './components/atoms/Checkbox.svelte';
9
+ export { default as Divider } from './components/atoms/Divider.svelte';
9
10
  export { default as Dot } from './components/atoms/Dot.svelte';
10
11
  export { default as Gauge, type GaugeTone } from './components/atoms/Gauge.svelte';
11
12
  export { default as Heading } from './components/atoms/Heading.svelte';
@@ -30,6 +31,7 @@ export { default as AutoGrid } from './components/layouts/AutoGrid.svelte';
30
31
  export { default as Cluster } from './components/layouts/Cluster.svelte';
31
32
  export { default as Container } from './components/layouts/Container.svelte';
32
33
  export { default as MasterDetail } from './components/layouts/MasterDetail.svelte';
34
+ export { default as NavBar, type NavBarItem } from './components/layouts/NavBar.svelte';
33
35
  export { default as NavItem } from './components/layouts/NavItem.svelte';
34
36
  export { default as NavSection } from './components/layouts/NavSection.svelte';
35
37
  export { default as ResizablePanel } from './components/layouts/ResizablePanel.svelte';
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ export { default as Badge } from './components/atoms/Badge.svelte';
12
12
  export { default as Button } from './components/atoms/Button.svelte';
13
13
  export { default as Card } from './components/atoms/Card.svelte';
14
14
  export { default as Checkbox } from './components/atoms/Checkbox.svelte';
15
+ export { default as Divider } from './components/atoms/Divider.svelte';
15
16
  export { default as Dot } from './components/atoms/Dot.svelte';
16
17
  export { default as Gauge } from './components/atoms/Gauge.svelte';
17
18
  export { default as Heading } from './components/atoms/Heading.svelte';
@@ -36,6 +37,7 @@ export { default as AutoGrid } from './components/layouts/AutoGrid.svelte';
36
37
  export { default as Cluster } from './components/layouts/Cluster.svelte';
37
38
  export { default as Container } from './components/layouts/Container.svelte';
38
39
  export { default as MasterDetail } from './components/layouts/MasterDetail.svelte';
40
+ export { default as NavBar } from './components/layouts/NavBar.svelte';
39
41
  export { default as NavItem } from './components/layouts/NavItem.svelte';
40
42
  export { default as NavSection } from './components/layouts/NavSection.svelte';
41
43
  export { default as ResizablePanel } from './components/layouts/ResizablePanel.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.49.0",
3
+ "version": "0.51.0",
4
4
  "description": "Minimal, dependency-free Svelte 5 + pure-CSS UI kit. Token-driven atoms, molecules & layouts with theming out of the box.",
5
5
  "type": "module",
6
6
  "license": "MIT",