@lyeve-labs/ui-kit 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +35 -9
  2. package/dist/components/AccountMenu.svelte +1 -1
  3. package/dist/components/AccountMenu.svelte.d.ts +1 -1
  4. package/dist/components/AppShell.svelte +1 -1
  5. package/dist/components/AppShell.svelte.d.ts +1 -1
  6. package/dist/components/AuthShell.svelte +76 -0
  7. package/dist/components/AuthShell.svelte.d.ts +19 -0
  8. package/dist/components/Autocomplete.svelte +2 -2
  9. package/dist/components/Checkbox.svelte +1 -1
  10. package/dist/components/CheckboxGroup.svelte +6 -1
  11. package/dist/components/DatePicker.svelte +2 -2
  12. package/dist/components/DateTimePicker.svelte +1 -1
  13. package/dist/components/Drawer.svelte +43 -13
  14. package/dist/components/Drawer.svelte.d.ts +13 -2
  15. package/dist/components/Dropdown.svelte +1 -1
  16. package/dist/components/Field.svelte +1 -1
  17. package/dist/components/FileInput.svelte +1 -1
  18. package/dist/components/Input.svelte +1 -1
  19. package/dist/components/Modal.svelte +32 -12
  20. package/dist/components/Modal.svelte.d.ts +11 -2
  21. package/dist/components/MultiSelect.svelte +2 -2
  22. package/dist/components/NumberInput.svelte +1 -1
  23. package/dist/components/PasswordInput.svelte +1 -1
  24. package/dist/components/Radio.svelte +1 -1
  25. package/dist/components/RadioGroup.svelte +1 -0
  26. package/dist/components/SearchInput.svelte +1 -1
  27. package/dist/components/SegmentedControl.svelte +1 -1
  28. package/dist/components/Select.svelte +2 -2
  29. package/dist/components/Textarea.svelte +1 -1
  30. package/dist/components/TimePicker.svelte +1 -1
  31. package/dist/components/Toaster.svelte +1 -1
  32. package/dist/components/dialog/Dialog.svelte +2 -2
  33. package/dist/components/dialog/types.d.ts +3 -1
  34. package/dist/components/dialog/types.js +2 -8
  35. package/dist/index.d.ts +2 -1
  36. package/dist/index.js +2 -1
  37. package/dist/internal/field.d.ts +20 -0
  38. package/dist/internal/field.js +23 -0
  39. package/dist/internal/layout.d.ts +34 -0
  40. package/dist/internal/layout.js +47 -3
  41. package/dist/motion.js +7 -2
  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
@@ -196,19 +196,45 @@ leaves through the same presets the kit's overlays use:
196
196
  </script>
197
197
 
198
198
  {#if open}
199
- <div transition:motion.popover>...</div>
199
+ <div transition:motion.popover|global>...</div>
200
200
  {/if}
201
201
 
202
202
  {#each items as item (item.id)}
203
- <li transition:motion.toast animate:motion.reorder>...</li>
203
+ <li transition:motion.toast|global animate:motion.reorder>...</li>
204
204
  {/each}
205
205
  ```
206
206
 
207
207
  `dialog`, `scrim`, `drawer`, `popover` and `toast` read the tokens at run
208
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. Do not write `duration-150`,
210
- `ease-out` or `transition-all` beside a kit class: they are a fifth speed and
211
- a fourth curve, and the kit's own test suite refuses them.
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.
212
238
 
213
239
  ## Local development
214
240
 
@@ -227,7 +253,7 @@ This repo is a single-purpose component library. Nothing but `src/lib/`.
227
253
  ```
228
254
  src/
229
255
  └── lib/ # → published as @lyeve-labs/ui-kit
230
- ├── components/ # 67 .svelte files
256
+ ├── components/ # 68 .svelte files
231
257
  ├── stores/ # toast.svelte.ts
232
258
  ├── styles/ # theme.css (the one stylesheet)
233
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
@@ -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
@@ -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}
@@ -235,7 +235,7 @@
235
235
  {/if}
236
236
 
237
237
  {#if box.open}
238
- <div use:placePanel transition:motion.popover class="{PANEL_SURFACE} w-full">
238
+ <div use:placePanel transition:motion.popover|global class="{PANEL_SURFACE} w-full">
239
239
  <div class={PANEL_LIST} data-panel-list use:panel {...box.listAttrs}>
240
240
  {#each rows as option, index (option.value)}
241
241
  {@const isSelected = option.value === value}
@@ -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}
@@ -254,7 +254,7 @@
254
254
  role="dialog"
255
255
  aria-label="Choose date"
256
256
  use:placePanel
257
- transition:motion.popover
257
+ transition:motion.popover|global
258
258
  class="{PANEL_SURFACE} w-68"
259
259
  >
260
260
  <div class="overflow-y-auto overscroll-contain p-3" data-panel-list={PANEL_LIST_UNCAPPED}>
@@ -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`;
@@ -65,13 +95,13 @@
65
95
  aria-hidden="true"
66
96
  class="absolute inset-0 bg-black/60 backdrop-blur-sm cursor-default"
67
97
  onclick={close}
68
- transition:motion.scrim
98
+ transition:motion.scrim|global
69
99
  ></button>
70
100
 
71
101
  <div
72
102
  use:overlay
73
- transition:motion.drawer={{ side }}
74
- class="relative flex h-full max-w-full flex-col {widths[size]} bg-surface shadow-2xl
103
+ transition:motion.drawer|global={{ side }}
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;
@@ -256,7 +256,7 @@
256
256
  bind:this={menuEl}
257
257
  role="menu"
258
258
  use:placePanel
259
- transition:motion.popover
259
+ transition:motion.popover|global
260
260
  class="{PANEL_SURFACE} min-w-36 {align === 'right' ? 'end-0' : 'start-0'}"
261
261
  >
262
262
  <div class={PANEL_LIST} data-panel-list>
@@ -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.
@@ -60,13 +80,13 @@
60
80
  aria-hidden="true"
61
81
  class="absolute inset-0 bg-black/60 backdrop-blur-sm cursor-default"
62
82
  onclick={close}
63
- transition:motion.scrim
83
+ transition:motion.scrim|global
64
84
  ></button>
65
85
 
66
86
  <div
67
87
  use:overlay
68
- transition:motion.dialog
69
- class="relative flex max-h-[calc(100dvh-2rem)] w-full {widths[size]} flex-col
88
+ transition:motion.dialog|global
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}
@@ -254,7 +254,7 @@
254
254
  </div>
255
255
 
256
256
  {#if box.open}
257
- <div use:placePanel transition:motion.popover class="{PANEL_SURFACE} w-full">
257
+ <div use:placePanel transition:motion.popover|global class="{PANEL_SURFACE} w-full">
258
258
  {#if searchable}
259
259
  <div class="border-b border-line p-2">
260
260
  <input
@@ -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}
@@ -409,7 +409,7 @@
409
409
  <input type="hidden" {name} {disabled} value={value ?? ''} />
410
410
 
411
411
  {#if box.open}
412
- <div use:placePanel transition:motion.popover class="{PANEL_SURFACE} w-full">
412
+ <div use:placePanel transition:motion.popover|global class="{PANEL_SURFACE} w-full">
413
413
  {#if searchable}
414
414
  <div class="border-b border-line p-2">
415
415
  <!--
@@ -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,
@@ -27,7 +27,7 @@
27
27
  >
28
28
  {#each toast.items as t (t.id)}
29
29
  <div
30
- transition:motion.toast
30
+ transition:motion.toast|global
31
31
  animate:motion.reorder
32
32
  class="pointer-events-auto flex items-start gap-3 overflow-hidden rounded-lg border border-line
33
33
  bg-surface ps-0 pe-3 py-3 shadow-xl"
@@ -93,7 +93,7 @@
93
93
  <!-- Backdrop -->
94
94
  <!-- svelte-ignore a11y_no_static_element_interactions -->
95
95
  <div
96
- transition:motion.scrim
96
+ transition:motion.scrim|global
97
97
  class="absolute inset-0 bg-black/60 backdrop-blur-sm"
98
98
  onclick={handleBackdropClick}
99
99
  onkeydown={(e: KeyboardEvent) => {
@@ -107,7 +107,7 @@
107
107
  <div
108
108
  bind:this={dialogEl}
109
109
  use:overlay
110
- transition:motion.dialog
110
+ transition:motion.dialog|global
111
111
  class="relative w-full {sizeClass(entry.options.size ?? 'md')} mx-4
112
112
  bg-surface border border-line rounded-xl shadow-2xl
113
113
  transition-transform duration-slow
@@ -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.0";
96
+ export declare const VERSION = "0.26.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.0';
106
+ export const VERSION = '0.26.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.
@@ -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.
package/dist/motion.js CHANGED
@@ -1,10 +1,15 @@
1
1
  /**
2
2
  * The entrances and exits, as Svelte transitions that read the motion tokens.
3
3
  *
4
- * <div in:motion.dialog out:motion.dialog>
5
- * <div transition:motion.popover>
4
+ * <div transition:motion.dialog|global>
5
+ * <div transition:motion.popover|global>
6
6
  * <li animate:motion.reorder>
7
7
  *
8
+ * `|global` is not optional. A Svelte transition is local by default and
9
+ * plays only when its own block toggles; a page that wraps a Modal in its own
10
+ * `{#if}` to reset the form each time removes the whole component, and a
11
+ * local exit never runs. Global plays it on any ancestor change.
12
+ *
8
13
  * A CSS animation plays an entrance and nothing else: the element it ran on
9
14
  * is gone the moment `{#if}` turns false, so a dialog that eased open snapped
10
15
  * shut. A Svelte transition keeps the element until the exit has played, and
@@ -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.0",
3
+ "version": "0.26.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