Skip to content
Clone
main
568 lines 15.1 KB
/**
* 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<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)
}