@lyeve-labs/ui-kit 0.40.0 → 0.41.1

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
@@ -28,7 +28,7 @@ No config file, no theme provider, no setup ceremony.
28
28
 
29
29
  ## What's in the box
30
30
 
31
- - **72 components:** buttons, inputs, modals, drawers, tabs, tables, toasts, the works.
31
+ - **75 components:** buttons, inputs, modals, drawers, tabs, tables, toasts, the works.
32
32
  - **Two themes:** Soft Dark (default) and Soft Light, switched by a single `data-theme` attribute on `<html>`.
33
33
  - **One CSS file:** `@lyeve-labs/ui-kit/styles.css` declares every token. The rest is just Tailwind.
34
34
  - **Svelte 5 native:** built on runes and snippets, fully typed end-to-end.
@@ -37,10 +37,10 @@ No config file, no theme provider, no setup ceremony.
37
37
  ## Component list
38
38
 
39
39
  <details>
40
- <summary>72 components, organized by purpose</summary>
40
+ <summary>75 components, organized by purpose</summary>
41
41
 
42
42
  **Layout and structure**
43
- Card, Panel, AppShell, AuthShell, PageShell, PageHeader, SectionHeading, Divider, Accordion, AccordionItem, Collapsible, Table, DescriptionList, Toolbar, TreeView
43
+ Card, Panel, AppShell, AuthShell, PageShell, PageHeader, SectionHeading, Divider, Accordion, AccordionItem, Collapsible, Table, DescriptionList, Toolbar, TreeView, SplitPane
44
44
 
45
45
  **Forms and inputs**
46
46
  Button, ButtonGroup, Input, PasswordInput, CopyField, Textarea, NumberInput, SearchInput, FileInput, Label, Field, FormMessage, SegmentedControl, FilterChips, Select, MultiSelect, Autocomplete, DatePicker, TimePicker, DateTimePicker, Checkbox, CheckboxGroup, Radio, RadioGroup, Toggle
@@ -55,7 +55,7 @@ Modal, Drawer, Tooltip
55
55
  Dialog, DialogContainer, ConfirmDialog (plus the `openDialog` and `confirm` services)
56
56
 
57
57
  **Feedback and status**
58
- Alert, Banner, Badge, Tag, Indicator, Progress, Spinner, Skeleton, CheckoutSteps, EmptyState, PluginCart, Stat, Kbd, CopyButton
58
+ Alert, Banner, RefusalNotice, GateNotice, Badge, Tag, Indicator, Progress, Spinner, Skeleton, CheckoutSteps, EmptyState, PluginCart, Stat, Kbd, CopyButton
59
59
 
60
60
  **Media**
61
61
  Avatar, AvatarGroup, Logo
@@ -70,7 +70,7 @@ ThemeToggle, Toaster (plus the `toast` service)
70
70
  - **Svelte 5.0** or newer
71
71
  - **Tailwind CSS v4** with the `@tailwindcss/vite` plugin
72
72
  - **`@lucide/svelte`** 0.511 or newer, below 2.0
73
- - **Node 20** or newer
73
+ - **Node 24** or newer
74
74
 
75
75
  ## Install
76
76
 
@@ -385,6 +385,9 @@ const JOB_TONE: Record<JobState, StatusTone> = {
385
385
 
386
386
  ## Local development
387
387
 
388
+ The kit is developed on Node 26 and installs with pnpm only. Applications that
389
+ consume it need Node 24 or newer and may use any package manager.
390
+
388
391
  ```bash
389
392
  pnpm install # install dependencies
390
393
  pnpm test # run unit tests
@@ -400,7 +403,7 @@ This repo is a single-purpose component library. Nothing but `src/lib/`.
400
403
  ```
401
404
  src/
402
405
  └── lib/ # → published as @lyeve-labs/ui-kit
403
- ├── components/ # 72 .svelte files
406
+ ├── components/ # 75 .svelte files
404
407
  ├── stores/ # toast.svelte.ts
405
408
  ├── styles/ # theme.css (the one stylesheet)
406
409
  ├── utils/ # cn.ts, theme.ts
@@ -6,6 +6,7 @@
6
6
  FIELD_WRAP,
7
7
  describedBy,
8
8
  } from '../internal/field.js';
9
+ import { describeAccept } from '../internal/accept.js';
9
10
  interface Props {
10
11
  id?: string;
11
12
  name?: string;
@@ -33,6 +34,7 @@
33
34
  }: Props = $props();
34
35
 
35
36
  let dragOver = $state(false);
37
+ const types = $derived(describeAccept(accept ?? ''));
36
38
 
37
39
  function handleChange(e: Event & { currentTarget: HTMLInputElement }) {
38
40
  onchange?.(e.currentTarget.files);
@@ -93,7 +95,7 @@
93
95
  <div class="text-sm">
94
96
  <span class="{error ? 'text-danger' : 'text-brand'} font-medium">Click to upload</span>
95
97
  <span class="text-muted"> or drag & drop</span>
96
- {#if accept}<p class="mt-0.5 text-xs text-faint [overflow-wrap:anywhere]">{accept}</p>{/if}
98
+ {#if types}<p class="mt-0.5 text-xs text-faint [overflow-wrap:anywhere]">{types}</p>{/if}
97
99
  </div>
98
100
  <input
99
101
  type="file"
@@ -0,0 +1,68 @@
1
+ <script lang="ts">
2
+ import { RefreshCw } from '@lucide/svelte';
3
+ import type { Snippet } from 'svelte';
4
+ import Alert from './Alert.svelte';
5
+ import Button from './Button.svelte';
6
+ import type { Gate, GateWords } from '../internal/gate.js';
7
+
8
+ /**
9
+ * What a page shows in place of what a refused or failed read would have
10
+ * filled, the same way on every page. The page passes the gate it mapped
11
+ * from the server's answer and every sentence in its own words. Only the
12
+ * states that waiting can change offer to try again.
13
+ */
14
+ interface Props {
15
+ gate: Gate;
16
+ words: GateWords;
17
+ /** Drawn for a feature that is not enabled, given the link forward the gate carried. */
18
+ locked: Snippet<[forwardHref: string]>;
19
+ /** The address that asks for this page again, usually its own path and query. */
20
+ retryHref: string;
21
+ class?: string;
22
+ }
23
+
24
+ let { gate, words, locked, retryHref, class: klass = '' }: Props = $props();
25
+ </script>
26
+
27
+ {#snippet retry()}
28
+ <div class="mt-3">
29
+ <!-- A plain link works before the page hydrates. After it hydrates, a
30
+ SvelteKit router keeps the data it holds for an address that did not
31
+ change, so the link asks for a fresh document instead. Other hosts
32
+ ignore the attribute. -->
33
+ <Button variant="secondary" size="sm" href={retryHref} data-sveltekit-reload>
34
+ <RefreshCw size={14} aria-hidden="true" />
35
+ {words.retry}
36
+ </Button>
37
+ </div>
38
+ {/snippet}
39
+
40
+ {#if gate.state !== 'ok'}
41
+ <div class={klass} data-testid="gate-notice" data-state={gate.state}>
42
+ {#if gate.state === 'absent'}
43
+ <Alert tone="brand">{words.absent}</Alert>
44
+ {:else if gate.state === 'locked'}
45
+ {@render locked(gate.forwardHref)}
46
+ {:else if gate.state === 'full'}
47
+ <Alert tone="warn" title={words.full.title}>{words.full.body}</Alert>
48
+ {:else if gate.state === 'forbidden'}
49
+ <!-- Trying again cannot change a role, so this one offers nothing to press. -->
50
+ <Alert tone="warn">{words.forbidden}</Alert>
51
+ {:else if gate.state === 'busy'}
52
+ <Alert tone="warn" title={words.busy.title}>
53
+ <p>{words.busy.body}</p>
54
+ {@render retry()}
55
+ </Alert>
56
+ {:else if gate.state === 'unavailable'}
57
+ <Alert tone="warn" title={words.unavailable.title}>
58
+ <p>{words.unavailable.body}</p>
59
+ {@render retry()}
60
+ </Alert>
61
+ {:else if gate.state === 'error'}
62
+ <Alert tone="danger">
63
+ <p>{gate.message}</p>
64
+ {@render retry()}
65
+ </Alert>
66
+ {/if}
67
+ </div>
68
+ {/if}
@@ -0,0 +1,20 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { Gate, GateWords } from '../internal/gate.js';
3
+ /**
4
+ * What a page shows in place of what a refused or failed read would have
5
+ * filled, the same way on every page. The page passes the gate it mapped
6
+ * from the server's answer and every sentence in its own words. Only the
7
+ * states that waiting can change offer to try again.
8
+ */
9
+ interface Props {
10
+ gate: Gate;
11
+ words: GateWords;
12
+ /** Drawn for a feature that is not enabled, given the link forward the gate carried. */
13
+ locked: Snippet<[forwardHref: string]>;
14
+ /** The address that asks for this page again, usually its own path and query. */
15
+ retryHref: string;
16
+ class?: string;
17
+ }
18
+ declare const GateNotice: import("svelte").Component<Props, {}, "">;
19
+ type GateNotice = ReturnType<typeof GateNotice>;
20
+ export default GateNotice;
@@ -0,0 +1,61 @@
1
+ <script lang="ts">
2
+ import Alert from './Alert.svelte';
3
+ import Button from './Button.svelte';
4
+ import { isExternalHref } from '../internal/href.js';
5
+
6
+ /**
7
+ * A write the server refused, said the same way on every page: a title, the
8
+ * sentence that says why, the way forward when there is one, and a link that
9
+ * is always offered, such as the page where the setting is read. The page
10
+ * passes every word, so a lifted limit never reads as a stale one here.
11
+ */
12
+ interface NoticeLink {
13
+ href: string;
14
+ label: string;
15
+ }
16
+
17
+ interface Props {
18
+ title: string;
19
+ body: string;
20
+ /** Where the reader gets past the refusal, when the refusal names a place. */
21
+ forward?: NoticeLink | null;
22
+ /** The link offered on every refusal. */
23
+ link: NoticeLink;
24
+ /**
25
+ * True where the way forward is the one action on its surface. A surface
26
+ * holds one primary, and the notice usually sits in a drawer or a page that
27
+ * has its own Create or Save, so by default its links stay below that: the
28
+ * way forward secondary and the other link ghost.
29
+ */
30
+ lead?: boolean;
31
+ class?: string;
32
+ }
33
+
34
+ let { title, body, forward = null, link, lead = false, class: klass = '' }: Props = $props();
35
+
36
+ // A link to another origin opens in a tab of its own, so the page it opens
37
+ // cannot reach back into this one through window.opener.
38
+ const away = $derived(forward !== null && isExternalHref(forward.href));
39
+ </script>
40
+
41
+ <div class={klass} data-testid="refusal-notice">
42
+ <Alert tone="warn" {title}>
43
+ <p>{body}</p>
44
+ <div class="mt-3 flex flex-wrap gap-2">
45
+ {#if forward}
46
+ <Button
47
+ variant={lead ? 'primary' : 'secondary'}
48
+ size="sm"
49
+ href={forward.href}
50
+ target={away ? '_blank' : undefined}
51
+ rel={away ? 'noopener noreferrer' : undefined}
52
+ >
53
+ {forward.label}
54
+ </Button>
55
+ {/if}
56
+ <Button variant={forward && !lead ? 'ghost' : 'secondary'} size="sm" href={link.href}>
57
+ {link.label}
58
+ </Button>
59
+ </div>
60
+ </Alert>
61
+ </div>
@@ -0,0 +1,29 @@
1
+ /**
2
+ * A write the server refused, said the same way on every page: a title, the
3
+ * sentence that says why, the way forward when there is one, and a link that
4
+ * is always offered, such as the page where the setting is read. The page
5
+ * passes every word, so a lifted limit never reads as a stale one here.
6
+ */
7
+ interface NoticeLink {
8
+ href: string;
9
+ label: string;
10
+ }
11
+ interface Props {
12
+ title: string;
13
+ body: string;
14
+ /** Where the reader gets past the refusal, when the refusal names a place. */
15
+ forward?: NoticeLink | null;
16
+ /** The link offered on every refusal. */
17
+ link: NoticeLink;
18
+ /**
19
+ * True where the way forward is the one action on its surface. A surface
20
+ * holds one primary, and the notice usually sits in a drawer or a page that
21
+ * has its own Create or Save, so by default its links stay below that: the
22
+ * way forward secondary and the other link ghost.
23
+ */
24
+ lead?: boolean;
25
+ class?: string;
26
+ }
27
+ declare const RefusalNotice: import("svelte").Component<Props, {}, "">;
28
+ type RefusalNotice = ReturnType<typeof RefusalNotice>;
29
+ export default RefusalNotice;
@@ -0,0 +1,428 @@
1
+ <script lang="ts">
2
+ import {
3
+ Lock,
4
+ LockOpen,
5
+ PanelLeftClose,
6
+ PanelLeftOpen,
7
+ PanelRightClose,
8
+ PanelRightOpen,
9
+ } from '@lucide/svelte';
10
+ import { tick, untrack, type Snippet } from 'svelte';
11
+ import Button from './Button.svelte';
12
+ import SectionHeading from './SectionHeading.svelte';
13
+ import { clampWidth, readAside, viewerStorage, writeAside } from '../internal/aside.js';
14
+
15
+ /**
16
+ * A column docked to one side of a page, with one width discipline for every
17
+ * page that has one. Above the breakpoint its inner edge drags, a lock fixes
18
+ * the width, the keyboard moves the edge, and the width and the lock are
19
+ * remembered per viewer. A collapsible one folds to a strip carrying its
20
+ * name. Below the breakpoint none of that applies: it stacks in the page's
21
+ * own flow, hides, or becomes the overlay a toolbar opens, as the page
22
+ * decides. The drag overlays the width while the pointer moves and writes
23
+ * once on release.
24
+ */
25
+ type Breakpoint = 'md' | 'lg' | 'xl';
26
+
27
+ interface Props {
28
+ /** The localStorage key the width and lock are kept under. */
29
+ storageKey: string;
30
+ /** The word on the strip and the header. */
31
+ label: string;
32
+ /** Which edge of the page it docks to. The handle sits on the other edge. */
33
+ side: 'start' | 'end';
34
+ /** The width it opens at until somebody drags it. */
35
+ width?: number;
36
+ min?: number;
37
+ max?: number;
38
+ /** Where the desktop controls start. */
39
+ breakpoint?: Breakpoint;
40
+ /**
41
+ * What it does under the breakpoint: stays in the page's flow across the
42
+ * full width, disappears, or floats over the page while `overlay` is set.
43
+ */
44
+ below?: 'stack' | 'overlay' | 'hidden';
45
+ /** Shown as an overlay below the breakpoint. Only read with `below="overlay"`. */
46
+ overlay?: boolean;
47
+ /** Offers a collapse to a strip. */
48
+ collapsible?: boolean;
49
+ /** Expanded above the breakpoint. Bindable so a page can open it from an offer. */
50
+ open?: boolean;
51
+ /** Keeps the header's count or status beside the label. */
52
+ meta?: Snippet;
53
+ /** Classes for the aside element itself, for the stacked layout's share of the height. */
54
+ class?: string;
55
+ testId?: string;
56
+ children: Snippet;
57
+ }
58
+
59
+ let {
60
+ storageKey,
61
+ label,
62
+ side,
63
+ width: defaultWidth = 280,
64
+ min = 200,
65
+ max = 560,
66
+ breakpoint = 'xl',
67
+ below = 'stack',
68
+ overlay = false,
69
+ collapsible = false,
70
+ open = $bindable(true),
71
+ meta,
72
+ class: klass = '',
73
+ testId,
74
+ children,
75
+ }: Props = $props();
76
+
77
+ // Tailwind reads class names from source, so each breakpoint spells its
78
+ // variants out rather than building them from the prop. The dock entry hands
79
+ // an overlay back to the page at the breakpoint, and nothing moves or
80
+ // transitions there. It resets the offset with translate-none rather than a
81
+ // zero offset, because any translate makes the aside the containing block
82
+ // for a fixed popover inside it. The overlay's layer applies only under the
83
+ // breakpoint: a docked pane is a flex item, and a stacking layer on it would
84
+ // lift it, and every popover inside it, over the main column's popovers. The
85
+ // room entry keeps the main column at least the pane's minimum wide, so a
86
+ // width stored on a wider screen never pushes it off this one.
87
+ type Variant =
88
+ | 'dock'
89
+ | 'w8'
90
+ | 'flex'
91
+ | 'block'
92
+ | 'hidden'
93
+ | 'width'
94
+ | 'room'
95
+ | 'noBorderB'
96
+ | 'borderE'
97
+ | 'borderS'
98
+ | 'flexNone'
99
+ | 'maxHNone';
100
+ const AT: Record<Breakpoint, Record<Variant, string>> = {
101
+ md: {
102
+ dock: 'max-md:z-dropdown md:static md:visible md:translate-none md:opacity-100 md:transition-none',
103
+ w8: 'md:w-8',
104
+ flex: 'md:flex',
105
+ block: 'md:block',
106
+ hidden: 'md:hidden',
107
+ width: 'md:w-(--panel-w)',
108
+ room: 'md:max-w-[calc(100%_-_var(--panel-min))]',
109
+ noBorderB: 'md:border-b-0',
110
+ borderE: 'md:border-e',
111
+ borderS: 'md:border-s',
112
+ flexNone: 'md:flex-none',
113
+ maxHNone: 'md:max-h-none',
114
+ },
115
+ lg: {
116
+ dock: 'max-lg:z-dropdown lg:static lg:visible lg:translate-none lg:opacity-100 lg:transition-none',
117
+ w8: 'lg:w-8',
118
+ flex: 'lg:flex',
119
+ block: 'lg:block',
120
+ hidden: 'lg:hidden',
121
+ width: 'lg:w-(--panel-w)',
122
+ room: 'lg:max-w-[calc(100%_-_var(--panel-min))]',
123
+ noBorderB: 'lg:border-b-0',
124
+ borderE: 'lg:border-e',
125
+ borderS: 'lg:border-s',
126
+ flexNone: 'lg:flex-none',
127
+ maxHNone: 'lg:max-h-none',
128
+ },
129
+ xl: {
130
+ dock: 'max-xl:z-dropdown xl:static xl:visible xl:translate-none xl:opacity-100 xl:transition-none',
131
+ w8: 'xl:w-8',
132
+ flex: 'xl:flex',
133
+ block: 'xl:block',
134
+ hidden: 'xl:hidden',
135
+ width: 'xl:w-(--panel-w)',
136
+ room: 'xl:max-w-[calc(100%_-_var(--panel-min))]',
137
+ noBorderB: 'xl:border-b-0',
138
+ borderE: 'xl:border-e',
139
+ borderS: 'xl:border-s',
140
+ flexNone: 'xl:flex-none',
141
+ maxHNone: 'xl:max-h-none',
142
+ },
143
+ };
144
+ const at = $derived(AT[breakpoint]);
145
+ const id = $derived(testId ?? `aside-${storageKey}`);
146
+
147
+ // Each breakpoint as the stylesheet compiles it, in rem, so the script and
148
+ // the variants above change over at the same width.
149
+ const WIDE: Record<Breakpoint, string> = {
150
+ md: '(min-width: 48rem)',
151
+ lg: '(min-width: 64rem)',
152
+ xl: '(min-width: 80rem)',
153
+ };
154
+
155
+ /**
156
+ * Whether the page is under the breakpoint, once the browser has said. Only
157
+ * an overlay asks, and only to set inert. Every other part of the overlay is
158
+ * a class, and its breakpoint variant hands the docked column back. Inert is
159
+ * an attribute, which no variant can scope, and the docked column has to take
160
+ * focus and clicks whatever the overlay flag says. Until the browser answers,
161
+ * invisible keeps a closed overlay out of focus and out of reach.
162
+ */
163
+ let narrow = $state(false);
164
+
165
+ $effect(() => {
166
+ if (below !== 'overlay' || typeof window.matchMedia !== 'function') return;
167
+ const wide = window.matchMedia(WIDE[breakpoint]);
168
+ const sync = () => {
169
+ narrow = !wide.matches;
170
+ };
171
+ sync();
172
+ wide.addEventListener('change', sync);
173
+ return () => wide.removeEventListener('change', sync);
174
+ });
175
+
176
+ const bounds = $derived({ width: defaultWidth, min, max });
177
+
178
+ // The key never changes for the life of the pane, so the read happens once,
179
+ // at mount. The server has no storage, so it renders the default width.
180
+ const initial = readAside(
181
+ viewerStorage(),
182
+ untrack(() => storageKey),
183
+ untrack(() => bounds),
184
+ );
185
+ let width = $state(initial.width);
186
+ let locked = $state(initial.locked);
187
+
188
+ let aside: HTMLElement | undefined = $state();
189
+ let drag = $state<{ startX: number; startWidth: number; grows: number } | null>(null);
190
+
191
+ /**
192
+ * Which way a pointer or an arrow moving right changes the width. The inner
193
+ * edge is on the right of a start pane and on the left of an end pane, and a
194
+ * right-to-left page swaps both, so the edge always follows the key the way
195
+ * it looks.
196
+ */
197
+ function growsRight(): number {
198
+ const rtl = aside ? getComputedStyle(aside).direction === 'rtl' : false;
199
+ return (side === 'start' ? 1 : -1) * (rtl ? -1 : 1);
200
+ }
201
+
202
+ /**
203
+ * The bounds as this screen allows them. The upper bound also leaves the
204
+ * main column the pane's minimum, the same cap the stylesheet draws. A
205
+ * parent with no measured width (before layout, or in a test) leaves the
206
+ * bounds as given.
207
+ */
208
+ function room() {
209
+ const total = aside?.parentElement?.clientWidth ?? 0;
210
+ const upper = total > 0 ? Math.max(min, Math.min(max, total - min)) : max;
211
+ return { min, max: upper };
212
+ }
213
+
214
+ /**
215
+ * The upper bound the room allows, kept current as the page resizes, so the
216
+ * handle announces the width drawn and a maximum the keys can reach. The
217
+ * server has no layout and renders the bounds as given.
218
+ */
219
+ let roomMax = $state(untrack(() => max));
220
+
221
+ $effect(() => {
222
+ const parent = aside?.parentElement;
223
+ const sync = () => {
224
+ roomMax = room().max;
225
+ };
226
+ sync();
227
+ if (!parent || typeof ResizeObserver !== 'function') return;
228
+ const watch = new ResizeObserver(sync);
229
+ watch.observe(parent);
230
+ return () => watch.disconnect();
231
+ });
232
+
233
+ /** The width on screen, which the room cap can hold below the stored one. */
234
+ function shown(): number {
235
+ const drawn = aside?.getBoundingClientRect().width ?? 0;
236
+ return drawn > 0 ? Math.min(width, Math.round(drawn)) : width;
237
+ }
238
+
239
+ function remember() {
240
+ writeAside(viewerStorage(), storageKey, bounds, { width, locked });
241
+ }
242
+
243
+ function startResize(e: PointerEvent) {
244
+ if (e.button !== 0) return;
245
+ e.preventDefault();
246
+ drag = { startX: e.clientX, startWidth: shown(), grows: growsRight() };
247
+ (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
248
+ }
249
+
250
+ function resizeMove(e: PointerEvent) {
251
+ if (!drag) return;
252
+ width = clampWidth(room(), drag.startWidth + (e.clientX - drag.startX) * drag.grows);
253
+ }
254
+
255
+ function endResize(e: PointerEvent) {
256
+ if (!drag) return;
257
+ drag = null;
258
+ (e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
259
+ remember();
260
+ }
261
+
262
+ function resizeKey(e: KeyboardEvent) {
263
+ const step = e.shiftKey ? 80 : 24;
264
+ const limits = room();
265
+ let next: number | null = null;
266
+ if (e.key === 'ArrowRight') next = shown() + step * growsRight();
267
+ else if (e.key === 'ArrowLeft') next = shown() - step * growsRight();
268
+ else if (e.key === 'Home') next = limits.min;
269
+ else if (e.key === 'End') next = limits.max;
270
+ if (next === null) return;
271
+ e.preventDefault();
272
+ width = clampWidth(limits, next);
273
+ remember();
274
+ }
275
+
276
+ function toggleLock() {
277
+ locked = !locked;
278
+ remember();
279
+ }
280
+
281
+ let strip: HTMLElement | undefined = $state();
282
+ let controls: HTMLElement | undefined = $state();
283
+
284
+ /**
285
+ * Folds or opens the pane from its own control. The fold hides the control
286
+ * that was pressed, so once the markup carries the new state focus moves to
287
+ * the control that undoes it. Left where it was, a keyboard reader lands on
288
+ * the body or on a button nobody can see.
289
+ */
290
+ async function fold(next: boolean) {
291
+ open = next;
292
+ await tick();
293
+ (next ? controls : strip)?.querySelector<HTMLElement>('[aria-expanded]')?.focus();
294
+ }
295
+
296
+ // The panel glyphs are drawn for a left-to-right page, so a right-to-left
297
+ // one mirrors them with the rest of the layout.
298
+ const Collapse = $derived(side === 'start' ? PanelLeftClose : PanelRightClose);
299
+ const Expand = $derived(side === 'start' ? PanelLeftOpen : PanelRightOpen);
300
+
301
+ /** The strip is the only thing a collapsed pane shows, and only above the breakpoint. */
302
+ const folded = $derived(collapsible && !open);
303
+
304
+ const edge = $derived(side === 'start' ? at.borderE : at.borderS);
305
+
306
+ /**
307
+ * The overlay's motion under the breakpoint. It is CSS because the overlay
308
+ * stays mounted at every width, so what the reader typed in it survives a
309
+ * close and a resize. Closed, it sits off its own edge, transparent and
310
+ * invisible. It slides and fades in on the slow rung and out one rung
311
+ * faster. Visibility is in the exit's list only. Leaving, the panel turns
312
+ * invisible once the slide has finished. Arriving, it is visible at once, so
313
+ * a field inside can take focus on the first frame.
314
+ */
315
+ const slide = $derived(
316
+ overlay
317
+ ? 'transition-[translate,opacity] duration-slow ease-enter'
318
+ : `invisible opacity-0 ${side === 'start' ? '-translate-x-full rtl:translate-x-full' : 'translate-x-full rtl:-translate-x-full'} transition-[translate,opacity,visibility] duration-base ease-exit`,
319
+ );
320
+
321
+ const frame = $derived.by(() => {
322
+ if (below === 'overlay') {
323
+ // Floating under the breakpoint at the panel's own width. Only the
324
+ // docked variant folds to the strip.
325
+ return `absolute inset-y-0 w-(--panel-w) max-w-full shrink-0 border-line bg-surface ${slide} ${at.dock} ${folded ? at.w8 : at.room} ${side === 'start' ? 'start-0 border-e' : 'end-0 border-s'}`;
326
+ }
327
+ const shownBelow =
328
+ below === 'hidden'
329
+ ? `hidden ${at.flex}`
330
+ : `flex border-b ${at.noBorderB} ${at.flexNone} ${at.maxHNone}`;
331
+ return `${shownBelow} min-h-0 flex-col border-line bg-surface ${folded ? at.w8 : `${at.width} ${at.room}`} ${edge} ${klass}`;
332
+ });
333
+ </script>
334
+
335
+ <aside
336
+ bind:this={aside}
337
+ class={frame}
338
+ style="--panel-w:{width}px;--panel-min:{min}px"
339
+ aria-label={label}
340
+ data-testid={id}
341
+ data-open={open}
342
+ data-locked={locked}
343
+ inert={below === 'overlay' && narrow && !overlay}
344
+ >
345
+ {#if collapsible}
346
+ <!-- The strip: the name read down the edge and the one control that brings the pane back. -->
347
+ <div
348
+ bind:this={strip}
349
+ class="h-full flex-col items-center gap-2 py-1 {folded ? `hidden ${at.flex}` : 'hidden'}"
350
+ data-testid="{id}-strip"
351
+ >
352
+ <Button
353
+ variant="ghost"
354
+ size="sm"
355
+ aria-label="Expand {label}"
356
+ aria-expanded={false}
357
+ hint={false}
358
+ onclick={() => fold(true)}
359
+ >
360
+ <Expand size={14} class="rtl:-scale-x-100" />
361
+ </Button>
362
+ <span
363
+ class="text-xs font-medium tracking-wide text-faint uppercase [writing-mode:vertical-rl]"
364
+ >{label}</span
365
+ >
366
+ </div>
367
+ {/if}
368
+
369
+ <div class="relative flex h-full min-h-0 flex-1 flex-col {folded ? at.hidden : ''}">
370
+ <div
371
+ class="flex h-control shrink-0 items-center justify-between border-b border-line ps-3 pe-1"
372
+ >
373
+ <SectionHeading level={2} variant="eyebrow" actions={meta}>{label}</SectionHeading>
374
+ <div bind:this={controls} class="hidden items-center {at.flex}">
375
+ <Button
376
+ variant="ghost"
377
+ size="sm"
378
+ aria-label={locked ? `Unlock ${label} width` : `Lock ${label} width`}
379
+ aria-pressed={locked}
380
+ hint={false}
381
+ onclick={toggleLock}
382
+ >
383
+ {#if locked}<Lock size={14} />{:else}<LockOpen size={14} />{/if}
384
+ </Button>
385
+ {#if collapsible}
386
+ <Button
387
+ variant="ghost"
388
+ size="sm"
389
+ aria-label="Collapse {label}"
390
+ aria-expanded={true}
391
+ hint={false}
392
+ onclick={() => fold(false)}
393
+ >
394
+ <Collapse size={14} class="rtl:-scale-x-100" />
395
+ </Button>
396
+ {/if}
397
+ </div>
398
+ </div>
399
+ <div class="min-h-0 flex-1 overflow-y-auto">
400
+ {@render children()}
401
+ </div>
402
+ {#if !locked}
403
+ <!-- A separator that takes focus is the pattern for a splitter, and the
404
+ a11y rules read every separator as static. -->
405
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
406
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
407
+ <div
408
+ class="absolute inset-y-0 hidden w-2 cursor-col-resize hover:bg-brand/40 focus-visible:bg-brand/40 {at.block} {side ===
409
+ 'start'
410
+ ? '-end-1'
411
+ : '-start-1'} {drag ? 'bg-brand/40' : ''}"
412
+ role="separator"
413
+ aria-orientation="vertical"
414
+ aria-label="Resize {label}"
415
+ aria-valuenow={clampWidth({ min, max: roomMax }, width)}
416
+ aria-valuemin={min}
417
+ aria-valuemax={roomMax}
418
+ tabindex="0"
419
+ data-testid="{id}-handle"
420
+ onpointerdown={startResize}
421
+ onpointermove={resizeMove}
422
+ onpointerup={endResize}
423
+ onpointercancel={endResize}
424
+ onkeydown={resizeKey}
425
+ ></div>
426
+ {/if}
427
+ </div>
428
+ </aside>
@@ -0,0 +1,46 @@
1
+ import { type Snippet } from 'svelte';
2
+ /**
3
+ * A column docked to one side of a page, with one width discipline for every
4
+ * page that has one. Above the breakpoint its inner edge drags, a lock fixes
5
+ * the width, the keyboard moves the edge, and the width and the lock are
6
+ * remembered per viewer. A collapsible one folds to a strip carrying its
7
+ * name. Below the breakpoint none of that applies: it stacks in the page's
8
+ * own flow, hides, or becomes the overlay a toolbar opens, as the page
9
+ * decides. The drag overlays the width while the pointer moves and writes
10
+ * once on release.
11
+ */
12
+ type Breakpoint = 'md' | 'lg' | 'xl';
13
+ interface Props {
14
+ /** The localStorage key the width and lock are kept under. */
15
+ storageKey: string;
16
+ /** The word on the strip and the header. */
17
+ label: string;
18
+ /** Which edge of the page it docks to. The handle sits on the other edge. */
19
+ side: 'start' | 'end';
20
+ /** The width it opens at until somebody drags it. */
21
+ width?: number;
22
+ min?: number;
23
+ max?: number;
24
+ /** Where the desktop controls start. */
25
+ breakpoint?: Breakpoint;
26
+ /**
27
+ * What it does under the breakpoint: stays in the page's flow across the
28
+ * full width, disappears, or floats over the page while `overlay` is set.
29
+ */
30
+ below?: 'stack' | 'overlay' | 'hidden';
31
+ /** Shown as an overlay below the breakpoint. Only read with `below="overlay"`. */
32
+ overlay?: boolean;
33
+ /** Offers a collapse to a strip. */
34
+ collapsible?: boolean;
35
+ /** Expanded above the breakpoint. Bindable so a page can open it from an offer. */
36
+ open?: boolean;
37
+ /** Keeps the header's count or status beside the label. */
38
+ meta?: Snippet;
39
+ /** Classes for the aside element itself, for the stacked layout's share of the height. */
40
+ class?: string;
41
+ testId?: string;
42
+ children: Snippet;
43
+ }
44
+ declare const SplitPane: import("svelte").Component<Props, {}, "open">;
45
+ type SplitPane = ReturnType<typeof SplitPane>;
46
+ export default SplitPane;
package/dist/index.d.ts CHANGED
@@ -21,6 +21,7 @@ export { default as Table } from './components/Table.svelte';
21
21
  export { default as DescriptionList } from './components/DescriptionList.svelte';
22
22
  export { default as Toolbar } from './components/Toolbar.svelte';
23
23
  export { default as TreeView } from './components/TreeView.svelte';
24
+ export { default as SplitPane } from './components/SplitPane.svelte';
24
25
  export type { CellFit } from './components/Table.svelte';
25
26
  export type { SidebarState } from './components/AppShell.svelte';
26
27
  export type { PageWidth, SectionVariant } from './internal/layout.js';
@@ -77,6 +78,9 @@ export { default as Spinner } from './components/Spinner.svelte';
77
78
  export { default as Skeleton } from './components/Skeleton.svelte';
78
79
  export { default as EmptyState } from './components/EmptyState.svelte';
79
80
  export { default as CheckoutSteps } from './components/CheckoutSteps.svelte';
81
+ export { default as RefusalNotice } from './components/RefusalNotice.svelte';
82
+ export { default as GateNotice } from './components/GateNotice.svelte';
83
+ export type { Gate, GateText, GateWords } from './internal/gate.js';
80
84
  export { default as PluginCart } from './components/PluginCart.svelte';
81
85
  export type { QuickPick } from './components/PluginCart.svelte';
82
86
  export { billedCount, formatCents, ladderRows, ladderState, type BillingPeriod, type CartCatalog, type CartPlugin, type CartQuote, type CartQuoteItem, type DiscountBracket, type LadderState, } from './internal/pricing.js';
@@ -103,4 +107,4 @@ export { cn, type ClassValue } from './utils/cn.js';
103
107
  export { getTheme, getThemePreference, nextThemePreference, resolveTheme, setTheme, setThemePreference, systemTheme, themeBootScript, watchThemePreference, toggleTheme, watchSystemTheme, THEME_PREFERENCES, type Theme, type ThemePreference, } from './utils/theme.js';
104
108
  export * as motion from './motion.js';
105
109
  export type { Rung as MotionRung, Curve as MotionCurve } from './motion.js';
106
- export declare const VERSION = "0.40.0";
110
+ export declare const VERSION = "0.41.1";
package/dist/index.js CHANGED
@@ -22,6 +22,7 @@ export { default as Table } from './components/Table.svelte';
22
22
  export { default as DescriptionList } from './components/DescriptionList.svelte';
23
23
  export { default as Toolbar } from './components/Toolbar.svelte';
24
24
  export { default as TreeView } from './components/TreeView.svelte';
25
+ export { default as SplitPane } from './components/SplitPane.svelte';
25
26
  // Stated once in an app's root layout, the format turns every PageShell's
26
27
  // title into the browser tab's. An app that never states one gets no title
27
28
  // element from the kit.
@@ -77,6 +78,8 @@ export { default as Spinner } from './components/Spinner.svelte';
77
78
  export { default as Skeleton } from './components/Skeleton.svelte';
78
79
  export { default as EmptyState } from './components/EmptyState.svelte';
79
80
  export { default as CheckoutSteps } from './components/CheckoutSteps.svelte';
81
+ export { default as RefusalNotice } from './components/RefusalNotice.svelte';
82
+ export { default as GateNotice } from './components/GateNotice.svelte';
80
83
  export { default as PluginCart } from './components/PluginCart.svelte';
81
84
  export { billedCount, formatCents, ladderRows, ladderState, } from './internal/pricing.js';
82
85
  export { default as Stat } from './components/Stat.svelte';
@@ -116,4 +119,4 @@ export * as motion from './motion.js';
116
119
  // ── Version ────────────────────────────────────────────────────────────────
117
120
  // Generated from package.json by `pnpm version:sync`. Bump package.json, never
118
121
  // this line. The build and the test suite fail when the two disagree.
119
- export const VERSION = '0.40.0';
122
+ export const VERSION = '0.41.1';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * A file input's `accept` list, read out as the names a person scans for.
3
+ *
4
+ * The attribute is written for the browser: extensions and MIME types side by
5
+ * side, often naming one format twice (`.json,application/json`) and sometimes
6
+ * as a vendor type longer than the drop zone is wide. Printed as it stands it
7
+ * runs as one unbroken line. Each entry folds to the short name a reader knows
8
+ * the format by, duplicates collapse, and the list joins as an English "or".
9
+ *
10
+ * Not exported from the package entry point: this is an implementation detail.
11
+ */
12
+ /** The `accept` list as a short line, or an empty string when it names nothing. */
13
+ export declare function describeAccept(accept: string): string;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * A file input's `accept` list, read out as the names a person scans for.
3
+ *
4
+ * The attribute is written for the browser: extensions and MIME types side by
5
+ * side, often naming one format twice (`.json,application/json`) and sometimes
6
+ * as a vendor type longer than the drop zone is wide. Printed as it stands it
7
+ * runs as one unbroken line. Each entry folds to the short name a reader knows
8
+ * the format by, duplicates collapse, and the list joins as an English "or".
9
+ *
10
+ * Not exported from the package entry point: this is an implementation detail.
11
+ */
12
+ /**
13
+ * Names a reader knows a format by, keyed by an extension or a MIME subtype.
14
+ * Both spellings go through one table, so `.yml` and `application/yaml` fold
15
+ * into one entry.
16
+ */
17
+ const ALIASES = {
18
+ plain: 'txt',
19
+ jpeg: 'jpg',
20
+ yml: 'yaml',
21
+ htm: 'html',
22
+ tif: 'tiff',
23
+ 'ld+json': 'JSON-LD',
24
+ msword: 'DOC',
25
+ 'vnd.ms-excel': 'XLS',
26
+ 'vnd.ms-powerpoint': 'PPT',
27
+ 'vnd.openxmlformats-officedocument.wordprocessingml.document': 'DOCX',
28
+ 'vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'XLSX',
29
+ 'vnd.openxmlformats-officedocument.presentationml.presentation': 'PPTX',
30
+ 'vnd.oasis.opendocument.text': 'ODT',
31
+ 'vnd.oasis.opendocument.spreadsheet': 'ODS',
32
+ };
33
+ /** Whole types whose subtype alone names the wrong format. */
34
+ const TYPES = {
35
+ 'audio/mpeg': 'MP3',
36
+ };
37
+ /** What `type/*` accepts, said as a kind of file rather than a format. */
38
+ const WILDCARDS = {
39
+ image: 'images',
40
+ video: 'videos',
41
+ audio: 'audio',
42
+ text: 'text files',
43
+ };
44
+ /** Fixed to en-US for the reason `number.ts` gives: server and browser must agree. */
45
+ const OR_LIST = new Intl.ListFormat('en-US', { type: 'disjunction' });
46
+ function alias(format) {
47
+ return (ALIASES[format] ?? format).toUpperCase();
48
+ }
49
+ function name(entry) {
50
+ const token = entry.toLowerCase();
51
+ if (token.startsWith('.'))
52
+ return alias(token.slice(1));
53
+ const [type, sub] = token.split('/');
54
+ if (!sub)
55
+ return entry;
56
+ if (sub === '*')
57
+ return WILDCARDS[type] ?? entry;
58
+ if (TYPES[token])
59
+ return TYPES[token];
60
+ if (ALIASES[sub])
61
+ return alias(sub);
62
+ // A vendor type has no short form to derive, so an unknown one stays as
63
+ // written rather than turning into a guess.
64
+ if (sub.startsWith('vnd.'))
65
+ return entry;
66
+ return alias(sub.replace(/^x-/, '').split('+')[0]);
67
+ }
68
+ /** The `accept` list as a short line, or an empty string when it names nothing. */
69
+ export function describeAccept(accept) {
70
+ const names = accept
71
+ .split(',')
72
+ .map((entry) => entry.trim())
73
+ .filter(Boolean)
74
+ .map(name);
75
+ return OR_LIST.format([...new Set(names)]);
76
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * What a split pane remembers per viewer: its width and whether the width is
3
+ * locked. Browser storage can be absent or throw (a private window, cleared or
4
+ * blocked site data, a thumbnail capture), so every read and write is guarded
5
+ * and the pane falls back to its default. A convenience, not state a server
6
+ * holds.
7
+ *
8
+ * The stored value is `{"width": <px>, "locked": <bool>}` under the key the
9
+ * consumer names, so a reader keeps the width an earlier copy of this pane
10
+ * wrote under the same key.
11
+ *
12
+ * Not exported from the package entry point: this is an implementation detail.
13
+ */
14
+ export interface AsideBounds {
15
+ /** The width the pane opens at until somebody drags it. */
16
+ width: number;
17
+ min: number;
18
+ max: number;
19
+ }
20
+ export interface AsidePrefs {
21
+ width: number;
22
+ locked: boolean;
23
+ }
24
+ type Reader = Pick<Storage, 'getItem'> | null | undefined;
25
+ type Writer = Pick<Storage, 'setItem'> | null | undefined;
26
+ /**
27
+ * The page's local storage, or nothing. Reading the global can itself throw
28
+ * when site data is blocked. Outside a browser it is never read: a server
29
+ * runtime may define a localStorage of its own, warning on first access or
30
+ * holding one store that every request would share.
31
+ */
32
+ export declare function viewerStorage(): Storage | null;
33
+ export declare function clampWidth(bounds: Pick<AsideBounds, 'min' | 'max'>, width: number): number;
34
+ export declare function readAside(storage: Reader, key: string, bounds: AsideBounds): AsidePrefs;
35
+ export declare function writeAside(storage: Writer, key: string, bounds: AsideBounds, prefs: AsidePrefs): void;
36
+ export {};
@@ -0,0 +1,61 @@
1
+ /**
2
+ * What a split pane remembers per viewer: its width and whether the width is
3
+ * locked. Browser storage can be absent or throw (a private window, cleared or
4
+ * blocked site data, a thumbnail capture), so every read and write is guarded
5
+ * and the pane falls back to its default. A convenience, not state a server
6
+ * holds.
7
+ *
8
+ * The stored value is `{"width": <px>, "locked": <bool>}` under the key the
9
+ * consumer names, so a reader keeps the width an earlier copy of this pane
10
+ * wrote under the same key.
11
+ *
12
+ * Not exported from the package entry point: this is an implementation detail.
13
+ */
14
+ /**
15
+ * The page's local storage, or nothing. Reading the global can itself throw
16
+ * when site data is blocked. Outside a browser it is never read: a server
17
+ * runtime may define a localStorage of its own, warning on first access or
18
+ * holding one store that every request would share.
19
+ */
20
+ export function viewerStorage() {
21
+ if (typeof window === 'undefined')
22
+ return null;
23
+ try {
24
+ return typeof localStorage === 'undefined' ? null : localStorage;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ export function clampWidth(bounds, width) {
31
+ return Math.round(Math.min(bounds.max, Math.max(bounds.min, width)));
32
+ }
33
+ export function readAside(storage, key, bounds) {
34
+ const fallback = { width: bounds.width, locked: false };
35
+ try {
36
+ const raw = storage?.getItem(key);
37
+ if (!raw)
38
+ return fallback;
39
+ const parsed = JSON.parse(raw);
40
+ if (typeof parsed !== 'object' || parsed === null)
41
+ return fallback;
42
+ const { width, locked } = parsed;
43
+ return {
44
+ width: typeof width === 'number' && Number.isFinite(width)
45
+ ? clampWidth(bounds, width)
46
+ : fallback.width,
47
+ locked: locked === true,
48
+ };
49
+ }
50
+ catch {
51
+ return fallback;
52
+ }
53
+ }
54
+ export function writeAside(storage, key, bounds, prefs) {
55
+ try {
56
+ storage?.setItem(key, JSON.stringify({ width: clampWidth(bounds, prefs.width), locked: prefs.locked }));
57
+ }
58
+ catch {
59
+ // Storage refused: the width lasts the page and nothing else.
60
+ }
61
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Why a read a page needed did not fill it, as the page learned it from the
3
+ * server's answer. A page never decides how a feature is enabled, which role a
4
+ * route needs or whether the database is up. It maps the answer to one of
5
+ * these states and hands it to GateNotice with its own words.
6
+ */
7
+ export type Gate = {
8
+ state: 'ok';
9
+ }
10
+ /** The route is not there. */
11
+ | {
12
+ state: 'absent';
13
+ }
14
+ /** Not enabled here. The link forward is where it is turned on, when the server names one. */
15
+ | {
16
+ state: 'locked';
17
+ forwardHref: string;
18
+ }
19
+ /**
20
+ * At a capacity ceiling: as many are in use as the server allows, so it
21
+ * refuses one more. The notice draws the page's words for it and offers no
22
+ * link, since only the page knows a way past the ceiling. The numbers are
23
+ * there for the page to put into those words, and either is null when the
24
+ * server did not send it.
25
+ */
26
+ | {
27
+ state: 'full';
28
+ limit: number | null;
29
+ current: number | null;
30
+ }
31
+ /** The reader's role may not read it, which no retry changes. */
32
+ | {
33
+ state: 'forbidden';
34
+ }
35
+ /** Refused for the request rate. The same read succeeds once the window passes. */
36
+ | {
37
+ state: 'busy';
38
+ }
39
+ /** The server or its database did not answer. The same read may answer in a moment. */
40
+ | {
41
+ state: 'unavailable';
42
+ }
43
+ /** The read failed. The message says so in words that never read as an empty result. */
44
+ | {
45
+ state: 'error';
46
+ message: string;
47
+ };
48
+ /** A heading and the sentence under it. */
49
+ export interface GateText {
50
+ title: string;
51
+ body: string;
52
+ }
53
+ /**
54
+ * Every sentence a GateNotice can draw, in the page's own words. The kit holds
55
+ * none of them, so each console says a refusal its own way and in its own
56
+ * language.
57
+ */
58
+ export interface GateWords {
59
+ /** What a missing route means for this page. */
60
+ absent: string;
61
+ /** A capacity ceiling, with the numbers the gate carried already in the body. */
62
+ full: GateText;
63
+ /** A role that may not read it, and who to ask. */
64
+ forbidden: string;
65
+ /** A refusal for the request rate. */
66
+ busy: GateText;
67
+ /** A server or database that did not answer. */
68
+ unavailable: GateText;
69
+ /** The label of the link that asks for the page again. */
70
+ retry: string;
71
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -11,3 +11,11 @@
11
11
  */
12
12
  /** The href as given, or nothing when its scheme is one no component will emit. */
13
13
  export declare function safeHref(href: string | undefined | null): string | undefined;
14
+ /**
15
+ * Whether an href leaves the page's own origin: an absolute URL with a scheme
16
+ * and a host, or a protocol-relative one, whose origin is not the page's. A
17
+ * link that leaves opens in a tab of its own, so the page it opens cannot
18
+ * reach back through window.opener. A server render has no location to
19
+ * compare with, so there every absolute URL counts as leaving.
20
+ */
21
+ export declare function isExternalHref(href: string): boolean;
@@ -25,3 +25,21 @@ export function safeHref(href) {
25
25
  return undefined;
26
26
  return DENIED.test(href.replace(IGNORED, '')) ? undefined : href;
27
27
  }
28
+ /**
29
+ * Whether an href leaves the page's own origin: an absolute URL with a scheme
30
+ * and a host, or a protocol-relative one, whose origin is not the page's. A
31
+ * link that leaves opens in a tab of its own, so the page it opens cannot
32
+ * reach back through window.opener. A server render has no location to
33
+ * compare with, so there every absolute URL counts as leaving.
34
+ */
35
+ export function isExternalHref(href) {
36
+ const absolute = /^[a-z][a-z0-9+.-]*:\/\//i.test(href) || href.startsWith('//');
37
+ if (!absolute || typeof location === 'undefined')
38
+ return absolute;
39
+ try {
40
+ return new URL(href, location.href).origin !== location.origin;
41
+ }
42
+ catch {
43
+ return true;
44
+ }
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyeve-labs/ui-kit",
3
- "version": "0.40.0",
3
+ "version": "0.41.1",
4
4
  "description": "A clean, accessible, palette-aware Svelte 5 component library. The design system behind LyEve.",
5
5
  "license": "MIT",
6
6
  "author": "LyEve Labs <hello@lyeve.com>",
@@ -22,7 +22,9 @@
22
22
  "lyeve"
23
23
  ],
24
24
  "type": "module",
25
- "sideEffects": false,
25
+ "sideEffects": [
26
+ "**/*.css"
27
+ ],
26
28
  "engines": {
27
29
  "node": ">=24"
28
30
  },
@@ -52,20 +54,23 @@
52
54
  "access": "public"
53
55
  },
54
56
  "devDependencies": {
57
+ "@axe-core/playwright": "^4.10.1",
55
58
  "@lucide/svelte": "^0.511.0",
59
+ "@playwright/experimental-ct-svelte": "1.58.2",
56
60
  "@sveltejs/package": "^2.5.7",
57
- "@sveltejs/vite-plugin-svelte": "^4.0.4",
61
+ "@sveltejs/vite-plugin-svelte": "^5.1.1",
58
62
  "@testing-library/svelte": "^5.3.1",
59
- "@vitest/coverage-v8": "^2.1.9",
63
+ "@vitest/coverage-v8": "^4.1.12",
60
64
  "jsdom": "^25.0.1",
61
65
  "prettier": "^3.8.3",
62
66
  "prettier-plugin-svelte": "^4.0.1",
63
67
  "prettier-plugin-tailwindcss": "^0.8.0",
64
68
  "publint": "^0.2.12",
65
- "svelte": "^5.55.9",
69
+ "svelte": "^5.57.2",
66
70
  "svelte-check": "^4.4.8",
67
71
  "typescript": "^5.9.3",
68
- "vitest": "^2.1.9"
72
+ "vite": "^6.4.5",
73
+ "vitest": "^4.1.12"
69
74
  },
70
75
  "scripts": {
71
76
  "build": "pnpm version:check && svelte-package && publint && node scripts/check-dist.mjs",