Foundations
Spacing
Comitor uses a base-4 spacing scale (1 unit = 4 px). All space values in UI must come from this scale — no arbitrary pixel values. The base itself is Tailwind's --spacing: 0.25rem, which the package leaves alone on purpose.
Spacing scale
Bars are drawn at 2× for legibility. Because the scale is calc(var(--spacing) * n) in rem, every value here scales with the text-size axis — including icon sizes, which are drawn from this scale rather than the type scale.
Corner radius
One variable, --radius: 6px, and three steps derived from it. Six pixels is the visual signature of the system. It is written in px, not rem, and that is the point: every rem value follows the text-size axis, so a rem radius would become 6.75px at the large step and the signature would drift with a user preference. rounded-xl is the one step still declared in rem.
| Utility | Declared as | At default | Usage |
|---|---|---|---|
| rounded-sm | calc(var(--radius) - 2px) | 4px | Nested chips, small inner surfaces |
| rounded-md | --radius | 6px | The default. Buttons, inputs, badges, menu items |
| rounded-lg | calc(var(--radius) + 4px) | 10px | Cards, popovers, dialogs |
| rounded-xl | 0.75rem | 12px | Large panels and hero surfaces |
Elevation
Four steps, low to high. The raw values live as --elevation-* CSS variables rather than inside @theme, because a shadow has to change between light and dark at run time. In dark mode the geometry is unchanged but the ink shadow is replaced by pure black at a much higher alpha — on #0E1116 an ink shadow is simply invisible, and a dialog with no shadow reads as pasted flat onto the page.
shadow-surface
A plane at rest, where the border is still the main signal — Card by default
shadow-control
An interactive control lifted slightly off the page — solid-fill Button
shadow-card
A floating card or panel — StatCard, Toast, Card variant elevated, chart tooltip
| Utility | Variable | Light | Dark |
|---|---|---|---|
| shadow-surface | --elevation-surface | 0 1px 2px rgba(14,17,22,.04) | 0 1px 2px rgba(0,0,0,.32) |
| shadow-control | --elevation-control | 0 1px 2px rgba(14,17,22,.08) | 0 1px 2px rgba(0,0,0,.4) |
| shadow-card | --elevation-card | 0 1px 2px rgba(14,17,22,.04), 0 8px 24px rgba(14,17,22,.06) | 0 1px 2px rgba(0,0,0,.32), 0 8px 24px rgba(0,0,0,.4) |
| shadow-overlay | --elevation-overlay | 0 8px 32px rgba(14,17,22,.16) | 0 8px 32px rgba(0,0,0,.64) |
To restyle elevation across an app, override the four --elevation-* variables — never --shadow-*. One consequence of holding the value as a var(): Tailwind cannot isolate the colour component, so shadow-card/50 and colour modifiers such as shadow-red have no effect.
App frame measurements
Three measurements are published into the spacing namespace so the shell layer never has to write a magic number.
| Variable | Utility | Value | What it measures |
|---|---|---|---|
| --spacing-header | h-header | 3.5rem · 56px | App header height. Mirrored as layout.headerHeight in @comitor/ui/tokens. |
| --spacing-sidebar | w-sidebar | 16rem · 256px | Sidebar width when expanded. layout.sidebarWidth. |
| --spacing-sidebar-icon | w-sidebar-icon | 3.5rem · 56px | Sidebar width when collapsed to icons. layout.sidebarCollapsedWidth. |
All three are declared in rem, so they follow the text-size axis. The px figures mirrored in @comitor/ui/tokens are the values at the default step — use them for canvas and PDF work, not to lay out the DOM.
Density is a user setting
Compact layout is one of the four display axes, switched by <html data-density="compact">. It overrides exactly five tokens and touches nothing else. Note what it does not do: it does not override the global --spacing. That would be one line, but it would also shrink horizontal padding and drag every size-4 icon down to about 12.8px. Compact means more rows on screen — not text pressed against an edge — so the axis is vertical only.
| Variable | Comfortable | Compact | What it pads |
|---|---|---|---|
| --row-py | 0.75rem · 12px | 0.375rem · 6px | Vertical padding of a table cell |
| --menu-item-py | 0.375rem · 6px | 0.25rem · 4px | Vertical padding of a menu item |
| --list-item-py | 0.75rem · 12px | 0.5rem · 8px | Vertical padding of a list item |
| --nav-item-py | 0.375rem · 6px | 0.25rem · 4px | Vertical padding of a sidebar item |
| --table-head-h | 2.5rem · 40px | 2rem · 32px | Height of a table header row |
/* Consume a density token the way the package does — as a Tailwind
arbitrary-property value, not as a hardcoded py-3. */
<td className="py-(--row-py)">…</td>
<div className="py-(--list-item-py)">…</div>'use client'
// The density axis is wired once with <DensityProvider> in the root layout.
// Default labels are Vietnamese, so pass English ones explicitly.
import { DensityToggle } from '@comitor/ui/shell'
export function DisplaySettings() {
return (
<DensityToggle
label="Compact layout"
description="Tightens table rows, menus and list items."
/>
)
}Page layout
Page gutters and content width come from PageContainer rather than from a per-page max-w-… px-…. If every page writes its own, the left margin of two apps in the ecosystem ends up a few pixels apart — hard to see, and enough to make the whole system look loosely assembled.
import { PageContainer } from '@comitor/ui'
// Every variant renders mx-auto w-full px-4 md:px-6 py-4 md:py-6,
// plus one of four max widths:
// sm max-w-2xl 42rem — settings pages, one-column forms
// md max-w-5xl 64rem — the default, detail pages
// lg max-w-[90rem] — wide multi-column tables
// full max-w-none — full-bleed tables, kanban boards
<PageContainer width="lg" as="main">…</PageContainer>
// flush drops the vertical padding when the page paces itself.
<PageContainer width="full" flush>…</PageContainer>Breakpoints
Mirrored as layout.breakpoints in @comitor/ui/tokens for code that has to branch in JS.
Touch targets
WCAG 2.5.5. Compact density shortens rows but must never take an interactive target below this on a touch surface.