Foundations
The dimensional scale — spacing, radius, and sizing tokens measured from Attio. The single source of every px in crisp.
Every visual value in crisp is a token, never a literal in a component. Colors flip
per theme; dimensions (spacing, radius, sizing) do not — they live in one
theme-invariant scale at tokens/scale.ts and are emitted once into :root. A recipe
that writes a raw 8px is a bug: it writes c.space["2"] instead.
The scale isn't invented. It was derived by measuring the live Attio sidebar — every gap, padding, and margin — and the values collapse onto a clean 2px-granular, 4px-base ramp. Those measured numbers (1, 2, 4, 6, 8, 10, 12, 16…) are the scale below.
Color
Two tiers: palette (raw hex per theme) → semantic (role → palette ref). Recipes read
semantic only, through c.bgBrandSolid, c.fgMuted, and so on — never a hex. Brand and
text-on-brand are constant across light/dark; surfaces, borders, and text flip. A theme that
drifts fails tsc (the contract is a typed MapLeafNodes).
import { contract as c } from "../tokens";
background: c.bgBrandSolid; // the blue, same in both themes
color: c.fgMuted; // secondary text — flips with theme
border-top: `1px solid ${c.borderSubtle}`;Typography
Attio's UI runs entirely on Inter. Sidebar text measures to just a handful of sizes
(11 / 12 / 13 / 14 / 16px — text.lg = 14px is the default body/label) and three weights
(400 / 500 / 600, with 500 the UI default). Font family, size, and weight are all tokens;
line-height and letter-spacing stay recipe-local for now.
font-family: c.font.sans; // "Inter", system-ui, …
font-size: c.text.lg; // 14px
font-weight: c.weight.medium; // 500Spacing
Gaps, padding, and margins. Keys follow the Tailwind step convention — 0.5 is half a
4px step (2px) — so the numbers stay legible. space.px is the 1px hairline used between
rows in a dense list.
import { contract as c } from "../tokens";
// in a recipe — reference the token, never the px
gap: c.space["1.5"]; // 6px
padding: `0 ${c.space["2"]}`; // 0 8px
margin-top: c.space["2.5"]; // 10px| Use | Token | px |
|---|---|---|
| Row-to-row in a dense list | space.px | 1 |
| Tight inner padding (label top/bottom) | space["0.5"] | 2 |
| Icon ↔ label, chevron gaps | space["1"] | 4 |
| Search-row internal gap | space["1.5"] | 6 |
| Standard control padding, section spacing | space["2"] | 8 |
| Header block ↔ first control | space["2.5"] | 10 |
| Roomy inline padding | space["3"] | 12 |
| Pill / large inline padding | space["4"] | 16 |
Radius
Attio uses three corner radii on its stacked surfaces plus a pill. Nothing else.
| Token | px | Where |
|---|---|---|
radius.sm | 6 | small chips, badges |
radius.md | 8 | inputs, search box, nav-adjacent surfaces |
radius.lg | 9 | rows, workspace tile, list items |
radius.full | 999 | pills |
Sizing
Three recurring dimensions that are neither spacing nor radius — the nav glyph box, the standard row height, and the header height. Component-intrinsic sizes (a badge's 20px, a button's 32px) stay local to their recipe as measured values; only these genuinely-shared dimensions are tokenized.
Dividers — a zone separator, not a list separator
A divider earns its place only between two functionally different zones of a surface — never between items that belong to the same list (that is what spacing is for). In the sidebar there are exactly two:
- between the utility header (workspace switcher + search) and the navigation list, and
- between the navigation and the footer (trial / account block).
It renders full-bleed — a 1px border-top in borderSubtle, pulled to the sidebar edges
with a negative inline margin equal to the container padding:
.sidebar-divider {
border: none;
border-top: 1px solid var(--border-subtle);
margin: var(--space-2) calc(-1 * var(--space-2)) 0; /* 8px above, full-bleed */
}Rule of thumb: if you reach for a divider to separate two things in the same list, you actually want spacing. If the two things do different jobs, a divider is right.
Why a scale at all
Two reasons, both learned building the sidebar:
- Consistency is enforced, not hoped for. When every gap is one of eight tokens, a
stray
3pxcan't creep in and look "almost right." The palette of legal spacings is small and measured. - One place to tune. Matching Attio meant nudging real values (the workspace→search
gap was 2px; Attio's is 10px =
space["2.5"]). With a scale, that's a token reference, not a hunt through recipes for magic numbers.