Skip to content

Design System

The Patrimoine design system is built on OKLCH color space, Tailwind CSS v4, and shadcn/ui (base-nova style). It prioritizes perceptual color uniformity, accessibility, and dark mode parity.


All colors are defined in the OKLCH color space in src/index.css. OKLCH provides perceptual uniformity — equal numeric steps produce visually equal color differences.

Neutrals are tinted toward the anchor hue rather than being pure gray. This creates a warmer, more cohesive palette.

The dark theme uses the same OKLCH structure with adjusted lightness values. Chart tokens have higher lightness in dark mode for better contrast on dark backgrounds.

Eight chart color tokens are defined as OKLCH values:

Token Purpose
--chart-1 Primary chart color (blue)
--chart-2 Secondary (red)
--chart-3 Tertiary (green)
--chart-4 Quaternary (yellow)
--chart-5 Quinary (purple)
--chart-6 Senary (cyan)
--chart-7 Septenary (pink)
--chart-8 Octonary (orange)

24 OKLCH CSS variables (--color-preset-1 through --color-preset-24) are available for institution colors, organized by hue family. These are used by the ColorPicker component for institution color selection.


Variable Font Usage
--font-sans Geist Variable Body text, labels, UI elements
--font-heading Instrument Serif Page and section headings, the sidebar wordmark

Text is sized with the Ledger tokens, never with Tailwind’s raw text-xs, text-sm or text-base, or a text-[Npx] value. They are all in rem, so they follow the user’s text size. A source test fails when a file gains a raw size, so the count of each file can only go down.

Token Size at 100% Role
text-caption 12.4 px Hints, badges, table sub-lines
text-label 12.9 px Form labels, table headers
text-body 14 px Body text, table cells, buttons
text-title 15.5 px Section, card and dialog titles
text-kpi 19.6 px Figures in cards, empty-state titles, page title on a phone
text-display 22.1 px Page title from 640 px, large headline figures

The caption and label tokens never go under 12 px, even at the 87.5% text size. Inputs keep text-base below the md breakpoint so that a phone does not zoom in on focus.

Weight Class Usage
Regular (400) font-normal Body text
Medium (500) font-medium Labels, table headers, emphasis

Instrument Serif (font-heading) has a single weight, so titles and figures set in it are regular. Never combine it with font-semibold or font-bold; a test refuses it.

tabular-nums is applied to numeric cells (e.g., SnapshotGridView) for proper digit alignment in financial data.


Tailwind v4 uses the @tailwindcss/vite plugin. No tailwind.config.js is needed — configuration is done in CSS via the @theme directive in src/index.css.

The cn() helper from src/lib/utils.ts merges Tailwind classes conditionally using clsx and tailwind-merge:

import { cn } from '@/lib/utils'
<div className={cn('base-class', condition && 'conditional-class')} />

  • Style: base-nova (configured in components.json)
  • Location: src/components/ui/
  • Tailwind version: v4 (CSS-first configuration)

Theming is driven by CSS variables defined in :root (mode-independent tokens) and in the ten :root[data-skin][data-mode] blocks (see Skins and Modes below):

Variable Purpose
--background Page background
--foreground Default text color
--primary Primary action color
--secondary Secondary action color
--destructive Destructive actions (delete, error)
--muted Muted text and backgrounds
--accent Accent highlights
--popover Popover and dropdown background
--card Card background

Color identity is split into two independent axes:

  • skin (hue and character — Fintech/Azur/Noir/Swiss/Aurora)
  • mode (lightness — light/dark/system)

Every skin is selectable in either resolved mode; the two axes are picked separately, managed by the useSkin hook (frontend/src/hooks/useSkin.ts).

system follows the OS/browser’s prefers-color-scheme preference live (via window.matchMedia, including a change listener while system is selected) and is resolved to light or dark before ever reaching the DOM.

  • data-mode on <html> only ever holds one of those two values, never the literal 'system' string.
  • The hook exposes the resolved value separately as effectiveMode for anything that needs to render based on what’s actually showing (e.g. ModeToggle’s icon).

Each axis’s raw preference is persisted to its own localStorage key ('skin'/'mode', 'mode' storing the literal 'system' when chosen) and synced with the user’s profile (user.theme/user.theme_mode, PATCH /api/v1/auth/me).

index.css reflects this split:

  • [data-skin="X"] alone holds only mode-invariant character tokens (--radius, shape-level decisions).
  • The full surface token set (background/card/popover/border/muted/secondary/primary/accent/chart/heatmap/sidebar/shadow) lives in [data-skin="X"][data-mode="light"] / [data-skin="X"][data-mode="dark"] blocks — 10 in total.

A skin’s own structural rules (Noir’s glass-tinted cards, Aurora’s frosted-glass/gradient-blob background, Swiss/Fintech’s flat shadows) stay skin-scoped only where they’re genuinely token-driven already; anything that previously hardcoded a raw color tuned for one mode was re-derived per mode.

Skin Character Radius Shadow shape
Fintech Teal accent, dense/flat, hairline borders 0.3rem none (flat)
Azur Blue accent, softer and more rounded than Fintech 0.5rem soft resting shadow
Noir Warm gold accent, glass-tinted cards 0.45rem glass lift on hover
Swiss Navy primary + brick accent, near-flat 0.234rem none (flat)
Aurora Violet/pink accent, frosted-glass translucent surfaces 0.55rem colored glow
Ledger (default) Navy accent, dense and flat, square corners, hairline borders 0.25rem none (flat)

Noir defaults to dark mode and the other four default to light mode for a first-time visitor with nothing stored yet (matching what each skin looked like before mode became independent) — see useSkin.ts’s default-mode table.

Each skin×mode combination defines its own --chart-1 through --chart-8 walk and a matching set of --heatmap-* contrast-safe tints (verified by frontend/src/lib/__tests__/heatmap-contrast.test.ts).

The --color-preset-* tokens (institution/account colors) remain constant across every skin/mode — they are user-chosen data colors, not theme chrome.

A user’s custom theme (saved in Settings, stored as user.custom_css) is applied by the useCustomTheme hook (frontend/src/hooks/useCustomTheme.ts) as inline custom properties on the root element, one style.setProperty call per --name: value; line. Inline declarations outrank every stylesheet rule, so they win over the :root[data-skin][data-mode] blocks in both light and dark mode without any selector or !important, and the tokens derived from them (such as the --radius-* scale) follow.

Only the colour tokens every skin defines and --radius may be overridden. Shadows, the --heatmap-* ramp and the --color-preset-* colours are excluded: their values are free-form, their contrast is pinned by tests, or they are user data. The server validates the text and stores its canonical form (see custom_css_service in Backend Architecture), and the hook only applies lines in that exact form. The user-facing description, including the ?safe-theme=1 escape hatch, is in Settings & Profile.

Adding a new skin touches five hardcoded points (nothing is auto-discovered from index.css):

  • VALID_SKINS in useSkin.ts
  • the two [data-skin="..."][data-mode="..."] blocks in index.css
  • SKIN_SWATCHES in AppearanceCard.tsx
  • SKIN_ORDER in SkinToggle.tsx
  • the settings.skin.<name> i18n key in all five locales

Adding a new mode is a separate axis entirely — it would touch:

  • VALID_MODES in useSkin.ts
  • a new [data-mode="..."] block per existing skin in index.css
  • the mode picker in AppearanceCard.tsx/ModeToggle.tsx

--color-positive and --color-negative are registered in @theme inline, enabling Tailwind utilities text-positive, text-negative, bg-positive, bg-negative, border-positive, etc. These replaced 40+ arbitrary text-[var(--color-positive)] usages across the codebase.

Field outlines use their own token, --input-border (utility border-input-border), separate from --input, which stays a hairline and still drives the control fills and the switch track. The alert badge count uses --destructive-foreground (text-destructive-foreground) on --destructive. Both are colour tokens every skin defines, so a custom theme may override them.

A @media (prefers-reduced-motion: reduce) guard in index.css disables animations and transitions for users with vestibular disorders. All animate-* and transition-* utilities are affected.

<meta name="color-scheme" content="light dark"> in index.html lets the browser natively style form controls and scrollbars in the correct theme.

A single <meta name="theme-color" content="#0e0f12"> in index.html provides the browser chrome color. It is the same value as the manifest’s theme_color and background_color (see Progressive Web App).


Interactive elements meet the 44px minimum touch target size (WCAG 2.5.5) on mobile. This is enforced via min-h-[44px] min-w-[44px] on icon buttons and small interactive elements.

Icon-only buttons have descriptive aria-label attributes for screen reader accessibility.

focus-visible states are applied to all interactive elements for keyboard navigation visibility.

Every skin and mode meets two targets. Field outlines (--input-border) reach 3:1 against the background, cards and popovers. Gain, loss and alert badge text (--color-positive, --color-negative, and --destructive-foreground on --destructive) reach 4.5:1, including on a hovered row and on the tinted dashboard pill. frontend/src/lib/__tests__/skin-contrast.test.ts reads the ten blocks of index.css, checks both targets and prints the measured ratios, so a palette edit that drops a ratio under its target fails the frontend tests. axe does not check non-text contrast, so this test is the guard for field outlines.