zudo-text

検索したい単語を入力

いつでも検索バーを開ける

@takazudo/color-themes

A ramp-based color system. Each scheme defines a small set of ramps (ordered color stops) and a map of semantic tokens that each reference a ramp stop (or a literal color). Resolving a scheme walks the map through the ramps to produce concrete colors, which are then applied as CSS custom properties.

Main Exports

// Types
import type {
  RampRef,
  Ramps,
  SemanticKey,
  ModeMap,
  ModeMaps,
  SchemeMode,
  ResolvedMode,
  ColorScheme,
  ColorStructure,
  ColorSettings,
  ResolvedSemanticColors,
  StateRole,
} from "@takazudo/color-themes";

// Schemes
import {
  colorSchemes,
  defaultThemeName,
  DEFAULT_SCHEME_MODE,
  SCHEME_MODES,
  getSchemeByName,
  resolveMode,
  seedColorStructure,
  SEMANTIC_RAMP_DEFAULTS,
} from "@takazudo/color-themes";

// Resolving ramp refs → concrete colors
import { resolveRampRef, resolveScheme, resolveSemanticColors } from "@takazudo/color-themes";

// Applying to CSS custom properties
import { applyRamps, applyColors, applyTheme, dispatchSchemeChanged, SCHEME_CHANGED_EVENT } from "@takazudo/color-themes";

// WCAG contrast matrix (used by the Colors tab and by tests)
import { PAIR_MATRIX, getAllSchemeModes, evaluateScheme } from "@takazudo/color-themes";

// Color conversion / contrast utilities
import { hexToHsl, hslToHex, contrastTextColor, contrastRatio, relativeLuminance } from "@takazudo/color-themes";

Ramp-Based Color Architecture

Colors flow through three stages:

  1. Ramps — a small palette of ordered color stops (Ramps).

  2. Map — semantic tokens that reference a ramp stop, or fall back to a literal color (ModeMap, RampRef).

  3. Resolved colors — concrete color strings, applied to :root as CSS custom properties.

Ramps

interface Ramps {
  base: OKLCH[];   // 5 stops: lightest → darkest neutral
  accent: OKLCH[]; // 3 stops: accent hue
  state: Record<StateRole, OKLCH>; // danger | success | warning | info
}

OKLCH is just string — stops are typically written as oklch(l c h) but any valid CSS color string is accepted (the built-in schemes ship a mix of oklch(...) and #rrggbb).

RampRef

A RampRef points at a ramp stop, or is a literal color:

type StateRole = "danger" | "success" | "warning" | "info";
type RampRef = { base: number } | { accent: number } | { state: StateRole } | OKLCH;

resolveRampRef(ref, ramps) turns a RampRef into a concrete color string:

function resolveRampRef(ref: RampRef, ramps: Ramps): string;

resolveRampRef({ base: 4 }, ramps);      // ramps.base[4]
resolveRampRef({ accent: 1 }, ramps);    // ramps.accent[1]
resolveRampRef({ state: "danger" }, ramps); // ramps.state.danger
resolveRampRef("#ff0000", ramps);        // "#ff0000" (literal colors pass through)

Referencing an out-of-range base/accent index throws a RangeError — ramp refs are validated wherever they can be persisted (see @takazudo/app-defaults).

ModeMap and SemanticKey

interface ModeMap {
  bg: RampRef;
  fg: RampRef;
  cursor: RampRef;
  selectionBg: RampRef;
  semantic: Record<SemanticKey, RampRef>;
}

SemanticKey is a large union covering every semantic UI token: backgrounds/text (bgSecondary, surface, textSecondary), accent (accent, accentSubtle), status (danger, dangerStrong, warning, warningStrong, success, successStrong), interaction states (hoverBg, hoverFg, activeBg, activeFg, focusBorder, …), editor Markdown highlights (editorHeading, editorStrong, editorEmphasis, editorLink, editorQuote, editorInlineCode), preview inline tokens (previewItalicFg, previewEmphasisFg, previewStrongFg, …), mindmap depth colors, mermaid diagram colors, the inbox minimap palette, notify-time chip tokens, and the 9-token syntax-highlight vocabulary (syntaxComment, syntaxString, syntaxNumber, syntaxKeyword, syntaxCallable, syntaxType, syntaxName, syntaxInserted, syntaxDeleted). See the SemanticKey union in packages/color-themes/src/color-themes.ts for the authoritative list.

SEMANTIC_RAMP_DEFAULTS: Record<SemanticKey, RampRef> is the scheme-agnostic default mapping (e.g. accent: { accent: 1 }, editorInlineCode: { state: "success" }). deriveSemanticRampDefaults(baseMap, ramps) derives the color-mixed defaults (hover/active overlays, accentSubtle, notify chip tints, …) that individual schemes then layer per-mode overrides on top of.

ColorScheme, ColorStructure, and the two modes

One scheme owns BOTH appearances (epic #5893 D2):

type SchemeMode = "light" | "dark";

interface ModeMaps {
  light: ModeMap;
  dark: ModeMap;
}

interface ColorScheme {
  name: string;
  label: string;
  ramps: Ramps;
  modes: ModeMaps;
}

/** The persisted shape — `AppSettings["color"]` is exactly this. */
interface ColorStructure {
  ramps: Ramps;
  modes: ModeMaps;
}

/** One mode sliced out of a dual-mode structure. */
interface ResolvedMode {
  mode: SchemeMode;
  isDark: boolean;
  ramps: Ramps;
  map: ModeMap;
}

function resolveMode(structure: ColorStructure, mode: SchemeMode): ResolvedMode;

ColorScheme is a built-in preset (name + label + ramps + both mode maps). ColorStructure is what actually gets persisted to .zudotext.settings.json — a scheme with the preset-only name/label metadata stripped off. Both modes share the scheme's single ramps object; only the ModeMap differs.

There is deliberately no isDark on a scheme — it is meaningless when the scheme carries both appearances. Appearance is a property of the resolved mode, so consumers call resolveMode() first. ColorScheme is structurally a ColorStructure, so a registry entry can be passed straight in.

Built-in Schemes

Exactly one built-in scheme ships with the app; the light/dark split that used to be two registry entries is now its two modes:

NameLabelModes
defaultDefaultlight, dark
const colorSchemes: ColorScheme[];
const defaultThemeName = "default";
const SCHEME_MODES: readonly SchemeMode[]; // ["light", "dark"]
const DEFAULT_SCHEME_MODE: SchemeMode;     // "dark"

function getSchemeByName(name: string): ColorScheme;

getSchemeByName() falls back to the default scheme when name does not match a registered scheme — this is what heals a persisted colorScheme value left over from a scheme that no longer exists (see Settings Doctor).

DEFAULT_SCHEME_MODE is the fallback consumers resolve while the OS-following effective-mode store is still being built (#5901/#5902); it preserves the appearance the old default-dark default implied.

function seedColorStructure(scheme: ColorScheme): ColorStructure;

seedColorStructure() deep-clones a ColorScheme's ramps and BOTH mode maps into a fresh ColorStructure — this is how a freshly generated app, or a scheme switch in the Colors tab, produces the persisted color value.

Resolving Colors

function resolveScheme(resolved: ResolvedMode): ColorSettings;
function resolveSemanticColors(resolved: ResolvedMode): ResolvedSemanticColors; // Record<SemanticKey, string>

resolveScheme() walks every RampRef in one resolved mode's map through its ramps and returns a flat ColorSettings object of concrete color strings (bgPrimary, bgSecondary, bgSurface, textPrimary, textSecondary, accent, accentSubtle, border, danger, warning, success, onAccent, hoverBg, hoverFg, activeBg, activeFg, tooltipBg, tooltipFg, focusBorder, selection, cursor, vimCursor, the editor Markdown/preview tokens, mindmap/mermaid/minimap tokens, the notify chip tokens, and the syntax highlight tokens). Because the input is just { ramps, map } plus its mode, the same function resolves a built-in preset and a user's persisted, ramp-tweaked colors — resolveMode(settings.color, mode) for the latter.

Applying Colors

function applyRamps(ramps: Ramps): void;
function applyColors(colors: ColorSettings): void;
function applyTheme(themeName: string): ColorScheme;

applyRamps() writes the raw ramp stops as CSS custom properties on :root: --palette-base-0--palette-base-4, --palette-accent-0--palette-accent-2, and --palette-state-danger/-success/-warning/-info. applyColors() writes the resolved ColorSettings as --theme-* custom properties (see CSS & Color Strategy for the full variable list and the three-tier CSS story). applyTheme(themeName) is a convenience that looks up a scheme by name, calls applyRamps() on it, and returns the looked-up ColorScheme. (The return value's consumer was the retired xterm terminal; no in-repo caller uses it today.)

const SCHEME_CHANGED_EVENT = "zudotext:scheme-changed";
function dispatchSchemeChanged(): void;

dispatchSchemeChanged() fires a DOM event on window after a scheme switch so non-React consumers can re-read the new theme.

Syntax Highlight Tokens

Nine SemanticKeys (syntaxComment, syntaxString, syntaxNumber, syntaxKeyword, syntaxCallable, syntaxType, syntaxName, syntaxInserted, syntaxDeleted) back the preview pane's wasm class-mode syntax highlighter (Wasm Syntax Highlight epic #4005). applyColors() writes them as --theme-syntax-comment, --theme-syntax-string, etc. — the same --theme-* tier every other semantic token uses.

The highlighter emits a fixed 18-role class taxonomy (hi-str, hi-esc, hi-num, hi-const, hi-kw, hi-hd, hi-com, hi-fn, hi-ty, hi-ns, hi-prop, hi-var, hi-tag, hi-attr, hi-op, hi-punct, hi-ins, hi-del) with zero inline styles; tauri-app/renderer/styles/syntax-highlight.css contracts those 18 roles onto the 9 tokens above. Every color resolves through a two-level fallback, var(--ztp-syntax-<role>, var(--theme-syntax-<role>)), so an individual preview theme (see Preview Themes) may override a syntax color, while the color scheme drives the default.

Both modes carry hand-tuned literal palettes (#4010): the dark map derives from vitesse-dark (the previous shiki look) and the light map from catppuccin-latte hues darkened for that mode's bgSecondary code panel. The generic SEMANTIC_RAMP_DEFAULTS mapping (comment→base, string→success, number→warning, keyword→accent, callable→info, type→danger, name→fg) is the fallback for custom schemes. Comment/string/number — in fact all seven static roles — are held to WCAG AA (≥ 4.5:1) against bgSecondary by PAIR_MATRIX in contrast-pair-matrix.ts.

Perceptual separation contract (epic #4025). Passing the WCAG contrast floor against bgSecondary is not sufficient on its own — two syntax roles can each individually be legible against the background while sitting too close to each other to tell apart at a glance. evaluateSeparation() / SEPARATION_MATRIX in contrast-pair-matrix.ts measure CIEDE2000 perceptual distance (deltaE2000() in color-difference.ts) between every pair of the 7 static syntax roles (syntaxComment, syntaxString, syntaxNumber, syntaxKeyword, syntaxCallable, syntaxType, syntaxName) and enforce a floor of ΔE2000 ≥ 12 — calibrated to match the nocturne-dark reference preview theme, which never drops below ΔE 11.9 across the same seven roles. syntaxInserted/syntaxDeleted are excluded from the floor (though still reported informationally) for the same reason PAIR_MATRIX excludes them from the contrast guard above: their visible background is the 15% color-mix() diff tint, not bgSecondary, so neither check applies to them directly. Both modes' resolved palettes are locked to both floors (separation and contrast) by syntax-separation.test.ts. The light mode's syntaxNumber/syntaxType pair used to sit at ΔE 9.8 below the floor (#4035); syntaxType was darkened/desaturated to clear it (#5905/#5914).

The code-block surface follows the scheme, not the preview theme. A rendered code block's background is --codeblock-bg--theme-bg-secondary (see packages/code-block/src/styles.css); the preview theme's --ztp-pre-bg is never painted behind highlighted code, and the block's base ink (plain text, operators, punctuation) is likewise the scheme's textPrimary--ztp-code-fg styles inline code only, whose --ztp-code-bg surface is preview-theme-owned. Because of this split, applyColors() also stamps data-codeblock-appearance="dark" | "light" on the root element (from the resolved bgSecondary relative luminance, flip point 0.179; attribute removed when the value is missing or unparsable). Preview themes that ship their own syntax palette — the ten zudo-doc name-matched built-ins do — scope their --ztp-syntax-* overrides under :root[data-codeblock-appearance="…"] so the imported light/dark pair always matches the surface actually behind the code.

WCAG Contrast Matrix

const PAIR_MATRIX: PairSpec[];
const SELECTION_FAN: PairSpec[];

interface SchemeTarget {
  key: string; // e.g. "default/dark"
  name: string;
  label: string;
  mode: SchemeMode;
  resolved: ResolvedMode;
  source: "colorSchemes";
}

function getAllSchemeModes(): SchemeTarget[];
function evaluateScheme(target: SchemeTarget): SchemeReport;
function evaluateSeparation(target: SchemeTarget): SeparationReport;

PAIR_MATRIX lists every foreground/background pair that must clear a WCAG contrast threshold — Tier 1 pairs (text-bearing, e.g. textPrimary/bgPrimary) require ≥ 4.5:1, Tier 2 pairs (non-text boundaries, e.g. border/bgPrimary) require ≥ 3.0:1. It ends with SELECTION_FAN, the selection band measured against every ink that can sit on it (#5896). getAllSchemeModes() is the single fan-out point — one SchemeTarget per scheme and mode, so reports are keyed default/light and default/dark. evaluateScheme() resolves a target via resolveScheme() and checks every pair, returning pass/fail counts. This is the same matrix the ramp-tweaker's WCAG check view (WcagCheckView, in @takazudo/settings-sections) renders live as a user edits ramps — see Settings and Themes for the UI walkthrough.

Color Conversion Utilities

  • hexToHsl(hex) — convert hex color to { h, s, l } object

  • hslToHex(h, s, l) — convert HSL values to hex string

  • contrastTextColor(hex) — returns "#000000" or "#ffffff" based on background luminance

  • contrastRatio(colorA, colorB) — WCAG contrast ratio between two CSS colors

  • relativeLuminance(cssColor) — WCAG relative luminance of a CSS color

Dependencies

None (standalone package).