const STORAGE_KEY = 'shine-ui-theme-mode-v1'; // Выбранная палитра и личные правки цветов (для дня и ночи отдельно). const PALETTE_KEY = 'shine-ui-palette-v1'; // Итоговые значения для каждой темы: их читает скрипт в index.html до первой отрисовки, // чтобы при запуске не мелькала палитра по умолчанию. const APPLIED_KEY = 'shine-ui-palette-applied-v1'; const MODES = new Set(['system', 'light', 'dark']); let sessionMode = null; // Роли цветов — тот же контракт, что в docs/UI-Design/DESIGN.md (раздел 4.1) и styles/main.css. export const PALETTE_ROLES = [ { id: 'background', label: 'Фон' }, { id: 'surface', label: 'Поверхности: поля, меню, карточки' }, { id: 'surface-selected', label: 'Выбранное и лёгкое выделение' }, { id: 'text-primary', label: 'Основной текст' }, { id: 'text-secondary', label: 'Вторичный текст: время, подсказки' }, { id: 'border-subtle', label: 'Разделители' }, { id: 'border-control', label: 'Рамки полей и кнопок' }, { id: 'accent', label: 'Акцент: главные кнопки, ссылки' }, { id: 'on-accent', label: 'Текст на акцентной кнопке' }, { id: 'reaction-active', label: 'Поставленный лайк' }, { id: 'danger', label: 'Ошибки и удаление' }, { id: 'success', label: 'Успех' }, { id: 'warning', label: 'Предупреждение' }, ]; export const PALETTE_PRESETS = { club: { label: 'Индиго', dark: { background: '#12141f', surface: '#1c2031', 'surface-selected': '#272c45', 'text-primary': '#eceefa', 'text-secondary': '#a0a6c2', 'border-subtle': '#343a55', 'border-control': '#6d7396', accent: '#a9b4ff', 'on-accent': '#151a3d', 'reaction-active': '#ff9bb3', danger: '#ffa7a3', success: '#8fdab4', warning: '#e8c585', }, light: { background: '#eef0f5', surface: '#ffffff', 'surface-selected': '#dde3f5', 'text-primary': '#1e2233', 'text-secondary': '#5c6279', 'border-subtle': '#d5d9e5', 'border-control': '#8a90a8', accent: '#4c5caa', 'on-accent': '#ffffff', 'reaction-active': '#c02a55', danger: '#b3261e', success: '#2c6b4a', warning: '#7d5a10', }, }, shine: { label: 'Бирюза', dark: { background: '#101b20', surface: '#17272d', 'surface-selected': '#213b3a', 'text-primary': '#e8f2f1', 'text-secondary': '#98adaf', 'border-subtle': '#2a3b40', 'border-control': '#71888a', accent: '#94e1ce', 'on-accent': '#102c27', 'reaction-active': '#f29aab', danger: '#ffaba8', success: '#94e1ce', warning: '#e6c184', }, light: { background: '#faf7f0', surface: '#fffdf8', 'surface-selected': '#efe6d6', 'text-primary': '#302c25', 'text-secondary': '#706658', 'border-subtle': '#e6dfd1', 'border-control': '#978b79', accent: '#93511e', 'on-accent': '#fffaf3', 'reaction-active': '#a43350', danger: '#b13135', success: '#346a48', warning: '#845b14', }, }, mono: { label: 'Монохром', dark: { background: '#111212', surface: '#1c1d1d', 'surface-selected': '#2c2d2d', 'text-primary': '#f3f3f1', 'text-secondary': '#a3a3a0', 'border-subtle': '#2a2b2b', 'border-control': '#6f706e', accent: '#f3f3f1', 'on-accent': '#111212', 'reaction-active': '#ff8a9a', danger: '#ff9d97', success: '#9fd8a8', warning: '#e8c37e', }, light: { background: '#ffffff', surface: '#f6f6f5', 'surface-selected': '#ebebea', 'text-primary': '#141414', 'text-secondary': '#626262', 'border-subtle': '#e4e4e2', 'border-control': '#8c8c8a', accent: '#141414', 'on-accent': '#ffffff', 'reaction-active': '#d0214a', danger: '#b3261e', success: '#2f6b3b', warning: '#7d5a10', }, }, }; const DEFAULT_PRESET = 'club'; const HEX_COLOR = /^#[0-9a-f]{6}$/i; function normalizeMode(value) { const mode = String(value || '').trim().toLowerCase(); return MODES.has(mode) ? mode : 'system'; } function readJson(key) { try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; } } function writeJson(key, value) { try { if (value === null) localStorage.removeItem(key); else localStorage.setItem(key, JSON.stringify(value)); } catch { // В приватном режиме палитра всё равно действует до закрытия страницы. } } function cleanOverrides(raw) { const out = {}; for (const role of PALETTE_ROLES) { const value = String(raw?.[role.id] || '').trim(); if (HEX_COLOR.test(value)) out[role.id] = value.toLowerCase(); } return out; } let sessionPalette = null; export function getPaletteSettings() { if (sessionPalette) return sessionPalette; const raw = readJson(PALETTE_KEY) || {}; const preset = PALETTE_PRESETS[raw.preset] ? raw.preset : DEFAULT_PRESET; return { preset, custom: { dark: cleanOverrides(raw.custom?.dark), light: cleanOverrides(raw.custom?.light) }, }; } export function resolvePalette(resolvedTheme, settings = getPaletteSettings()) { const theme = resolvedTheme === 'light' ? 'light' : 'dark'; return { ...PALETTE_PRESETS[settings.preset][theme], ...settings.custom[theme] }; } function isDefaultPalette(settings) { return settings.preset === DEFAULT_PRESET && !Object.keys(settings.custom.dark).length && !Object.keys(settings.custom.light).length; } function applyPaletteVars(resolvedTheme) { const root = document.documentElement; const settings = getPaletteSettings(); for (const role of PALETTE_ROLES) root.style.removeProperty(`--${role.id}`); root.style.removeProperty('--focus-ring'); // Палитра по умолчанию живёт в styles/main.css — inline-переменные не нужны. if (isDefaultPalette(settings)) return; const colors = resolvePalette(resolvedTheme, settings); for (const [role, value] of Object.entries(colors)) root.style.setProperty(`--${role}`, value); root.style.setProperty('--focus-ring', colors.accent); } export function setPaletteSettings(next) { const settings = { preset: PALETTE_PRESETS[next?.preset] ? next.preset : DEFAULT_PRESET, custom: { dark: cleanOverrides(next?.custom?.dark), light: cleanOverrides(next?.custom?.light) }, }; sessionPalette = settings; if (isDefaultPalette(settings)) { writeJson(PALETTE_KEY, null); writeJson(APPLIED_KEY, null); } else { writeJson(PALETTE_KEY, settings); writeJson(APPLIED_KEY, { dark: resolvePalette('dark', settings), light: resolvePalette('light', settings) }); } return applyThemeMode(); } export function exportPalette() { const settings = getPaletteSettings(); return JSON.stringify({ preset: settings.preset, dark: resolvePalette('dark', settings), light: resolvePalette('light', settings), }, null, 2); } export function importPalette(text) { const data = JSON.parse(String(text || '')); const preset = PALETTE_PRESETS[data?.preset] ? data.preset : DEFAULT_PRESET; const base = PALETTE_PRESETS[preset]; const diff = (theme) => { const colors = cleanOverrides(data?.[theme]); return Object.fromEntries(Object.entries(colors).filter(([role, value]) => base[theme][role] !== value)); }; return setPaletteSettings({ preset, custom: { dark: diff('dark'), light: diff('light') } }); } export function getThemeMode() { if (sessionMode !== null) return sessionMode; try { return normalizeMode(localStorage.getItem(STORAGE_KEY)); } catch { return 'system'; } } export function resolveThemeMode(mode = getThemeMode()) { const normalized = normalizeMode(mode); if (normalized !== 'system') return normalized; return window.matchMedia?.('(prefers-color-scheme: light)')?.matches ? 'light' : 'dark'; } export function applyThemeMode(mode = getThemeMode()) { const normalized = normalizeMode(mode); const resolved = resolveThemeMode(normalized); document.documentElement.dataset.themeMode = normalized; document.documentElement.dataset.theme = resolved; document.documentElement.style.colorScheme = resolved; applyPaletteVars(resolved); const meta = document.querySelector('meta[name="theme-color"]'); if (meta) meta.setAttribute('content', resolvePalette(resolved).background); return { mode: normalized, resolved }; } export function setThemeMode(mode) { const normalized = normalizeMode(mode); sessionMode = normalized; try { localStorage.setItem(STORAGE_KEY, normalized); } catch { // В приватном режиме тема всё равно применяется до закрытия страницы. } return applyThemeMode(normalized); } export function watchSystemTheme() { const media = window.matchMedia?.('(prefers-color-scheme: light)'); if (!media) return () => {}; const onChange = () => { if (getThemeMode() === 'system') applyThemeMode('system'); }; media.addEventListener?.('change', onChange); return () => media.removeEventListener?.('change', onChange); }