zudo-tauri-wisdom

Type to search...

to open search from anywhere

Color Theme System

CreatedApr 3, 2026Takeshi Takatsudo

A two-tier color architecture from 16-color palettes to semantic CSS custom properties

Color Theme System

The color theme system uses a two-tier architecture: Tier 1 defines a 16-color palette (like a terminal color scheme), and Tier 2 derives semantic tokens (background, accent, danger, hover states) from palette indices. Both tiers are exposed as CSS custom properties for use in Tailwind CSS utilities and component styles.

Tier 1: Color Palette

Each color scheme defines a 16-color palette, matching the standard terminal color convention (8 normal + 8 bright colors):

export interface ColorScheme {
  name: string;
  label: string;
  isDark: boolean;
  background: ColorRef;    // Palette index or direct color
  foreground: ColorRef;
  cursor: ColorRef;
  selectionBg: ColorRef;
  selectionFg: ColorRef;
  palette: [
    string, string, string, string, // 0-3: black, red, green, yellow
    string, string, string, string, // 4-7: blue, magenta, cyan, white
    string, string, string, string, // 8-11: bright variants
    string, string, string, string, // 12-15: bright variants
  ];
  semantic?: { /* Tier 2 overrides */ };
}

The ColorRef type allows referencing colors by palette index (number) or by direct hex value (string):

export type ColorRef = number | string;

This flexibility lets schemes reference their own palette entries or use custom colors not in the palette:

// "default-dark" references palette indices
{
  background: 0,    // palette[0] = "#1C1C1C"
  foreground: 7,    // palette[7] = "#A0A0A0"
  cursor: 6,        // palette[6] = "#90A1B9"
}

// "catppuccin-mocha" uses direct hex values
{
  background: "#1e1e2e",
  foreground: "#cdd6f4",
  cursor: "#f5e0dc",
}

Built-in Schemes

The package ships with 10 color schemes:

SchemeTypeDescription
default-darkDarkCustom warm dark theme with semantic overrides
default-lightLightCustom light theme with semantic overrides
catppuccin-mochaDarkCatppuccin pastel palette (mocha variant)
catppuccin-latteLightCatppuccin pastel palette (latte variant)
tokyo-nightDarkTokyo Night color scheme
draculaDarkDracula classic theme
nordDarkArctic, north-bluish palette
solarized-darkDarkEthan Schoonover’s Solarized (dark)
one-darkDarkAtom One Dark theme
gruvbox-darkDarkRetro groove colors

Tier 2: Semantic Tokens

Raw palette colors are not meaningful for UI components. “Use palette[3]” does not convey intent. Semantic tokens bridge this gap:

export interface ColorSettings {
  bgPrimary: string;
  bgSecondary: string;
  bgSurface: string;
  textPrimary: string;
  textSecondary: string;
  accent: string;
  accentSubtle: string;
  border: string;
  danger: string;
  dangerStrong: string;
  onAccent: string;       // Text color on accent background
  hoverOverlay: string;
  hoverBg: string;
  hoverFg: string;
  selection: string;
  cursor: string;
}

Deriving Semantic Colors

The schemaToColors() function generates semantic tokens from a scheme’s palette:

export function schemaToColors(scheme: ColorScheme): ColorSettings {
  const p = scheme.palette;
  const bg = resolveColor(scheme.background, p, p[0]);
  const fg = resolveColor(scheme.foreground, p, p[15]);
  const sem = resolveSemanticColors(scheme);

  return {
    bgPrimary: bg,
    bgSecondary: sem.bgSecondary,
    bgSurface: sem.surface,
    textPrimary: fg,
    textSecondary: sem.textSecondary,
    accent: sem.accent,
    accentSubtle: sem.accentSubtle,
    border: sem.border,
    danger: sem.danger,
    dangerStrong: sem.dangerStrong,
    onAccent: sem.onAccent,
    hoverOverlay: sem.hoverOverlay,
    hoverBg: sem.hoverBg,
    hoverFg: sem.hoverFg,
    selection: resolveColor(scheme.selectionBg, p, p[8]),
    cursor: resolveColor(scheme.cursor, p, p[6]),
  };
}

Semantic Overrides

Schemes can override the default palette-to-semantic mapping via the semantic object. This is essential because different palettes need different mappings to look correct:

// "default-dark" overrides several semantic tokens
semantic: {
  bgSecondary: "bg",    // Use background color (special ref)
  surface: 0,           // Palette index 0
  accent: 3,            // Palette index 3 (yellow)
  accentSubtle: 12,     // Palette index 12
  border: 8,            // Palette index 8
  onAccent: "bg",       // Text on accent = background color
  hoverOverlay: 13,
  hoverBg: 13,
  hoverFg: 11,
}

The special string values "bg" and "fg" reference the scheme’s resolved background and foreground colors, enabling self-referential definitions.

Default Fallbacks

When a scheme does not provide semantic overrides, defaults are used:

export const SEMANTIC_DEFAULTS: Record<string, number> = {
  bgSecondary: 0,
  surface: 0,
  textSecondary: 15,
  accent: 3,
  accentSubtle: 3,
  border: 0,
  danger: 1,
  dangerStrong: 9,
  onAccent: 9,
  hoverOverlay: 15,
  hoverBg: 8,
  hoverFg: 7,
};

For schemes without overrides (e.g., Catppuccin, Tokyo Night), the system also generates computed fallbacks using color mixing:

accentSubtle: colorMixAlpha(accentColor, 0.1, bg),  // 10% accent over background
hoverOverlay: colorMixAlpha(fg, 0.03, bg),           // 3% foreground over background
hoverBg: colorMixAlpha(fg, 0.1, bg),                 // 10% foreground over background

CSS Custom Properties

Tier 1: Palette Variables

Applied to :root by the applyTheme() function:

export function applyTheme(themeName: string): ColorScheme {
  const scheme = getSchemeByName(themeName);
  const root = document.documentElement;
  const p = scheme.palette;

  root.style.setProperty("--palette-bg", resolveColor(scheme.background, p, p[0]));
  root.style.setProperty("--palette-fg", resolveColor(scheme.foreground, p, p[15]));
  root.style.setProperty("--palette-cursor", resolveColor(scheme.cursor, p, p[6]));
  root.style.setProperty("--palette-selection", resolveColor(scheme.selectionBg, p, p[8]));
  for (let i = 0; i <= 15; i++) {
    root.style.setProperty(`--palette-${i}`, p[i]);
  }
  return scheme;
}

This gives CSS access to --palette-0 through --palette-15, --palette-bg, --palette-fg, etc.

Tier 2: Semantic Variables

Applied by the applyColors() function:

const colorKeyToCssVar: Record<keyof ColorSettings, string> = {
  bgPrimary: "--theme-bg-primary",
  bgSecondary: "--theme-bg-secondary",
  bgSurface: "--theme-bg-surface",
  textPrimary: "--theme-text-primary",
  textSecondary: "--theme-text-secondary",
  accent: "--theme-accent",
  accentSubtle: "--theme-accent-subtle",
  border: "--theme-border",
  danger: "--theme-danger",
  dangerStrong: "--theme-danger-strong",
  onAccent: "--theme-on-accent",
  hoverOverlay: "--theme-hover-overlay",
  hoverBg: "--theme-hover-bg",
  hoverFg: "--theme-hover-fg",
  selection: "--theme-selection",
  cursor: "--theme-cursor",
};

Components use semantic variables rather than palette indices:

/* Good: semantic intent is clear */
.sidebar { background: var(--theme-bg-secondary); }
.button { background: var(--theme-accent); color: var(--theme-on-accent); }
.error { color: var(--theme-danger); }

/* Avoid: palette indices have no semantic meaning */
.sidebar { background: var(--palette-0); }

xterm Integration

The terminal needs its own theme object (not CSS variables). The toXtermTheme() function converts a ColorScheme to xterm’s ITheme format:

export function toXtermTheme(scheme: ColorScheme) {
  const p = scheme.palette;
  return {
    background: resolveColor(scheme.background, p, p[0]),
    foreground: resolveColor(scheme.foreground, p, p[15]),
    cursor: resolveColor(scheme.cursor, p, p[6]),
    black: p[0],
    red: p[1],
    green: p[2],
    yellow: p[3],
    blue: p[4],
    // ...all 16 ANSI colors
  };
}

A custom DOM event (zudotext:scheme-changed) notifies the terminal component when the scheme changes, so it can re-apply the xterm theme.

Color Utility Functions

The package includes utilities for color manipulation:

// Mix two hex colors at a given ratio
colorMix("#ff0000", "#0000ff", 0.5) // -> purple

// Blend foreground over background with alpha
colorMixAlpha(fg, 0.1, bg) // -> 10% fg over bg

// HSL conversion for programmatic color manipulation
hexToHsl("#1C1C1C") // -> { h: 0, s: 0, l: 11 }

// Determine readable text color for a background
contrastTextColor("#1C1C1C") // -> "#ffffff"

Key Takeaway

The two-tier system separates what colors are available (palette) from what they mean (semantic tokens). Schemes only need to define 16 palette colors and optionally override semantic mappings. The system handles the rest: generating hover states, computing subtle variants, setting CSS variables, and converting to format-specific theme objects. This keeps themes consistent across the editor, terminal, UI components, and Tailwind utilities.