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', }, }, }; function mixHex(first, second, amount) { const a = first.match(/[0-9a-f]{2}/gi).map((part) => parseInt(part, 16)); const b = second.match(/[0-9a-f]{2}/gi).map((part) => parseInt(part, 16)); return `#${a.map((value, index) => Math.round(value * (1 - amount) + b[index] * amount).toString(16).padStart(2, '0')).join('')}`; } const EXTRA_PRESETS = [ ['ocean', 'Океан', '#101c2b', '#182a3d', '#213c55', '#edf5ff', '#a6b9cf', '#75c8f0', '#102b40', '#f9fcff', '#e4f2fb', '#173247', '#536f83', '#087eae', '#ffffff'], ['lavender', 'Лаванда', '#211b2b', '#30263d', '#473658', '#f5efff', '#bcb0cf', '#c5a0f5', '#f1ebf7', '#fffaff', '#e9def3', '#31283a', '#6d6076', '#7950a8', '#ffffff'], ['graphite', 'Графит', '#17191c', '#222529', '#34383d', '#f2f4f5', '#adb4ba', '#c4cbd1', '#eef0f1', '#ffffff', '#e0e4e6', '#202427', '#5b646a', '#46535b', '#ffffff'], ['sakura', 'Сакура', '#291b25', '#382430', '#503140', '#fff0f5', '#c9aab7', '#f39abb', '#fff0f4', '#fffafd', '#f7dce6', '#39232d', '#795967', '#b73565', '#ffffff'], ['emerald', 'Изумруд', '#10231e', '#19332b', '#24483a', '#e8f8ef', '#a0c5ae', '#7fe0aa', '#eaf5ed', '#fbfffc', '#d8eddf', '#20372a', '#587363', '#187747', '#ffffff'], ['sunrise', 'Рассвет', '#2b2017', '#3b2b1c', '#58402a', '#fff4df', '#d0b894', '#ffc36b', '#fff3df', '#fffaf0', '#f5e4c4', '#3a2b1b', '#78664c', '#a45c12', '#ffffff'], ['cyberpunk', 'Киберпанк', '#171329', '#241b3b', '#382657', '#f5f0ff', '#b9a9d8', '#ff70dc', '#f6edfb', '#fffbff', '#eddcf5', '#30233b', '#705b7b', '#a12b91', '#ffffff'], ['arctic', 'Арктика', '#14242a', '#1d333a', '#294750', '#edfaff', '#a7c4cc', '#82e2e5', '#e8f7f8', '#fbffff', '#d6eef0', '#20373a', '#587477', '#087f83', '#ffffff'], ['coral', 'Коралл', '#2b1d1b', '#3a2925', '#563b34', '#fff1ec', '#d1b2a8', '#ff9d83', '#fff0e9', '#fffaf7', '#f9dfd4', '#3b2923', '#7b6258', '#bc4931', '#ffffff'], ['grape', 'Виноград', '#211829', '#302238', '#493151', '#f8efff', '#c1a9cd', '#d39afa', '#f4eafa', '#fffaff', '#e9d8f2', '#35253d', '#725d7a', '#8145a9', '#ffffff'], ['forest', 'Лес', '#19231b', '#263328', '#394b39', '#f0f7ed', '#b1c1a7', '#b0d17c', '#f0f4e8', '#fcfff7', '#e4ebd7', '#303828', '#69735d', '#52751f', '#ffffff'], ['ruby', 'Рубин', '#28181e', '#382128', '#512e39', '#fff0f2', '#ceabb3', '#fa8095', '#fff0f1', '#fffafb', '#f4dce0', '#3c242b', '#795861', '#b52d4d', '#ffffff'], ['lagoon', 'Лагуна', '#102323', '#193333', '#254949', '#e9f8f4', '#a1c4bc', '#67d8c2', '#e7f5ef', '#fbfffc', '#d3eee4', '#1e3932', '#56746b', '#087e70', '#ffffff'], ['copper', 'Медь', '#281e19', '#392a22', '#533d30', '#fff2e8', '#ceb5a2', '#e9a879', '#f8eee5', '#fffaf5', '#eddfd1', '#392a21', '#786657', '#a65e2b', '#ffffff'], ['neon', 'Неон', '#141f1b', '#1e3028', '#2b4939', '#edfff2', '#a6c6ae', '#a7f36b', '#eff7e9', '#fcfff8', '#dff0d0', '#253923', '#5b7352', '#4c861d', '#ffffff'], ['paper', 'Бумага', '#20201e', '#2e2e2b', '#454540', '#f6f5ef', '#bcbcb2', '#d6c49b', '#f3f0e8', '#fffdf7', '#e8e2d3', '#302e28', '#716d61', '#76613b', '#ffffff'], ['cosmos', 'Космос', '#171a30', '#222744', '#323b62', '#f0f2ff', '#aeb6d6', '#9eacff', '#edf0fc', '#fbfcff', '#dfe4fa', '#252b46', '#5c6687', '#4f61c5', '#ffffff'], ['mint', 'Мята', '#14231f', '#1f332c', '#2e4940', '#ebfbf4', '#a9c6b9', '#8fe2c0', '#eaf5ee', '#fbfffc', '#d7eee1', '#21392f', '#597568', '#16805b', '#ffffff'], ['sandstone', 'Песчаник', '#272019', '#382e23', '#514333', '#fff5e8', '#c9b69c', '#f0bd78', '#f6efe3', '#fffdf8', '#e9deca', '#3a3023', '#776b58', '#96601b', '#ffffff'], ['dusk', 'Сумерки', '#211b2a', '#30263c', '#493658', '#f5efff', '#b8acc8', '#e0a6c8', '#f2edf4', '#fffaff', '#e8dfe9', '#352a39', '#706174', '#98577d', '#ffffff'], ['lemon', 'Лимон', '#202216', '#30331e', '#454a2b', '#f8f9e9', '#bfc39c', '#e2e96d', '#f2f3e3', '#fdfff5', '#e6e9cc', '#353822', '#71755a', '#737c16', '#ffffff'], ['obsidian', 'Обсидиан', '#151719', '#202326', '#30363b', '#f2f5f7', '#aab5bc', '#8db7c7', '#e9eef0', '#fbfeff', '#dce5e8', '#252c30', '#5c6a70', '#3b778c', '#ffffff'], ]; function makePreset(label, dark, light) { const toColors = (palette, theme) => { const [background, surface, ...rest] = palette; const [selected, text, secondary, accent, onAccent] = rest.length === 5 ? rest : [mixHex(surface, rest[0], 0.08), ...rest]; return { background, surface, 'surface-selected': selected, 'text-primary': text, 'text-secondary': secondary, 'border-subtle': mixHex(surface, text, 0.14), 'border-control': mixHex(surface, text, 0.4), accent, 'on-accent': onAccent, 'reaction-active': theme === 'dark' ? '#ff9bb3' : '#c02a55', danger: theme === 'dark' ? '#ffa7a3' : '#b3261e', success: theme === 'dark' ? '#8fdab4' : '#2c6b4a', warning: theme === 'dark' ? '#e8c585' : '#7d5a10', }; }; return { label, dark: toColors(dark, 'dark'), light: toColors(light, 'light') }; } for (const [id, label, ...colors] of EXTRA_PRESETS) { PALETTE_PRESETS[id] = makePreset(label, colors.slice(0, 7), colors.slice(7)); } 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); }