Design & style guide
Tokens, primitives, and the live Style Lab.
docs/DESIGN.md
Tokens
Everything reads from CSS custom properties in apps/web/src/app/globals.css. Change a token and
every component, backdrop and animation follows — including the mermaid diagrams on the dashboard,
which resolve the tokens to concrete values at render time.
| Group | Tokens |
|---|---|
| Surface | --background --card --popover --muted |
| Text | --foreground --card-foreground --muted-foreground |
| Brand | --primary --secondary --accent --ring |
| State | --success --warning --destructive |
| Shape | --radius (sm/md/lg/xl derived) |
| Type | --font-sans / --font-display (Inter) · --font-mono (JetBrains Mono) |
| Signature | --highlight --highlight-foreground --ink --ink-2 |
The palette is Verjson's
Taken from verjson.com and verjson.ai, not invented here. This is an internal Verjson tool; one that looked nothing like the company running it would be its own kind of unfinished.
| Token | Value | Role |
|---|---|---|
--background | #faf8f3 | warm cream ground |
--foreground / --ink | #0a0a0a | near-black ink |
--primary | #1700b8 | electric indigo — action, links, the _ in a label |
--highlight | #e8ff47 | acid lime — the signature |
--accent | #16794d | deep green — approved / success |
--border | #e2ddce | warm hairline |
Lime is never text. At 88% luminance it fails contrast on cream at any size, so it is not
--accent — it lives in --highlight and appears only as a fill with --highlight-foreground
(near-black) on top, or as an accent on an ink band. This is how the brand itself uses it: the
verJSON logo is indigo on light and inverts to lime on dark.
Devices
Three things carry the identity. They are cheap, and they are most of why the UI does not read as a component library with the colours changed:
| Class | What it does |
|---|---|
.label | The _section kicker — lowercase, monospaced, underscore-prefixed. The underscore takes --label-accent, which .band flips to lime so it stays visible on ink. |
.mark | The lime highlighter: a skewed block behind a word, drawn as a pseudo-element so it never affects layout. |
.band | A full-bleed ink section with photography under a scrim. These exist to break rhythm — consecutive card grids on one background is what made the landing page read as generated. |
Headlines are Inter at font-extrabold, leading-[1.05], tracking-[-0.03em]. Ids, timestamps,
spend figures, counts and paths are all monospaced — that is what makes a data-dense page
scannable, and it is not decoration.
Photography
Lives in apps/web/public/img/, with provenance in
public/img/CREDITS.json. Unsplash, under the Unsplash
License, downloaded rather than hotlinked — the page then has no runtime dependency on a
third-party CDN, and no viewer's IP is disclosed to one.
Images are used where the product genuinely has images — the media library (F-033), the ad asset
pipeline (F-055), the composer's attached creative — plus two atmospheric bands. Decorative
photography is alt="" and aria-hidden; nothing a reader must know is carried by a picture.
The Style Lab
/style-guide retheme the whole app live. In development, Save writes the chosen values back
into globals.css between marker comments — so a theme decision becomes a commit rather than a
screenshot. It 404s in production; it is an authoring tool, not a runtime feature.
Primitives
apps/web/src/components/ui/ — button, card, input, badge, table, modal, feedback. Add new ones
with npx shadcn@latest add <name> and re-point them at the tokens above.
Animation
Ambient motion (the aurora backdrop, the gradient shimmer) keeps playing under
prefers-reduced-motion, because it conveys no information and cannot trap focus. Functional
motion — transitions, scroll reveals, count-ups — collapses to near-zero. Mark ambient elements
.ambient.
Rules
| Never a raw colour | text-muted-foreground, not text-slate-500. A hard-coded colour survives a retheme and then looks wrong. |
| Wide content scrolls in its own box | The page body never scrolls sideways. |
| Scrollbars are hidden, not disabled | Custom scroll containers use .no-scrollbar; they still scroll by wheel, drag, keyboard and touch. |
| Empty states say what to do | "No projects yet" plus the button, not a blank panel. |
| Disabled controls say why | title explaining the missing permission or precondition. |
| Icons are decorative | aria-hidden, with a real accessible name on the control. |