563bb99
/**
* 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 { useSyncExternalStore } from 'react'
import { t } from '@/shared/i18n'
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<string, string> {
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)
}