SHA256
- styles/main.css: роли цветов (по умолчанию «Индиго»), шкала отступов/скруглений/шрифтов, цвета отношений для обеих тем; жёсткие цвета в стилях заменены на роли. - Палитра: пресеты и личные правки, «Оформление» Авто/День/Ночь, долгое нажатие — редактор цветов с экспортом/импортом. - Каналы: пузыри постов автора, плашки дней, строка «Написать в канал…», «О канале», создание канала с адресом из названия; лента открывается на свежих постах. - Чаты: плоский список, чипы-фильтры, пузыри, плашки дней; нижняя панель скрыта в переписке. - Корневые разделы — единая шапка; профиль и чужой профиль — общая карточка; настройки, кошелёк, сеансы — меню-списки. - Нижняя панель: иконки без подписей, бейджи на иконках. - confirmDialog вместо window.confirm/alert; на телефоне диалоги — шторки снизу. - docs/UI-Design/ISSUES-for-dev.md — найденные ошибки сервера/UI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
233 lines
9.1 KiB
JavaScript
233 lines
9.1 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',
|
|
},
|
|
},
|
|
};
|
|
|
|
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);
|
|
}
|