Small
Dense tools and secondary tasks
Deployment
Same surface and spacing rules.
Sets containers, grids, spacing, and component sizes.
Pick a container by task. It fills the available width up to its maximum and centers beyond it.
| Container | Maximum width | Token | Use |
|---|---|---|---|
| Workspace | 75rem | --surf-layout-workspace | Operations screens and dashboards |
| Reading | 52rem | --surf-layout-reading | Documents and long forms |
| Wide | 96rem | --surf-layout-wide | Comparisons and wide tables |
Inside any container, reading text wraps at the line length Typography sets.
Review pipeline runs and deployment results.
Use Grid columns for equal cards, and the 12 / 6 / 4 layout grid for regions of different widths, such as main content beside a panel.
| Viewport | Layout grid | Gutter | Equal cards |
|---|---|---|---|
| Above 1100px | 12 tracks | 24px | 1–4 columns |
| 761–1100px | 6 tracks | 24px | 2 columns |
| 760px and below | 4 tracks | 16px | 1 column |
Primary content
8 / 4 / 4 tracks
Supporting details
4 / 2 / 4 tracks
Full-width results
12 / 6 / 4 tracks, long-resource-identifiers-for-staging-deployments-also-wrap
import { Container, Grid, GridItem, Card } from '@surflane/ui'
<Container width='workspace'>
<Grid columns='layout'>
<GridItem span={8} mediumSpan={4} smallSpan={4}>
Main
</GridItem>
<GridItem span={4} mediumSpan={2} smallSpan={4}>
Details
</GridItem>
<GridItem span={12} mediumSpan={6} smallSpan={4}>
Results
</GridItem>
</Grid>
</Container>
// Equal cards: 3 columns, then 2, then 1.
// Match bleed to the card padding so card text aligns with the text around it.
<Grid columns={3} gap={4} bleed={4}>
{items.map((item) => (
<Card.Root key={item.id} padding={4}>
...
</Card.Root>
))}
</Grid>
// As many columns as the grid's own width holds, each at least 20rem.
<Grid columns='auto' min='card'>
{items.map((item) => (
<Card.Root key={item.id}>...</Card.Root>
))}
</Grid>A fixed count steps down with the window. Where a grid's room does not follow the window, such as beside a sidebar or inside a panel, columns='auto' fits as many columns as the grid's own width holds, each at least min: 'tile' 10rem for a figure and its label, 'brief' 14rem for a card of a name and a line or two, such as one of many sessions, 'card' 20rem for a card, 'wide' 30rem for a chart or a small table.
Match bleed to the card padding, such as bleed={4} for padding={4}, so card text lines up with the text around the grid. Don't bleed at the window edge or inside a clipped area.
Scroll wide tables and code inside a ScrollArea instead of widening the page.
Pick each gap from the scale by how closely two things relate.
| Relationship | Gap | Token | Example |
|---|---|---|---|
| Parts of one item | 4–8px | --surf-space-1, --surf-space-2 | Icon and label, label and input |
| Controls in a row | 8px | --surf-space-2 | Buttons in an action row |
| Field and its actions | 16px | --surf-space-4 | Input and the buttons below |
| Task groups | 24px | --surf-space-6 | Field groups, filters, results |
| Sections | 48px | --surf-layout-section-gap | Independent topics or steps |
| Inside a container | 16–32px | --surf-space-4, --surf-space-6, --surf-layout-page-inset | Popover 16px, card 24px, page 32px |
The form below follows the table. Turn on the dimensions to measure each gap.
Enter the name to display.
<Stack gap={4}>
<Stack gap={1}>{/* Title and description; margin: 0 */}</Stack>
<Field.Root>{/* Field.Label + Input; gap: var(--surf-space-2) */}</Field.Root>
<Inline gap={2}>{/* Action buttons */}</Inline>
</Stack>Compact layouts tighten task groups to 16px and leave controls as they are. On phones, the page inset drops to 16px and section gaps to 32px. Size gaps from the scale, never from viewport height.
Small, Default, and Large change control height, text, and inner spacing. They don't scale the screen.
| Element | Small | Default | Large |
|---|---|---|---|
| Button / input height | 32px | 36px | 44px |
| Field and menu text | 13 / 20px | 14 / 22px | 16 / 24px |
| Button text | 13 / 18px | 14 / 20px | 16 / 22px |
| Checkbox / radio | 16px | 20px | 24px |
| Popover padding | 12px | 16px | 24px |
| Card padding | 16px | 24px | 32px |
| Card, sheet and dialog title | 16 / 24px | 20 / 28px | 20 / 28px |
| Slider thumb | 20px | 24px | 28px |
| Table cell vertical padding | 8px | 12px | 16px |
| Badge vertical padding | 2px | 4px | 6px |
| Avatar | 24px | 36px | 48px |
| Tree row | 36px | 44px | 52px |
On touch and narrow screens, controls never shrink below the target size. Size never changes chart data or line thickness. Only control text steps with the size: every type role stays the same, apart from the titles of cards, sheets, drawers and dialogs, which take the subsection role at Small. A default SizeProvider renders exactly as none.
Dense tools and secondary tasks
Same surface and spacing rules.
Default size for most screens
Same surface and spacing rules.
Primary tasks and roomy input
Same surface and spacing rules.
SizeProvider sizes a form, table, or menu, including the portals it opens. A nested provider or a size prop overrides it. Wrap Table.Container from outside instead of putting a provider inside a table. Its density='compact' tightens the blocks inside for a dense screen: 12px between them, 8px within, label-role card titles, compact tables and charts, and controls that keep their size on touch.
import { SizeProvider, Button, Input, Select } from '@surflane/ui'
<SizeProvider size='small'>
<Input aria-label='Project name' />
<Select aria-label='Environment' options={environments} />
<Button>Apply</Button>
<Button size='large'>Start deployment</Button>
</SizeProvider>
// The short sm / md / lg spellings still work, but are deprecated.
// A numeric size on Input is the standard HTML character-width hint.Density is a second axis beside size. Size sets how big a control is; density sets how close blocks sit. SizeProvider density='compact' packs a region people scan more than they edit, such as a monitoring dashboard: blocks sit 12px apart and keep 8px inside, and the parts inside take their compact forms.
| Part | Compact |
|---|---|
| Gaps between Dashboard panels and Grid columns, card padding, the AppShell page inset and the sidebar's padding | 12px, --surf-ui-density-gap |
| AppShell sidebar rows, its toggle and links on the rail | 32px, square on a 56px rail |
| Gaps inside a card or panel, FilterBar, PageHeader and Stat.Group gaps | 8px, --surf-ui-density-inset |
| Card and Dashboard.Panel titles | The label role |
| Table, LineChart, AreaChart, BarChart | Compact, unless density='default' |
| DescriptionList rows | The small row, 32px |
| Banner, Timeline events | 4px above and below |
| Controls on touch screens | Their own size, at least 24px |
Keep forms, documents and phone-first screens at the default density. Set compact on a whole region, not on single parts, so neighboring blocks match; a table people read closely opts back with density='default'.
Compact never takes a target under 24px, the WCAG 2.2 AA minimum. On touch screens its controls keep their size instead of growing to 44px.
A collection shows like items side by side, such as sessions, hosts or deployments, as cards in a Grid. People compare the cards across a row, so every card shows the same parts in the same places, and the grid fits the cards to the room it has.
| Part | Rule |
|---|---|
| Columns | Grid columns='auto' min='card': as many columns as fit the grid's own width, each at least 20rem. min='tile' fits a figure and its label, min='brief' a card of a name and a line or two, min='wide' a chart. A fixed count follows the window, so beside a sidebar or in a panel its cards get squeezed. |
| Padding and gaps | The card's own padding and the grid's gap, both from density: compact on the region sets cards 12px apart with 12px inside. Don't pad or space single cards. |
| Header | Who it is: an Avatar colored by categoryFor(name), the title with Card.Action, a muted line of metadata, and one StatusBadge. The row keeps one line, Inline wrap={false}: a long name cuts short with Card.Title lines={1}, and the badge keeps its place, so every card keeps the header's height. |
| State | One StatusBadge says the state in words. Avatar.Status live marks only the items working right now. No colored bands, borders or tinted cards: a state is a point on the card, not its plane. |
| Body | The text that varies, such as the last message, in Text lines={2}, so every body takes the same height. The whole text shows where the card opens. |
| Footer | Parts of a fixed height: a Meter takes a line of its own, then one row of small Stats in a Stat.Group, about four at the card minimum. The footer sits at the bottom, so footers line up across a row. |
| Missing values | Keep the part and show 0 or a dash, so the parts after it stay in place. |
| Details | Card.Action opens a Sheet, which keeps the collection in view. A Dialog is for a task, such as renaming; a long history, such as every turn of a session, gets a page of its own. |
Claude Code on devbox
Running the Playwright suite after the card footer change: 212 of 640 tests done, none failed so far.
Claude Code on nas
Asks to restart the worker container to load the new queue settings.
opencode on gpu-1
Moved three panels to LineChart and updated their tests.
Claude Code on devbox
The index token expired; the run stopped while fetching the second shard.
import {
Avatar,
Card,
Grid,
Inline,
Meter,
Sheet,
SizeProvider,
Stack,
Stat,
StatusBadge,
Text,
categoryFor,
} from '@surflane/ui'
<SizeProvider density='compact'>
<Grid columns='auto' min='card'>
{sessions.map((session) => (
<Sheet.Root key={session.id}>
<Card.Root>
<Card.Header>
<Inline justify='between' wrap={false}>
<Inline gap={3} wrap={false}>
<Avatar.Root
category={categoryFor(session.name)}
aria-hidden='true'
>
<Avatar.Fallback>{session.initials}</Avatar.Fallback>
{session.working && <Avatar.Status tone='progress' live />}
</Avatar.Root>
<Stack gap={0}>
<Card.Title lines={1}>
<Sheet.Trigger render={<Card.Action />}>
{session.name}
</Sheet.Trigger>
</Card.Title>
<Text variant='caption' color='secondary' lines={1}>
{session.agent}
</Text>
</Stack>
</Inline>
<StatusBadge tone={session.tone}>{session.state}</StatusBadge>
</Inline>
</Card.Header>
<Card.Body>
<Text lines={2}>{session.lastMessage}</Text>
</Card.Body>
<Card.Footer>
<Meter.Root value={session.context} low={60} high={85} optimum={0}>
<Meter.Label>Context</Meter.Label>
<Meter.Value />
<Meter.Track>
<Meter.Indicator />
</Meter.Track>
</Meter.Root>
<Stat.Group label='Session counts' labelHidden size='small'>
<Stat.Root>
<Stat.Label>Turns</Stat.Label>
<Stat.Value>{session.turns}</Stat.Value>
</Stat.Root>
{/* Tools, Tokens, Time: a value not yet known shows a dash. */}
</Stat.Group>
</Card.Footer>
</Card.Root>
<Sheet.Content>
{/* The whole session: counts, context, turns. */}
</Sheet.Content>
</Sheet.Root>
))}
</Grid>
</SizeProvider>Components mirror for right-to-left languages when two things are set: dir='rtl' on the page or region turns the layout, and DirectionProvider direction='rtl' around it turns arrow keys and the popups that render outside the region. Every side in the stylesheets is logical, so padding, alignment and docked parts follow the reading direction.
| Part | Right to left |
|---|---|
| Layout, padding and alignment | Mirrors |
| Menu, Select, Popover and Tooltip popups | Take the provider's direction |
| Arrow keys in Tabs, Toolbar, Menubar and Slider | Swap left and right |
| Chevrons, back arrows and breadcrumb separators | Mirror |
| Scroll fades, the Switch thumb and Tree toggles | Mirror |
| A chart's key and legend | Sit at the inline end |
| A chart's plot | Runs as drawn, left to right |
| Arrow keys written with Kbd | Stay: they name physical keys |
| A centered Dialog, a Sheet's side, a horizontal scrollbar | Stay physical |
For text that mixes directions, such as names and identifiers, see Content and localization.