Files
dbx/apps/desktop/src/lib/app/appTheme.ts
T

284 lines
11 KiB
TypeScript

import type { Theme } from "@tauri-apps/api/window";
export const APP_THEME_STORAGE_KEY = "dbx-theme";
export const APP_THEME_PALETTE_STORAGE_KEY = "dbx-theme-palette";
export const APP_CUSTOM_UI_STORAGE_KEY = "dbx-theme-custom-ui";
export const APP_CUSTOM_UI_DARK_STORAGE_KEY = "dbx-theme-custom-ui-dark";
export const APP_CORNER_STYLE_STORAGE_KEY = "dbx-corner-style";
export type AppThemeMode = "light" | "dark" | "system";
export type AppThemeAppearance = "light" | "dark";
export type AppThemePalette = "pearl" | "mist" | "graphite" | "cobalt" | "sage" | "amber" | "blush" | "vscode" | "idea" | "xcode" | "jetbrains" | "cursor" | "claude" | "custom";
export type AppCornerStyle = "none" | "small" | "large";
export interface AppCustomUiColors {
background: string;
foreground: string;
primary: string;
border: string;
sidebar: string;
}
export const DEFAULT_APP_CUSTOM_UI_COLORS: AppCustomUiColors = {
background: "#ffffff",
foreground: "#0a0a0a",
primary: "#171717",
border: "#e5e5e5",
sidebar: "#fafafa",
};
export const DEFAULT_APP_CUSTOM_UI_COLORS_DARK: AppCustomUiColors = {
background: "#131416",
foreground: "#d7d7db",
primary: "#d0d0d6",
border: "#6e6e72",
sidebar: "#19191c",
};
export interface AppCustomUiColorDef {
key: keyof AppCustomUiColors;
varName: string;
rgbVarName?: string;
labelKey: string;
}
export const APP_CUSTOM_UI_COLOR_DEFS: readonly AppCustomUiColorDef[] = [
{ key: "background", varName: "--background", labelKey: "settings.customUiBackground" },
{ key: "foreground", varName: "--foreground", labelKey: "settings.customUiForeground" },
{ key: "primary", varName: "--primary", rgbVarName: "--dbx-primary-rgb", labelKey: "settings.customUiPrimary" },
{ key: "border", varName: "--border", labelKey: "settings.customUiBorder" },
{ key: "sidebar", varName: "--sidebar", labelKey: "settings.customUiSidebar" },
];
// Every surface and its paired foreground is derived from the chosen colors as
// concrete rgb values (no color-mix dependency, so legacy WebViews work). The
// pairings are the readability contract: text colors are computed for the
// surface they sit on (primary/sidebar-primary foregrounds pick black or white
// by WCAG contrast).
export const APP_CUSTOM_UI_DERIVED_VAR_NAMES: readonly string[] = [
"--card",
"--card-foreground",
"--popover",
"--popover-foreground",
"--secondary",
"--secondary-foreground",
"--muted",
"--muted-foreground",
"--accent",
"--accent-foreground",
"--primary-foreground",
"--sidebar-foreground",
"--sidebar-accent",
"--sidebar-accent-foreground",
"--sidebar-primary",
"--sidebar-primary-foreground",
"--sidebar-border",
"--sidebar-ring",
"--input",
"--ring",
"--dbx-chrome",
"--dbx-chrome-muted",
"--dbx-content",
"--dbx-editor-toolbar",
"--dbx-gutter",
"--dbx-sidebar-header",
];
export function mixHex(a: string, b: string, weight: number): string {
const pa = parseInt(a.slice(1), 16);
const pb = parseInt(b.slice(1), 16);
const ar = (pa >> 16) & 255;
const ag = (pa >> 8) & 255;
const ab = pa & 255;
const br = (pb >> 16) & 255;
const bg2 = (pb >> 8) & 255;
const bb = pb & 255;
const r = Math.round(ar + (br - ar) * weight);
const g = Math.round(ag + (bg2 - ag) * weight);
const bl = Math.round(ab + (bb - ab) * weight);
return `rgb(${r} ${g} ${bl})`;
}
/** Pick white or near-black text for the best contrast on the given background. */
export function readableTextOn(hex: string): string {
return wcagContrastRatio(hex, "#ffffff") >= wcagContrastRatio(hex, "#0a0a0a") ? "#ffffff" : "#0a0a0a";
}
function rgbValue(hex: string): string {
return appCustomUiColorValue(hex).color;
}
export function deriveCustomUiColors(colors: AppCustomUiColors): Record<string, string> {
const { background, foreground, primary, border, sidebar } = colors;
const card = mixHex(background, foreground, 0.03);
const secondary = mixHex(background, foreground, 0.06);
const accent = mixHex(background, foreground, 0.12);
const primaryFg = readableTextOn(primary);
// Chrome surfaces are near-background tints so foreground text stays readable
// even when the user pairs an inverted (dark bg + light fg) custom palette.
const chrome = mixHex(background, foreground, 0.02);
const chromeMuted = mixHex(background, foreground, 0.035);
const toolbar = mixHex(background, foreground, 0.02);
const gutter = mixHex(background, foreground, 0.035);
const sidebarHeader = mixHex(sidebar, foreground, 0.02);
return {
"--card": card,
"--card-foreground": rgbValue(foreground),
"--popover": card,
"--popover-foreground": rgbValue(foreground),
"--secondary": secondary,
"--secondary-foreground": rgbValue(foreground),
"--muted": secondary,
"--muted-foreground": mixHex(foreground, background, 0.58),
"--accent": accent,
"--accent-foreground": rgbValue(foreground),
"--primary-foreground": rgbValue(primaryFg),
"--sidebar-foreground": rgbValue(foreground),
"--sidebar-accent": mixHex(sidebar, foreground, 0.06),
"--sidebar-accent-foreground": rgbValue(foreground),
"--sidebar-primary": rgbValue(primary),
"--sidebar-primary-foreground": rgbValue(primaryFg),
"--sidebar-border": rgbValue(border),
"--sidebar-ring": rgbValue(primary),
"--input": rgbValue(border),
"--ring": rgbValue(primary),
"--dbx-chrome": chrome,
"--dbx-chrome-muted": chromeMuted,
"--dbx-content": rgbValue(background),
"--dbx-editor-toolbar": toolbar,
"--dbx-gutter": gutter,
"--dbx-sidebar-header": sidebarHeader,
};
}
export type AppThemePaletteOption = {
value: AppThemePalette;
labelKey: string;
className: string | null;
previewColor: string;
};
export const APP_THEME_PALETTES: AppThemePaletteOption[] = [
{ value: "pearl", labelKey: "settings.themePalettePearl", className: null, previewColor: "#ffffff" },
{ value: "mist", labelKey: "settings.themePaletteMist", className: "theme-soft", previewColor: "#e4eaf2" },
{ value: "graphite", labelKey: "settings.themePaletteGraphite", className: "theme-graphite", previewColor: "#d8dce4" },
{ value: "cobalt", labelKey: "settings.themePaletteCobalt", className: "theme-cobalt", previewColor: "#d8e6f7" },
{ value: "sage", labelKey: "settings.themePaletteSage", className: "theme-sage", previewColor: "#dbe9e2" },
{ value: "amber", labelKey: "settings.themePaletteAmber", className: "theme-amber", previewColor: "#f4e4b8" },
{ value: "blush", labelKey: "settings.themePaletteBlush", className: "theme-blush", previewColor: "#f4d9e6" },
{ value: "vscode", labelKey: "settings.themePaletteVscode", className: "theme-vscode", previewColor: "#007acc" },
{ value: "idea", labelKey: "settings.themePaletteIdea", className: "theme-idea", previewColor: "#4b6eaf" },
{ value: "xcode", labelKey: "settings.themePaletteXcode", className: "theme-xcode", previewColor: "#0a84ff" },
{ value: "jetbrains", labelKey: "settings.themePaletteJetbrains", className: "theme-jetbrains", previewColor: "#7b61ff" },
{ value: "cursor", labelKey: "settings.themePaletteCursor", className: "theme-cursor", previewColor: "#6ba4ff" },
{ value: "claude", labelKey: "settings.themePaletteClaude", className: "theme-claude", previewColor: "#c47a50" },
{ value: "custom", labelKey: "settings.themePaletteCustom", className: null, previewColor: "#171717" },
];
export const APP_THEME_PALETTE_CLASS_NAMES = APP_THEME_PALETTES.map((palette) => palette.className).filter((className): className is string => Boolean(className));
export function normalizeAppThemeMode(value: string | null): AppThemeMode {
if (value === "soft-light") return "light";
if (value === "soft-dark") return "dark";
if (value === "soft-system") return "system";
if (value === "dark" || value === "light" || value === "system") return value;
return "light";
}
export function normalizeAppThemePalette(value: string | null): AppThemePalette {
if (
value === "mist" ||
value === "graphite" ||
value === "cobalt" ||
value === "sage" ||
value === "amber" ||
value === "blush" ||
value === "vscode" ||
value === "idea" ||
value === "xcode" ||
value === "jetbrains" ||
value === "cursor" ||
value === "claude" ||
value === "custom" ||
value === "pearl"
)
return value;
return "pearl";
}
export function normalizeAppCustomUiColors(value: unknown): AppCustomUiColors {
const source = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
const out = {} as AppCustomUiColors;
for (const key of Object.keys(DEFAULT_APP_CUSTOM_UI_COLORS) as (keyof AppCustomUiColors)[]) {
const candidate = source[key];
out[key] = typeof candidate === "string" && /^#[0-9a-fA-F]{6}$/.test(candidate) ? candidate : DEFAULT_APP_CUSTOM_UI_COLORS[key];
}
return out;
}
export function appCustomUiColorValue(hex: string): { color: string; rgbTriplet: string } {
const value = hex.replace("#", "");
const r = parseInt(value.slice(0, 2), 16);
const g = parseInt(value.slice(2, 4), 16);
const b = parseInt(value.slice(4, 6), 16);
return { color: `rgb(${r} ${g} ${b})`, rgbTriplet: `${r}, ${g}, ${b}` };
}
function rgbChannels(value: string): [number, number, number] {
const trimmed = value.trim();
const rgbMatch = /^rgb\((\d+)\s+(\d+)\s+(\d+)\)$/.exec(trimmed);
if (rgbMatch) return [Number(rgbMatch[1]), Number(rgbMatch[2]), Number(rgbMatch[3])];
const hex = trimmed.replace("#", "");
return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)];
}
/** WCAG 2.x relative luminance of a color, 0 (black) .. 1 (white). Accepts #rrggbb or rgb(r g b). */
export function hexRelativeLuminance(color: string): number {
const [r8, g8, b8] = rgbChannels(color);
const toLinear = (channel: number): number => {
const c = channel / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
};
return 0.2126 * toLinear(r8) + 0.7152 * toLinear(g8) + 0.0722 * toLinear(b8);
}
/** WCAG 2.x contrast ratio between two colors, 1 .. 21. Accepts #rrggbb or rgb(r g b). */
export function wcagContrastRatio(a: string, b: string): number {
const lighter = Math.max(hexRelativeLuminance(a), hexRelativeLuminance(b));
const darker = Math.min(hexRelativeLuminance(a), hexRelativeLuminance(b));
return (lighter + 0.05) / (darker + 0.05);
}
/**
* Decides the effective light/dark appearance for a custom UI palette from the
* chosen background's luminance, so surfaces driven by the app palette (such as
* the "Follow app theme" editor) stay readable no matter how dark or light the
* background is.
*/
export function customUiAppearance(colors: AppCustomUiColors): AppThemeAppearance {
return hexRelativeLuminance(colors.background) < 0.2 ? "dark" : "light";
}
export function normalizeAppCornerStyle(value: string | null): AppCornerStyle {
if (value === "none" || value === "small") return value;
return "large";
}
export function getAppThemePaletteClass(palette: AppThemePalette): string | null {
return APP_THEME_PALETTES.find((option) => option.value === palette)?.className ?? null;
}
export function isSystemAppThemeMode(mode: AppThemeMode): boolean {
return mode === "system";
}
export function resolveAppThemeAppearance(mode: AppThemeMode, systemPrefersDark: boolean): AppThemeAppearance {
if (isSystemAppThemeMode(mode)) return systemPrefersDark ? "dark" : "light";
return mode === "dark" ? "dark" : "light";
}
export function getTauriThemeForMode(mode: AppThemeMode): Theme | null {
if (isSystemAppThemeMode(mode)) return null;
return resolveAppThemeAppearance(mode, false);
}