@lyeve-labs/ui-kit 0.25.1 → 0.27.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.
Files changed (44) hide show
  1. package/README.md +34 -9
  2. package/dist/components/AccountMenu.svelte +1 -1
  3. package/dist/components/AccountMenu.svelte.d.ts +1 -1
  4. package/dist/components/Alert.svelte +109 -41
  5. package/dist/components/Alert.svelte.d.ts +10 -0
  6. package/dist/components/AppShell.svelte +6 -2
  7. package/dist/components/AppShell.svelte.d.ts +1 -1
  8. package/dist/components/AuthShell.svelte +76 -0
  9. package/dist/components/AuthShell.svelte.d.ts +19 -0
  10. package/dist/components/Autocomplete.svelte +1 -1
  11. package/dist/components/Badge.svelte +9 -2
  12. package/dist/components/Checkbox.svelte +1 -1
  13. package/dist/components/CheckboxGroup.svelte +6 -1
  14. package/dist/components/DatePicker.svelte +1 -1
  15. package/dist/components/DateTimePicker.svelte +1 -1
  16. package/dist/components/Drawer.svelte +41 -11
  17. package/dist/components/Drawer.svelte.d.ts +13 -2
  18. package/dist/components/Field.svelte +1 -1
  19. package/dist/components/FileInput.svelte +1 -1
  20. package/dist/components/Input.svelte +1 -1
  21. package/dist/components/Modal.svelte +30 -10
  22. package/dist/components/Modal.svelte.d.ts +11 -2
  23. package/dist/components/MultiSelect.svelte +1 -1
  24. package/dist/components/NumberInput.svelte +1 -1
  25. package/dist/components/PasswordInput.svelte +1 -1
  26. package/dist/components/Radio.svelte +1 -1
  27. package/dist/components/RadioGroup.svelte +1 -0
  28. package/dist/components/SearchInput.svelte +1 -1
  29. package/dist/components/SegmentedControl.svelte +1 -1
  30. package/dist/components/Select.svelte +1 -1
  31. package/dist/components/Textarea.svelte +1 -1
  32. package/dist/components/TimePicker.svelte +1 -1
  33. package/dist/components/Toggle.svelte +4 -0
  34. package/dist/components/dialog/types.d.ts +3 -1
  35. package/dist/components/dialog/types.js +2 -8
  36. package/dist/index.d.ts +2 -1
  37. package/dist/index.js +2 -1
  38. package/dist/internal/field.d.ts +20 -0
  39. package/dist/internal/field.js +23 -0
  40. package/dist/internal/layout.d.ts +41 -1
  41. package/dist/internal/layout.js +54 -4
  42. package/dist/styles/theme.css +26 -1
  43. package/package.json +1 -1
  44. package/src/lib/styles/theme.css +26 -1
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
- - **67 components:** buttons, inputs, modals, drawers, tabs, tables, toasts, the works.
31
+ - **68 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>67 components, organized by purpose</summary>
40
+ <summary>68 components, organized by purpose</summary>
41
41
 
42
42
  **Layout and structure**
43
- Card, Panel, AppShell, 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
44
44
 
45
45
  **Forms and inputs**
46
46
  Button, ButtonGroup, Input, PasswordInput, Textarea, NumberInput, SearchInput, FileInput, Label, Field, FormMessage, SegmentedControl, Select, MultiSelect, Autocomplete, DatePicker, TimePicker, DateTimePicker, Checkbox, CheckboxGroup, Radio, RadioGroup, Toggle
@@ -205,11 +205,36 @@ leaves through the same presets the kit's overlays use:
205
205
  ```
206
206
 
207
207
  `dialog`, `scrim`, `drawer`, `popover` and `toast` read the tokens at run
208
- time; the `|global` modifier is what lets the exit play when a parent block
209
- removes the surface, so keep it. They so retuning a token retunes them, and every one of them plays nothing
210
- for a reader who has asked for reduced motion. Do not write `duration-150`,
211
- `ease-out` or `transition-all` beside a kit class: they are a fifth speed and
212
- a fourth curve, and the kit's own test suite refuses them.
208
+ time, so retuning a token retunes them, and every one of them plays nothing
209
+ for a reader who has asked for reduced motion. The `|global` modifier is what
210
+ lets the exit play when a parent block removes the surface, so keep it. Do not
211
+ write `duration-150`, `ease-out` or `transition-all` beside a kit class: they
212
+ are a fifth speed and a fourth curve, and the kit's own test suite refuses
213
+ them.
214
+
215
+ ## Sizing
216
+
217
+ Two ladders, both declared as tokens and both read by name.
218
+
219
+ A page picks a role and `PageShell` picks the cap:
220
+
221
+ | `width` | Cap | For |
222
+ | --------- | ------ | -------------------------------------- |
223
+ | `narrow` | 896px | one column: a form, a settings pane |
224
+ | `default` | 1152px | a page of stacked cards |
225
+ | `wide` | 1536px | a data page whose table needs the room |
226
+ | `full` | none | a canvas or a split pane |
227
+
228
+ A surface lifted off the page - `Modal`, `Drawer`, a dialog - takes a rung of
229
+ one shared ladder, so the same form is the same size whichever of the three a
230
+ page opens it in: `sm` 448px, `md` 576px, `lg` 704px, `xl` 896px, and `full`
231
+ 1088px for a dialog holding a table.
232
+
233
+ `Modal` and `Drawer` default to `size="auto"` and take the rung their body
234
+ earns: `md` up to four fields, `lg` past four, `xl` past eight. The count is
235
+ the fields the panel actually rendered, re-read when the form reveals more, and
236
+ a radio or checkbox group counts as the one question it asks. Name a rung and
237
+ it is kept.
213
238
 
214
239
  ## Local development
215
240
 
@@ -228,7 +253,7 @@ This repo is a single-purpose component library. Nothing but `src/lib/`.
228
253
  ```
229
254
  src/
230
255
  └── lib/ # → published as @lyeve-labs/ui-kit
231
- ├── components/ # 67 .svelte files
256
+ ├── components/ # 68 .svelte files
232
257
  ├── stores/ # toast.svelte.ts
233
258
  ├── styles/ # theme.css (the one stylesheet)
234
259
  ├── utils/ # cn.ts, theme.ts
@@ -4,7 +4,7 @@
4
4
  * app header.
5
5
  *
6
6
  * Where it lives is the point. The admin and the customer portal both put it
7
- * in the bottom left corner of the sidebar and the ops console put it in the
7
+ * in the bottom left corner of the sidebar and the third console put it in the
8
8
  * header, so the same account block was in two places depending on which of
9
9
  * our own products you were looking at. The sidebar is also the worst of the
10
10
  * two: it is already full height, so opening a menu in its last row pushes
@@ -3,7 +3,7 @@
3
3
  * app header.
4
4
  *
5
5
  * Where it lives is the point. The admin and the customer portal both put it
6
- * in the bottom left corner of the sidebar and the ops console put it in the
6
+ * in the bottom left corner of the sidebar and the third console put it in the
7
7
  * header, so the same account block was in two places depending on which of
8
8
  * our own products you were looking at. The sidebar is also the worst of the
9
9
  * two: it is already full height, so opening a menu in its last row pushes
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
3
  import { HIT_AREA } from '../internal/touch.js';
4
+ import * as motion from '../motion.js';
4
5
  import {
5
6
  TONE_GLYPH,
6
7
  statusTone,
@@ -8,10 +9,30 @@
8
9
  type StatusToneInput,
9
10
  } from '../internal/tone.js';
10
11
 
12
+ /**
13
+ * How long a confirmation holds before it clears itself.
14
+ *
15
+ * A toast waits 4000ms. This waits longer because it carries more: a toast is
16
+ * one line floating over the page, and an alert is a title and a sentence
17
+ * sitting in the reading order, which takes a beat to find and a beat to
18
+ * read.
19
+ */
20
+ const DISMISS_AFTER = 5000;
21
+
11
22
  interface Props {
12
23
  tone?: StatusToneInput;
13
24
  title?: string;
14
25
  dismissible?: boolean;
26
+ /**
27
+ * Milliseconds before the alert closes itself, or `true` for the default.
28
+ *
29
+ * A confirmation clears itself. A warning and a failure stay until
30
+ * dismissed, so this is off by default and a danger alert never takes it.
31
+ * Pass it wherever the alert reports the outcome of a submit; leave it off
32
+ * where a success tone states a standing condition, which does not stop
33
+ * being true after five seconds.
34
+ */
35
+ autoDismiss?: number | boolean;
15
36
  ondismiss?: () => void;
16
37
  class?: string;
17
38
  children?: Snippet;
@@ -21,6 +42,7 @@
21
42
  tone = 'brand',
22
43
  title = undefined,
23
44
  dismissible = false,
45
+ autoDismiss = false,
24
46
  ondismiss = undefined,
25
47
  class: klass = '',
26
48
  children,
@@ -35,54 +57,100 @@
35
57
  };
36
58
 
37
59
  const t = $derived(statusTone(tone));
60
+
61
+ /**
62
+ * A failure and a warning interrupt, because the reader has to act. Anything
63
+ * else waits for a pause. FormMessage already splits them this way; Alert
64
+ * announced every tone assertively, so a green confirmation cut across
65
+ * whatever was being read. An alert that also clears itself must not be
66
+ * assertive: it would interrupt to say something and then take it away.
67
+ */
68
+ const live = $derived(t === 'danger' || t === 'warn');
69
+
70
+ const delay = $derived(autoDismiss === true ? DISMISS_AFTER : Number(autoDismiss) || 0);
71
+
72
+ /** An alert that can close itself can also be closed by hand. */
73
+ const closable = $derived(dismissible || delay > 0);
74
+
75
+ let closed = $state(false);
76
+ /** The pointer is over the alert, or focus is inside it. */
77
+ let held = $state(false);
78
+
79
+ function close(): void {
80
+ closed = true;
81
+ ondismiss?.();
82
+ }
83
+
84
+ /**
85
+ * The alert owns whether it is on screen, rather than asking the page to
86
+ * un-render it. An outcome alert lives inside `{#if form}` in the page that
87
+ * submitted, and nothing clears a form result short of a navigation, so a
88
+ * callback has nothing to act on.
89
+ */
90
+ $effect(() => {
91
+ if (closed || held || delay <= 0) return;
92
+ const timer = setTimeout(close, delay);
93
+ return () => clearTimeout(timer);
94
+ });
38
95
  </script>
39
96
 
40
- <div
41
- class="flex items-start gap-3 rounded-lg border px-4 py-3 {tones[t].wrap} {klass}"
42
- role="alert"
43
- >
44
- <span
45
- class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-current {tones[
46
- t
47
- ].icon}"
48
- aria-hidden="true"
97
+ {#if !closed}
98
+ <!-- The timer stops while the pointer is over the alert or focus is inside
99
+ it, and starts again when they leave. Text is never pulled away from
100
+ somebody reading it. -->
101
+ <div
102
+ transition:motion.toast|global
103
+ class="flex items-start gap-3 rounded-lg border px-4 py-3 {tones[t].wrap} {klass}"
104
+ role={live ? 'alert' : 'status'}
105
+ aria-live={live ? 'assertive' : 'polite'}
106
+ onmouseenter={closable ? () => (held = true) : undefined}
107
+ onmouseleave={closable ? () => (held = false) : undefined}
108
+ onfocusin={closable ? () => (held = true) : undefined}
109
+ onfocusout={closable ? () => (held = false) : undefined}
49
110
  >
50
- <svg
51
- width="11"
52
- height="11"
53
- viewBox="0 0 24 24"
54
- fill="none"
55
- stroke="currentColor"
56
- stroke-width="2.5"
57
- stroke-linecap="round"
58
- stroke-linejoin="round"
59
- >
60
- <path d={TONE_GLYPH[t]} />
61
- </svg>
62
- </span>
63
- <div class="flex-1 min-w-0">
64
- {#if title}<p class="text-sm font-semibold text-fg">{title}</p>{/if}
65
- {#if children}<div class="text-sm text-muted {title ? 'mt-0.5' : ''}">
66
- {@render children()}
67
- </div>{/if}
68
- </div>
69
- {#if dismissible}
70
- <button
71
- type="button"
72
- onclick={ondismiss}
73
- class="{HIT_AREA} shrink-0 text-faint transition-colors hover:text-fg"
74
- aria-label="Dismiss"
111
+ <span
112
+ class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-current {tones[
113
+ t
114
+ ].icon}"
115
+ aria-hidden="true"
75
116
  >
76
117
  <svg
77
- width="16"
78
- height="16"
118
+ width="11"
119
+ height="11"
79
120
  viewBox="0 0 24 24"
80
121
  fill="none"
81
122
  stroke="currentColor"
82
- stroke-width="2"
123
+ stroke-width="2.5"
83
124
  stroke-linecap="round"
84
- aria-hidden="true"><path d="M18 6L6 18M6 6l12 12" /></svg
125
+ stroke-linejoin="round"
126
+ >
127
+ <path d={TONE_GLYPH[t]} />
128
+ </svg>
129
+ </span>
130
+ <div class="flex-1 min-w-0">
131
+ {#if title}<p class="text-sm font-semibold text-fg">{title}</p>{/if}
132
+ {#if children}<div class="text-sm text-muted {title ? 'mt-0.5' : ''}">
133
+ {@render children()}
134
+ </div>{/if}
135
+ </div>
136
+ {#if closable}
137
+ <button
138
+ type="button"
139
+ onclick={close}
140
+ class="{HIT_AREA} shrink-0 rounded text-faint transition-colors hover:text-fg outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
141
+ aria-label="Dismiss"
85
142
  >
86
- </button>
87
- {/if}
88
- </div>
143
+ <svg
144
+ width="16"
145
+ height="16"
146
+ viewBox="0 0 24 24"
147
+ fill="none"
148
+ stroke="currentColor"
149
+ stroke-width="2"
150
+ stroke-linecap="round"
151
+ aria-hidden="true"><path d="M18 6L6 18M6 6l12 12" /></svg
152
+ >
153
+ </button>
154
+ {/if}
155
+ </div>
156
+ {/if}
@@ -4,6 +4,16 @@ interface Props {
4
4
  tone?: StatusToneInput;
5
5
  title?: string;
6
6
  dismissible?: boolean;
7
+ /**
8
+ * Milliseconds before the alert closes itself, or `true` for the default.
9
+ *
10
+ * A confirmation clears itself. A warning and a failure stay until
11
+ * dismissed, so this is off by default and a danger alert never takes it.
12
+ * Pass it wherever the alert reports the outcome of a submit; leave it off
13
+ * where a success tone states a standing condition, which does not stop
14
+ * being true after five seconds.
15
+ */
16
+ autoDismiss?: number | boolean;
7
17
  ondismiss?: () => void;
8
18
  class?: string;
9
19
  children?: Snippet;
@@ -16,7 +16,7 @@
16
16
  * The authed application frame: the sidebar, the header bar and the content
17
17
  * column, owned once so three apps cannot each invent their own.
18
18
  *
19
- * They did. The admin, the customer portal and the ops console each hand
19
+ * They did. The admin, the customer portal and the third console each hand
20
20
  * rolled this shell, and no two agreed: the sidebar was 224px in one and
21
21
  * 240px in the other two, opaque in two and 30% translucent in the third,
22
22
  * built from the kit's SidebarNav in one and from inline anchors in the
@@ -256,7 +256,11 @@
256
256
  onfocusin={() => (railOpen = true)}
257
257
  onfocusout={onRailFocusOut}
258
258
  >
259
- <div class="absolute inset-y-0 start-0 z-dropdown flex {railOpen ? 'shadow-2xl' : ''}">
259
+ <div
260
+ class="absolute inset-y-0 start-0 z-dropdown flex transition-shadow {railOpen
261
+ ? 'shadow-2xl'
262
+ : ''}"
263
+ >
260
264
  {@render sidebar(isMobile, railIcons)}
261
265
  </div>
262
266
  </div>
@@ -12,7 +12,7 @@ export interface SidebarState {
12
12
  * The authed application frame: the sidebar, the header bar and the content
13
13
  * column, owned once so three apps cannot each invent their own.
14
14
  *
15
- * They did. The admin, the customer portal and the ops console each hand
15
+ * They did. The admin, the customer portal and the third console each hand
16
16
  * rolled this shell, and no two agreed: the sidebar was 224px in one and
17
17
  * 240px in the other two, opaque in two and 30% translucent in the third,
18
18
  * built from the kit's SidebarNav in one and from inline anchors in the
@@ -0,0 +1,76 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import Card from './Card.svelte';
4
+ import Logo from './Logo.svelte';
5
+
6
+ type Width = 'md' | 'lg';
7
+
8
+ interface Props {
9
+ /** The one heading on the page. A sign-in swaps it for the second-factor step. */
10
+ title: string;
11
+ description?: string;
12
+ /** Where the lockup links. Left unset, it is a mark and not a link. */
13
+ href?: string;
14
+ /** `md` is a form; `lg` is a walkthrough with more than one column. */
15
+ width?: Width;
16
+ /** Controls at the top end of the column: a theme toggle, a language switch. */
17
+ actions?: Snippet;
18
+ /** Under the card: the one link that leads off the page, such as "Create an account". */
19
+ footer?: Snippet;
20
+ children: Snippet;
21
+ }
22
+
23
+ let {
24
+ title,
25
+ description = undefined,
26
+ href = undefined,
27
+ width = 'md',
28
+ actions = undefined,
29
+ footer = undefined,
30
+ children,
31
+ }: Props = $props();
32
+
33
+ const widths: Record<Width, string> = { md: 'max-w-md', lg: 'max-w-3xl' };
34
+ </script>
35
+
36
+ <!--
37
+ The frame for a page the app shell does not wrap: sign in, sign up, a
38
+ password reset, an invitation, first-run setup. PageShell is the wrong
39
+ frame for these: they carry the product name, not a page title, and there
40
+ is no navigation to sit beside. Every console hand-wrote this main, column,
41
+ lockup, heading and card, and no two agreed: three heading sizes, a lockup
42
+ on some pages, a theme control on others, and one class that named no
43
+ token at all. Stated once here, the way AppShell states the signed-in frame.
44
+ -->
45
+ <main class="flex min-h-screen items-center justify-center bg-ink px-4 py-10">
46
+ <div class="w-full {widths[width]}">
47
+ {#if actions}
48
+ <div class="mb-4 flex justify-end">{@render actions()}</div>
49
+ {/if}
50
+
51
+ <div class="mb-8 text-center">
52
+ {#if href}
53
+ <a
54
+ {href}
55
+ class="mb-5 inline-flex rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-brand"
56
+ >
57
+ <Logo size="lg" />
58
+ </a>
59
+ {:else}
60
+ <Logo size="lg" class="mb-5" />
61
+ {/if}
62
+ <h1 class="text-2xl font-bold text-fg">{title}</h1>
63
+ {#if description}
64
+ <p class="mt-2 text-muted">{description}</p>
65
+ {/if}
66
+ </div>
67
+
68
+ <Card pad="lg">
69
+ {@render children()}
70
+ </Card>
71
+
72
+ {#if footer}
73
+ <p class="mt-6 text-center text-sm text-muted">{@render footer()}</p>
74
+ {/if}
75
+ </div>
76
+ </main>
@@ -0,0 +1,19 @@
1
+ import type { Snippet } from 'svelte';
2
+ type Width = 'md' | 'lg';
3
+ interface Props {
4
+ /** The one heading on the page. A sign-in swaps it for the second-factor step. */
5
+ title: string;
6
+ description?: string;
7
+ /** Where the lockup links. Left unset, it is a mark and not a link. */
8
+ href?: string;
9
+ /** `md` is a form; `lg` is a walkthrough with more than one column. */
10
+ width?: Width;
11
+ /** Controls at the top end of the column: a theme toggle, a language switch. */
12
+ actions?: Snippet;
13
+ /** Under the card: the one link that leads off the page, such as "Create an account". */
14
+ footer?: Snippet;
15
+ children: Snippet;
16
+ }
17
+ declare const AuthShell: import("svelte").Component<Props, {}, "">;
18
+ type AuthShell = ReturnType<typeof AuthShell>;
19
+ export default AuthShell;
@@ -173,7 +173,7 @@
173
173
  }
174
174
  </script>
175
175
 
176
- <div class="{FIELD_WRAP} {cls}">
176
+ <div data-field class="{FIELD_WRAP} {cls}">
177
177
  {#if label}
178
178
  <label for={fieldId} class={FIELD_LABEL}>
179
179
  {label}{#if required}<span class="ms-0.5 text-danger" aria-hidden="true">*</span>{/if}
@@ -42,12 +42,19 @@
42
42
  Under a Table's default `overflow-wrap: anywhere` a status rendered as
43
43
  `dra ft` and a role as `sup er_a dmi n`. The label sits in a truncating
44
44
  span so a badge whose caller caps its width ends in an ellipsis instead of
45
- painting past its own border; with no cap the span never shrinks. -->
45
+ painting past its own border; with no cap the span never shrinks.
46
+
47
+ The svg rules are what let a caller put an icon in the label. Preflight
48
+ makes every svg a block, and a block inside the label takes a line of its
49
+ own, so an icon and its text stacked and the badge rendered two rows tall
50
+ inside a pill. A flex row here would fix the stacking and lose the
51
+ ellipsis, which is the thing the span exists for, so the icon goes back to
52
+ being inline and sits on the text's optical centre. -->
46
53
  <span
47
54
  class="inline-flex max-w-full items-center font-medium whitespace-nowrap rounded-full border {tones[
48
55
  tone
49
56
  ]} {sizes[size]} {klass}"
50
57
  >
51
58
  {#if dot}<span class="w-1.5 h-1.5 shrink-0 rounded-full {dotColor[tone]}"></span>{/if}
52
- <span class="min-w-0 truncate">{@render children()}</span>
59
+ <span class="min-w-0 truncate [&>svg]:inline [&>svg]:align-middle">{@render children()}</span>
53
60
  </span>
@@ -167,7 +167,7 @@
167
167
  {/if}
168
168
  {/snippet}
169
169
 
170
- <div class="{rootClass} {cls}">
170
+ <div data-field class="{rootClass} {cls}">
171
171
  {#if variant === 'card'}
172
172
  <!-- The input covers the whole card, so the card surface is the element the
173
173
  peer ring can reach and the box inside it is not. That is deliberate:
@@ -117,7 +117,12 @@
117
117
  asterisk stays decoration, and the hint, when the caller writes one, says what
118
118
  is required and reaches the reader through aria-describedby.
119
119
  -->
120
- <fieldset class="{CHOICE_GROUP} {cls}" {disabled} aria-describedby={describedBy(uid, error, hint)}>
120
+ <fieldset
121
+ data-field
122
+ class="{CHOICE_GROUP} {cls}"
123
+ {disabled}
124
+ aria-describedby={describedBy(uid, error, hint)}
125
+ >
121
126
  <!--
122
127
  The legend stays a legend when it is hidden. Swapping it for an aria-label
123
128
  on the fieldset would name the group and drop it out of the reading order,
@@ -176,7 +176,7 @@
176
176
  });
177
177
  </script>
178
178
 
179
- <div class="{FIELD_WRAP} {cls}" bind:this={containerEl}>
179
+ <div data-field class="{FIELD_WRAP} {cls}" bind:this={containerEl}>
180
180
  {#if label}
181
181
  <label for={fieldId} class={FIELD_LABEL}>
182
182
  {label}{#if required}<span class="text-danger ms-0.5" aria-hidden="true">*</span>{/if}
@@ -275,7 +275,7 @@
275
275
  }
276
276
  </script>
277
277
 
278
- <div class="{FIELD_WRAP} {klass}">
278
+ <div data-field class="{FIELD_WRAP} {klass}">
279
279
  {#if label}
280
280
  <!-- `for` the date trigger. A button is labelable, so the field's own label
281
281
  names it and clicking that label opens the calendar. The group below
@@ -1,18 +1,30 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
+ import { countFields } from '../internal/field.js';
4
+ import { fitOverlay, OVERLAY_WIDTH, type OverlaySize } from '../internal/layout.js';
3
5
  import { HIT_AREA } from '../internal/touch.js';
4
6
  import { overlay } from '../internal/overlay.js';
5
7
  import * as motion from '../motion.js';
6
8
 
7
9
  type Side = 'left' | 'right';
8
- type Size = 'sm' | 'md' | 'lg' | 'xl';
10
+ /** The overlay ladder, minus the rung a docked panel has no business taking. */
11
+ type Size = Exclude<OverlaySize, 'full'>;
9
12
 
10
13
  interface Props {
11
14
  open?: boolean;
12
15
  title?: string;
13
16
  description?: string;
14
17
  side?: Side;
15
- size?: Size;
18
+ /**
19
+ * A rung of the shared overlay ladder, or `auto` to take the one the body
20
+ * needs: `md` up to four fields, `lg` past four, `xl` past eight.
21
+ *
22
+ * `auto` is the default because the caller was the wrong one to ask. Every
23
+ * drawer in three consoles asked for the widest rung the kit had, which is
24
+ * what a ladder that stops too early looks like from the outside, and the
25
+ * body is the only thing that knows whether it is two fields or twelve.
26
+ */
27
+ size?: Size | 'auto';
16
28
  onclose?: () => void;
17
29
  children: Snippet;
18
30
  footer?: Snippet;
@@ -23,18 +35,36 @@
23
35
  title = undefined,
24
36
  description = undefined,
25
37
  side = 'right',
26
- size = 'md',
38
+ size = 'auto',
27
39
  onclose = undefined,
28
40
  children,
29
41
  footer,
30
42
  }: Props = $props();
31
43
 
32
- const widths: Record<Size, string> = {
33
- sm: 'w-72',
34
- md: 'w-80',
35
- lg: 'w-96',
36
- xl: 'w-[480px]',
37
- };
44
+ let body: HTMLElement | undefined = $state();
45
+ let fields = $state(0);
46
+
47
+ /*
48
+ * The count is measured rather than declared, so it cannot go stale. A form
49
+ * that reveals two more fields when a period is set to custom, or drops a
50
+ * whole section behind a toggle, changes what it needs while it is open,
51
+ * which is why the observer stays for as long as the panel does.
52
+ *
53
+ * It runs before the browser paints the panel, so a drawer opens at the
54
+ * width it will keep: the effect is flushed in the same task as the mount,
55
+ * and the entrance animates transform alone.
56
+ */
57
+ $effect(() => {
58
+ const el = body;
59
+ if (!open || !el) return;
60
+ const measure = () => (fields = countFields(el));
61
+ measure();
62
+ const observer = new MutationObserver(measure);
63
+ observer.observe(el, { childList: true, subtree: true });
64
+ return () => observer.disconnect();
65
+ });
66
+
67
+ const rung = $derived(size === 'auto' ? fitOverlay(fields) : size);
38
68
 
39
69
  const headingId = $props.id();
40
70
  const descriptionId = `${headingId}-description`;
@@ -71,7 +101,7 @@
71
101
  <div
72
102
  use:overlay
73
103
  transition:motion.drawer|global={{ side }}
74
- class="relative flex h-full max-w-full flex-col {widths[size]} bg-surface shadow-2xl
104
+ class="relative flex h-full w-full flex-col {OVERLAY_WIDTH[rung]} bg-surface shadow-2xl
75
105
  {side === 'right' ? 'border-s' : 'border-e'} border-line"
76
106
  role="dialog"
77
107
  aria-modal="true"
@@ -107,7 +137,7 @@
107
137
  </div>
108
138
  {/if}
109
139
 
110
- <div class="flex-1 overflow-y-auto px-5 py-4">
140
+ <div bind:this={body} class="flex-1 overflow-y-auto px-5 py-4">
111
141
  {@render children()}
112
142
  </div>
113
143
 
@@ -1,12 +1,23 @@
1
1
  import type { Snippet } from 'svelte';
2
+ import { type OverlaySize } from '../internal/layout.js';
2
3
  type Side = 'left' | 'right';
3
- type Size = 'sm' | 'md' | 'lg' | 'xl';
4
+ /** The overlay ladder, minus the rung a docked panel has no business taking. */
5
+ type Size = Exclude<OverlaySize, 'full'>;
4
6
  interface Props {
5
7
  open?: boolean;
6
8
  title?: string;
7
9
  description?: string;
8
10
  side?: Side;
9
- size?: Size;
11
+ /**
12
+ * A rung of the shared overlay ladder, or `auto` to take the one the body
13
+ * needs: `md` up to four fields, `lg` past four, `xl` past eight.
14
+ *
15
+ * `auto` is the default because the caller was the wrong one to ask. Every
16
+ * drawer in three consoles asked for the widest rung the kit had, which is
17
+ * what a ladder that stops too early looks like from the outside, and the
18
+ * body is the only thing that knows whether it is two fields or twelve.
19
+ */
20
+ size?: Size | 'auto';
10
21
  onclose?: () => void;
11
22
  children: Snippet;
12
23
  footer?: Snippet;
@@ -81,7 +81,7 @@
81
81
  });
82
82
  </script>
83
83
 
84
- <div class="{FIELD_WRAP} {klass}">
84
+ <div data-field class="{FIELD_WRAP} {klass}">
85
85
  {#if label}
86
86
  <!--
87
87
  A hidden label stays a real label with a real `for`. Swapping it for an
@@ -46,7 +46,7 @@
46
46
  }
47
47
  </script>
48
48
 
49
- <div class="{FIELD_WRAP} {cls}">
49
+ <div data-field class="{FIELD_WRAP} {cls}">
50
50
  {#if label}
51
51
  <label for={id} class={FIELD_LABEL}>{label}</label>
52
52
  {/if}
@@ -51,7 +51,7 @@
51
51
  const fieldId = $derived(id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined));
52
52
  </script>
53
53
 
54
- <div class="{FIELD_WRAP} {klass}">
54
+ <div data-field class="{FIELD_WRAP} {klass}">
55
55
  {#if label}
56
56
  <label for={fieldId} class={FIELD_LABEL}>
57
57
  {label}{#if required}<span class="text-danger ms-0.5" aria-hidden="true">*</span>{/if}
@@ -1,16 +1,26 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
+ import { countFields } from '../internal/field.js';
4
+ import { fitOverlay, OVERLAY_WIDTH, type OverlaySize } from '../internal/layout.js';
3
5
  import { HIT_AREA } from '../internal/touch.js';
4
6
  import { overlay } from '../internal/overlay.js';
5
7
  import * as motion from '../motion.js';
6
8
 
7
- type Size = 'sm' | 'md' | 'lg';
9
+ /** The shared overlay ladder, minus the rung the dialog stack keeps for a table. */
10
+ type Size = Exclude<OverlaySize, 'full'>;
8
11
 
9
12
  interface Props {
10
13
  open?: boolean;
11
14
  title?: string;
12
15
  description?: string;
13
- size?: Size;
16
+ /**
17
+ * A rung of the shared overlay ladder, or `auto` to take the one the body
18
+ * needs: `md` up to four fields, `lg` past four, `xl` past eight.
19
+ *
20
+ * The same rule a Drawer follows, so an edit form reads the same size
21
+ * whichever of the two a page opens it in.
22
+ */
23
+ size?: Size | 'auto';
14
24
  onclose?: () => void;
15
25
  children: Snippet;
16
26
  footer?: Snippet;
@@ -20,17 +30,27 @@
20
30
  open = $bindable(false),
21
31
  title = undefined,
22
32
  description = undefined,
23
- size = 'md',
33
+ size = 'auto',
24
34
  onclose = undefined,
25
35
  children,
26
36
  footer,
27
37
  }: Props = $props();
28
38
 
29
- const widths: Record<Size, string> = {
30
- sm: 'max-w-sm',
31
- md: 'max-w-lg',
32
- lg: 'max-w-2xl',
33
- };
39
+ let body: HTMLElement | undefined = $state();
40
+ let fields = $state(0);
41
+
42
+ /* Measured, and kept measured: see Drawer, which sizes itself the same way. */
43
+ $effect(() => {
44
+ const el = body;
45
+ if (!open || !el) return;
46
+ const measure = () => (fields = countFields(el));
47
+ measure();
48
+ const observer = new MutationObserver(measure);
49
+ observer.observe(el, { childList: true, subtree: true });
50
+ return () => observer.disconnect();
51
+ });
52
+
53
+ const rung = $derived(size === 'auto' ? fitOverlay(fields) : size);
34
54
 
35
55
  // aria-labelledby needs an id that is unique per instance, because two modals
36
56
  // can be mounted at once while one animates out.
@@ -66,7 +86,7 @@
66
86
  <div
67
87
  use:overlay
68
88
  transition:motion.dialog|global
69
- class="relative flex max-h-[calc(100dvh-2rem)] w-full {widths[size]} flex-col
89
+ class="relative flex max-h-[calc(100dvh-2rem)] w-full {OVERLAY_WIDTH[rung]} flex-col
70
90
  overflow-hidden rounded-xl border border-line bg-surface shadow-2xl"
71
91
  role="dialog"
72
92
  aria-modal="true"
@@ -103,7 +123,7 @@
103
123
  </div>
104
124
  {/if}
105
125
 
106
- <div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
126
+ <div bind:this={body} class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
107
127
  {@render children()}
108
128
  </div>
109
129
 
@@ -1,10 +1,19 @@
1
1
  import type { Snippet } from 'svelte';
2
- type Size = 'sm' | 'md' | 'lg';
2
+ import { type OverlaySize } from '../internal/layout.js';
3
+ /** The shared overlay ladder, minus the rung the dialog stack keeps for a table. */
4
+ type Size = Exclude<OverlaySize, 'full'>;
3
5
  interface Props {
4
6
  open?: boolean;
5
7
  title?: string;
6
8
  description?: string;
7
- size?: Size;
9
+ /**
10
+ * A rung of the shared overlay ladder, or `auto` to take the one the body
11
+ * needs: `md` up to four fields, `lg` past four, `xl` past eight.
12
+ *
13
+ * The same rule a Drawer follows, so an edit form reads the same size
14
+ * whichever of the two a page opens it in.
15
+ */
16
+ size?: Size | 'auto';
8
17
  onclose?: () => void;
9
18
  children: Snippet;
10
19
  footer?: Snippet;
@@ -159,7 +159,7 @@
159
159
  });
160
160
  </script>
161
161
 
162
- <div class="{FIELD_WRAP} {cls}">
162
+ <div data-field class="{FIELD_WRAP} {cls}">
163
163
  {#if label}
164
164
  <label id="{fieldId}-label" for={fieldId} class={FIELD_LABEL}>
165
165
  {label}{#if required}<span class="ms-0.5 text-danger" aria-hidden="true">*</span>{/if}
@@ -76,7 +76,7 @@
76
76
  'disabled:opacity-50 disabled:cursor-not-allowed';
77
77
  </script>
78
78
 
79
- <div class="{FIELD_WRAP} {cls}">
79
+ <div data-field class="{FIELD_WRAP} {cls}">
80
80
  {#if label}
81
81
  <label for={fieldId} class={FIELD_LABEL}>
82
82
  {label}{#if required}<span class="text-danger ms-0.5" aria-hidden="true">*</span>{/if}
@@ -83,7 +83,7 @@
83
83
  }
84
84
  </script>
85
85
 
86
- <div class="{FIELD_WRAP} {klass}">
86
+ <div data-field class="{FIELD_WRAP} {klass}">
87
87
  {#if label}
88
88
  <label for={fieldId} class="{FIELD_LABEL} {labelHidden ? 'sr-only' : ''}">
89
89
  {label}{#if required}<span class="ms-0.5 text-danger" aria-hidden="true">*</span>{/if}
@@ -157,7 +157,7 @@
157
157
  {/if}
158
158
  {/snippet}
159
159
 
160
- <div class="{rootClass} {cls}">
160
+ <div data-field class="{rootClass} {cls}">
161
161
  {#if variant === 'card'}
162
162
  <!-- The input covers the whole card, so the card surface is the element the
163
163
  peer ring can reach and the ring inside it is not. That is deliberate:
@@ -96,6 +96,7 @@
96
96
  reader to find the message paragraph on their own.
97
97
  -->
98
98
  <fieldset
99
+ data-field
99
100
  class="{CHOICE_GROUP} {cls}"
100
101
  role="radiogroup"
101
102
  {disabled}
@@ -37,7 +37,7 @@
37
37
  }
38
38
  </script>
39
39
 
40
- <div class="{FIELD_WRAP} {cls}">
40
+ <div data-field class="{FIELD_WRAP} {cls}">
41
41
  {#if label}
42
42
  <label for={fieldId} class={FIELD_LABEL}>{label}</label>
43
43
  {/if}
@@ -172,7 +172,7 @@
172
172
  }
173
173
  </script>
174
174
 
175
- <div class="{FIELD_WRAP} items-start {klass}">
175
+ <div data-field class="{FIELD_WRAP} items-start {klass}">
176
176
  {#if !labelHidden}
177
177
  <!-- A caption, not a label element. The group is named by aria-label, and a
178
178
  label has nothing to point at here: role="radiogroup" is not a form
@@ -345,7 +345,7 @@
345
345
  </button>
346
346
  {/snippet}
347
347
 
348
- <div class="{FIELD_WRAP} {cls}">
348
+ <div data-field class="{FIELD_WRAP} {cls}">
349
349
  {#if label}
350
350
  <label for={fieldId} class={FIELD_LABEL}>
351
351
  {label}{#if required}<span class="text-danger ms-0.5" aria-hidden="true">*</span>{/if}
@@ -51,7 +51,7 @@
51
51
  const fieldId = $derived(id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined));
52
52
  </script>
53
53
 
54
- <div class="{FIELD_WRAP} {cls}">
54
+ <div data-field class="{FIELD_WRAP} {cls}">
55
55
  {#if label}
56
56
  <label for={fieldId} class={FIELD_LABEL}>
57
57
  {label}{#if required}<span class="text-danger ms-0.5" aria-hidden="true">*</span>{/if}
@@ -395,7 +395,7 @@
395
395
  }
396
396
  </script>
397
397
 
398
- <div class="{FIELD_WRAP} {klass}">
398
+ <div data-field class="{FIELD_WRAP} {klass}">
399
399
  {#if label}
400
400
  <!-- `for` the hour so a click lands somewhere useful, while the group below
401
401
  takes its name from this same element. The hour keeps its own aria-label,
@@ -67,7 +67,11 @@
67
67
  }
68
68
  </script>
69
69
 
70
+ <!-- The marker is on the label because the label is the whole control: the
71
+ switch and its text are one field, and an overlay sizing itself to a form
72
+ counts what it can see. -->
70
73
  <label
74
+ data-field
71
75
  class="inline-flex items-start gap-2.5 cursor-pointer select-none {disabled
72
76
  ? 'opacity-50 cursor-not-allowed'
73
77
  : ''} {cls}"
@@ -1,5 +1,7 @@
1
1
  import type { Snippet } from 'svelte';
2
- export type DialogSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
2
+ import { type OverlaySize } from '../../internal/layout.js';
3
+ /** The shared overlay ladder, whole: a dialog is the one surface that may hold a table. */
4
+ export type DialogSize = OverlaySize;
3
5
  export interface DialogOptions<T = void> {
4
6
  /** Unique id - auto-generated if omitted */
5
7
  id?: string;
@@ -1,10 +1,4 @@
1
- const SIZE_CLASSES = {
2
- sm: 'max-w-sm',
3
- md: 'max-w-md',
4
- lg: 'max-w-lg',
5
- xl: 'max-w-xl',
6
- full: 'max-w-3xl',
7
- };
1
+ import { OVERLAY_WIDTH } from '../../internal/layout.js';
8
2
  export function sizeClass(size) {
9
- return SIZE_CLASSES[size];
3
+ return OVERLAY_WIDTH[size];
10
4
  }
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  export { default as Card } from './components/Card.svelte';
10
10
  export { default as Panel } from './components/Panel.svelte';
11
11
  export { default as AppShell } from './components/AppShell.svelte';
12
+ export { default as AuthShell } from './components/AuthShell.svelte';
12
13
  export { default as PageShell } from './components/PageShell.svelte';
13
14
  export { default as PageHeader } from './components/PageHeader.svelte';
14
15
  export { default as SectionHeading } from './components/SectionHeading.svelte';
@@ -92,4 +93,4 @@ export { cn, type ClassValue } from './utils/cn.js';
92
93
  export { getTheme, getThemePreference, nextThemePreference, resolveTheme, setTheme, setThemePreference, systemTheme, themeBootScript, toggleTheme, watchSystemTheme, THEME_PREFERENCES, type Theme, type ThemePreference, } from './utils/theme.js';
93
94
  export * as motion from './motion.js';
94
95
  export type { Rung as MotionRung, Curve as MotionCurve } from './motion.js';
95
- export declare const VERSION = "0.25.1";
96
+ export declare const VERSION = "0.27.0";
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@
10
10
  export { default as Card } from './components/Card.svelte';
11
11
  export { default as Panel } from './components/Panel.svelte';
12
12
  export { default as AppShell } from './components/AppShell.svelte';
13
+ export { default as AuthShell } from './components/AuthShell.svelte';
13
14
  export { default as PageShell } from './components/PageShell.svelte';
14
15
  export { default as PageHeader } from './components/PageHeader.svelte';
15
16
  export { default as SectionHeading } from './components/SectionHeading.svelte';
@@ -102,4 +103,4 @@ export * as motion from './motion.js';
102
103
  // ── Version ────────────────────────────────────────────────────────────────
103
104
  // Generated from package.json by `pnpm version:sync`. Bump package.json, never
104
105
  // this line; the build and the test suite fail when the two disagree.
105
- export const VERSION = '0.25.1';
106
+ export const VERSION = '0.27.0';
@@ -11,6 +11,26 @@
11
11
  */
12
12
  /** Vertical rhythm inside a labelled field: label, control, hint/error. */
13
13
  export declare const FIELD_WRAP = "flex flex-col gap-1.5";
14
+ /**
15
+ * The marker every labelled field carries on its outermost element, and the
16
+ * only way a surface can ask how much form it is holding.
17
+ *
18
+ * A class would not do: FIELD_WRAP is three utilities a caller may legitimately
19
+ * write by hand, so counting it would count any column with a 6px gap. The
20
+ * attribute says what the element is rather than how it looks, and a radio or
21
+ * checkbox group carries it on the fieldset, so a group of eight options is
22
+ * one field and not eight.
23
+ */
24
+ export declare const FIELD_MARKER = "[data-field]";
25
+ /**
26
+ * How many fields a subtree renders, counting a group as one.
27
+ *
28
+ * Only the outermost markers count. A group marks its fieldset and each option
29
+ * inside it marks its own wrapper, so a plain `querySelectorAll` length would
30
+ * read a five-option radio group as six fields and size a panel for a form
31
+ * that is not there.
32
+ */
33
+ export declare function countFields(root: ParentNode): number;
14
34
  /** The label above a control. */
15
35
  export declare const FIELD_LABEL = "text-sm font-medium text-fg";
16
36
  /** Hint text below a control. Shown only when there is no error. */
@@ -11,6 +11,29 @@
11
11
  */
12
12
  /** Vertical rhythm inside a labelled field: label, control, hint/error. */
13
13
  export const FIELD_WRAP = 'flex flex-col gap-1.5';
14
+ /**
15
+ * The marker every labelled field carries on its outermost element, and the
16
+ * only way a surface can ask how much form it is holding.
17
+ *
18
+ * A class would not do: FIELD_WRAP is three utilities a caller may legitimately
19
+ * write by hand, so counting it would count any column with a 6px gap. The
20
+ * attribute says what the element is rather than how it looks, and a radio or
21
+ * checkbox group carries it on the fieldset, so a group of eight options is
22
+ * one field and not eight.
23
+ */
24
+ export const FIELD_MARKER = '[data-field]';
25
+ /**
26
+ * How many fields a subtree renders, counting a group as one.
27
+ *
28
+ * Only the outermost markers count. A group marks its fieldset and each option
29
+ * inside it marks its own wrapper, so a plain `querySelectorAll` length would
30
+ * read a five-option radio group as six fields and size a panel for a form
31
+ * that is not there.
32
+ */
33
+ export function countFields(root) {
34
+ const all = [...root.querySelectorAll(FIELD_MARKER)];
35
+ return all.filter((el) => !all.some((other) => other !== el && other.contains(el))).length;
36
+ }
14
37
  /** The label above a control. */
15
38
  export const FIELD_LABEL = 'text-sm font-medium text-fg';
16
39
  /** Hint text below a control. Shown only when there is no error. */
@@ -40,6 +40,40 @@ export declare const PAGE_PAD = "mx-auto w-full px-page-x py-page-y";
40
40
  * The names carry the decision, so a page picks a role rather than a number.
41
41
  */
42
42
  export declare const PAGE_WIDTH: Record<PageWidth, string>;
43
+ /** How wide a surface lifted off the page may get. */
44
+ export type OverlaySize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
45
+ /**
46
+ * One ladder for Modal, Drawer and the dialog stack.
47
+ *
48
+ * The three carried three ladders and none of them agreed: a modal's `lg` was
49
+ * 672px, a drawer's was 384px, and a dialog's was 512px, so the same form read
50
+ * as three different sizes depending on which surface a page happened to open
51
+ * it in. Every rung here is wider than the widest of the three it replaces.
52
+ *
53
+ * A component takes the slice of the ladder its role allows, which is why the
54
+ * names and not the numbers are its prop: a drawer stops at `xl` because a
55
+ * panel docked to an edge that covers the page is a modal with extra steps.
56
+ *
57
+ * Spelled out in full rather than composed from the rung name. Tailwind
58
+ * generates a utility only for a class it can read whole in the source, so
59
+ * `max-w-${rung}` would compile to nothing at all.
60
+ */
61
+ export declare const OVERLAY_WIDTH: Record<OverlaySize, string>;
62
+ /**
63
+ * The rung a body of this many labelled fields needs.
64
+ *
65
+ * A panel is sized by its caller today, and the caller is guessing: all 18
66
+ * drawers measured across three consuming applications ask for the widest rung
67
+ * the ladder had, and that rung still puts a two-column field grid into two
68
+ * 170px columns. The content knows the answer, so it gives it: four fields fit
69
+ * a single column, more than four is where a form starts pairing them, and
70
+ * past eight it is a page that happens to be in a panel.
71
+ *
72
+ * Fields, not controls. A radio group is one field however many inputs it
73
+ * renders, and the marker the count reads sits on the field wrapper for
74
+ * exactly that reason.
75
+ */
76
+ export declare function fitOverlay(fields: number): OverlaySize;
43
77
  /**
44
78
  * The vertical rhythm between a page's top-level sections. A property of the
45
79
  * shell, so a page cannot choose its own.
@@ -139,13 +173,19 @@ export declare function sectionHeading(level: 2 | 3, variant?: SectionVariant):
139
173
  * `border-e` and not `border-r`: the shell is the one place a right-to-left
140
174
  * locale flips, and a physical border leaves the rule on the wrong edge.
141
175
  */
142
- export declare const APP_SIDEBAR = "h-full shrink-0 flex-col border-e border-line bg-surface";
176
+ export declare const APP_SIDEBAR = "h-full shrink-0 flex-col border-e border-line bg-surface transition-[width] duration-base ease-move";
143
177
  /**
144
178
  * The sidebar's two widths. Expanded is the 224px column every authed screen
145
179
  * had. The rail is the 56px icon column the theme had named and nothing used:
146
180
  * at 768px the expanded column left 496px for the page, and a flow editor with
147
181
  * two docked panes had no canvas at all. The shell picks between them; the
148
182
  * width is not part of APP_SIDEBAR so the aside cannot carry both.
183
+ *
184
+ * The travel between them is on APP_SIDEBAR, which is the one class both
185
+ * widths share. It is a disclosure opening sideways, which is the same thing
186
+ * an accordion does downwards, so it takes the accordion's rung and curve:
187
+ * base and move. It was the most frequent state change in the product and the
188
+ * only one that happened in a single frame.
149
189
  */
150
190
  export declare const APP_SIDEBAR_WIDE = "w-sidebar";
151
191
  export declare const APP_SIDEBAR_RAIL = "w-nav-rail";
@@ -36,11 +36,55 @@ export const PAGE_PAD = 'mx-auto w-full px-page-x py-page-y';
36
36
  * The names carry the decision, so a page picks a role rather than a number.
37
37
  */
38
38
  export const PAGE_WIDTH = {
39
- narrow: 'max-w-3xl',
40
- default: 'max-w-5xl',
41
- wide: 'max-w-7xl',
39
+ narrow: 'max-w-page-narrow',
40
+ default: 'max-w-page-default',
41
+ wide: 'max-w-page-wide',
42
42
  full: 'max-w-full',
43
43
  };
44
+ /**
45
+ * One ladder for Modal, Drawer and the dialog stack.
46
+ *
47
+ * The three carried three ladders and none of them agreed: a modal's `lg` was
48
+ * 672px, a drawer's was 384px, and a dialog's was 512px, so the same form read
49
+ * as three different sizes depending on which surface a page happened to open
50
+ * it in. Every rung here is wider than the widest of the three it replaces.
51
+ *
52
+ * A component takes the slice of the ladder its role allows, which is why the
53
+ * names and not the numbers are its prop: a drawer stops at `xl` because a
54
+ * panel docked to an edge that covers the page is a modal with extra steps.
55
+ *
56
+ * Spelled out in full rather than composed from the rung name. Tailwind
57
+ * generates a utility only for a class it can read whole in the source, so
58
+ * `max-w-${rung}` would compile to nothing at all.
59
+ */
60
+ export const OVERLAY_WIDTH = {
61
+ sm: 'max-w-overlay-sm',
62
+ md: 'max-w-overlay-md',
63
+ lg: 'max-w-overlay-lg',
64
+ xl: 'max-w-overlay-xl',
65
+ full: 'max-w-overlay-full',
66
+ };
67
+ /**
68
+ * The rung a body of this many labelled fields needs.
69
+ *
70
+ * A panel is sized by its caller today, and the caller is guessing: all 18
71
+ * drawers measured across three consuming applications ask for the widest rung
72
+ * the ladder had, and that rung still puts a two-column field grid into two
73
+ * 170px columns. The content knows the answer, so it gives it: four fields fit
74
+ * a single column, more than four is where a form starts pairing them, and
75
+ * past eight it is a page that happens to be in a panel.
76
+ *
77
+ * Fields, not controls. A radio group is one field however many inputs it
78
+ * renders, and the marker the count reads sits on the field wrapper for
79
+ * exactly that reason.
80
+ */
81
+ export function fitOverlay(fields) {
82
+ if (fields > 8)
83
+ return 'xl';
84
+ if (fields > 4)
85
+ return 'lg';
86
+ return 'md';
87
+ }
44
88
  /**
45
89
  * The vertical rhythm between a page's top-level sections. A property of the
46
90
  * shell, so a page cannot choose its own.
@@ -163,13 +207,19 @@ export function sectionHeading(level, variant = 'default') {
163
207
  * `border-e` and not `border-r`: the shell is the one place a right-to-left
164
208
  * locale flips, and a physical border leaves the rule on the wrong edge.
165
209
  */
166
- export const APP_SIDEBAR = 'h-full shrink-0 flex-col border-e border-line bg-surface';
210
+ export const APP_SIDEBAR = 'h-full shrink-0 flex-col border-e border-line bg-surface transition-[width] duration-base ease-move';
167
211
  /**
168
212
  * The sidebar's two widths. Expanded is the 224px column every authed screen
169
213
  * had. The rail is the 56px icon column the theme had named and nothing used:
170
214
  * at 768px the expanded column left 496px for the page, and a flow editor with
171
215
  * two docked panes had no canvas at all. The shell picks between them; the
172
216
  * width is not part of APP_SIDEBAR so the aside cannot carry both.
217
+ *
218
+ * The travel between them is on APP_SIDEBAR, which is the one class both
219
+ * widths share. It is a disclosure opening sideways, which is the same thing
220
+ * an accordion does downwards, so it takes the accordion's rung and curve:
221
+ * base and move. It was the most frequent state change in the product and the
222
+ * only one that happened in a single frame.
173
223
  */
174
224
  export const APP_SIDEBAR_WIDE = 'w-sidebar';
175
225
  export const APP_SIDEBAR_RAIL = 'w-nav-rail';
@@ -291,6 +291,31 @@
291
291
  --spacing-sidebar: 14rem; /* 224px - the expanded sidebar */
292
292
  --spacing-stack: 1rem; /* 16px - default vertical stack gap */
293
293
  --spacing-inline: 0.5rem; /* 8px - default inline gap */
294
+
295
+ /* ── Widths · how much room a surface is allowed to take ───── *
296
+ * Tailwind reads --container-*, so each name below is a max-w-* *
297
+ * and a w-* utility. Named for the surface rather than for a *
298
+ * t-shirt size, so the cap is chosen by role. *
299
+ * *
300
+ * Measured 2026-09-22 across three consuming applications: the *
301
+ * page cap was picked four different ways for the same kind of *
302
+ * screen (45 wide pages in one, 17 uncapped ones in another, *
303
+ * and two settings pages in a 768px column), and all 18 of their *
304
+ * drawers asked for the widest rung there was, which is what a *
305
+ * ladder that stops too early looks like from the outside. */
306
+ --container-page-narrow: 56rem; /* 896px - one column: a form, a settings pane */
307
+ --container-page-default: 72rem; /* 1152px - a page of stacked cards */
308
+ --container-page-wide: 96rem; /* 1536px - a data page whose table needs the room */
309
+
310
+ /* One ladder for every surface lifted off the page, so a drawer and
311
+ * a modal opened from the same screen are the same size. Each rung
312
+ * is the one above 3:2 of the one below, which is wide enough that a
313
+ * two-column field grid gets real columns at every step. */
314
+ --container-overlay-sm: 28rem; /* 448px - a confirmation, a short form */
315
+ --container-overlay-md: 36rem; /* 576px - up to four fields */
316
+ --container-overlay-lg: 44rem; /* 704px - a form with paired fields */
317
+ --container-overlay-xl: 56rem; /* 896px - a form beside a preview */
318
+ --container-overlay-full: 68rem; /* 1088px - a table inside a dialog */
294
319
  }
295
320
 
296
321
  /*
@@ -478,7 +503,7 @@ html[data-theme='light'] {
478
503
  * Print.
479
504
  *
480
505
  * The consuming applications had no `@media print` rule anywhere, and three surfaces print:
481
- * the portal prints an invoice, the ops console prints an audit log, the admin
506
+ * the portal prints an invoice, an operations console prints an audit log, the admin
482
507
  * prints a subject-access export. All three printed the sidebar, the theme
483
508
  * toggle and the nav, in a dark palette, and all three printed one screenful
484
509
  * and stopped, because the shell is `h-screen` with the content column set to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyeve-labs/ui-kit",
3
- "version": "0.25.1",
3
+ "version": "0.27.0",
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>",
@@ -291,6 +291,31 @@
291
291
  --spacing-sidebar: 14rem; /* 224px - the expanded sidebar */
292
292
  --spacing-stack: 1rem; /* 16px - default vertical stack gap */
293
293
  --spacing-inline: 0.5rem; /* 8px - default inline gap */
294
+
295
+ /* ── Widths · how much room a surface is allowed to take ───── *
296
+ * Tailwind reads --container-*, so each name below is a max-w-* *
297
+ * and a w-* utility. Named for the surface rather than for a *
298
+ * t-shirt size, so the cap is chosen by role. *
299
+ * *
300
+ * Measured 2026-09-22 across three consuming applications: the *
301
+ * page cap was picked four different ways for the same kind of *
302
+ * screen (45 wide pages in one, 17 uncapped ones in another, *
303
+ * and two settings pages in a 768px column), and all 18 of their *
304
+ * drawers asked for the widest rung there was, which is what a *
305
+ * ladder that stops too early looks like from the outside. */
306
+ --container-page-narrow: 56rem; /* 896px - one column: a form, a settings pane */
307
+ --container-page-default: 72rem; /* 1152px - a page of stacked cards */
308
+ --container-page-wide: 96rem; /* 1536px - a data page whose table needs the room */
309
+
310
+ /* One ladder for every surface lifted off the page, so a drawer and
311
+ * a modal opened from the same screen are the same size. Each rung
312
+ * is the one above 3:2 of the one below, which is wide enough that a
313
+ * two-column field grid gets real columns at every step. */
314
+ --container-overlay-sm: 28rem; /* 448px - a confirmation, a short form */
315
+ --container-overlay-md: 36rem; /* 576px - up to four fields */
316
+ --container-overlay-lg: 44rem; /* 704px - a form with paired fields */
317
+ --container-overlay-xl: 56rem; /* 896px - a form beside a preview */
318
+ --container-overlay-full: 68rem; /* 1088px - a table inside a dialog */
294
319
  }
295
320
 
296
321
  /*
@@ -478,7 +503,7 @@ html[data-theme='light'] {
478
503
  * Print.
479
504
  *
480
505
  * The consuming applications had no `@media print` rule anywhere, and three surfaces print:
481
- * the portal prints an invoice, the ops console prints an audit log, the admin
506
+ * the portal prints an invoice, an operations console prints an audit log, the admin
482
507
  * prints a subject-access export. All three printed the sidebar, the theme
483
508
  * toggle and the nav, in a dark palette, and all three printed one screenful
484
509
  * and stopped, because the shell is `h-screen` with the content column set to