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.
Color System
Section titled “Color System”OKLCH Color Space
Section titled “OKLCH Color Space”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.
Light Theme
Section titled “Light Theme”Neutrals are tinted toward the anchor hue rather than being pure gray. This creates a warmer, more cohesive palette.
Dark Theme
Section titled “Dark Theme”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.
Chart Tokens
Section titled “Chart Tokens”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) |
Color Presets
Section titled “Color Presets”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.
Typography
Section titled “Typography”Font Family
Section titled “Font Family”| Variable | Font | Usage |
|---|---|---|
--font-sans |
Geist Variable | Body text, labels, UI elements |
--font-heading |
Instrument Serif | Page and section headings, the sidebar wordmark |
Type Scale
Section titled “Type Scale”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.
Font Weight
Section titled “Font Weight”| 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.
Numeric Cells
Section titled “Numeric Cells”tabular-nums is applied to numeric cells (e.g., SnapshotGridView) for proper digit alignment in financial data.
Tailwind CSS v4
Section titled “Tailwind CSS v4”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.
Custom Utilities
Section titled “Custom Utilities”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')} />Component Styling
Section titled “Component Styling”shadcn/ui
Section titled “shadcn/ui”- Style: base-nova (configured in
components.json) - Location:
src/components/ui/ - Tailwind version: v4 (CSS-first configuration)
CSS Variables for Theming
Section titled “CSS Variables for Theming”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 |
Skins and Modes
Section titled “Skins and Modes”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).
Mode Resolution
Section titled “Mode Resolution”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-modeon<html>only ever holds one of those two values, never the literal'system'string.- The hook exposes the resolved value separately as
effectiveModefor anything that needs to render based on what’s actually showing (e.g.ModeToggle’s icon).
Persistence
Section titled “Persistence”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).
CSS Structure
Section titled “CSS Structure”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.
Chart and Heatmap Tokens
Section titled “Chart and Heatmap Tokens”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.
Custom Theme
Section titled “Custom Theme”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 Skin or Mode
Section titled “Adding a Skin or Mode”Adding a new skin touches five hardcoded points (nothing is auto-discovered from index.css):
VALID_SKINSinuseSkin.ts- the two
[data-skin="..."][data-mode="..."]blocks inindex.css SKIN_SWATCHESinAppearanceCard.tsxSKIN_ORDERinSkinToggle.tsx- the
settings.skin.<name>i18n key in all five locales
Adding a new mode is a separate axis entirely — it would touch:
VALID_MODESinuseSkin.ts- a new
[data-mode="..."]block per existing skin inindex.css - the mode picker in
AppearanceCard.tsx/ModeToggle.tsx
Semantic Color Utilities
Section titled “Semantic Color Utilities”--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.
Reduced Motion
Section titled “Reduced Motion”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.
Color Scheme
Section titled “Color Scheme”<meta name="color-scheme" content="light dark"> in index.html lets the browser natively style form controls and scrollbars in the correct theme.
Theme Color
Section titled “Theme Color”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).
Accessibility
Section titled “Accessibility”Touch Targets
Section titled “Touch Targets”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.
ARIA Labels
Section titled “ARIA Labels”Icon-only buttons have descriptive aria-label attributes for screen reader accessibility.
Focus States
Section titled “Focus States”focus-visible states are applied to all interactive elements for keyboard navigation visibility.
Contrast
Section titled “Contrast”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.