@dorsk/tsumikit 0.24.0 → 0.26.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 +88 -3
- package/dist/artwork.d.ts +7 -0
- package/dist/artwork.js +28 -0
- package/dist/components/atoms/Artwork.svelte +152 -0
- package/dist/components/atoms/Artwork.svelte.d.ts +27 -0
- package/dist/components/atoms/Button.svelte +1 -1
- package/dist/components/atoms/Card.svelte +65 -2
- package/dist/components/atoms/Card.svelte.d.ts +6 -0
- package/dist/components/atoms/Icon.svelte +5 -1
- package/dist/components/atoms/Icon.svelte.d.ts +2 -0
- package/dist/components/layouts/NavItem.svelte +30 -3
- package/dist/components/layouts/NavItem.svelte.d.ts +9 -1
- package/dist/components/layouts/ResizablePanel.svelte.d.ts +1 -1
- package/dist/components/molecules/ConfirmModal.svelte +102 -0
- package/dist/components/molecules/ConfirmModal.svelte.d.ts +16 -0
- package/dist/components/molecules/KeyValue.svelte +117 -0
- package/dist/components/molecules/KeyValue.svelte.d.ts +20 -0
- package/dist/components/molecules/LoadMore.svelte +78 -0
- package/dist/components/molecules/LoadMore.svelte.d.ts +15 -0
- package/dist/components/molecules/Modal.svelte +66 -10
- package/dist/components/molecules/Modal.svelte.d.ts +12 -2
- package/dist/components/molecules/Pagination.svelte +209 -0
- package/dist/components/molecules/Pagination.svelte.d.ts +22 -0
- package/dist/components/molecules/SectionHeader.svelte +233 -0
- package/dist/components/molecules/SectionHeader.svelte.d.ts +29 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -145,7 +145,8 @@ for a light theme (`:root` defaults to dark, so dark themes may omit it).
|
|
|
145
145
|
## Components
|
|
146
146
|
|
|
147
147
|
**Atoms:** Text, Heading, Button, Input, Textarea, Select, Switch, Checkbox,
|
|
148
|
-
Slider, Progress,
|
|
148
|
+
Slider, Progress, Artwork (lazy cover image with seeded gradient + initials
|
|
149
|
+
fallback, `aspect`, `status` overlay), Card (`tone` tints the surface for inline banners), Badge
|
|
149
150
|
(`tone` semantic palette or `color` for any CSS colour, `size` xs/sm/md, `dot`,
|
|
150
151
|
`icon`, `numeric`, `truncate`, `variant="text"`; all tints derive from
|
|
151
152
|
`--badge-tone`), Dot (`ring` dark halo over artwork), Link (`tone`, `underline`
|
|
@@ -154,7 +155,8 @@ any custom SVG).
|
|
|
154
155
|
|
|
155
156
|
**Molecules:** Field, IconButton, SelectButton, Toggle, OptionButton, Modal,
|
|
156
157
|
Popover, Menu, Tabs, RadioGroup, Tooltip, Accordion, CopyButton, FileButton,
|
|
157
|
-
Dropzone, CodeBlock, Callout, EmptyState,
|
|
158
|
+
Dropzone, CodeBlock, Callout, EmptyState, ConfirmModal, Pagination, Toaster,
|
|
159
|
+
ThemePicker, FontScalePicker, SectionHeader, KeyValue, LoadMore.
|
|
158
160
|
|
|
159
161
|
**Organisms:** DataTable (generic `<T>`, typed columns + cell snippets;
|
|
160
162
|
`layout="fixed"` makes column widths authoritative, `Column.truncate` /
|
|
@@ -173,7 +175,9 @@ the content column only, `stickySidebar` pins it to the viewport, and
|
|
|
173
175
|
`sidebarPadding="none" | "sm" | "md"` sets the aside gutter — the header and its
|
|
174
176
|
children are `min-width: 0` so a wide title/actions row can't widen the grid on
|
|
175
177
|
mobile), NavItem
|
|
176
|
-
(collapses to an icon rail when the sidebar is narrow
|
|
178
|
+
(collapses to an icon rail when the sidebar is narrow; `icon` from the
|
|
179
|
+
registry, `iconPath` / `iconChildren` for custom glyphs, `activeStyle="bar"`
|
|
180
|
+
for the inset-bar active look), Container, Stack
|
|
177
181
|
(vertical), Cluster (wrapping row; `stackAt="xs|sm|md|lg"` makes it its own
|
|
178
182
|
query container and stacks children full-width below 18/30/40/48rem — phone
|
|
179
183
|
action rows without a viewport query), AutoGrid (intrinsically responsive
|
|
@@ -255,6 +259,87 @@ is a live region — `role="status"`, or `role="alert"` when `tone="danger"`.
|
|
|
255
259
|
</Callout>
|
|
256
260
|
```
|
|
257
261
|
|
|
262
|
+
### Modal, ConfirmModal & Pagination
|
|
263
|
+
|
|
264
|
+
`Modal` opens on mount by default; pass `bind:open` instead to keep it mounted
|
|
265
|
+
and drive `showModal()`/`close()` from state (closing writes `open = false`).
|
|
266
|
+
`tone="danger" | "warn" | "info"` adds a title glyph and a 3px top border;
|
|
267
|
+
`busy` makes the body inert, shows a spinner by the title and disables Escape,
|
|
268
|
+
backdrop and the close button. The `footer` snippet is a right-aligned flex row
|
|
269
|
+
(`justify-content: flex-end; gap: var(--sp-2)`).
|
|
270
|
+
|
|
271
|
+
`ConfirmModal` wraps it as a yes/no dialog: `title`, `message` (or `children`),
|
|
272
|
+
`confirmLabel`/`cancelLabel`, `tone="primary" | "danger" | "warn"`, `busy`,
|
|
273
|
+
`onconfirm`, `oncancel`. An async `onconfirm` keeps the dialog busy until it
|
|
274
|
+
settles, closes only on success and shows a rejection's message (`role="alert"`)
|
|
275
|
+
under the body. `Button` accepts `tone="danger"` as an alias of
|
|
276
|
+
`variant="danger"` so the tone axis works alone.
|
|
277
|
+
|
|
278
|
+
```svelte
|
|
279
|
+
<ConfirmModal bind:open title="Delete library?" tone="danger" confirmLabel="Delete"
|
|
280
|
+
onconfirm={() => api.deleteLibrary(id)} />
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
`Pagination` renders `<nav aria-label>` with prev/next IconButtons, numbered
|
|
284
|
+
pages (`aria-current="page"`), ellipses and an optional `showRange` readout.
|
|
285
|
+
Page mode: `bind:page` + `pageCount`. Offset mode: `bind:offset` + `limit` +
|
|
286
|
+
`total` (page and count are derived, `offset` is written back). `onchange(page)`,
|
|
287
|
+
`siblings` (1), `showEdges` (true), `size="sm" | "md"`, `label`. Under 24rem of
|
|
288
|
+
container width it collapses to prev / "3 / 12" / next.
|
|
289
|
+
|
|
290
|
+
```svelte
|
|
291
|
+
<Pagination bind:offset limit={20} total={412} showRange onchange={load} />
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### SectionHeader, Card header/footer, KeyValue, LoadMore
|
|
295
|
+
|
|
296
|
+
`SectionHeader` is the one "title + meta + right-aligned actions" row:
|
|
297
|
+
`title` (or `label`), `level` 1–4 (default 2), `size`, `subtitle`, `icon`,
|
|
298
|
+
`count` (faint tabular figure after the title), `tone`, `hue` (0–360 swatch
|
|
299
|
+
chip), `uppercase` (eyebrow group label), `divider`, `sticky` (pins at
|
|
300
|
+
`--sticky-offset` / `--header-h` and publishes `--section-header-h` on its
|
|
301
|
+
parent), `collapsible` + `bind:open` (title becomes a disclosure button with
|
|
302
|
+
`aria-expanded`; `children` render beneath while open), `actions` snippet.
|
|
303
|
+
|
|
304
|
+
`Card` gains `header` / `footer` snippets rendered outside the padded body
|
|
305
|
+
behind a divider, `title` / `subtitle` / `actions` sugar that renders a
|
|
306
|
+
`SectionHeader` in the header slot, and `gap` to stack children as a flex
|
|
307
|
+
column. A Card without any of these renders exactly as before.
|
|
308
|
+
|
|
309
|
+
`KeyValue` renders `rows: { label, value: string | number | Snippet, mono?,
|
|
310
|
+
tone?, hint? }[]` as a semantic `<dl>` grid; `columns` 1 | 2, `dense`, `align`
|
|
311
|
+
start | end. `LoadMore` is the tri-state list footer: `state` idle | loading |
|
|
312
|
+
error | done, `onload`, `label`, `loadingLabel`, `errorLabel`, `retryLabel`,
|
|
313
|
+
`doneLabel`, `pill` for the compact "load older" chip.
|
|
314
|
+
|
|
315
|
+
```svelte
|
|
316
|
+
<Card title="Recent sessions" subtitle="last 24h">
|
|
317
|
+
{#snippet actions()}<Link href="/sessions">View all</Link>{/snippet}
|
|
318
|
+
<KeyValue rows={[{ label: 'Running', value: 3, tone: 'ok' }, { label: 'Host', value: 'sakura', mono: true }]} />
|
|
319
|
+
{#snippet footer()}<LoadMore state={more} onload={loadMore} />{/snippet}
|
|
320
|
+
</Card>
|
|
321
|
+
|
|
322
|
+
<SectionHeader label="Blocked" count={4} uppercase hue={12} collapsible bind:open>
|
|
323
|
+
…group rows…
|
|
324
|
+
</SectionHeader>
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### Artwork
|
|
328
|
+
|
|
329
|
+
`Artwork` is the one cover/thumbnail tile: `src` + `alt` (required) render a
|
|
330
|
+
lazy, async-decoded `<img>`; a missing or failing source swaps to a gradient
|
|
331
|
+
seeded from `seed` (default `alt`) with `fallback` initials | icon | none.
|
|
332
|
+
`aspect` ('1/1', '2/3', '16/9' or any ratio), `size` (width), `radius`
|
|
333
|
+
sm | md | lg | pill, `fit` cover | contain, `hover` for tappable tiles,
|
|
334
|
+
`status` snippet overlaid bottom-right, `onerror`. `artworkGradient(seed)`,
|
|
335
|
+
`artworkHue(seed)` and `initials(text)` are exported for non-component use.
|
|
336
|
+
|
|
337
|
+
```svelte
|
|
338
|
+
<Artwork src={album.cover} alt={album.title} aspect="1/1" size="9rem" hover>
|
|
339
|
+
{#snippet status()}<Dot status="active" ring />{/snippet}
|
|
340
|
+
</Artwork>
|
|
341
|
+
```
|
|
342
|
+
|
|
258
343
|
## Container queries
|
|
259
344
|
|
|
260
345
|
AppShell's `main` and `sidebar` are query containers (`container-name: main` /
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Hue (0–359) derived from `seed`; stable across runs and environments. */
|
|
2
|
+
export declare function artworkHue(seed: string): number;
|
|
3
|
+
/** CSS `linear-gradient(...)` for the artwork fallback, keyed on `seed`. */
|
|
4
|
+
export declare function artworkGradient(seed: string): string;
|
|
5
|
+
/** Up to two uppercase initials: first letters of the first two words, or the
|
|
6
|
+
* first two characters of a single word. Empty input yields ''. */
|
|
7
|
+
export declare function initials(text: string): string;
|
package/dist/artwork.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Placeholder look for missing artwork: a deterministic hue from `seed` so the
|
|
2
|
+
// same album/show always gets the same tint, tinted over the surface tokens so
|
|
3
|
+
// the fallback tracks the active theme instead of being a fixed dark gradient.
|
|
4
|
+
function hash(text) {
|
|
5
|
+
let h = 0;
|
|
6
|
+
for (const ch of text) {
|
|
7
|
+
h = (h * 31 + (ch.codePointAt(0) ?? 0)) >>> 0;
|
|
8
|
+
}
|
|
9
|
+
return h;
|
|
10
|
+
}
|
|
11
|
+
/** Hue (0–359) derived from `seed`; stable across runs and environments. */
|
|
12
|
+
export function artworkHue(seed) {
|
|
13
|
+
return hash(seed) % 360;
|
|
14
|
+
}
|
|
15
|
+
/** CSS `linear-gradient(...)` for the artwork fallback, keyed on `seed`. */
|
|
16
|
+
export function artworkGradient(seed) {
|
|
17
|
+
const h = artworkHue(seed);
|
|
18
|
+
return `linear-gradient(160deg, color-mix(in srgb, hsl(${h} 60% 55%) 28%, var(--bg-elevated-2)), color-mix(in srgb, hsl(${h} 60% 40%) 16%, var(--bg-elevated)))`;
|
|
19
|
+
}
|
|
20
|
+
/** Up to two uppercase initials: first letters of the first two words, or the
|
|
21
|
+
* first two characters of a single word. Empty input yields ''. */
|
|
22
|
+
export function initials(text) {
|
|
23
|
+
const words = text.trim().split(/\s+/).filter(Boolean);
|
|
24
|
+
if (words.length === 0)
|
|
25
|
+
return '';
|
|
26
|
+
const picked = words.length === 1 ? [...words[0]].slice(0, 2) : words.slice(0, 2).map((w) => [...w][0]);
|
|
27
|
+
return picked.join('').toUpperCase();
|
|
28
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Cover / thumbnail tile: a lazy <img> that swaps to a seeded gradient with
|
|
3
|
+
// initials (or an icon) when there is no source or it fails to load. Width
|
|
4
|
+
// comes from `size`, height from `aspect`; `status` overlays a Dot/Badge in
|
|
5
|
+
// the bottom-right corner.
|
|
6
|
+
import type { Snippet } from 'svelte';
|
|
7
|
+
import { artworkGradient, initials as initialsOf } from '../../artwork';
|
|
8
|
+
import Icon from './Icon.svelte';
|
|
9
|
+
import type { IconName } from './Icon.svelte';
|
|
10
|
+
|
|
11
|
+
let {
|
|
12
|
+
src,
|
|
13
|
+
alt,
|
|
14
|
+
seed,
|
|
15
|
+
aspect = '1/1',
|
|
16
|
+
size,
|
|
17
|
+
radius = 'md',
|
|
18
|
+
fit = 'cover',
|
|
19
|
+
fallback = 'initials',
|
|
20
|
+
icon = 'image',
|
|
21
|
+
status,
|
|
22
|
+
hover = false,
|
|
23
|
+
onerror,
|
|
24
|
+
class: klass = '',
|
|
25
|
+
...rest
|
|
26
|
+
}: {
|
|
27
|
+
src?: string | null;
|
|
28
|
+
alt: string;
|
|
29
|
+
/** Drives the gradient hue and the initials. Defaults to `alt`. */
|
|
30
|
+
seed?: string;
|
|
31
|
+
/** CSS aspect-ratio, e.g. '1/1', '2/3', '16/9'. */
|
|
32
|
+
aspect?: '1/1' | '2/3' | '16/9' | (string & {});
|
|
33
|
+
/** Width (any CSS length). Omit to fill the container. */
|
|
34
|
+
size?: string;
|
|
35
|
+
radius?: 'sm' | 'md' | 'lg' | 'pill';
|
|
36
|
+
fit?: 'cover' | 'contain';
|
|
37
|
+
fallback?: 'initials' | 'icon' | 'none';
|
|
38
|
+
/** Glyph shown when `fallback="icon"`. */
|
|
39
|
+
icon?: IconName;
|
|
40
|
+
/** Overlay rendered bottom-right (a Dot or Badge). */
|
|
41
|
+
status?: Snippet;
|
|
42
|
+
/** Stronger border + shadow on hover, for tappable tiles. */
|
|
43
|
+
hover?: boolean;
|
|
44
|
+
onerror?: () => void;
|
|
45
|
+
class?: string;
|
|
46
|
+
[key: string]: unknown;
|
|
47
|
+
} = $props();
|
|
48
|
+
|
|
49
|
+
let failed = $state(false);
|
|
50
|
+
$effect(() => {
|
|
51
|
+
src;
|
|
52
|
+
failed = false;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const key = $derived(seed ?? alt);
|
|
56
|
+
const showImage = $derived(Boolean(src) && !failed);
|
|
57
|
+
const gradient = $derived(showImage ? undefined : artworkGradient(key));
|
|
58
|
+
const text = $derived(fallback === 'initials' ? initialsOf(key) : '');
|
|
59
|
+
|
|
60
|
+
function fail() {
|
|
61
|
+
failed = true;
|
|
62
|
+
onerror?.();
|
|
63
|
+
}
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<div
|
|
67
|
+
data-tsu="Artwork"
|
|
68
|
+
class="artwork r-{radius} {klass}"
|
|
69
|
+
class:hover
|
|
70
|
+
style:aspect-ratio={aspect}
|
|
71
|
+
style:width={size}
|
|
72
|
+
style:background={gradient}
|
|
73
|
+
role={showImage ? undefined : 'img'}
|
|
74
|
+
aria-label={showImage ? undefined : alt}
|
|
75
|
+
{...rest}
|
|
76
|
+
>
|
|
77
|
+
{#if showImage}
|
|
78
|
+
<img {src} {alt} class="img fit-{fit}" loading="lazy" decoding="async" onerror={fail} />
|
|
79
|
+
{:else if fallback === 'initials' && text}
|
|
80
|
+
<span class="initials" aria-hidden="true">{text}</span>
|
|
81
|
+
{:else if fallback === 'icon'}
|
|
82
|
+
<span class="glyph" aria-hidden="true"><Icon name={icon} /></span>
|
|
83
|
+
{/if}
|
|
84
|
+
{#if status}
|
|
85
|
+
<span class="status">{@render status()}</span>
|
|
86
|
+
{/if}
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<style>
|
|
90
|
+
.artwork {
|
|
91
|
+
position: relative;
|
|
92
|
+
display: flex;
|
|
93
|
+
align-items: center;
|
|
94
|
+
justify-content: center;
|
|
95
|
+
overflow: hidden;
|
|
96
|
+
flex: none;
|
|
97
|
+
background: var(--bg-elevated-2);
|
|
98
|
+
border: 1px solid var(--border);
|
|
99
|
+
color: var(--text-muted);
|
|
100
|
+
container-type: inline-size;
|
|
101
|
+
transition:
|
|
102
|
+
border-color 0.12s var(--ease),
|
|
103
|
+
box-shadow 0.12s var(--ease);
|
|
104
|
+
}
|
|
105
|
+
.r-sm {
|
|
106
|
+
border-radius: var(--r-sm);
|
|
107
|
+
}
|
|
108
|
+
.r-md {
|
|
109
|
+
border-radius: var(--r-md);
|
|
110
|
+
}
|
|
111
|
+
.r-lg {
|
|
112
|
+
border-radius: var(--r-lg);
|
|
113
|
+
}
|
|
114
|
+
.r-pill {
|
|
115
|
+
border-radius: var(--r-pill);
|
|
116
|
+
}
|
|
117
|
+
.hover:hover {
|
|
118
|
+
border-color: var(--border-strong);
|
|
119
|
+
box-shadow: var(--shadow-md);
|
|
120
|
+
}
|
|
121
|
+
.img {
|
|
122
|
+
position: absolute;
|
|
123
|
+
inset: 0;
|
|
124
|
+
width: 100%;
|
|
125
|
+
height: 100%;
|
|
126
|
+
display: block;
|
|
127
|
+
}
|
|
128
|
+
.fit-cover {
|
|
129
|
+
object-fit: cover;
|
|
130
|
+
}
|
|
131
|
+
.fit-contain {
|
|
132
|
+
object-fit: contain;
|
|
133
|
+
}
|
|
134
|
+
.initials {
|
|
135
|
+
font-size: 28cqw;
|
|
136
|
+
font-weight: var(--fw-semibold);
|
|
137
|
+
letter-spacing: 0.04em;
|
|
138
|
+
color: color-mix(in srgb, var(--text) 55%, transparent);
|
|
139
|
+
user-select: none;
|
|
140
|
+
}
|
|
141
|
+
.glyph {
|
|
142
|
+
font-size: 30cqw;
|
|
143
|
+
display: flex;
|
|
144
|
+
color: color-mix(in srgb, var(--text) 40%, transparent);
|
|
145
|
+
}
|
|
146
|
+
.status {
|
|
147
|
+
position: absolute;
|
|
148
|
+
right: var(--sp-2);
|
|
149
|
+
bottom: var(--sp-2);
|
|
150
|
+
display: flex;
|
|
151
|
+
}
|
|
152
|
+
</style>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { IconName } from './Icon.svelte';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
src?: string | null;
|
|
5
|
+
alt: string;
|
|
6
|
+
/** Drives the gradient hue and the initials. Defaults to `alt`. */
|
|
7
|
+
seed?: string;
|
|
8
|
+
/** CSS aspect-ratio, e.g. '1/1', '2/3', '16/9'. */
|
|
9
|
+
aspect?: '1/1' | '2/3' | '16/9' | (string & {});
|
|
10
|
+
/** Width (any CSS length). Omit to fill the container. */
|
|
11
|
+
size?: string;
|
|
12
|
+
radius?: 'sm' | 'md' | 'lg' | 'pill';
|
|
13
|
+
fit?: 'cover' | 'contain';
|
|
14
|
+
fallback?: 'initials' | 'icon' | 'none';
|
|
15
|
+
/** Glyph shown when `fallback="icon"`. */
|
|
16
|
+
icon?: IconName;
|
|
17
|
+
/** Overlay rendered bottom-right (a Dot or Badge). */
|
|
18
|
+
status?: Snippet;
|
|
19
|
+
/** Stronger border + shadow on hover, for tappable tiles. */
|
|
20
|
+
hover?: boolean;
|
|
21
|
+
onerror?: () => void;
|
|
22
|
+
class?: string;
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
};
|
|
25
|
+
declare const Artwork: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
26
|
+
type Artwork = ReturnType<typeof Artwork>;
|
|
27
|
+
export default Artwork;
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
class:btn-grow={grow}
|
|
113
113
|
class:btn-primary={variant === 'primary'}
|
|
114
114
|
class:btn-ghost={variant === 'ghost'}
|
|
115
|
-
class:btn-danger={variant === 'danger'}
|
|
115
|
+
class:btn-danger={variant === 'danger' || (tone === 'danger' && variant === 'default')}
|
|
116
116
|
class:btn-sm={size === 'sm'}
|
|
117
117
|
class:btn-lg={size === 'lg'}
|
|
118
118
|
class:btn-control={control}
|
|
@@ -13,7 +13,12 @@
|
|
|
13
13
|
// keeps them on the plain border colour. `stackY`/`stackX` set the per-layer
|
|
14
14
|
// vertical / horizontal offset in px (vertical spacing stays even across the
|
|
15
15
|
// 3 borders); horizontal defaults to a tiny 2px peek.
|
|
16
|
+
//
|
|
17
|
+
// `header`/`footer` snippets (or the `title`/`subtitle`/`actions` sugar,
|
|
18
|
+
// which renders a SectionHeader) sit outside the padded body behind a
|
|
19
|
+
// divider; `gap` stacks children as a flex column without a wrapper.
|
|
16
20
|
import type { Snippet } from 'svelte';
|
|
21
|
+
import SectionHeader from '../molecules/SectionHeader.svelte';
|
|
17
22
|
|
|
18
23
|
type Tone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info';
|
|
19
24
|
|
|
@@ -27,8 +32,14 @@
|
|
|
27
32
|
stackTone = 'neutral',
|
|
28
33
|
stackY = 8,
|
|
29
34
|
stackX = 2,
|
|
35
|
+
title,
|
|
36
|
+
subtitle,
|
|
37
|
+
gap,
|
|
30
38
|
class: klass = '',
|
|
31
39
|
style = '',
|
|
40
|
+
header,
|
|
41
|
+
footer,
|
|
42
|
+
actions,
|
|
32
43
|
children,
|
|
33
44
|
...rest
|
|
34
45
|
}: {
|
|
@@ -41,8 +52,14 @@
|
|
|
41
52
|
stackTone?: Tone;
|
|
42
53
|
stackY?: number;
|
|
43
54
|
stackX?: number;
|
|
55
|
+
title?: string;
|
|
56
|
+
subtitle?: string;
|
|
57
|
+
gap?: string;
|
|
44
58
|
class?: string;
|
|
45
59
|
style?: string;
|
|
60
|
+
header?: Snippet;
|
|
61
|
+
footer?: Snippet;
|
|
62
|
+
actions?: Snippet;
|
|
46
63
|
children?: Snippet;
|
|
47
64
|
[key: string]: unknown;
|
|
48
65
|
} = $props();
|
|
@@ -50,6 +67,7 @@
|
|
|
50
67
|
let stackStyle = $derived(
|
|
51
68
|
stacked ? `--stack-y:${stackY}px;--stack-x:${stackX}px;` : ''
|
|
52
69
|
);
|
|
70
|
+
const framed = $derived(!!(header || footer || title));
|
|
53
71
|
</script>
|
|
54
72
|
|
|
55
73
|
<svelte:element
|
|
@@ -71,18 +89,55 @@
|
|
|
71
89
|
class:stack-warn={stacked && stackTone === 'warn'}
|
|
72
90
|
class:stack-danger={stacked && stackTone === 'danger'}
|
|
73
91
|
class:stack-info={stacked && stackTone === 'info'}
|
|
92
|
+
class:card-framed={framed}
|
|
93
|
+
class:card-gap={!framed && gap !== undefined}
|
|
94
|
+
style:--card-gap={gap}
|
|
74
95
|
style={`${stackStyle}${style}`}
|
|
75
96
|
{...rest}
|
|
76
97
|
>
|
|
77
|
-
{
|
|
98
|
+
{#if framed}
|
|
99
|
+
{#if title}
|
|
100
|
+
<div class="card-head">
|
|
101
|
+
<SectionHeader {title} {subtitle} {actions} level={3} size="md" />
|
|
102
|
+
</div>
|
|
103
|
+
{:else if header}
|
|
104
|
+
<div class="card-head">{@render header()}</div>
|
|
105
|
+
{/if}
|
|
106
|
+
<div class="card-body" class:card-gap={gap !== undefined}>{@render children?.()}</div>
|
|
107
|
+
{#if footer}
|
|
108
|
+
<div class="card-foot">{@render footer()}</div>
|
|
109
|
+
{/if}
|
|
110
|
+
{:else}
|
|
111
|
+
{@render children?.()}
|
|
112
|
+
{/if}
|
|
78
113
|
</svelte:element>
|
|
79
114
|
|
|
80
115
|
<style>
|
|
81
116
|
.card {
|
|
117
|
+
--card-pad: var(--sp-4);
|
|
82
118
|
background: var(--bg-elevated);
|
|
83
119
|
border: 1px solid var(--border);
|
|
84
120
|
border-radius: var(--r-lg);
|
|
85
|
-
padding: var(--
|
|
121
|
+
padding: var(--card-pad);
|
|
122
|
+
}
|
|
123
|
+
.card-gap {
|
|
124
|
+
display: flex;
|
|
125
|
+
flex-direction: column;
|
|
126
|
+
gap: var(--card-gap);
|
|
127
|
+
}
|
|
128
|
+
.card-framed {
|
|
129
|
+
padding: 0;
|
|
130
|
+
}
|
|
131
|
+
.card-head,
|
|
132
|
+
.card-body,
|
|
133
|
+
.card-foot {
|
|
134
|
+
padding: var(--card-pad);
|
|
135
|
+
}
|
|
136
|
+
.card-head {
|
|
137
|
+
border-bottom: 1px solid var(--border);
|
|
138
|
+
}
|
|
139
|
+
.card-foot {
|
|
140
|
+
border-top: 1px solid var(--border);
|
|
86
141
|
}
|
|
87
142
|
/* Theme-aware surface shade, selected by prop so it adapts across themes
|
|
88
143
|
instead of being overridden with app-level :global hacks. `base` is the
|
|
@@ -94,14 +149,22 @@
|
|
|
94
149
|
background: var(--bg-elevated-2);
|
|
95
150
|
}
|
|
96
151
|
.pad-none {
|
|
152
|
+
--card-pad: 0;
|
|
97
153
|
padding: 0;
|
|
98
154
|
}
|
|
99
155
|
.pad-sm {
|
|
156
|
+
--card-pad: var(--sp-2);
|
|
100
157
|
padding: var(--sp-2);
|
|
101
158
|
}
|
|
102
159
|
.pad-lg {
|
|
160
|
+
--card-pad: var(--sp-6);
|
|
103
161
|
padding: var(--sp-6);
|
|
104
162
|
}
|
|
163
|
+
.card-framed.pad-none,
|
|
164
|
+
.card-framed.pad-sm,
|
|
165
|
+
.card-framed.pad-lg {
|
|
166
|
+
padding: 0;
|
|
167
|
+
}
|
|
105
168
|
.card-tap {
|
|
106
169
|
cursor: pointer;
|
|
107
170
|
transition:
|
|
@@ -10,8 +10,14 @@ type $$ComponentProps = {
|
|
|
10
10
|
stackTone?: Tone;
|
|
11
11
|
stackY?: number;
|
|
12
12
|
stackX?: number;
|
|
13
|
+
title?: string;
|
|
14
|
+
subtitle?: string;
|
|
15
|
+
gap?: string;
|
|
13
16
|
class?: string;
|
|
14
17
|
style?: string;
|
|
18
|
+
header?: Snippet;
|
|
19
|
+
footer?: Snippet;
|
|
20
|
+
actions?: Snippet;
|
|
15
21
|
children?: Snippet;
|
|
16
22
|
[key: string]: unknown;
|
|
17
23
|
};
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// e.g. a lucide-svelte component's contents — without adding it to the
|
|
6
6
|
// registry. Sized at 1em so it tracks the surrounding text (and the
|
|
7
7
|
// --fs-scale font control).
|
|
8
|
-
const
|
|
8
|
+
const GLYPHS = {
|
|
9
9
|
// — navigation / chevrons —
|
|
10
10
|
back: '<path d="m12 19-7-7 7-7" /><path d="M19 12H5" />',
|
|
11
11
|
'arrow-right': '<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />',
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
folder: '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />',
|
|
56
56
|
archive: '<rect width="20" height="5" x="2" y="3" rx="1" /><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" /><path d="M10 12h4" />',
|
|
57
57
|
image: '<rect width="18" height="18" x="3" y="3" rx="2" ry="2" /><circle cx="9" cy="9" r="2" /><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />',
|
|
58
|
+
book: '<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20" />',
|
|
58
59
|
markdown: '<rect x="3" y="5" width="18" height="14" rx="2" /><path d="M7 15V9l3 3 3-3v6" /><path d="m15 11 2 2 2-2" />',
|
|
59
60
|
list: '<path d="M3 5h.01" /><path d="M3 12h.01" /><path d="M3 19h.01" /><path d="M8 5h13" /><path d="M8 12h13" /><path d="M8 19h13" />',
|
|
60
61
|
grid: '<rect width="7" height="7" x="3" y="3" rx="1" /><rect width="7" height="7" x="14" y="3" rx="1" /><rect width="7" height="7" x="14" y="14" rx="1" /><rect width="7" height="7" x="3" y="14" rx="1" />',
|
|
@@ -93,6 +94,9 @@
|
|
|
93
94
|
'alert-circle': '<circle cx="12" cy="12" r="10" /><line x1="12" x2="12" y1="8" y2="12" /><line x1="12" x2="12.01" y1="16" y2="16" />',
|
|
94
95
|
} as const;
|
|
95
96
|
|
|
97
|
+
// Alternate names resolving to the same path data.
|
|
98
|
+
const ICONS = { ...GLYPHS, alert: GLYPHS.warning } as const;
|
|
99
|
+
|
|
96
100
|
export type IconName = keyof typeof ICONS;
|
|
97
101
|
|
|
98
102
|
/** True when `s` is a registered glyph name (vs. e.g. a raw emoji string). */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
declare const ICONS: {
|
|
2
|
+
readonly alert: "<path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\" /><path d=\"M12 9v4\" /><path d=\"M12 17h.01\" />";
|
|
2
3
|
readonly back: "<path d=\"m12 19-7-7 7-7\" /><path d=\"M19 12H5\" />";
|
|
3
4
|
readonly 'arrow-right': "<path d=\"M5 12h14\" /><path d=\"m12 5 7 7-7 7\" />";
|
|
4
5
|
readonly 'arrow-up': "<path d=\"m5 12 7-7 7 7\" /><path d=\"M12 19V5\" />";
|
|
@@ -39,6 +40,7 @@ declare const ICONS: {
|
|
|
39
40
|
readonly folder: "<path d=\"M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z\" />";
|
|
40
41
|
readonly archive: "<rect width=\"20\" height=\"5\" x=\"2\" y=\"3\" rx=\"1\" /><path d=\"M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8\" /><path d=\"M10 12h4\" />";
|
|
41
42
|
readonly image: "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" ry=\"2\" /><circle cx=\"9\" cy=\"9\" r=\"2\" /><path d=\"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21\" />";
|
|
43
|
+
readonly book: "<path d=\"M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20\" />";
|
|
42
44
|
readonly markdown: "<rect x=\"3\" y=\"5\" width=\"18\" height=\"14\" rx=\"2\" /><path d=\"M7 15V9l3 3 3-3v6\" /><path d=\"m15 11 2 2 2-2\" />";
|
|
43
45
|
readonly list: "<path d=\"M3 5h.01\" /><path d=\"M3 12h.01\" /><path d=\"M3 19h.01\" /><path d=\"M8 5h13\" /><path d=\"M8 12h13\" /><path d=\"M8 19h13\" />";
|
|
44
46
|
readonly grid: "<rect width=\"7\" height=\"7\" x=\"3\" y=\"3\" rx=\"1\" /><rect width=\"7\" height=\"7\" x=\"14\" y=\"3\" rx=\"1\" /><rect width=\"7\" height=\"7\" x=\"14\" y=\"14\" rx=\"1\" /><rect width=\"7\" height=\"7\" x=\"3\" y=\"14\" rx=\"1\" />";
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// collapses to a small dot indicator so the count cue survives the rail.
|
|
7
7
|
// `title`/`aria-label` carry the name so the icon-only state stays accessible
|
|
8
8
|
// and shows a tooltip. Renders an <a> when `href` is set, else a <button>.
|
|
9
|
+
// The glyph is `iconChildren` (raw 24×24 SVG content), else `iconPath`
|
|
10
|
+
// (single path d), else the registry `icon`.
|
|
9
11
|
import type { Snippet } from 'svelte';
|
|
10
12
|
import Icon from '../atoms/Icon.svelte';
|
|
11
13
|
import type { IconName } from '../atoms/Icon.svelte';
|
|
@@ -15,19 +17,31 @@
|
|
|
15
17
|
|
|
16
18
|
let {
|
|
17
19
|
icon,
|
|
20
|
+
iconChildren,
|
|
21
|
+
iconPath,
|
|
22
|
+
iconSize = 18,
|
|
18
23
|
label,
|
|
19
24
|
href,
|
|
20
25
|
active = false,
|
|
26
|
+
activeStyle = 'fill',
|
|
21
27
|
onclick,
|
|
22
28
|
badge,
|
|
23
29
|
badgeTone = 'neutral',
|
|
24
30
|
children,
|
|
25
31
|
...rest
|
|
26
32
|
}: {
|
|
27
|
-
icon
|
|
33
|
+
icon?: IconName;
|
|
34
|
+
/** Raw 24×24 SVG content, for glyphs outside the registry. Wins over `iconPath` and `icon`. */
|
|
35
|
+
iconChildren?: Snippet;
|
|
36
|
+
/** Single `<path d>` for a 24×24 outline glyph. Wins over `icon`. */
|
|
37
|
+
iconPath?: string;
|
|
38
|
+
iconSize?: number;
|
|
28
39
|
label: string;
|
|
29
40
|
href?: string;
|
|
30
41
|
active?: boolean;
|
|
42
|
+
/** 'fill' = accent-tinted pill with a rounded left marker; 'bar' = lighter
|
|
43
|
+
* tint with a flat 2px inset accent bar. */
|
|
44
|
+
activeStyle?: 'fill' | 'bar';
|
|
31
45
|
onclick?: (e: MouseEvent) => void;
|
|
32
46
|
/** Optional trailing count pill (e.g. unread / missing items). When the
|
|
33
47
|
* item is `active` it adopts the accent fill; otherwise it uses `badgeTone`. */
|
|
@@ -49,13 +63,20 @@
|
|
|
49
63
|
type={href ? undefined : 'button'}
|
|
50
64
|
class="nav-item"
|
|
51
65
|
class:active
|
|
66
|
+
class:bar={activeStyle === 'bar'}
|
|
52
67
|
title={label}
|
|
53
68
|
aria-label={label}
|
|
54
69
|
aria-current={active ? 'page' : undefined}
|
|
55
70
|
{onclick}
|
|
56
71
|
{...rest}
|
|
57
72
|
>
|
|
58
|
-
|
|
73
|
+
{#if iconChildren}
|
|
74
|
+
<Icon size={iconSize}>{@render iconChildren()}</Icon>
|
|
75
|
+
{:else if iconPath}
|
|
76
|
+
<Icon size={iconSize}><path d={iconPath} /></Icon>
|
|
77
|
+
{:else if icon}
|
|
78
|
+
<Icon name={icon} size={iconSize} />
|
|
79
|
+
{/if}
|
|
59
80
|
<span class="nav-label">{label}</span>
|
|
60
81
|
{#if hasBadge}
|
|
61
82
|
<span class="nav-trail">
|
|
@@ -101,7 +122,7 @@
|
|
|
101
122
|
}
|
|
102
123
|
/* Active-route accent inset: a left rail marker that survives the icon-rail
|
|
103
124
|
collapse (the tint background does too, but the marker reads at a glance). */
|
|
104
|
-
.nav-item.active::before {
|
|
125
|
+
.nav-item.active:not(.bar)::before {
|
|
105
126
|
content: '';
|
|
106
127
|
position: absolute;
|
|
107
128
|
left: 0;
|
|
@@ -112,6 +133,12 @@
|
|
|
112
133
|
border-radius: var(--r-pill);
|
|
113
134
|
background: var(--accent);
|
|
114
135
|
}
|
|
136
|
+
.nav-item.bar.active {
|
|
137
|
+
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
|
138
|
+
box-shadow: inset 2px 0 0 var(--accent);
|
|
139
|
+
border-start-start-radius: 0;
|
|
140
|
+
border-end-start-radius: 0;
|
|
141
|
+
}
|
|
115
142
|
.nav-label {
|
|
116
143
|
flex: 1;
|
|
117
144
|
min-width: 0; /* allow the label to shrink/ellipsis instead of overflowing */
|
|
@@ -2,10 +2,18 @@ import type { Snippet } from 'svelte';
|
|
|
2
2
|
import type { IconName } from '../atoms/Icon.svelte';
|
|
3
3
|
type BadgeTone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info';
|
|
4
4
|
type $$ComponentProps = {
|
|
5
|
-
icon
|
|
5
|
+
icon?: IconName;
|
|
6
|
+
/** Raw 24×24 SVG content, for glyphs outside the registry. Wins over `iconPath` and `icon`. */
|
|
7
|
+
iconChildren?: Snippet;
|
|
8
|
+
/** Single `<path d>` for a 24×24 outline glyph. Wins over `icon`. */
|
|
9
|
+
iconPath?: string;
|
|
10
|
+
iconSize?: number;
|
|
6
11
|
label: string;
|
|
7
12
|
href?: string;
|
|
8
13
|
active?: boolean;
|
|
14
|
+
/** 'fill' = accent-tinted pill with a rounded left marker; 'bar' = lighter
|
|
15
|
+
* tint with a flat 2px inset accent bar. */
|
|
16
|
+
activeStyle?: 'fill' | 'bar';
|
|
9
17
|
onclick?: (e: MouseEvent) => void;
|
|
10
18
|
/** Optional trailing count pill (e.g. unread / missing items). When the
|
|
11
19
|
* item is `active` it adopts the accent fill; otherwise it uses `badgeTone`. */
|
|
@@ -42,6 +42,6 @@ type $$ComponentProps = {
|
|
|
42
42
|
/** Overlay mode: cap the width at the viewport and re-clamp on window resize. */
|
|
43
43
|
clampToViewport?: boolean;
|
|
44
44
|
};
|
|
45
|
-
declare const ResizablePanel: import("svelte").Component<$$ComponentProps, {}, "
|
|
45
|
+
declare const ResizablePanel: import("svelte").Component<$$ComponentProps, {}, "open" | "collapsed">;
|
|
46
46
|
type ResizablePanel = ReturnType<typeof ResizablePanel>;
|
|
47
47
|
export default ResizablePanel;
|