import { clampNum } from "../shared/math.js"; import { prefersColorSchemeDark } from "../shared/media.js"; export const STORAGE_KEY = "siteThemeV1"; export const RADIUS_IDS = ["soft", "medium", "strong"]; export const THEME_MODE_IDS = ["system", "light", "dark"]; export const FONT_IDS = ["merriweather", "mono", "geologica"]; const LEGACY_FONT_MAP = { onest: "geologica", anonymous: "mono", }; export function normalizeFontId(font) { if (LEGACY_FONT_MAP[font]) return LEGACY_FONT_MAP[font]; return FONT_IDS.includes(font) ? font : defaultThemeState().font; } export const MESH_BG_IDS = ["on", "off"]; export const PICK = { sMin: 30, sMax: 100, lMin: 20, lMax: 70, }; export const S_RANGE = PICK.sMax - PICK.sMin; export const L_RANGE = PICK.lMax - PICK.lMin; export function defaultThemeState() { return { mode: "system", light: { h: 210, s: 90, l: 40 }, dark: { h: 210, s: 90, l: 40 }, radius: "medium", font: "geologica", meshBg: "on", }; } export function effectiveThemeMode(state) { if (state.mode === "system") return prefersColorSchemeDark() ? "dark" : "light"; return state.mode; } export function activeAccent(state) { const key = effectiveThemeMode(state); return state[key] || state.light || { h: 210, s: 90, l: 40 }; } function parseAccent(obj, base) { if (!obj) return { ...base }; return { h: clampNum(obj.h ?? obj.hue, 0, 360, base.h), s: clampNum(obj.s ?? obj.sat, PICK.sMin, PICK.sMax, base.s), l: clampNum(obj.l ?? obj.light, PICK.lMin, PICK.lMax, base.l), }; } export function readThemeState() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return defaultThemeState(); const o = JSON.parse(raw); const base = defaultThemeState(); const mode = o.mode === "light" || o.mode === "dark" || o.mode === "system" ? o.mode : base.mode; const radius = RADIUS_IDS.includes(o.radius) ? o.radius : base.radius; const font = normalizeFontId(o.font ?? base.font); const meshBg = MESH_BG_IDS.includes(o.meshBg) ? o.meshBg : base.meshBg; let light = base.light; let dark = base.dark; if (o.light || o.dark) { light = parseAccent(o.light, base.light); dark = parseAccent(o.dark, base.dark); } else if ( o.h !== undefined || o.hue !== undefined || o.s !== undefined || o.sat !== undefined || o.l !== undefined || o.light !== undefined ) { // legacy: единый акцент → копируем в обе темы const h = clampNum(o.h ?? o.hue, 0, 360, base.light.h); const s = clampNum(o.s ?? o.sat, PICK.sMin, PICK.sMax, base.light.s); const l = clampNum(o.l ?? o.light, PICK.lMin, PICK.lMax, base.light.l); light = { h, s, l }; dark = { h, s, l }; } return { mode, light, dark, radius, font, meshBg }; } catch { return defaultThemeState(); } } export function writeThemeState(state) { localStorage.setItem( STORAGE_KEY, JSON.stringify({ mode: state.mode, light: state.light, dark: state.dark, radius: state.radius, font: state.font, meshBg: state.meshBg, }), ); } export function hslToRgb(h, s, l) { const H = (((h % 360) + 360) % 360) / 360; const S = Math.min(100, Math.max(0, s)) / 100; const L = Math.min(100, Math.max(0, l)) / 100; let r; let g; let b; if (S === 0) { r = g = b = L; } else { const hue2rgb = (p, q, t) => { let T = t; if (T < 0) T += 1; if (T > 1) T -= 1; if (T < 1 / 6) return p + (q - p) * 6 * T; if (T < 1 / 2) return q; if (T < 2 / 3) return p + (q - p) * (2 / 3 - T) * 6; return p; }; const q = L < 0.5 ? L * (1 + S) : L + S - L * S; const p = 2 * L - q; r = hue2rgb(p, q, H + 1 / 3); g = hue2rgb(p, q, H); b = hue2rgb(p, q, H - 1 / 3); } return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; } export function relativeLuminance(rgb) { const lin = rgb.map((v) => { const x = v / 255; return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4); }); return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]; } export function applyThemeState(state) { const root = document.documentElement; const mode = effectiveThemeMode(state); root.dataset.theme = mode; root.dataset.radius = state.radius; root.dataset.font = state.font; root.dataset.meshBg = state.meshBg === "off" ? "off" : "on"; const acc = activeAccent(state); root.style.setProperty("--theme-h", String(acc.h)); root.style.setProperty("--theme-s", String(acc.s)); root.style.setProperty("--theme-l", String(acc.l)); const rgb = hslToRgb(acc.h, acc.s, acc.l); const lum = relativeLuminance(rgb); root.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)"); window.dispatchEvent(new CustomEvent("site-theme-applied", { bubbles: true })); } export function drawSLPlane(canvas, hue) { const ctx = canvas.getContext("2d", { willReadFrequently: true }); const w = canvas.width; const h = canvas.height; const img = ctx.createImageData(w, h); const { data } = img; for (let y = 0; y < h; y++) { const l = PICK.lMax - (y / (h - 1 || 1)) * L_RANGE; for (let x = 0; x < w; x++) { const s = PICK.sMin + (x / (w - 1 || 1)) * S_RANGE; const [r, g, b] = hslToRgb(hue, s, l); const i = (y * w + x) * 4; data[i] = r; data[i + 1] = g; data[i + 2] = b; data[i + 3] = 255; } } ctx.putImageData(img, 0, 0); } export function updateKnob(knob, s, l) { if (!knob) return; knob.style.left = `${((s - PICK.sMin) / S_RANGE) * 100}%`; knob.style.top = `${(1 - (l - PICK.lMin) / L_RANGE) * 100}%`; }