Foundations
Sizing
Two ladders, both declared as tokens beside the palette. A page names a role and the shell resolves it. A surface lifted off the page takes a rung, and if it holds a form it takes the rung its content earns.
The page cap
PageShell's width, and nothing else. A page never writes max-w-*, mx-auto or a gutter of its own.
| Width | Cap | For |
|---|---|---|
narrow | 896px | outside an app shell only: a first-run setup step that sits in no shell |
default | 1152px | kept for pages outside an app shell. No console page uses it |
wide | 1536px | every page inside an app shell: lists, settings, forms and detail |
full | no cap | a canvas, a split pane or a page that scrolls itself. Takes fill |
full is not wider than wide. It is the absence of a cap, and a table
stretched across a 2560px window is unreadable in the same way a 768px column in that window is
wasteful.
Every page inside an app's shell is wide, whatever it holds: a list, a settings form
or a detail page. One cap keeps the content's left edge in one place from screen to screen. A form
inside a wide page stays readable because its card and its fields carry their own width, not the
page. narrow and default are for pages outside the shell. Name a width
on every page: left unset, it falls back to default.
The overlay ladder
One ladder for Modal, Drawer and the dialog stack. Each component takes
the slice its role allows: a docked panel and a modal stop at xl. Modal and Drawer
size themselves to their fields. A form goes in a Drawer, and a Modal holds a one-field prompt at
most. The dialog stack has no auto size: openDialog() takes md unless told, so a form of more than four fields is wider in a Drawer than in a dialog.
| Rung | Width | For |
|---|---|---|
sm | 448px | a confirmation |
md | 576px | where auto puts a form of up to four fields |
lg | 704px | where auto puts a form past four fields |
xl | 896px | where auto puts a form past eight fields, and a preview or a log, named by hand |
full | 1088px | a dialog holding a table. Dialog only |
A panel sizes itself to its form
Modal and Drawer default to size="auto", which takes the
rung the body needs: md up to four fields, lg past four and xl past eight. The count is measured from what the panel rendered, and re-measured while it
is open, so a form that reveals two more fields when a period is set to custom grows with them.
So never pass size to a Drawer whose body is a form, or to a Modal holding a prompt. Name
a rung only when the body holds no field and the measurement has nothing to read: a preview, a log,
a picture.
Fields are counted by the marker every labeled control carries, and only the outermost one counts, so a radio or checkbox group is the one question it asks rather than one field per option. Hand-rolled markup carries no marker and is invisible to the fit, which is one more reason to build a form from the kit's own fields.
<Drawer bind:open title="New budget"> <!-- eight fields: 704px -->
<Drawer bind:open title="Rename"> <!-- one field: 576px -->
<Drawer bind:open title="Preview" size="xl"> <!-- no fields: name the rung -->Touch targets
Every single-line control takes its height from one token, --spacing-control: 38px
under a mouse and 44px under a touch screen. A field and the button beside it grow
together, so a row of them still lines up. A control drawn small on purpose, such as a switch, a
checkbox or a close button, keeps its drawing and takes an invisible hit box of at least 44px
instead.
A control made of several targets gives each one that size. Every segment of a SegmentedControl and both steppers of a NumberInput take a 44px hit box, and the collapsed SidebarNav rail stacks a group's disclosure under its link so the two still fit the rail.
Where targets wrap or sit in a row, the kit spaces them so the boxes do not overlap: FilterChips rows 18px apart, Pagination pages 16px apart, and TimePicker segments spread apart, CheckboxGroup and RadioGroup options far enough apart for each, and DatePicker weeks 12px apart in a 320px calendar, each target 44px. Spacing you write yourself follows the same rule: stacked Toggle rows need 24px between them under a touch screen.
Icons
An icon on a line of text matches that text. Everything else is named for the shape of the space
it fills. An app keeps the five rungs in one module and every icon takes its size from there,
never from a class such as h-4 w-4, which reads as styling and lets two pages
disagree unseen.
| Rung | Size | For | Drawn |
|---|---|---|---|
xs | 12px | on the line with text-xs: a badge, a chip, a meta line | |
sm | 14px | on the line with text-sm: a button, a row action, a table cell | |
md | 16px | a glyph in its own column, not on the text's baseline | |
lg | 20px | alone in its box: an empty state, a section medallion | |
placeholder | 40px | standing in for a file that cannot be previewed |
<script lang="ts">
import { Plus } from '@lucide/svelte';
import { ICON } from '$lib/icon'; // { xs: 12, sm: 14, md: 16, lg: 20, placeholder: 40 }
</script>
<Button size="sm"><Plus size={ICON.sm} /> New entry</Button>Where a measurement is still yours
Inside a panel, sm: is the viewport's breakpoint and not the panel's, so sm:grid-cols-2 splits a row in two however narrow the panel is. Pair fields only
where the ladder guarantees the room: at the md rung and above, two columns
are
255px each or wider.