Riftix Console Bridge

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:

  • --primary is 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.
  • --navbar is its own token. It equals --background in 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:

VariantMeansLooks like
defaultactive, healthy, in progresssolid --primary fill, highest contrast
secondarypending, not yet, pausedlow-contrast fill
outlineterminal, inert, archivedno fill, hairline only
destructivethe only bad statetinted --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:

RoleRecipe
Body — table cells, labels, buttons, badgestext-xs
Prose — descriptions, help text, card bodiestext-xs/relaxed
Section titles, card titlesfont-heading text-sm font-medium
Page title (h1)font-heading text-xl font-semibold tracking-tight text-balance
Eyebrow above a titletext-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-balance on headings, text-pretty on descriptions. Both are free and both prevent the single-word last line that makes a dense page look broken.
  • tabular-nums on 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.

LevelValue
Page containerp-6 (ramp to p-4 md:p-6), gap-6 between sections
Sectiongap-3; title stack gap-0.5
Card padding and internal gapone variable, 1rem (0.75rem when compact)
Grid of cardsgap-4
Field group in a formgap-5
Inline row of controlsgap-2
Icon and its labelgap-1.5 (gap-1 at small sizes)
Table cellp-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

SizeHeight
xs1.5rem (24px)
sm1.75rem (28px)
default2rem (32px)
lg2.25rem (36px)
Top bars, table headers2.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.

DurationUse
100msoverlays, backdrops, popover open/close
150msopacity crossfades
200msgeometry — sidebar width, sheet slide
500mstoast 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 a width + height pair.
  • 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-4 at default and large, size-3.5 at sm, size-3 at xs.
  • 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.

On this page