Foundations
The dimensional scale — spacing, radius, and sizing tokens measured from a live UI. 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 a live 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
The measured 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 stays recipe-local, and so does letter-spacing for body-text sizes (the
Attio-measured button and table-cell tracking are each a single use, not a scale). Tracking
for display/heading type (18px+) is a ramp — see below.
c.font.sans leads with "VK Sans Display" — an owner override, not a remeasure of
Attio (carrot-systems/crisp#857). VK Sans Display is a bespoke corporate typeface with no
redistribution grant, so crisp never bundles the font files; it only ever matches an
installed copy via a local()-only @font-face (see build/generate-fonts.mjs).
Anywhere it isn't installed, the stack falls straight through to Inter, unchanged.
font-family: c.font.sans; // "VK Sans Display", "Inter", system-ui, …
font-size: c.text.lg; // 14px
font-weight: c.weight.medium; // 500Letter-spacing (display type)
crisp's few existing headings already tighten as they grow (authshell 20px/-0.4px,
objectsettings 24px/-0.48px, richtext 32px/-0.6px — all Attio-measured, on Inter), but
that pattern had never been named, so new display text (EmptyState's title, a Dialog's
alert title) shipped with no tracking at all — it read as default, not set.
docs/study/korean/ found the same size-tightens-tracking rule on both Naver and Kakao;
the study supplied the pattern only, never a number — Korean values are specific to
those brands' own type and were not copied.
tracking.* measures that rule directly on VK Sans Display (crisp's font override,
above) rendering real Mongolian Cyrillic — "Эрсдэлийн бүртгэл", "Хяналтын самбар" — at
every display size crisp uses today (18–32px): six candidates per size (0–3% of the
font-size) were screenshotted and checked for glyph collision. None collided even at 3%,
so the ~2%-of-size rule the existing Attio headings already happen to follow was kept, one
ramp instead of five scattered literals. It only fills genuinely untracked display type —
it never overwrites an Attio-measured value.
font-size: "24px";
letter-spacing: c.tracking["24"]; // -0.48pxSpacing
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
The measured UI 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(--crisp-border-subtle);
margin: var(--crisp-space-2) calc(-1 * var(--crisp-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.
Direction
Every preview on this site carries an LTR / RTL toggle in its top corner. It sets
dir on the preview canvas rather than the page, which is how a consumer embeds crisp
inside an RTL app: the component becomes the direction root and everything around it
stays put.
Recipes use logical properties (padding-inline, inset-inline-start), so layout
mirrors on its own. Two things cannot:
- Icons. A chevron meaning "back" is an SVG path with no direction. The ones that carry a direction are marked and mirrored under RTL; a downward disclosure chevron or a search glass is deliberately left alone.
- Anything JavaScript measures. A resize handle anchored with a physical
rightwhose drag handler readsclientXis consistent — both are physical, so they agree in either direction. Converting one without the other is what breaks it, so those rules stay physical on purpose.
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 the measured UI meant nudging real values (the
workspace→search gap was 2px; the measured value is 10px =
space["2.5"]). With a scale, that's a token reference, not a hunt through recipes for magic numbers.