Design system

Everything here already exists in src/components. Compose from it, change the accent in globals.css, and delete this route when the real product is built.

Make it theirs

Nothing here breaks if you skip it, which is exactly why it gets skipped. Set all five before you hand the app over.

App name

The browser tab, bookmarks, and the card when someone pastes the URL into Slack.

lib/app-config.ts → APP.name
Not set

Tagline

Under the name in the app bar, and the meta description on link previews.

lib/app-config.ts → APP.tagline
Not set

Their domain

Puts the customer's own logo top-left instead of a generated square.

lib/app-config.ts → APP.domain
Not set

The accent

Take it from their brand. The same blue on every org is the clearest tell.

globals.css → --primary
Check by hand

Tab icon

Replace with their real mark — it carries the placeholder accent right now.

src/app/icon.svg
Check by hand
The accent and the icon can’t be read from config — only you know whether what is there is theirs or what it shipped with.

The three planes

Depth is the system, not a finish. Everything sits on exactly one of three planes, and which one is not a matter of taste. The test: do I put something into this, or do I press it? Things you press rise; things you fill sink.

Raised

Comes toward you. Anything you actuate, and anything floating above the page — buttons, switches, chips, panels, menus, modals, toasts.

Flat

Sits in the page. Rows, table cells, body text, ghost buttons — anything that is only there to be read.

Recessed

Goes into the page. Anything that receives content — text inputs, search, wells, chart stages, progress tracks.

Press itpressing a raised thing pushes it toward flat — that travel is what makes it a button
A switch is raised like a button because you flip it; a text field sinks because you type into it. The moment that inverts, the metaphor stops reading. Use the raised* / recessed / pressable classes rather than picking shadows by hand.

Depth & glass

Four steps of raised, one of recessed. Each is a rim light plus shadows at several radii — a single drop shadow reads as a sticker.

lift-1

rows, chips, quiet marks

.raised-sm
lift-2

buttons, tiles

.raised
lift-3

panels, cards

.raised-panel
lift-4

menus, modals, toasts

.raised-over
inset · .recessed

Wells, chart stages, empty regions, anything set into the page.

Glassonly over something — it needs content behind it to refract

Floating surface

Sticky headers, HUDs and overlays. The app bar above uses this. Over a flat white page it does nothing.

Colour

One accent, and it is the only thing that gets to be bright. The blue below is a placeholder — take the real one from the customer's brand. Identity hues are for people; state colours mean state, never decoration.

The accenta placeholder — set --primary from the customer’s brand
--primary
--success
--warning
--destructive
Ground & inkwarm, light-first — panels read lighter than the page
background
card
secondary
sunken

foreground — body

muted — secondary

subtle — metadata

Identity huespeople and org marks only — never chrome
--id-1--id-2--id-3--id-4--id-5--id-6

Type

One family, medium weights, tight tracking on headings. Numbers are tabular so columns line up.

Page title · 3xl medium

Section · lg medium

Body · sm. The size most of the app is set in.

Secondary · sm muted.

Eyebrow · uppercase, tracked, above a group

1,284,000 · 12.4% · tabular-nums

At most two families, and never bold-700 headings — weight 500 with tight tracking reads as considered, 700 reads as a slide deck.

Buttons

Raised and pressable, except ghost and link — those are flat because they are text that happens to be clickable.

One accented button per surface — if two things look equally primary, neither is. There is deliberately no “link” variant: underlined text pretending to be a button neither looks pressable nor navigates. If it navigates, use an <a>; if it acts, give it a real button.

Fields

Recessed, with no border — the inset shadow is the edge. Errors go under the field that failed, on blur. A toast for a field error is a dead end.

As it appears on the lease.

Shown on the company's timeline.

Tabs

Two shapes, one primitive — with real arrow-key navigation and a real tablist. Never hand-roll tabs out of buttons.

Underline — for page-level sections. Reads as part of the page.
The track is recessed and the active thumb is raised: the plane language doing the explaining, with no label needed.

Overlays

Everything opens from where you clicked, floats on the top plane, and nothing gets clipped. All of these portal, trap focus, close on Escape and restore focus to the trigger.

A modal asks permission and costs a decision. If the action is reversible, just do it and fire a toast with Undo instead.

Feedback

Say what happened, and offer the way back. Undo beats a confirmation dialog almost every time.

Enrichment runs nightly

Company details were last refreshed 6 hours ago.

12 contacts are missing an email

They will be skipped by outreach until one is added.
62%

State

Colour never carries meaning alone — the label does, and the dot reinforces it, so it survives greyscale and colourblindness.

Not startedIn progressSignedWaiting on replyLost
RunningNeeds inputBlockedIdle
CSVInboundUnread 3Total 128
The ping on a live dot is only for things genuinely happening now. Animate a static state and people learn to ignore the animation.

People

Initials in the identity hue, on no coloured plate. A coloured tile behind two letters is the most generated-looking thing you can put in a list.

Priya RamanOperations leadMarcus WebbHead of real estateAna OyelaranFinance
StripeStripeProspect
The hue is hashed from the name, so a person is the same colour on every screen.

Data

Numbers are the main content. Tabular figures, explanations attached to anything derived, and charts you can interrogate.

In pipeline38+4
Qualified12-2
Committed rent£48.2k
Table
CompanyStageFitLast activity
FigmaFigmaQualified
LinearLinearContacted
NotionNotionNew
StripeStripeQualified
VercelVercelNew
Page 1 of 1 · 5 in total
Bar chartclick a bar to filter everything
20
NewContactedQualified
Share of totalsame data, same filter
The chart, the breakdown and the table above share one selection — click a stage in any of them and all three narrow to it; click again to clear. Whenever a screen shows the same dimension twice, wire them together with useCrossFilter. Two views that don’t talk to each other are two chances to reach different conclusions.
Selection listthe right container for any “pick one of these” screen
Dense rows beat a grid of cards: scannable at a glance, and not a pixel wasted.

Empty & loading

The two states nobody designs and everybody sees. An empty screen is an invitation to act; a skeleton is shaped like what is coming.

Empty

No companies yet

Import your book or add the first company, and it will show up here with its growth signals.

Loadingnever the word “Loading…”
Attachments & keyboard
q3-pipeline.csv24 KBlease-draft.pdfToo large
⌘K search openEsc closeJ K move
The command menu is wired to ⌘K already — add your destinations in src/components/command-menu.tsx
Also here
lucide-react for every icon — one family, never mixed