Foundations
Tokens, color, radius, type, spacing, motion and icons — the layer every console app shares.
Everything on this page is plain CSS custom properties. That is deliberate: the
token file is the one artifact every console app shares regardless of stack.
Import it, and a Vue app and a React app resolve the same --primary.
The Tailwind class names quoted throughout are the reference implementations'
shorthand for these tokens. If you are not using Tailwind, read
text-muted-foreground as color: var(--muted-foreground) and
ring-1 ring-foreground/10 as box-shadow: 0 0 0 1px color-mix(in oklch, var(--foreground), transparent 90%).
Source of truth
The canonical token file is apps/console/src/styles.css in the
riftix/console repository. Apps vendor a copy and keep it in sync by hand —
the theme is not yet published as a package.
Mark your copy so the next reader knows it is not yours to evolve:
/*
* Riftix Console theme, ported from riftix/console
* apps/console/src/styles.css (the theme package is not published, so the
* tokens are vendored — keep them in sync with the console). The host pushes
* light/dark via the bridge; we mirror it by toggling the `dark` class on
* <html>.
*/Rule. Divergence from the token file is a bug, not a customization. If your app needs a value the tokens do not have, the change belongs in the console's token file so every app gets it.
Color
The palette is OKLCH throughout, every hue locked at roughly 290 (violet),
derived from the Riftix brand color #0A051C.
/* Palette derived from the Riftix brand color #0A051C = oklch(0.1418 0.0493 290.23). */
:root {
/* --background is exactly #F6F6F6; --primary is exactly #0A051C. */
--background: oklch(0.9702 0 0);
--navbar: oklch(0.9702 0 0);
--foreground: oklch(0.1418 0.0493 290.23);
--card: oklch(1 0 0);
--card-foreground: oklch(0.1418 0.0493 290.23);
--primary: oklch(0.1418 0.0493 290.23);
--primary-foreground: oklch(0.9702 0 0);
--secondary: oklch(0.967 0.005 290);
--secondary-foreground: oklch(0.21 0.045 290);
--muted: oklch(0.96 0.008 290);
--muted-foreground: oklch(0.542 0.045 290);
--accent: oklch(0.96 0.008 290);
--accent-foreground: oklch(0.212 0.045 290);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0.012 290);
--input: oklch(0.922 0.012 290);
--ring: oklch(0.711 0.03 290);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.1418 0.0493 290.23);
--radius: 0;
}
.dark {
/* --background is #050213, a darker cut of the brand #0A051C; --primary is exactly #F6F6F6. */
--background: oklch(0.11 0.045 290.23);
/* --navbar is exactly the brand #0A051C. */
--navbar: oklch(0.1418 0.0493 290.23);
--foreground: oklch(0.985 0 0);
--card: oklch(0.13 0.047 290);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.13 0.047 290);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.9702 0 0);
--primary-foreground: oklch(0.1418 0.0493 290.23);
--secondary: oklch(0.274 0.04 290);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.263 0.05 290);
--muted-foreground: oklch(0.711 0.035 290);
--accent: oklch(0.263 0.05 290);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.542 0.04 290);
}Read the full file — including the --sidebar-* and --chart-* ramps — from
the console repo. Three things about it are worth stating explicitly, because
they are not what a generic shadcn theme would do:
--primaryis ink, not an accent. In light mode it is the same value as--foreground. There is no brand hue to sprinkle on links and buttons; emphasis is contrast.--navbaris its own token. It equals--backgroundin light and diverges in dark. The dark palette is not an inversion of the light one.- The chart ramp is monochromatic — one hue, descending lightness. It is not a categorical palette. If you need to distinguish series, distinguish them by lightness, pattern or direct labelling.
Status colors: there are none
This is the most surprising decision in the system, so it gets the loudest rationale.
There is no success, warning or info token. --destructive is the only
non-neutral color in the palette.
State is expressed through weight, not hue. A console is dense with status —
every row of every table has one — and a palette of greens and ambers turns a
list into confetti. Neutral weight reads instantly at text-xs and survives
both themes without a second set of tints.
The four badge variants carry the whole vocabulary:
| Variant | Means | Looks like |
|---|---|---|
default | active, healthy, in progress | solid --primary fill, highest contrast |
secondary | pending, not yet, paused | low-contrast fill |
outline | terminal, inert, archived | no fill, hairline only |
destructive | the only bad state | tinted --destructive, never solid |
Rule. Each app defines exactly one status-badge mapping covering its whole domain vocabulary, in one file, so the same kind of news looks the same on every page:
// One badge for every lifecycle in the domain — project status, queue state,
// entry state — so "live" and "open" read as the same kind of good news
// everywhere.
const VARIANTS = {
archived: "outline",
booked: "secondary",
called: "default",
canceled: "outline",
closed: "outline",
draft: "secondary",
live: "default",
no_show: "destructive",
open: "default",
paused: "secondary",
reminded: "default",
removed: "outline",
served: "outline",
waiting: "secondary",
};Success, warning and info do exist — as toast variants, which the host renders. That is the right place for them: a toast is transient and singular, so color carries meaning there without competing with anything.
Destructive is tinted, never solid
A solid red button is the loudest object on a page, and destructive actions are usually the least important thing on it. Tint the surface and color the text:
/* destructive button */
background: color-mix(in oklch, var(--destructive), transparent 90%);
color: var(--destructive);
/* hover: 80% transparent. dark mode: 80% / 70%. */Alerts go further and do not tint the background at all — they sit on --card
and color only the text and icon.
Raw palette colors
Rule. No hardcoded hex values and no raw palette colors (text-zinc-600,
bg-white) in application code.
There is one sanctioned exception: rendering a preview of a surface that is not ours. A tenant-branded guest page is always light and uses the tenant's own colors, so a live preview of it inside the console legitimately steps outside the token set. Keep such previews visually contained — a bordered box that reads as a screenshot, not as part of the page.
Tenant-supplied colors are data. Validate them (/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/)
and apply them via inline style, never by generating class names.
Radius: zero
--radius: 0;The whole radius ramp is derived from that one value:
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
/* … through --radius-4xl */So every rounded utility computes to 0. This is intentional, and it is half of
what makes the product look like itself. Do not fight it with a literal
border-radius or rounded-lg. The only rounding in the reference apps is on
avatars and dots, which are fully circular.
Elevation: rings, not shadows
Rule. A surface is delimited by a 1px ring at 10% foreground. Shadows are reserved for things that genuinely float above the page.
box-shadow: 0 0 0 1px color-mix(in oklch, var(--foreground), transparent 90%);
/* Tailwind: ring-1 ring-foreground/10 */A translucent foreground ring adapts to both themes with no dark: variant,
which a --border value cannot do as gracefully at these contrasts. Shadows are
for popovers, sheets, dropdowns and toasts — anything with a backdrop behind it.
Cards, tables and panels get a ring.
Hairline grids
To divide a grid without drawing borders on each cell, bleed the container's background through a 1px gap:
<dl class="bg-foreground/10 ring-foreground/10 grid grid-cols-2 gap-px ring-1 md:grid-cols-4">
<div class="bg-card flex flex-col gap-1 p-3">…</div>
<div class="bg-card flex flex-col gap-1 p-3">…</div>
</dl>Every cell is a --card rectangle; the 1px gaps show the tinted container
underneath. No double borders at the seams, and it stays correct when the
column count changes at a breakpoint.
Type
One family: Space Grotesk Variable, self-hosted.
--font-sans: "Space Grotesk Variable", sans-serif;
--font-heading: var(--font-sans);--font-heading is an alias, not a second face. It exists as a semantic hook:
mark titles with it so a display face could be introduced later in one line.
Headings are distinguished by weight and tracking, not family.
The scale
This is a dense console UI. The working scale is four steps:
| Role | Recipe |
|---|---|
| Body — table cells, labels, buttons, badges | text-xs |
| Prose — descriptions, help text, card bodies | text-xs/relaxed |
| Section titles, card titles | font-heading text-sm font-medium |
Page title (h1) | font-heading text-xl font-semibold tracking-tight text-balance |
| Eyebrow above a title | text-muted-foreground text-xs font-medium tracking-wide uppercase |
text-xs/relaxed is the prose variant. The relaxed leading is the signal
that something is a paragraph rather than a label — it is doing the work that a
larger size would do in a roomier product. Use it for anything longer than a few
words.
Two more rules:
text-balanceon headings,text-prettyon descriptions. Both are free and both prevent the single-word last line that makes a dense page look broken.tabular-numson every number that can change — counts, clocks, countdowns, currency, IDs. A digit that shifts width while polling is the most distracting thing on a live page.
uppercase is only ever the eyebrow pattern, and always paired with
tracking-wide at text-xs. Never uppercase a sentence.
Spacing rhythm
Rule. Space with gap on flex and grid containers. Do not use margin
utilities for rhythm (space-y-*, mt-*). The reference app has zero
occurrences of space-y-*, and that is enforceable in review.
Gap owns the space between siblings, which means a component never carries spacing that belongs to its parent's layout, and reordering or conditionally rendering a child cannot leave a stray margin behind.
| Level | Value |
|---|---|
| Page container | p-6 (ramp to p-4 md:p-6), gap-6 between sections |
| Section | gap-3; title stack gap-0.5 |
| Card padding and internal gap | one variable, 1rem (0.75rem when compact) |
| Grid of cards | gap-4 |
| Field group in a form | gap-5 |
| Inline row of controls | gap-2 |
| Icon and its label | gap-1.5 (gap-1 at small sizes) |
| Table cell | p-2 |
Drive card padding from a single local variable so a size change retunes header, content and footer together:
.card { --card-spacing: 1rem; gap: var(--card-spacing); padding-block: var(--card-spacing); }
.card[data-size="sm"] { --card-spacing: 0.75rem; }Control heights
| Size | Height |
|---|---|
xs | 1.5rem (24px) |
sm | 1.75rem (28px) |
| default | 2rem (32px) |
lg | 2.25rem (36px) |
| Top bars, table headers | 2.5rem (40px) |
The 32px default is tighter than stock shadcn's 36px. Match it — a console page is mostly controls, and the difference compounds.
Motion
Motion is functional here: it explains a state change, it does not decorate.
| Duration | Use |
|---|---|
| 100ms | overlays, backdrops, popover open/close |
| 150ms | opacity crossfades |
| 200ms | geometry — sidebar width, sheet slide |
| 500ms | toast stack settling |
Declare one easing token and use it:
--ease-out-strong: cubic-bezier(0.23, 1, 0.32, 1);The spinner is deliberately faster than the browser default:
/* Faster than the 1s default: a quicker spinner makes identical waits feel shorter. */
--animate-spin: spin 0.6s linear infinite;Rule. Ship a reduced-motion baseline. Per-component motion-safe: guards
get forgotten; a base-layer rule cannot.
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}Keep purely informational motion — a live-status pulse — behind an explicit
motion-safe: so the base rule does not silently remove the only signal that
data is fresh. Pair it with a text affordance either way.
Skeletons pulse; there is no shimmer keyframe in this system.
Icons
lucide, and only lucide. Default stroke width — do not set strokeWidth.
- Size with a single square utility (
size-4), never awidth+heightpair. - Let the component size its icons. Buttons, menu items and sidebar entries declare the icon size for their descendants, so call sites write a bare icon element. A hand-written size on an icon inside a button is a smell — it means either the button is wrong or you are fighting it.
- Standard sizes:
size-4at default and large,size-3.5atsm,size-3atxs. - Mark icon position with a data attribute rather than a wrapper element, so the container can tune its own padding:
<button class="…"><svg data-icon="inline-start">…</svg>New project</button>Icons that sit beside a visible label are decorative — hide them from assistive technology. Icon-only controls need an accessible name.