src/lib/appearance.ts
Linked repository file from this build.
/**
* Appearance preferences: color mode, palette and application layout.
*
* Palettes come from established open palette projects. Each palette has a light and a
* dark token set in `app/styles/palettes.css` (`[data-palette]` and `.dark[data-palette]`). Layouts are
* CSS driven through `html[data-layout]`, so the same DOM serves every layout without a
* hydration mismatch.
*/
export const themes = ["light", "dark", "system"] as const;
export const visions = [
"standard",
"rg-safe",
"blue-yellow-safe",
"contrast-color",
"contrast-mono",
] as const;
export const palettes = [
"one",
"dracula",
"monokai",
"tomorrow",
"gruvbox",
"flexoki",
"catppuccin",
"rose-pine",
"solarized",
"nord",
"broadsheet",
"neon",
"swiss",
"moss",
"terracotta",
"plain",
] as const;
export const layouts = ["classic", "workbench", "desk"] as const;
export const animationDurations = ["off", "short", "moderate", "long"] as const;
export const animationDurationMs = { off: 0, short: 100, moderate: 150, long: 250 } as const;
export type AnimationDuration = (typeof animationDurations)[number];
export const FONT_SIZE_MIN = 12;
export const FONT_SIZE_MAX = 32;
export const FONT_SIZE_STEP = 2;
export const fontSizes = [12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32] as const;
/** Marks the five-step named picker so stored `small` / `medium` / `large` are not read as the old three sizes. */
const NAMED_FONT_SIZE_SCALE = 2;
export type Theme = (typeof themes)[number];
export type Vision = (typeof visions)[number];
export type PaletteId = (typeof palettes)[number];
export type LayoutId = (typeof layouts)[number];
export type FontSize = (typeof fontSizes)[number];
export interface Appearance {
animationDuration: AnimationDuration;
sidebar: "icons" | "hidden";
fontSize: FontSize;
font: "system" | "sans" | "serif" | "mono";
theme: Theme;
/** Status color handling. `rg-safe` swaps green/red cues for blue/red and purple. */
vision: Vision;
palette: PaletteId;
layout: LayoutId;
}
export const APPEARANCE_KEY = "qccrm.appearance";
export const defaultAppearance: Appearance = {
animationDuration: "moderate",
sidebar: "hidden",
fontSize: 16,
font: "system",
theme: "system",
vision: "standard",
palette: "one",
layout: "classic",
};
/** Snap any number onto the 12–32px even scale. */
export function snapFontSize(value: number): FontSize {
const snapped = Math.round(value / FONT_SIZE_STEP) * FONT_SIZE_STEP;
return Math.min(FONT_SIZE_MAX, Math.max(FONT_SIZE_MIN, snapped)) as FontSize;
}
const namedFontSizes: Record<string, FontSize> = {
"extra-small": 14,
small: 16,
medium: 22,
large: 28,
"extra-large": 32,
};
const legacyFontSizes: Record<string, FontSize> = {
small: 14,
medium: 16,
large: 22,
};
/** Palette ids from earlier pickers map to their nearest current palette. */
const legacyPalettes: Record<string, PaletteId> = {
current: "one",
slate: "nord",
paper: "flexoki",
"high-contrast": "plain",
teal: "solarized",
};
const legacyLayouts: Record<string, LayoutId> = {
topbar: "classic",
stacked: "desk",
sidebar: "workbench",
rail: "workbench",
console: "workbench",
focus: "workbench",
simple: "classic",
};
export interface PaletteInfo {
name: string;
source: string;
description: string;
/** Preview tones: background, foreground, primary, accent. */
light: string[];
dark: string[];
}
export const paletteMeta: Record<PaletteId, PaletteInfo> = {
one: {
name: "Atom One",
source: "One Light / One Dark",
description: "The editor classic: calm grays with a clear blue.",
light: ["#fafafa", "#383a42", "#4078f2", "#e45649"],
dark: ["#282c34", "#abb2bf", "#61afef", "#e06c75"],
},
dracula: {
name: "Dracula",
source: "Alucard / Dracula",
description: "Vivid purple, pink and cyan on deep slate.",
light: ["#fffbeb", "#1f1f1f", "#644ac9", "#a3144d"],
dark: ["#282a36", "#f8f8f2", "#bd93f9", "#ff79c6"],
},
monokai: {
name: "Monokai Pro",
source: "Filter Light / Pro",
description: "Warm charcoal with sunlit yellow and coral.",
light: ["#faf4f2", "#29242a", "#7058be", "#e14775"],
dark: ["#2d2a2e", "#fcfcfa", "#ffd866", "#ff6188"],
},
tomorrow: {
name: "Tomorrow",
source: "Tomorrow / Tomorrow Night",
description: "Restrained hues and serif headings for long sessions.",
light: ["#ffffff", "#4d4d4c", "#4271ae", "#c82829"],
dark: ["#1d1f21", "#c5c8c6", "#81a2be", "#cc6666"],
},
gruvbox: {
name: "Gruvbox",
source: "Gruvbox Light / Dark",
description: "Retro groove: cream paper, umber and burnt orange.",
light: ["#fbf1c7", "#3c3836", "#af3a03", "#9d0006"],
dark: ["#282828", "#ebdbb2", "#fe8019", "#fb4934"],
},
flexoki: {
name: "Flexoki",
source: "Steph Ango",
description: "Ink on paper with muted, print-like accents.",
light: ["#fffcf0", "#100f0f", "#205ea6", "#af3029"],
dark: ["#100f0f", "#cecdc3", "#4385be", "#d14d41"],
},
catppuccin: {
name: "Catppuccin",
source: "Latte / Mocha",
description: "Soft pastels and mauve on a cozy base.",
light: ["#eff1f5", "#4c4f69", "#8839ef", "#d20f39"],
dark: ["#1e1e2e", "#cdd6f4", "#cba6f7", "#f38ba8"],
},
"rose-pine": {
name: "Rosé Pine",
source: "Dawn / Main",
description: "Dusky rose, pine and gold with serif headings.",
light: ["#fffaf3", "#464261", "#286983", "#b4637a"],
dark: ["#191724", "#e0def4", "#9ccfd8", "#eb6f92"],
},
solarized: {
name: "Solarized",
source: "Ethan Schoonover",
description: "Precision-tuned low contrast with a blue accent.",
light: ["#fdf6e3", "#43575e", "#268bd2", "#dc322f"],
dark: ["#002b36", "#9db7b7", "#268bd2", "#dc322f"],
},
nord: {
name: "Nord",
source: "Arctic Ice Studio",
description: "Arctic blues and frost on polar night.",
light: ["#eceff4", "#2e3440", "#5e81ac", "#bf616a"],
dark: ["#2e3440", "#eceff4", "#88c0d0", "#bf616a"],
},
broadsheet: {
name: "Broadsheet",
source: "Original",
description: "Newsprint, ink and a masthead red. Serif body text.",
light: ["#f7f2e8", "#1c1a17", "#a31f1f", "#1f4d78"],
dark: ["#1e1b16", "#e8e0cf", "#e2695c", "#7fb1dd"],
},
neon: {
name: "Neon",
source: "Original",
description: "Synthwave night, pastel daybreak. Geometric type.",
light: ["#f9f7ff", "#211838", "#7a26cf", "#0d7a9b"],
dark: ["#181030", "#efe9ff", "#ff5fd2", "#4fd8ff"],
},
swiss: {
name: "Swiss",
source: "Original",
description: "Black, white and one red. Square corners, heavy type.",
light: ["#ffffff", "#000000", "#d7141a", "#000000"],
dark: ["#141414", "#ffffff", "#ff3b30", "#ffffff"],
},
moss: {
name: "Moss",
source: "Original",
description: "Sage, fern and bark with rounded, friendly type.",
light: ["#f4f6ec", "#1f2a20", "#3b6d3f", "#2b6a86"],
dark: ["#1a241c", "#dfe6d7", "#8fc48f", "#7dbbd6"],
},
terracotta: {
name: "Terracotta",
source: "Original",
description: "Clay, sand and deep teal with a display serif.",
light: ["#f9f1e7", "#33231c", "#b5573a", "#2a6f8f"],
dark: ["#251c18", "#efe3d6", "#e0876a", "#79bcd3"],
},
plain: {
name: "Plain",
source: "Utility",
description: "High contrast, system font and minimal decoration.",
light: ["#ffffff", "#000000", "#0645ad", "#b00020"],
dark: ["#141414", "#ffffff", "#8ab4f8", "#ff6b6b"],
},
};
export const paletteGroups: { name: string; palettes: PaletteId[] }[] = [
{ name: "Editor", palettes: ["one", "dracula", "monokai", "tomorrow", "gruvbox"] },
{ name: "Community", palettes: ["flexoki", "catppuccin", "rose-pine", "solarized", "nord"] },
{ name: "Expressive", palettes: ["broadsheet", "neon", "swiss", "moss", "terracotta"] },
{ name: "Utility", palettes: ["plain"] },
];
export const layoutMeta: Record<LayoutId, { name: string; description: string }> = {
classic: {
name: "Navigation bar",
description: "Navigation tabs, centered search and full-width pages.",
},
workbench: {
name: "Sidebar",
description: "Collapsible navigation with floating search.",
},
desk: {
name: "Queue",
description: "Mail-style: your review queues stay docked beside every page.",
},
};
export const visionMeta: Record<Vision, { name: string; description: string }> = {
"contrast-color": {
name: "High contrast · color",
description: "Strong borders and vivid, readable status colors.",
},
"contrast-mono": {
name: "High contrast · black & white",
description: "Monochrome surfaces with explicit status labels.",
},
"blue-yellow-safe": {
name: "Blue-yellow safe",
description: "Distinct red and teal status cues.",
},
standard: { name: "Standard", description: "Green means good, red means stop." },
"rg-safe": {
name: "Red-green safe",
description: "Good is blue, warnings stay amber, problems stay red.",
},
};
function normalizeFontSize(input: Record<string, unknown>): FontSize {
if (typeof input.fontSize === "number" && Number.isFinite(input.fontSize)) {
return snapFontSize(input.fontSize);
}
const raw = typeof input.fontSize === "string" ? input.fontSize : "";
const numeric = Number(raw);
if (raw && Number.isFinite(numeric)) return snapFontSize(numeric);
if (input.fontSizeScale === NAMED_FONT_SIZE_SCALE) {
return namedFontSizes[raw] ?? defaultAppearance.fontSize;
}
return legacyFontSizes[raw] ?? namedFontSizes[raw] ?? defaultAppearance.fontSize;
}
/** Accepts stored or remote values of any shape and returns a valid appearance. */
export function normalizeAppearance(value: unknown): Appearance {
const input = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
const theme = themes.includes(input.theme as Theme) ? (input.theme as Theme) : undefined;
const rawPalette = typeof input.palette === "string" ? input.palette : "";
const palette = palettes.includes(rawPalette as PaletteId)
? (rawPalette as PaletteId)
: legacyPalettes[rawPalette];
const vision = visions.includes(input.vision as Vision) ? (input.vision as Vision) : undefined;
const rawLayout = typeof input.layout === "string" ? input.layout : "";
const layout = layouts.includes(rawLayout as LayoutId)
? (rawLayout as LayoutId)
: legacyLayouts[rawLayout];
return {
animationDuration: animationDurations.includes(input.animationDuration as AnimationDuration)
? (input.animationDuration as AnimationDuration)
: defaultAppearance.animationDuration,
sidebar: input.sidebar === "icons" ? "icons" : "hidden",
fontSize: normalizeFontSize(input),
font:
input.font === "sans" || input.font === "serif" || input.font === "mono"
? input.font
: "system",
theme: theme ?? defaultAppearance.theme,
vision: vision ?? defaultAppearance.vision,
palette: palette ?? defaultAppearance.palette,
layout: layout ?? defaultAppearance.layout,
};
}
export function readStoredAppearance(): Appearance {
try {
return normalizeAppearance(JSON.parse(localStorage.getItem(APPEARANCE_KEY) || "null"));
} catch {
return defaultAppearance;
}
}
export function storeAppearance(value: Appearance) {
try {
localStorage.setItem(APPEARANCE_KEY, JSON.stringify(value));
} catch {
/* Storage can be unavailable in private browsing; the current tab still works. */
}
}
export function resolveDark(theme: Theme): boolean {
return (
theme === "dark" ||
(theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches)
);
}
/** Applies the appearance to `<html>`; mirrors the inline head script in `app/layout.tsx`. */
export function applyAppearance(value: Appearance) {
const root = document.documentElement;
const dark = resolveDark(value.theme);
root.classList.toggle("dark", dark);
root.style.colorScheme = dark ? "dark" : "light";
root.dataset.palette = value.palette;
root.dataset.layout = value.layout;
root.dataset.vision = value.vision;
root.dataset.sidebar = value.sidebar;
root.style.fontSize = `${value.fontSize}px`;
root.dataset.fontSize = String(value.fontSize);
root.dataset.font = value.font;
root.dataset.animationDuration = value.animationDuration;
}
/**
* Inline script for `<head>`. It runs before hydration so the first paint already has the
* stored mode, palette and layout. Keep it in sync with `normalizeAppearance`.
*/
export const appearanceBootScript = `(function(){var a={};try{a=JSON.parse(localStorage.getItem(${JSON.stringify(APPEARANCE_KEY)})||'null')||{};}catch(e){}try{var r=document.documentElement;var d=a.theme==='dark'||(a.theme!=='light'&&matchMedia('(prefers-color-scheme: dark)').matches);r.classList.toggle('dark',d);r.style.colorScheme=d?'dark':'light';var P=${JSON.stringify(palettes)};var M=${JSON.stringify(legacyPalettes)};var L=${JSON.stringify(layouts)};var N=${JSON.stringify(legacyLayouts)};r.dataset.palette=P.indexOf(a.palette)>=0?a.palette:(M[a.palette]||${JSON.stringify(defaultAppearance.palette)});r.dataset.layout=L.indexOf(a.layout)>=0?a.layout:(N[a.layout]||${JSON.stringify(defaultAppearance.layout)});r.dataset.vision=${JSON.stringify(visions)}.includes(a.vision)?a.vision:'standard';r.dataset.layout=['console','focus'].includes(r.dataset.layout)?'workbench':r.dataset.layout==='simple'?'classic':r.dataset.layout;r.dataset.sidebar=a.sidebar==='icons'?'icons':'hidden';var n=+a.fontSize;var px=a.fontSize!=null&&a.fontSize!==""&&n===n?Math.min(${FONT_SIZE_MAX},Math.max(${FONT_SIZE_MIN},Math.round(n/${FONT_SIZE_STEP})*${FONT_SIZE_STEP})):(a.fontSizeScale===${NAMED_FONT_SIZE_SCALE}?${JSON.stringify(namedFontSizes)}:${JSON.stringify({ ...namedFontSizes, ...legacyFontSizes })})[a.fontSize]||${defaultAppearance.fontSize};r.style.fontSize=px+"px";r.dataset.fontSize=String(px);r.dataset.font=['sans','serif','mono'].includes(a.font)?a.font:'system';r.dataset.animationDuration=${JSON.stringify(animationDurations)}.includes(a.animationDuration)?a.animationDuration:${JSON.stringify(defaultAppearance.animationDuration)};}catch(e){}})();`;