feat: add persistent theme switcher
This commit is contained in:
@@ -93,7 +93,7 @@ FastAPI ─ PostgreSQL 17
|
|||||||
|
|
||||||
Production CSP ограничивает browser-запросы текущим доменом и отдельным files-доменом для изображений, запрещает plugins, frames, inline handlers и attributes, eval, wildcard и HTTP. Page scripts и scoped styles принудительно выпускаются отдельными same-origin `_astro`-ассетами; единственное временное inline-исключение остаётся для динамического JSON-LD и описано в [CSP inventory](docs/csp-inventory.md).
|
Production CSP ограничивает browser-запросы текущим доменом и отдельным files-доменом для изображений, запрещает plugins, frames, inline handlers и attributes, eval, wildcard и HTTP. Page scripts и scoped styles принудительно выпускаются отдельными same-origin `_astro`-ассетами; единственное временное inline-исключение остаётся для динамического JSON-LD и описано в [CSP inventory](docs/csp-inventory.md).
|
||||||
|
|
||||||
Первый этап тёмной темы следует системному `prefers-color-scheme` без JavaScript, сохранённого состояния и вспышки неверной палитры. Семантические роли и ограничения миграции описаны в [dark-theme.md](docs/dark-theme.md); ручной выбор и SSR-cookie запланированы следующими этапами D03–D04.
|
Тема по умолчанию следует системному `prefers-color-scheme`, а переключатель в header позволяет выбрать системную, светлую или тёмную палитру. Выбор сохраняется в cookie и применяется Astro при SSR без localStorage-only flash и ослабления CSP. Семантические роли и ограничения дальнейшей миграции компонентов описаны в [dark-theme.md](docs/dark-theme.md).
|
||||||
|
|
||||||
Gitea Actions workflow `.gitea/workflows/ci.yml` на каждый push и pull request проверяет Python, миграции на чистой PostgreSQL, Astro build и полный Compose/Playwright-сценарий. При падении E2E сохраняются логи контейнеров и Playwright-артефакты.
|
Gitea Actions workflow `.gitea/workflows/ci.yml` на каждый push и pull request проверяет Python, миграции на чистой PostgreSQL, Astro build и полный Compose/Playwright-сценарий. При падении E2E сохраняются логи контейнеров и Playwright-артефакты.
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const {
|
|||||||
errorPage = false,
|
errorPage = false,
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
const path = Astro.url.pathname;
|
const path = Astro.url.pathname;
|
||||||
|
const storedTheme = Astro.cookies.get("rf4-theme")?.value;
|
||||||
|
const theme = storedTheme === "light" || storedTheme === "dark" ? storedTheme : "system";
|
||||||
const siteUrl = import.meta.env.PUBLIC_SITE_URL || "https://rf4spotter.ru";
|
const siteUrl = import.meta.env.PUBLIC_SITE_URL || "https://rf4spotter.ru";
|
||||||
const canonical = new URL(path, siteUrl).toString();
|
const canonical = new URL(path, siteUrl).toString();
|
||||||
const socialImage = new URL(image, siteUrl).toString();
|
const socialImage = new URL(image, siteUrl).toString();
|
||||||
@@ -39,7 +41,7 @@ const jsonLd = JSON.stringify({
|
|||||||
}).replaceAll("<", "\\u003c");
|
}).replaceAll("<", "\\u003c");
|
||||||
---
|
---
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru">
|
<html lang="ru" data-theme={theme === "system" ? undefined : theme}>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width" />
|
<meta name="viewport" content="width=device-width" />
|
||||||
@@ -75,10 +77,37 @@ const jsonLd = JSON.stringify({
|
|||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<a href="/" class="brand"><span class="brand-mark" aria-hidden="true"><FishingIcon name="hook" size={24}/></span><span class="brand-name"><strong>RF4 Spotter</strong><span>Ни хвоста, ни чешуи</span></span></a>
|
<a href="/" class="brand"><span class="brand-mark" aria-hidden="true"><FishingIcon name="hook" size={24}/></span><span class="brand-name"><strong>RF4 Spotter</strong><span>Ни хвоста, ни чешуи</span></span></a>
|
||||||
<nav aria-label="Разделы сайта"><a class:list={{active:path === "/"}} aria-current={path === "/" ? "page" : undefined} href="/"><FishingIcon name="float"/> <span>Сейчас клюёт</span></a><a class:list={{active:path.startsWith("/waterbodies") || path.startsWith("/fish")}} aria-current={path.startsWith("/waterbodies") || path.startsWith("/fish") ? "page" : undefined} href="/waterbodies"><FishingIcon name="ripple"/> <span>Каталог</span></a><a class:list={{active:path.startsWith("/records")}} aria-current={path.startsWith("/records") ? "page" : undefined} href="/records"><FishingIcon name="trophy"/> <span>Рекорды</span></a><a class:list={{active:path.startsWith("/report")}} aria-current={path.startsWith("/report") ? "page" : undefined} href="/report"><FishingIcon name="plus"/> <span>Добавить улов</span></a></nav>
|
<nav aria-label="Разделы сайта"><a class:list={{active:path === "/"}} aria-current={path === "/" ? "page" : undefined} href="/"><FishingIcon name="float"/> <span>Сейчас клюёт</span></a><a class:list={{active:path.startsWith("/waterbodies") || path.startsWith("/fish")}} aria-current={path.startsWith("/waterbodies") || path.startsWith("/fish") ? "page" : undefined} href="/waterbodies"><FishingIcon name="ripple"/> <span>Каталог</span></a><a class:list={{active:path.startsWith("/records")}} aria-current={path.startsWith("/records") ? "page" : undefined} href="/records"><FishingIcon name="trophy"/> <span>Рекорды</span></a><a class:list={{active:path.startsWith("/report")}} aria-current={path.startsWith("/report") ? "page" : undefined} href="/report"><FishingIcon name="plus"/> <span>Добавить улов</span></a></nav>
|
||||||
|
<div class="header-tools">
|
||||||
<p class="live-badge"><span></span> Свежие данные и честная оценка</p>
|
<p class="live-badge"><span></span> Свежие данные и честная оценка</p>
|
||||||
|
<div class="theme-switcher" role="group" aria-label="Цветовая тема">
|
||||||
|
<button type="button" data-theme-option="system" aria-pressed={theme === "system"} title="Использовать системную тему"><span aria-hidden="true">◐</span><b>Системная</b></button>
|
||||||
|
<button type="button" data-theme-option="light" aria-pressed={theme === "light"} title="Включить светлую тему"><span aria-hidden="true">☀</span><b>Светлая</b></button>
|
||||||
|
<button type="button" data-theme-option="dark" aria-pressed={theme === "dark"} title="Включить тёмную тему"><span aria-hidden="true">●</span><b>Тёмная</b></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<AlphaBanner />
|
<AlphaBanner />
|
||||||
<main id="main-content" tabindex="-1"><slot /></main>
|
<main id="main-content" tabindex="-1"><slot /></main>
|
||||||
<footer><a href="/" class="brand"><span class="brand-mark" aria-hidden="true"><FishingIcon name="hook" size={24}/></span><span class="brand-name"><strong>RF4 Spotter</strong><span>Ни хвоста, ни чешуи</span></span></a><p>Неофициальный проект для игроков Russian Fishing 4. <a href="/status">Статус</a> · <a href="/rules">Правила</a> · <a href="/privacy">Конфиденциальность</a></p><span>RF4S · 2026</span></footer>
|
<footer><a href="/" class="brand"><span class="brand-mark" aria-hidden="true"><FishingIcon name="hook" size={24}/></span><span class="brand-name"><strong>RF4 Spotter</strong><span>Ни хвоста, ни чешуи</span></span></a><p>Неофициальный проект для игроков Russian Fishing 4. <a href="/status">Статус</a> · <a href="/rules">Правила</a> · <a href="/privacy">Конфиденциальность</a></p><span>RF4S · 2026</span></footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
<script>
|
||||||
|
const root = document.documentElement;
|
||||||
|
const themeButtons = document.querySelectorAll<HTMLButtonElement>("[data-theme-option]");
|
||||||
|
const allowedThemes = new Set(["system", "light", "dark"]);
|
||||||
|
|
||||||
|
const applyTheme = (value: string) => {
|
||||||
|
const theme = allowedThemes.has(value) ? value : "system";
|
||||||
|
if (theme === "system") delete root.dataset.theme;
|
||||||
|
else root.dataset.theme = theme;
|
||||||
|
themeButtons.forEach((button) => {
|
||||||
|
button.setAttribute("aria-pressed", String(button.dataset.themeOption === theme));
|
||||||
|
});
|
||||||
|
const secure = location.protocol === "https:" ? "; Secure" : "";
|
||||||
|
document.cookie = `rf4-theme=${theme}; Max-Age=31536000; Path=/; SameSite=Lax${secure}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
themeButtons.forEach((button) => {
|
||||||
|
button.addEventListener("click", () => applyTheme(button.dataset.themeOption ?? "system"));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -1,31 +1,16 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
--canvas: #f2f5ee;
|
--canvas: light-dark(#f2f5ee, #071719);
|
||||||
--surface-elevated: #ffffff;
|
--surface-elevated: light-dark(#ffffff, #0d2325);
|
||||||
--surface-raised: #fbfcf9;
|
--surface-raised: light-dark(#fbfcf9, #10282a);
|
||||||
--surface-control: #f3f6f1;
|
--surface-control: light-dark(#f3f6f1, #0a1d1f);
|
||||||
--text-primary: #092226;
|
--text-primary: light-dark(#092226, #edf5ef);
|
||||||
--text-secondary: #526662;
|
--text-secondary: light-dark(#526662, #b8c7c3);
|
||||||
--text-tertiary: #647572;
|
--text-tertiary: light-dark(#647572, #93a7a2);
|
||||||
--text-on-dark: #f5f8f3;
|
--text-on-dark: #f5f8f3;
|
||||||
--border-strong: #cbd6ce;
|
--border-strong: light-dark(#cbd6ce, #3a5658);
|
||||||
--border-subtle: #d6dfd7;
|
--border-subtle: light-dark(#d6dfd7, #294244);
|
||||||
--shadow-color: #14333812;
|
--shadow-color: light-dark(#14333812, #00000052);
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--canvas: #071719;
|
|
||||||
--surface-elevated: #0d2325;
|
|
||||||
--surface-raised: #10282a;
|
|
||||||
--surface-control: #0a1d1f;
|
|
||||||
--text-primary: #edf5ef;
|
|
||||||
--text-secondary: #b8c7c3;
|
|
||||||
--text-tertiary: #93a7a2;
|
|
||||||
--text-on-dark: #f4f8f5;
|
|
||||||
--border-strong: #3a5658;
|
|
||||||
--border-subtle: #294244;
|
|
||||||
--shadow-color: #00000052;
|
|
||||||
--paper: var(--canvas);
|
--paper: var(--canvas);
|
||||||
--surface: var(--surface-elevated);
|
--surface: var(--surface-elevated);
|
||||||
--surface-soft: var(--surface-raised);
|
--surface-soft: var(--surface-raised);
|
||||||
@@ -37,14 +22,17 @@
|
|||||||
--line: var(--border-strong);
|
--line: var(--border-strong);
|
||||||
--border: var(--border-strong);
|
--border: var(--border-strong);
|
||||||
--border-soft: var(--border-subtle);
|
--border-soft: var(--border-subtle);
|
||||||
--focus: #c9f45b;
|
--focus: light-dark(#7da529, #c9f45b);
|
||||||
--success: #c9ec8a;
|
--success: light-dark(#426315, #c9ec8a);
|
||||||
--success-soft: #243e25;
|
--success-soft: light-dark(#e2efcf, #243e25);
|
||||||
--warning: #f3ce7a;
|
--warning: light-dark(#73550c, #f3ce7a);
|
||||||
--warning-soft: #46391d;
|
--warning-soft: light-dark(#fff0c7, #46391d);
|
||||||
--danger: #ffafa3;
|
--danger: light-dark(#842f25, #ffafa3);
|
||||||
--danger-soft: #4b2928;
|
--danger-soft: light-dark(#f5d8d2, #4b2928);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] { color-scheme: light; }
|
||||||
|
:root[data-theme="dark"] { color-scheme: dark; }
|
||||||
|
|
||||||
body { background: var(--canvas); color: var(--text-primary); }
|
body { background: var(--canvas); color: var(--text-primary); }
|
||||||
.brand-name span, .topbar nav a, .live-badge, .eyebrow, .overline,
|
.brand-name span, .topbar nav a, .live-badge, .eyebrow, .overline,
|
||||||
@@ -71,9 +59,9 @@
|
|||||||
border-color: var(--border-subtle);
|
border-color: var(--border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.alpha-banner { background: linear-gradient(90deg, #132a26, #102426); color: var(--text-secondary); border-color: var(--border-strong); }
|
.alpha-banner { background: linear-gradient(90deg, light-dark(#e8efdf, #132a26), light-dark(#f7f9f3, #102426)); color: var(--text-secondary); border-color: var(--border-strong); }
|
||||||
.alpha-banner strong { color: var(--text-primary); }
|
.alpha-banner strong { color: var(--text-primary); }
|
||||||
.alpha-banner__cta { background: var(--lime); color: #082226; }
|
.alpha-banner__cta { background: light-dark(var(--deep), var(--lime)); color: light-dark(#fff, #082226); }
|
||||||
|
|
||||||
.spot-card:hover { background: var(--surface-raised); border-color: #6f8988; box-shadow: 0 18px 40px var(--shadow-color); }
|
.spot-card:hover { background: var(--surface-raised); border-color: #6f8988; box-shadow: 0 18px 40px var(--shadow-color); }
|
||||||
.report-form input, .report-form textarea, .report-form select,
|
.report-form input, .report-form textarea, .report-form select,
|
||||||
@@ -85,24 +73,31 @@
|
|||||||
border-color: var(--border-subtle);
|
border-color: var(--border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.signal-card { background: linear-gradient(145deg, #20291f, #151f20); border-color: #756b3d; }
|
.signal-card { background: linear-gradient(145deg, light-dark(#fffdf5, #20291f), light-dark(#f8f5e9, #151f20)); border-color: light-dark(#d6cda9, #756b3d); }
|
||||||
.signal-card > p, .signal-card dd, .signal-heading p { color: var(--text-secondary); }
|
.signal-card > p, .signal-card dd, .signal-heading p { color: var(--text-secondary); }
|
||||||
.signal-card dt { color: #b9ae82; }
|
.signal-card dt { color: light-dark(#776f56, #b9ae82); }
|
||||||
.signal-card .missing-note { background: #453c20; color: #f0d889; }
|
.signal-card .missing-note { background: light-dark(#efe5bd, #453c20); color: light-dark(#6b550f, #f0d889); }
|
||||||
.signal-card .data-passport { background: #172628; border-color: #59605a; }
|
.signal-card .data-passport { background: light-dark(#fffdf7, #172628); border-color: light-dark(#ddd3af, #59605a); }
|
||||||
.source-chip { background: color-mix(in srgb, var(--chip) 24%, var(--surface-elevated)); }
|
.source-chip { background: color-mix(in srgb, var(--chip) 24%, var(--surface-elevated)); }
|
||||||
.notice.success { background: var(--success-soft); color: var(--success); }
|
.notice.success { background: var(--success-soft); color: var(--success); }
|
||||||
.notice.warning, .data-quality { background: var(--warning-soft); color: var(--warning); }
|
.notice.warning, .data-quality { background: var(--warning-soft); color: var(--warning); }
|
||||||
.notice.error { background: var(--danger-soft); color: var(--danger); }
|
.notice.error { background: var(--danger-soft); color: var(--danger); }
|
||||||
.moderation-summary blockquote { background: var(--surface-raised); color: var(--text-secondary); }
|
.moderation-summary blockquote { background: var(--surface-raised); color: var(--text-secondary); }
|
||||||
.moderation-actions .reject { background: var(--danger-soft); color: var(--danger); }
|
.moderation-actions .reject { background: var(--danger-soft); color: var(--danger); }
|
||||||
.moderation-actions .delete { color: var(--danger); border-color: #82504c; }
|
.moderation-actions .delete { color: var(--danger); border-color: light-dark(#d8aaa1, #82504c); }
|
||||||
|
|
||||||
|
.header-tools { justify-self: end; display: flex; align-items: center; gap: 14px; min-width: 0; }
|
||||||
|
.theme-switcher { display: inline-flex; padding: 3px; border: 1px solid var(--border-strong); border-radius: 999px; background: var(--surface-elevated); }
|
||||||
|
.theme-switcher button { min-height: 34px; display: inline-flex; align-items: center; gap: 5px; padding: 0 9px; border: 0; border-radius: 999px; background: transparent; color: var(--text-secondary); font-size: 11px; }
|
||||||
|
.theme-switcher button[aria-pressed="true"] { background: var(--deep); color: var(--lime); box-shadow: 0 2px 8px var(--shadow-color); }
|
||||||
|
.theme-switcher button:focus-visible { outline-offset: 2px; }
|
||||||
|
.theme-switcher b { font: inherit; }
|
||||||
|
@media (max-width: 1320px) { .header-tools .live-badge { display: none; } }
|
||||||
|
@media (max-width: 720px) { .header-tools { margin-left: auto; } .theme-switcher button { width: 34px; justify-content: center; padding: 0; } .theme-switcher b { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } }
|
||||||
.activity-timeline { background: var(--surface-elevated); border-color: var(--border-subtle); }
|
.activity-timeline { background: var(--surface-elevated); border-color: var(--border-subtle); }
|
||||||
.timeline-chart { border-color: var(--border-strong); background: repeating-linear-gradient(to top, transparent 0, transparent 35px, #294244 36px); }
|
.timeline-chart { border-color: var(--border-strong); background: repeating-linear-gradient(to top, transparent 0, transparent 35px, var(--border-subtle) 36px); }
|
||||||
.timeline-slot strong { color: var(--text-primary); }
|
.timeline-slot strong { color: var(--text-primary); }
|
||||||
.timeline-slot small, .activity-legend { color: var(--text-tertiary); }
|
.timeline-slot small, .activity-legend { color: var(--text-tertiary); }
|
||||||
}
|
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
:root { color-scheme: light; }
|
:root { color-scheme: light; }
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -42,8 +42,8 @@
|
|||||||
|
|
||||||
- [x] **D01 · Семантическая палитра и color-scheme.** Жёсткие цвета и конфликтующая роль исторического `--deep` проинвентаризированы; добавлены независимые canvas/surface/elevated/control, text, border, shadow и контрастные status-роли для light/dark. Базовые public/admin поверхности получили первый dark-layer, нативные controls используют `color-scheme: light dark`; стратегия миграции зафиксирована в [dark-theme.md](dark-theme.md).
|
- [x] **D01 · Семантическая палитра и color-scheme.** Жёсткие цвета и конфликтующая роль исторического `--deep` проинвентаризированы; добавлены независимые canvas/surface/elevated/control, text, border, shadow и контрастные status-роли для light/dark. Базовые public/admin поверхности получили первый dark-layer, нативные controls используют `color-scheme: light dark`; стратегия миграции зафиксирована в [dark-theme.md](dark-theme.md).
|
||||||
- [x] **D02 · Системный режим без вспышки.** Первый визит следует `prefers-color-scheme` полностью через CSS: серверный HTML сразу совместим с системной темой, состояние не хранится, inline bootstrap не добавлен и CSP не ослаблена. Явное переопределение появится только вместе с D03–D04.
|
- [x] **D02 · Системный режим без вспышки.** Первый визит следует `prefers-color-scheme` полностью через CSS: серверный HTML сразу совместим с системной темой, состояние не хранится, inline bootstrap не добавлен и CSP не ослаблена. Явное переопределение появится только вместе с D03–D04.
|
||||||
- [ ] **D03 · Переключатель темы.** Добавить доступный трёхпозиционный выбор «Системная / Светлая / Тёмная» в header и компактный вариант для мобильной навигации. Управление должно иметь текстовое accessible name, видимый focus, корректные `aria-pressed`/состояние и работать с клавиатуры.
|
- [x] **D03 · Переключатель темы.** В header добавлен доступный трёхпозиционный выбор «Системная / Светлая / Тёмная»; на узких экранах он сохраняет три понятные иконки и доступные названия. Кнопки работают с клавиатуры, имеют видимый focus и синхронизируют `aria-pressed`.
|
||||||
- [ ] **D04 · Сохранение и SSR-согласование.** Хранить явный выбор в cookie с `SameSite=Lax`, безопасным сроком жизни и значениями allowlist; Astro SSR читает cookie и выставляет `data-theme` на `<html>`. Внешний client module мгновенно переключает тему и синхронизирует изменение системной схемы только в режиме «Системная», без localStorage-only flash и без inline-кода.
|
- [x] **D04 · Сохранение и SSR-согласование.** Выбор хранится год в allowlist-cookie `rf4-theme` с `SameSite=Lax` и `Secure` на HTTPS; Astro SSR выставляет `data-theme` до отрисовки, а системный режим оставляет выбор браузеру. Собранный Astro client script переключает тему без inline-кода, localStorage и ослабления CSP.
|
||||||
- [ ] **D05 · Темизация компонентов и графики.** Перевести header/footer, hero, карточки активности, таблицы, формы, provenance/quality badges, состояния, admin-панель, радар, timeline, силуэты, глифы и декоративные водные мотивы на семантические токены. Отдельно проверить фотографии, прозрачные PNG, screenshots, тени, градиенты и режим forced-colors; источники и статусы не должны различаться только цветом.
|
- [ ] **D05 · Темизация компонентов и графики.** Перевести header/footer, hero, карточки активности, таблицы, формы, provenance/quality badges, состояния, admin-панель, радар, timeline, силуэты, глифы и декоративные водные мотивы на семантические токены. Отдельно проверить фотографии, прозрачные PNG, screenshots, тени, градиенты и режим forced-colors; источники и статусы не должны различаться только цветом.
|
||||||
- [ ] **D06 · Метаданные браузера и CSP.** Добавить парные `theme-color` для light/dark media queries, проверить manifest/PWA chrome и отсутствие новых inline script/style/attributes. Сохранить `inlinedScripts: []`, `style-src 'self'`, `style-src-attr 'none'` и не возвращать `unsafe-inline` ради инициализации темы.
|
- [ ] **D06 · Метаданные браузера и CSP.** Добавить парные `theme-color` для light/dark media queries, проверить manifest/PWA chrome и отсутствие новых inline script/style/attributes. Сохранить `inlinedScripts: []`, `style-src 'self'`, `style-src-attr 'none'` и не возвращать `unsafe-inline` ради инициализации темы.
|
||||||
- [ ] **D07 · Визуальная и accessibility-приёмка.** Проверить light/dark/system на 320/390/768/1280 px для главной, каталогов, detail, records, report, status и всех admin-экранов; покрыть normal/hover/focus/disabled/error/loading/empty и длинные данные. Для обеих тем обеспечить WCAG AA, отсутствие горизонтального scroll и CLS, корректную печать, reduced motion и переключение без потери введённых данных; сохранить эталонные screenshots и краткий отчёт.
|
- [ ] **D07 · Визуальная и accessibility-приёмка.** Проверить light/dark/system на 320/390/768/1280 px для главной, каталогов, detail, records, report, status и всех admin-экранов; покрыть normal/hover/focus/disabled/error/loading/empty и длинные данные. Для обеих тем обеспечить WCAG AA, отсутствие горизонтального scroll и CLS, корректную печать, reduced motion и переключение без потери введённых данных; сохранить эталонные screenshots и краткий отчёт.
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
- `--border-strong`, `--border-subtle`, `--shadow-color` — разделители и глубина;
|
- `--border-strong`, `--border-subtle`, `--shadow-color` — разделители и глубина;
|
||||||
- существующие status/source-токены сохраняют смысл, но получают контрастные dark-значения там, где используются как текст.
|
- существующие status/source-токены сохраняют смысл, но получают контрастные dark-значения там, где используются как текст.
|
||||||
|
|
||||||
Первый этап следует `prefers-color-scheme` только через CSS и поэтому не создаёт вспышку неверной темы, не записывает состояние и не требует inline script. Явный выбор появится на этапе D03–D04 и будет согласован с SSR через allowlist-cookie.
|
По умолчанию сайт следует `prefers-color-scheme` через CSS. Переключатель в header позволяет явно выбрать системную, светлую или тёмную тему; выбранное значение хранится в allowlist-cookie `rf4-theme` (`SameSite=Lax`, год, `Secure` на HTTPS). Astro читает cookie на сервере и сразу выставляет `data-theme="light|dark"`; для системного режима атрибут отсутствует. Client script собирается Astro во внешний asset, поэтому inline-код, localStorage и ослабление CSP не требуются.
|
||||||
|
|
||||||
## Дальнейшая миграция
|
## Дальнейшая миграция
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user