191 lines
5.8 KiB
JavaScript
191 lines
5.8 KiB
JavaScript
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", "medeo"];
|
|
|
|
const LEGACY_FONT_MAP = {
|
|
onest: "medeo",
|
|
geologica: "medeo",
|
|
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: "medeo",
|
|
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}%`;
|
|
}
|