Foundations

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).

Brand
bg-brand-solid
bg-brand-solid-pressed
fg-on-brand
Surface
bg-surface
bg-raised
bg-sidebar
Border
border-default
border-subtle
Text
fg-primary
fg-muted
Accent & status
bg-danger
bg-success
bg-warning
bg-orange
bg-cyan
bg-purple
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: var(--crisp-font-sans) — VK Sans Display (installed only) → Inter
text.xs11pxThe quick brown fox — shortcut chips
text.sm12pxThe quick brown fox — section headers, meta
text.md13pxThe quick brown fox
text.lg14pxThe quick brown fox — default body / nav labels
text.xl16pxThe quick brown fox — palette input
Aa
weight.regular
400
Aa
weight.medium
500
Aa
weight.semibold
600
font-family: c.font.sans;      // "VK Sans Display", "Inter", system-ui, …
font-size: c.text.lg;          // 14px
font-weight: c.weight.medium;  // 500

Letter-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.

measured on VK Sans Display rendering Mongolian Cyrillic — see Typography above
tracking.18-0.36pxХяналтын самбар
tracking.20-0.4pxХяналтын самбар
tracking.24-0.48pxХяналтын самбар
tracking.26-0.52pxХяналтын самбар
tracking.28-0.56pxХяналтын самбар
tracking.32-0.64pxХяналтын самбар
font-size: "24px";
letter-spacing: c.tracking["24"]; // -0.48px

Spacing

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.

space.px1px
space.0.52px
space.14px
space.1.56px
space.28px
space.2.510px
space.312px
space.416px
space.520px
space.624px
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
UseTokenpx
Row-to-row in a dense listspace.px1
Tight inner padding (label top/bottom)space["0.5"]2
Icon ↔ label, chevron gapsspace["1"]4
Search-row internal gapspace["1.5"]6
Standard control padding, section spacingspace["2"]8
Header block ↔ first controlspace["2.5"]10
Roomy inline paddingspace["3"]12
Pill / large inline paddingspace["4"]16

Radius

The measured UI uses three corner radii on its stacked surfaces plus a pill. Nothing else.

radius.sm6px
radius.md8px
radius.lg9px
radius.xl12px
radius.2xl16px
radius.full999px
TokenpxWhere
radius.sm6small chips, badges
radius.md8inputs, search box, nav-adjacent surfaces
radius.lg9rows, workspace tile, list items
radius.full999pills

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.

size.icon16px
size.row28px
size.header48px

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:

  1. between the utility header (workspace switcher + search) and the navigation list, and
  2. 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 right whose drag handler reads clientX is 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 3px can'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.

On this page