Design system
Every colour token, every primitive and every state freepass is built from. Inherited from direbot without changing a single value — this page is where you check that claim rather than take it.
Two tests lean on this page: one fails if a primitive is not reachable from it, one fails if a colour token has no caller. It is a fixture as much as a document.
Colour
One theme, dark. Thirty-six tokens, and the list below is checked against globals.css by a test — a token added there and forgotten here fails the build.
The accent has two weights, and this is the rule broken most often
Fill with --primary, write with --link. As text on the page, the fill measures 4.9:1; the link weight measures 8.5:1.
Terracotta as text — 4.9:1, do not
Terracotta as text — 8.5:1, this one
Pairings, with the measured ratio
A swatch shows a colour. It does not show whether text reads on it — which is the half that actually breaks.
подпись тёмная, а не белая: белая дала бы 3.81:1 — ниже AA
обычное главное действие — эта пара, а не терракота
акцентный текст лежит только на этой подложке
Sunken, card, page
A card sits above the page.
A field is a well cut into it, not a plate laid on it.
Type
A closed ladder of ten steps, written by the pixel. Naming them sm / base / lg would invent relationships the design does not have.
- text-12 · 12px/1.45Free access to AI services, and what it costs after
- text-13 · 13px/1.5Free access to AI services, and what it costs after
- text-14 · 14px/1.5Free access to AI services, and what it costs after
- text-15 · 15px/1.55Free access to AI services, and what it costs after
- text-16 · 16px/1.5Free access to AI services, and what it costs after
- text-17 · 17px/1.45Free access to AI services, and what it costs after
- text-18 · 18px/1.45Free access to AI services, and what it costs after
- text-19 · 19px/1.45Free access to AI services, and what it costs after
- text-20 · 20px/1.35Free access to AI services, and what it costs after
- text-32 · 32px/1.15Free access to AI services, and what it costs after
Three families
font-sans · Onest
Body copy, labels, everything the interface says. Кириллица тоже есть.
font-serif · Source Serif 4
Fifteen pixels — the text cut.
Thirty-two
Different letterforms, not the same cut scaled — that is the opsz axis. If they look identical, it was dropped from next/font.
.tnum · JetBrains Mono
1 111 111
8 000 000
Tabular figures: the column does not jitter when a number changes.
Radius & elevation
Six on everything you click, twelve on everything that holds content, 999 on pills. Elevation is carried by the border, not by a shadow.
4px
6px · controls
12px · content
14px
pills
--shadow-sm is none, deliberately
--shadow-md
--shadow-lg · only things that float
Badges
A tag is something the system tells you. A pill is something you click to narrow a list. Different objects, so shape is a separate axis.
No dot beside the word: a dot next to a 12px label reads as an unloaded avatar.
Surfaces
One raised surface for everything: card, panel, dropdown, dialog. Hover lifts the border and nothing else.
A plain card
Border, no shadow.
A card with a header
- First row
- Second row
The header rule is stronger than the rules between rows. Swapping them turns a list into a grid.
A card that is a link
free tierSurface and hit area are the same rectangle.
asChild
Forms
A label is quieter than the value it names. A field is sunken; a picker sits on the surface.
Focus takes a 3px ring at 35% and a terracotta border. Hover lifts the border only.
Table
Built on the mock catalogue rather than lorem ipsum: real names truncate, real offers wrap, and an unchecked entry has to say so.
| Service | Category | Offer | Checked |
|---|---|---|---|
| ClaudeLong-context assistant with a code-capable agent mode. | chat | Free tier, no cardMessage limits reset every few hours | not checked |
| PerplexitySearch that answers in prose and shows where each claim came from. | chat | 1 month ProNew accounts only | not checked |
| MidjourneyImage generation with the strongest house style of the lot. | images | 25 free generations | not checked |
| ElevenLabsSpeech synthesis and voice cloning in about thirty languages. | audio | 10k characters a monthAttribution required on the free tier | not checked |
| RunwayText- and image-to-video, plus the editing tools around it. | video | 125 credits once | not checked |
| CursorAn editor built around a model that can read the whole repository. | code | 14 days ProCard required to start the trial | not checked |
| Replit AgentBuilds and deploys a small app from a prompt, in the browser. | agents | $10 in credit | not checked |
| NotebookLMGrounds every answer in documents you upload, and cites them. | agents | Free, no paid tier yet | not checked |
| 8 services in the mock | 0 verified | ||
States & motion
Loading, empty, confirmation. The pulse is slower and shallower than Tailwind's, and reduced-motion kills it outright.
Skeleton
The 110×40 block is the shape the header’s identity slot used in direbot, where the guest answer was a button that size. Here the guest answer is nothing, so the header ships no visible placeholder — see site-header-shell.tsx.
Empty
No services match that filter
An empty state says what is missing, not that something went wrong.
Confirmation
Pulse
animate-pulse-soft · 1.4s, 0.35→0.7
Tailwind’s animate-pulse, for comparison
Both stop dead under prefers-reduced-motion: the media query at the bottom of globals.css kills every animation and transition on the page.
The cn() seam
The one failure that typecheck, unit tests and the build all miss: a class written in the source that never reaches the DOM.
cn('text-contrast-foreground', 'text-15')→text-contrast-foreground text-15cn('text-15', 'text-contrast-foreground')→text-15 text-contrast-foregroundcn('text-13', 'text-16')→text-16cn('text-destructive', 'text-success')→text-successThe first two must keep both the size and the colour. The last two must resolve — a real conflict still has to have a winner. Printed rather than described, because the two are indistinguishable in a diff.
Chrome
The header and footer hang 60px outside the text column on purpose. Above 1240 the inset grows on its own.
px-[max(60px,calc((100%-1240px)/2))] max-lg:px-8 max-sm:px-[18px]Header states
The identity corner is the one part of the header that is not in the prerendered HTML: it reads the session at request time, inside a Suspense boundary. All three states are drawn below directly, out of the real components — the live header above shows whichever one applies to you.
All three are the same width band by design: the pending block is exactly the guest button’s 110×40, so the common answer lands without moving anything. The signed-in corner is wider and grows leftward — the nav never moves.