SHA256
293 lines
14 KiB
JavaScript
293 lines
14 KiB
JavaScript
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);
|
|
}
|