UI: новый общий стиль, тёмная/светлая тема, настраиваемая палитра

- 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>
This commit is contained in:
q
2026-09-26 10:05:26 +03:00
co-authored by Claude Opus 5.5
parent 6e5b57fd7c
commit f8900e531a
94 changed files with 3337 additions and 2432 deletions
+186 -3
View File
@@ -1,12 +1,188 @@
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 {
@@ -16,14 +192,21 @@ export function getThemeMode() {
}
}
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 = normalized === 'system'
? (window.matchMedia?.('(prefers-color-scheme: light)')?.matches ? 'light' : 'dark')
: normalized;
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 };
}