@lyeve-labs/ui-kit 0.25.1 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -9
- package/dist/components/AccountMenu.svelte +1 -1
- package/dist/components/AccountMenu.svelte.d.ts +1 -1
- package/dist/components/AppShell.svelte +1 -1
- package/dist/components/AppShell.svelte.d.ts +1 -1
- package/dist/components/AuthShell.svelte +76 -0
- package/dist/components/AuthShell.svelte.d.ts +19 -0
- package/dist/components/Autocomplete.svelte +1 -1
- package/dist/components/Checkbox.svelte +1 -1
- package/dist/components/CheckboxGroup.svelte +6 -1
- package/dist/components/DatePicker.svelte +1 -1
- package/dist/components/DateTimePicker.svelte +1 -1
- package/dist/components/Drawer.svelte +41 -11
- package/dist/components/Drawer.svelte.d.ts +13 -2
- package/dist/components/Field.svelte +1 -1
- package/dist/components/FileInput.svelte +1 -1
- package/dist/components/Input.svelte +1 -1
- package/dist/components/Modal.svelte +30 -10
- package/dist/components/Modal.svelte.d.ts +11 -2
- package/dist/components/MultiSelect.svelte +1 -1
- package/dist/components/NumberInput.svelte +1 -1
- package/dist/components/PasswordInput.svelte +1 -1
- package/dist/components/Radio.svelte +1 -1
- package/dist/components/RadioGroup.svelte +1 -0
- package/dist/components/SearchInput.svelte +1 -1
- package/dist/components/SegmentedControl.svelte +1 -1
- package/dist/components/Select.svelte +1 -1
- package/dist/components/Textarea.svelte +1 -1
- package/dist/components/TimePicker.svelte +1 -1
- package/dist/components/dialog/types.d.ts +3 -1
- package/dist/components/dialog/types.js +2 -8
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/internal/field.d.ts +20 -0
- package/dist/internal/field.js +23 -0
- package/dist/internal/layout.d.ts +34 -0
- package/dist/internal/layout.js +47 -3
- package/dist/styles/theme.css +26 -1
- package/package.json +1 -1
- 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
|
-
- **
|
|
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>
|
|
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
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
`ease-out` or `transition-all` beside a kit class: they
|
|
212
|
-
a fourth curve, and the kit's own test suite refuses
|
|
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/ #
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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}
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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 = '
|
|
38
|
+
size = 'auto',
|
|
27
39
|
onclose = undefined,
|
|
28
40
|
children,
|
|
29
41
|
footer,
|
|
30
42
|
}: Props = $props();
|
|
31
43
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 = '
|
|
33
|
+
size = 'auto',
|
|
24
34
|
onclose = undefined,
|
|
25
35
|
children,
|
|
26
36
|
footer,
|
|
27
37
|
}: Props = $props();
|
|
28
38
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
@@ -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,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
106
|
+
export const VERSION = '0.26.0';
|
package/dist/internal/field.d.ts
CHANGED
|
@@ -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. */
|
package/dist/internal/field.js
CHANGED
|
@@ -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.
|
package/dist/internal/layout.js
CHANGED
|
@@ -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-
|
|
40
|
-
default: 'max-w-
|
|
41
|
-
wide: 'max-w-
|
|
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/styles/theme.css
CHANGED
|
@@ -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,
|
|
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
package/src/lib/styles/theme.css
CHANGED
|
@@ -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,
|
|
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
|