/** * Palette store: named colour schemes that override the `--shell-*` tokens. * * The selected palette is persisted as **base64 CSS**: `paletteCss()` renders a * stylesheet, `encodeCss()` puts it in localStorage, and on the next boot * `decodeCss()` reverses the path — decode, sanitise (only `--custom: value` * declarations survive), then inject. A corrupt or hand-edited payload * therefore degrades to "no palette injected" instead of becoming an XSS or a * broken page. * * A palette is orthogonal to light/dark: `Theme` decides the mode, and each * palette ships whichever variants it has. A dark-only scheme (Dracula) simply * pulls the mode with it, which `initPalette` keeps in sync. */ import { t } from '@app/i18n' import { useSyncExternalStore } from 'react' import { getTheme, onThemeChange, setTheme, type Theme } from './theme' export type PaletteMode = Theme export interface PaletteCore { /** Sidebar / app background. */ base: string /** Inputs, chips, secondary surfaces. */ panel: string /** Cards and content columns. */ surface: string /** Body text. */ text: string /** Secondary text; must stay legible on `base` and `surface`. */ muted: string /** Hairlines. */ line: string /** The single solid action colour; one button per screen. */ accent: string /** Keyboard focus ring; defaults to `muted`. */ focus?: string } export interface Palette { id: string name: string /** Origin of the scheme, shown under the name. */ origin: string light?: PaletteCore dark?: PaletteCore } const PALETTE_ID_KEY = 'kernel-palette' const PALETTE_CSS_KEY = 'kernel-palette-css' const STYLE_ID = 'gitdata-palette' export const DEFAULT_PALETTE_ID = 'graphite' export const PALETTES: Palette[] = [ // Names/origins resolve lazily so the picker follows the active language. { id: 'graphite', get name() { return t('palette.graphite.name') }, get origin() { return t('palette.graphite.origin') }, light: { base: '#fafafa', panel: '#f5f5f4', surface: '#ffffff', text: '#23272b', muted: '#687078', line: '#e1e3e3', accent: '#2f3438', }, dark: { base: '#1a1c1e', panel: '#282a2d', surface: '#202224', text: '#eceeef', muted: '#b1b5ba', line: '#3a3d40', accent: '#565e67', }, }, { id: 'paper', get name() { return t('palette.paper.name') }, get origin() { return t('palette.paper.origin') }, light: { base: '#faf7f2', panel: '#f2ede4', surface: '#fffdf9', text: '#2f2a26', muted: '#7d7367', line: '#e6ded2', accent: '#847459', }, }, { id: 'mint', get name() { return t('palette.mint.name') }, get origin() { return t('palette.mint.origin') }, light: { base: '#f4f9f7', panel: '#e8f1ed', surface: '#ffffff', text: '#1f2d2a', muted: '#5f7a73', line: '#d9e7e2', accent: '#2f6f5f', }, }, { id: 'github', get name() { return t('palette.github.name') }, origin: 'GitHub', light: { base: '#f6f8fa', panel: '#eaeef2', surface: '#ffffff', text: '#1f2328', muted: '#59636e', line: '#d1d9e0', accent: '#215a8e', }, dark: { base: '#0d1117', panel: '#161b22', surface: '#11161d', text: '#e6edf3', muted: '#9198a1', line: '#30363d', accent: '#3d6a94', }, }, { id: 'solarized', get name() { return t('palette.solarized.name') }, origin: 'Solarized', light: { base: '#fdf6e3', panel: '#eee8d5', surface: '#fffdf1', text: '#073642', muted: '#657b83', line: '#e3dcc5', accent: '#268bd2', }, dark: { base: '#002b36', panel: '#0a3d4a', surface: '#06333f', text: '#e8e5d8', muted: '#93a1a1', line: '#1b4a56', accent: '#2aa198', }, }, { id: 'nord', get name() { return t('palette.nord.name') }, origin: 'Nord', light: { base: '#eceff4', panel: '#e5e9f0', surface: '#f7f9fc', text: '#2e3440', muted: '#4c566a', line: '#d8dee9', accent: '#5e81ac', }, dark: { base: '#2e3440', panel: '#3b4252', surface: '#353c4a', text: '#eceff4', muted: '#c8d1de', line: '#434c5e', accent: '#88c0d0', }, }, { id: 'catppuccin', get name() { return t('palette.catppuccin.name') }, origin: 'Catppuccin', light: { base: '#eff1f5', panel: '#e6e9ef', surface: '#f7f8fc', text: '#4c4f69', muted: '#6c6f85', line: '#ccd0da', accent: '#7287fd', }, dark: { base: '#1e1e2e', panel: '#313244', surface: '#24243a', text: '#cdd6f4', muted: '#a6adc8', line: '#45475a', accent: '#89b4fa', }, }, { id: 'everforest', get name() { return t('palette.everforest.name') }, origin: 'Everforest', light: { base: '#f0f4ec', panel: '#e4ebdd', surface: '#f9fbf5', text: '#2d353b', muted: '#6c7a80', line: '#d5dfc9', accent: '#7f9e4c', }, dark: { base: '#2b3339', panel: '#343f44', surface: '#303a40', text: '#d3c6aa', muted: '#9da9a0', line: '#404d53', accent: '#a7c080', }, }, { id: 'gruvbox', get name() { return t('palette.gruvbox.name') }, origin: 'Gruvbox', light: { base: '#f2e5bc', panel: '#ebdbb2', surface: '#fbf1c7', text: '#3c3836', muted: '#7c6f64', line: '#d5c4a1', accent: '#076678', }, dark: { base: '#282828', panel: '#3c3836', surface: '#32302f', text: '#ebdbb2', muted: '#a89984', line: '#504945', accent: '#83a598', }, }, { id: 'onedark', get name() { return t('palette.onedark.name') }, origin: 'One Dark', dark: { base: '#282c34', panel: '#2c313a', surface: '#31363f', text: '#abb2bf', muted: '#8b95a7', line: '#3e4451', accent: '#61afef', }, }, { id: 'tokyonight', get name() { return t('palette.tokyonight.name') }, origin: 'Tokyo Night', dark: { base: '#1a1b26', panel: '#24283b', surface: '#1f2335', text: '#c0caf5', muted: '#9aa5ce', line: '#3b4261', accent: '#7aa2f7', }, }, { id: 'dracula', get name() { return t('palette.dracula.name') }, origin: 'Dracula', dark: { base: '#282a36', panel: '#343746', surface: '#2f3341', text: '#f8f8f2', muted: '#b8c0d0', line: '#44475a', accent: '#bd93f9', }, }, ] /* ------------------------------------------------------------------ maths -- */ function toRgb(hex: string): [number, number, number] { const h = hex.replace('#', '') const full = h.length === 3 ? h .split('') .map((c) => c + c) .join('') : h const n = Number.parseInt(full.slice(0, 6), 16) return [(n >> 16) & 255, (n >> 8) & 255, n & 255] } function toHex(rgb: [number, number, number]): string { return `#${rgb .map((c) => Math.round(Math.min(255, Math.max(0, c))) .toString(16) .padStart(2, '0'), ) .join('')}` } /** Linear blend, `t` weighted towards `b`. */ function mix(a: string, b: string, t: number): string { const [r1, g1, b1] = toRgb(a) const [r2, g2, b2] = toRgb(b) return toHex([r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t]) } function rgbOf(hex: string): string { return toRgb(hex).join(' ') } /* ----------------------------------------------------------------- render -- */ /** * One palette variant rendered to token values. Derived tokens (rail, active, * tile, board) come from the core colours so a new scheme needs only seven * hand-picked values. */ function tokensFor(core: PaletteCore, mode: PaletteMode): Record { const sink = mode === 'light' ? core.text : '#000000' return { // The rail recedes from the sidebar in both modes; in dark it goes darker, // not lighter, or pale icons lose their footing. '--shell-rail': mode === 'light' ? mix(core.base, core.text, 0.035) : mix(core.base, sink, 0.3), '--shell-sidebar': core.base, '--shell-surface': core.surface, '--shell-field': core.panel, '--shell-active': mix(core.panel, core.text, mode === 'light' ? 0.07 : 0.18), '--shell-tile': mix(core.panel, core.text, mode === 'light' ? 0.14 : 0.28), '--shell-board': mode === 'light' ? mix(core.base, core.text, 0.04) : mix(core.base, sink, 0.12), '--shell-ink': core.text, '--shell-ink-muted': core.muted, '--shell-hairline': core.line, '--shell-action': core.accent, '--shell-focus': core.focus ?? core.muted, '--shell-shadow': mode === 'light' ? `0 6px 24px rgb(${rgbOf(core.text)} / 6%)` : `0 6px 24px rgb(0 0 0 / 22%)`, } } export function paletteVariants(palette: Palette): PaletteMode[] { return [palette.light ? 'light' : null, palette.dark ? 'dark' : null].filter(Boolean) as PaletteMode[] } export function findPalette(id: string | null | undefined): Palette | undefined { return PALETTES.find((palette) => palette.id === id) } /** Which variant a palette renders in, given the requested mode. */ export function resolveVariant(palette: Palette, mode: PaletteMode): PaletteMode { if (mode === 'light' && palette.light) return 'light' if (mode === 'dark' && palette.dark) return 'dark' return palette.light ? 'light' : 'dark' } /** `html:root` outranks both `:root` and `.dark`, so one rule wins either mode. */ export function paletteCss(id: string, mode: PaletteMode): string { const palette = findPalette(id) ?? findPalette(DEFAULT_PALETTE_ID)! const core = palette[resolveVariant(palette, mode)] if (!core) return '' const tokens = tokensFor(core, resolveVariant(palette, mode)) return `html:root{${Object.entries(tokens) .map(([k, v]) => `${k}:${v}`) .join(';')}}` } /** Swatches for the picker: card, app, hairline, action, text. */ export function previewColors(id: string, mode: PaletteMode): string[] { const palette = findPalette(id) ?? findPalette(DEFAULT_PALETTE_ID)! const core = palette[resolveVariant(palette, mode)] return core ? [core.surface, core.base, core.line, core.accent, core.text] : [] } /* ------------------------------------------------------- base64 + safety -- */ export function encodeCss(css: string): string { const bytes = new TextEncoder().encode(css) let binary = '' for (const byte of bytes) binary += String.fromCharCode(byte) return btoa(binary) } const FORBIDDEN = /(?:\{|\}|<|>|;|url\(|expression|@|javascript:|behavior)/i /** * Keep only `--custom-property: value` declarations. Anything else — a nested * rule, a selector, an `@import`, a `url()` — rejects the whole payload, which * the caller treats as "no palette". */ export function sanitizeCss(css: string): string | null { const body = css.replace(/\/\*[\s\S]*?\*\//g, '').trim() const wrapped = /^html:root\s*\{([\s\S]*)\}$/.exec(body) const declarations = (wrapped ? wrapped[1] : body).split(';') const clean: string[] = [] for (const entry of declarations) { const item = entry.trim() if (!item) continue const colon = item.indexOf(':') if (colon < 0) return null const prop = item.slice(0, colon).trim() const value = item.slice(colon + 1).trim() if (!/^--[a-z][a-z0-9-]*$/.test(prop)) return null if (!value || value.length > 200 || FORBIDDEN.test(value)) return null clean.push(`${prop}:${value}`) } return clean.length ? `html:root{${clean.join(';')}}` : null } export function decodeCss(payload: string | null): string | null { if (!payload) return null try { const binary = atob(payload.trim()) const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)) return sanitizeCss(new TextDecoder().decode(bytes)) } catch { return null } } /* ------------------------------------------------------------------ store -- */ let paletteId = DEFAULT_PALETTE_ID let snapshot: PaletteSnapshot | null = null const listeners = new Set<() => void>() export interface PaletteSnapshot { id: string palette: Palette /** Variant actually rendered. */ variant: PaletteMode /** Base64 CSS as persisted. */ encoded: string } function inject(css: string | null): void { const existing = document.getElementById(STYLE_ID) if (!css) { existing?.remove() return } const tag = existing ?? Object.assign(document.createElement('style'), { id: STYLE_ID }) tag.textContent = css if (!existing) document.head.appendChild(tag) } function emit(): void { snapshot = null for (const listener of listeners) listener() } function read(): PaletteSnapshot { if (snapshot) return snapshot const palette = findPalette(paletteId) ?? findPalette(DEFAULT_PALETTE_ID)! const variant = resolveVariant(palette, getTheme()) snapshot = { id: palette.id, palette, variant, encoded: encodeCss(paletteCss(palette.id, getTheme())) } return snapshot } /** Re-render the injected stylesheet after the mode or palette changed. */ function apply(): void { snapshot = null // the cached variant/premium pair is stale once the mode moves const state = read() inject(paletteCss(state.id, getTheme())) emit() } export function getPaletteId(): string { return read().id } export function getPalette(): Palette { return read().palette } /** Persist as base64 CSS and paint it immediately. */ export function setPalette(id: string): void { const palette = findPalette(id) if (!palette) return paletteId = palette.id const css = paletteCss(palette.id, getTheme()) localStorage.setItem(PALETTE_ID_KEY, palette.id) localStorage.setItem(PALETTE_CSS_KEY, encodeCss(css)) // A scheme with only one variant also owns the light/dark class. const variants = paletteVariants(palette) if (variants.length === 1 && getTheme() !== variants[0]) setTheme(variants[0]) else apply() } /** Back to the built-in tokens: drop the override and both stored values. */ export function resetPalette(): void { paletteId = DEFAULT_PALETTE_ID localStorage.removeItem(PALETTE_ID_KEY) localStorage.removeItem(PALETTE_CSS_KEY) inject(null) emit() } /** * Bootstrap: prefer the stored base64 payload, fall back to the stored palette * id, and ignore either if it does not decode to safe declarations. Call next * to `initTheme()`, before the first paint. */ export function initPalette(): void { const fromUrl = new URLSearchParams(window.location.search).get('palette') const stored = findPalette(fromUrl) ?? findPalette(localStorage.getItem(PALETTE_ID_KEY)) if (stored) paletteId = stored.id const decoded = decodeCss(localStorage.getItem(PALETTE_CSS_KEY)) if (decoded) inject(decoded) else if (paletteId !== DEFAULT_PALETTE_ID) { localStorage.removeItem(PALETTE_CSS_KEY) inject(paletteCss(paletteId, getTheme())) } onThemeChange(apply) } function subscribe(listener: () => void): () => void { listeners.add(listener) return () => listeners.delete(listener) } export function usePalette(): PaletteSnapshot { return useSyncExternalStore(subscribe, read) }