Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
703a0ba32f | ||
|
|
35b00d7fd6 | ||
|
|
8ffbd7f9ec | ||
|
|
b7b49a3a63 | ||
|
|
fe9367b5e9 | ||
|
|
14958be302 |
@@ -46,7 +46,7 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
|
||||
|
||||
`python -m rf4_research.media_cli --coverage` сравнивает manifest с датированным `data/media/catalog-baseline.json`: отдельно считает файлы, уникальные нормализованные подписи и кандидатов без подписи, поэтому дубли и общие учебные схемы не завышают покрытие. Сейчас не покрыты минимум 24 рыбы и все 19 водоёмов, а до ручного review не подтверждены 251 рыба и все 19 водоёмов. Общий target снастей остаётся `null`, пока разрешённый источник не отдаст проверяемый полный счётчик.
|
||||
|
||||
Актуальный внешний ориентир — 19 водоёмов и 252 вида рыб; локальная альфа пока содержит 2+2 сущности. Media-manifest включает 452 кандидата: 228 изображений рыб, 149 приманок и 75 справочных изображений; подтверждённых entity-карт водоёмов пока нет. Вручную проверены и сопоставлены 1 рыба, 2 приманки и 1 официальная схема; 446 файлов остаются в очереди, 1 URL признан невалидным. Полное число «снастей» пока не заявляется: приманки — лишь одна часть каталога наряду с удилищами, катушками, лесками, крючками и оснастками.
|
||||
Актуальный внешний ориентир — 19 водоёмов и 252 вида рыб; локальная альфа пока содержит 2+2 сущности. Media-manifest включает 452 кандидата: 228 изображений рыб, 149 приманок и 75 справочных изображений; подтверждённых entity-карт водоёмов пока нет. Вручную проверены 20 ассетов: 1 рыба, 9 приманок/наживок и 10 справочных схем; 425 записей остаются в очереди, 1 файл ожидает ревью, 6 URL признаны невалидными. Полное число «снастей» пока не заявляется: приманки — лишь одна часть каталога наряду с удилищами, катушками, лесками, крючками и оснастками.
|
||||
|
||||
Для измерений на собственном сервере подготовлен read-only `deploy/load-smoke.py`: он считает p50/p95/max и HTTP-коды для activity/records, а при наличии `ADMIN_TOKEN` — staging/moderation. Методика и безопасные ступени нагрузки описаны в [docs/load-testing.md](docs/load-testing.md); локальные цифры не выдаются за production baseline.
|
||||
|
||||
@@ -91,7 +91,9 @@ FastAPI ─ PostgreSQL 17
|
||||
|
||||
Наружу production-профиль публикует только Caddy. PostgreSQL, API, Astro и MinIO находятся в Docker-сетях. Caddy завершает TLS и защищает административные страницы Basic Auth; административный API отдельно проверяет Bearer-токен в FastAPI. Basic не накладывается на API-запросы.
|
||||
|
||||
Production CSP ограничивает browser-запросы текущим доменом и отдельным files-доменом для изображений, запрещает plugins, frames, inline handlers, eval, wildcard и HTTP. Оставшиеся inline script/style зависимости и путь к nonce/hash перечислены в [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`, а переключатель в 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-артефакты.
|
||||
|
||||
|
||||
@@ -7,4 +7,9 @@ export default defineConfig({
|
||||
output: "server",
|
||||
adapter: node({ mode: "standalone" }),
|
||||
server: { host: true, port: 4321 },
|
||||
vite: {
|
||||
// Keep executable scripts and compiled component styles in same-origin
|
||||
// assets so production CSP does not need broad inline allowances.
|
||||
build: { assetsInlineLimit: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
---
|
||||
const { buckets } = Astro.props as { buckets: { start: string; end: string; count: number }[] };
|
||||
const max = Math.max(1, ...buckets.map(bucket => bucket.count));
|
||||
const meterLevel = (count: number) => Math.round((count / max * 100) / 5) * 5;
|
||||
---
|
||||
<section class="activity-timeline" aria-labelledby="timeline-title">
|
||||
<header><div><span class="overline">Последние 72 часа</span><h2 id="timeline-title">Леска активности</h2></div><p>Все одобренные записи · по времени поступления · шаг 12 часов</p></header>
|
||||
<div class="timeline-chart">
|
||||
{buckets.map((bucket, index) => <div class="timeline-slot"><span aria-hidden="true" class="timeline-line" style={`--height:${bucket.count / max * 100}%`}>{bucket.count > 0 && <i></i>}</span><strong>{bucket.count}</strong><small>{`${(6-index)*12}–${(5-index)*12} ч назад`}</small></div>)}
|
||||
{buckets.map((bucket, index) => <div class="timeline-slot"><span aria-hidden="true" class:list={["timeline-line", `meter-level-${meterLevel(bucket.count)}`]}>{bucket.count > 0 && <i></i>}</span><strong>{bucket.count}</strong><small>{`${(6-index)*12}–${(5-index)*12} ч назад`}</small></div>)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -13,6 +13,7 @@ import "../styles/alpha-banner.css";
|
||||
import "../styles/signal-pagination.css";
|
||||
import "../styles/dashboard-polish.css";
|
||||
import "../styles/loading-states.css";
|
||||
import "../styles/theme.css";
|
||||
import FishingIcon from "../components/FishingIcon.astro";
|
||||
import AlphaBanner from "../components/AlphaBanner.astro";
|
||||
const {
|
||||
@@ -24,6 +25,8 @@ const {
|
||||
errorPage = false,
|
||||
} = Astro.props;
|
||||
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 canonical = new URL(path, siteUrl).toString();
|
||||
const socialImage = new URL(image, siteUrl).toString();
|
||||
@@ -38,7 +41,7 @@ const jsonLd = JSON.stringify({
|
||||
}).replaceAll("<", "\\u003c");
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<html lang="ru" data-theme={theme === "system" ? undefined : theme}>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
@@ -74,10 +77,37 @@ const jsonLd = JSON.stringify({
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<AlphaBanner />
|
||||
<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>
|
||||
</body>
|
||||
</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>
|
||||
|
||||
@@ -56,6 +56,7 @@ if (!filterError && allUnavailable) {
|
||||
Astro.response.headers.set("Cache-Control", "no-store");
|
||||
}
|
||||
const leaderLevel = items.length > 1 && items[0] ? activityLevel(items[0].activity_score) : null;
|
||||
const leaderMeter = items[0] ? Math.round(Math.min(100, Math.max(0, items[0].activity_score)) / 5) * 5 : 0;
|
||||
const selectedWaterbody = waterbodies.find(item => item.slug === waterbody)?.name_ru ?? "Все водоёмы";
|
||||
const selectedFish = fishes.find(item => item.slug === fish)?.name_ru ?? "Любая рыба";
|
||||
const periodLabel = { "6": "6 часов", "12": "12 часов", "24": "24 часа", "72": "72 часа" }[hours] ?? hours;
|
||||
@@ -86,7 +87,7 @@ const datasetJsonLd = {
|
||||
</form></section>
|
||||
<div class="active-filters content-grid" aria-label="Применённые фильтры"><span>{selectedWaterbody}</span><span>{selectedFish}</span><span>{periodLabel}</span><span>{sortLabel}</span>{filtersChanged && <a href="/#results">Сбросить</a>}</div>
|
||||
<section class="dashboard content-grid" id="results"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} из {totalItems} {plural(totalItems, ["точка", "точки", "точек"])}</span></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a data-action="secondary" href="/">Сбросить фильтры</a></div> : activityUnavailable ? <StatePanel contained={false} tone="unavailable" title="Горячие точки временно недоступны" description="Полевые сигналы и справочники продолжают работать независимо." /> : items.length ? <><div class="spot-list">{items.map(item => <ActivityCard item={item} />)}</div>{offset + items.length < totalItems && <a class="load-more" data-action="secondary" href={`/?${(() => { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(offset + items.length)); return p.toString(); })()}#results`}>Показать ещё <span>{offset + items.length} из {totalItems}</span> ↓</a>}</> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p></div>}</div>
|
||||
{items[0] && leaderLevel && <aside class="detail-card"><div class="detail-head"><div><span class="overline">Лидер активности</span><h2>{items[0].waterbody} <em>{items[0].x}:{items[0].y}</em></h2></div><a href={`/spots/${items[0].spot_id}`} aria-label="Открыть точку"><FishingIcon name="arrow"/></a></div><div class="source-strip">{items[0].sources.map(source => <SourceBadge source={source}/>)}</div><div class="detail-score"><div class="float-gauge" style={`--level:${items[0].activity_score}%`} aria-label={`Индекс активности: ${items[0].activity_score} из 100`}><span class="float-gauge__line"></span><span class="float-gauge__water"></span><span class="float-gauge__bob"><i></i></span><strong>{items[0].activity_score}</strong><small>из 100</small></div><div><span>Индекс активности</span><strong data-activity-level={leaderLevel.short}>{leaderLevel.description}</strong><p>{items[0].explanation}</p></div></div><div class="metric-grid"><div><span><FishingIcon name="ripple"/></span><small>Уверенность</small><strong>{items[0].confidence_score}%</strong></div><div><span><FishingIcon name="angler"/></span><small>{plural(items[0].unique_players, ["Игрок", "Игрока", "Игроков"])}</small><strong>{items[0].unique_players}</strong></div><div><span><FishingIcon name="clock"/></span><small>Последний</small><strong>{ago(items[0].last_confirmed_at)}</strong></div><div><span><FishingIcon name="scale"/></span><small>Средний вес</small><strong>{kg(items[0].average_weight_g)}</strong></div></div><div class="best-lure"><span class="overline">Лучшая связка</span><div><TackleGlyph name={items[0].best_bait}/><strong>{items[0].best_bait ?? "Не указана"}</strong><span>{items[0].catches} {plural(items[0].catches, ["улов", "улова", "уловов"])}</span></div></div><p class="confidence-note"><span>✓</span><span><strong>Оценка объяснима.</strong> Один игрок не может искусственно поднять уверенность.</span></p></aside>}
|
||||
{items[0] && leaderLevel && <aside class="detail-card"><div class="detail-head"><div><span class="overline">Лидер активности</span><h2>{items[0].waterbody} <em>{items[0].x}:{items[0].y}</em></h2></div><a href={`/spots/${items[0].spot_id}`} aria-label="Открыть точку"><FishingIcon name="arrow"/></a></div><div class="source-strip">{items[0].sources.map(source => <SourceBadge source={source}/>)}</div><div class="detail-score"><div class:list={["float-gauge", `meter-level-${leaderMeter}`]} aria-label={`Индекс активности: ${items[0].activity_score} из 100`}><span class="float-gauge__line"></span><span class="float-gauge__water"></span><span class="float-gauge__bob"><i></i></span><strong>{items[0].activity_score}</strong><small>из 100</small></div><div><span>Индекс активности</span><strong data-activity-level={leaderLevel.short}>{leaderLevel.description}</strong><p>{items[0].explanation}</p></div></div><div class="metric-grid"><div><span><FishingIcon name="ripple"/></span><small>Уверенность</small><strong>{items[0].confidence_score}%</strong></div><div><span><FishingIcon name="angler"/></span><small>{plural(items[0].unique_players, ["Игрок", "Игрока", "Игроков"])}</small><strong>{items[0].unique_players}</strong></div><div><span><FishingIcon name="clock"/></span><small>Последний</small><strong>{ago(items[0].last_confirmed_at)}</strong></div><div><span><FishingIcon name="scale"/></span><small>Средний вес</small><strong>{kg(items[0].average_weight_g)}</strong></div></div><div class="best-lure"><span class="overline">Лучшая связка</span><div><TackleGlyph name={items[0].best_bait}/><strong>{items[0].best_bait ?? "Не указана"}</strong><span>{items[0].catches} {plural(items[0].catches, ["улов", "улова", "уловов"])}</span></div></div><p class="confidence-note"><span>✓</span><span><strong>Оценка объяснима.</strong> Один игрок не может искусственно поднять уверенность.</span></p></aside>}
|
||||
</section>
|
||||
{signals.length > 0 && <SignalFeed signals={signals}/>}
|
||||
{signalsUnavailable && <div class="content-grid"><StatePanel tone="unavailable" title="Полевые сигналы временно недоступны" description="Горячие точки и справочники продолжают работать независимо." /></div>}
|
||||
|
||||
@@ -7,6 +7,7 @@ const state = Astro.url.searchParams.get("state");
|
||||
const reportId = Astro.url.searchParams.get("report_id");
|
||||
---
|
||||
<Layout title="Добавить улов Russian Fishing 4 — RF4 Spotter" description="Отправьте собственное наблюдение об улове RF4 на проверку и помогите игрокам находить актуальные точки.">
|
||||
<span id="report-state" data-state={state ?? ""} hidden></span>
|
||||
<section class="form-hero"><div><span class="eyebrow"><b>+1</b> Помочь сообществу</span><h1>Добавить<br/><em>свой улов</em></h1></div><p>Около минуты — и рабочая точка появится в общей статистике после проверки модератором.</p></section>
|
||||
{state === "sent" && <div class="notice success">Улов отправлен на модерацию. Спасибо!</div>}
|
||||
{state === "screenshot_sent" && <div class="notice success">Скриншот добавлен к ранее созданной заявке.</div>}
|
||||
@@ -20,13 +21,15 @@ const reportId = Astro.url.searchParams.get("report_id");
|
||||
<fieldset><legend>Главное <span>обязательно</span></legend><div class="form-grid"><label>Рыба *<select name="fish_slug" required>{fishes.map(x => <option value={x.slug}>{x.name_ru}</option>)}</select></label><label>Водоём *<select name="waterbody_slug" required>{waterbodies.map(x => <option value={x.slug}>{x.name_ru}</option>)}</select></label><label>Координата X *<input name="x" type="number" min="-10000" max="10000" placeholder="Например, 72" required /></label><label>Координата Y *<input name="y" type="number" min="-10000" max="10000" placeholder="Например, 84" required /></label><label>Вес, граммы *<input name="weight_g" type="number" min="1" max="3000000" placeholder="1250" required /></label><label>Приманка<input name="bait_name" maxlength="200" placeholder="Название в игре" /></label></div><p class="field-help">Координаты — два целых числа с карты. Вес: 1,25 кг = 1250 г.</p></fieldset>
|
||||
<details class="optional-fields"><summary>Дополнительные сведения <span>необязательно</span></summary><div class="form-grid"><label>Способ ловли<select name="fishing_method"><option value="">Не указан</option><option value="spinning">Спиннинг</option><option value="bottom">Донная</option><option value="float">Поплавочная</option></select></label><label>Проводка<input name="retrieve_method" maxlength="100" /></label><label>Скорость проводки<input name="retrieve_speed" type="number" min="0" max="100" /></label><label>Ник игрока<input name="player_name" maxlength="100" /></label></div><label class="wide">Комментарий<textarea name="comment" maxlength="1000" rows="4"></textarea></label><label class="wide">Скриншот, JPEG/PNG/WebP до 8 МБ<input name="screenshot" type="file" accept="image/jpeg,image/png,image/webp" /></label></details><label class="honeypot" aria-hidden="true">Сайт<input name="website" tabindex="-1" autocomplete="off" /></label><p class="privacy">Ник и скриншот необязательны. Из изображения удаляются EXIF и прочие метаданные.</p><label class="consent"><input name="consent" type="checkbox" required /> <span>Я отправляю собственное наблюдение и принимаю <a href="/rules" target="_blank">правила</a> и <a href="/privacy" target="_blank">политику конфиденциальности</a>.</span></label><button data-action="primary" type="submit">Отправить на проверку</button>
|
||||
</form>}
|
||||
<script is:inline define:vars={{ state }}>
|
||||
const form = document.querySelector(".report-form"); const key = "rf4-report-draft";
|
||||
<script>
|
||||
const form = document.querySelector<HTMLFormElement>(".report-form");
|
||||
const state = document.querySelector<HTMLElement>("#report-state")?.dataset.state ?? "";
|
||||
const key = "rf4-report-draft";
|
||||
// A05: Safe sessionStorage access with error handling
|
||||
const safeStorage = {
|
||||
getItem: (k) => { try { return sessionStorage.getItem(k); } catch { return null; } },
|
||||
setItem: (k, v) => { try { sessionStorage.setItem(k, v); } catch {} },
|
||||
removeItem: (k) => { try { sessionStorage.removeItem(k); } catch {} },
|
||||
getItem: (k: string) => { try { return sessionStorage.getItem(k); } catch { return null; } },
|
||||
setItem: (k: string, v: string) => { try { sessionStorage.setItem(k, v); } catch {} },
|
||||
removeItem: (k: string) => { try { sessionStorage.removeItem(k); } catch {} },
|
||||
};
|
||||
// A05: Restore draft on all error states that don't destroy the submission
|
||||
const recoverableStates = ["create_error", "rate_limited", "server_error", "timeout"];
|
||||
@@ -35,17 +38,19 @@ const reportId = Astro.url.searchParams.get("report_id");
|
||||
const draft = JSON.parse(safeStorage.getItem(key) || "{}");
|
||||
for (const [name, value] of Object.entries(draft)) {
|
||||
const field = form.elements.namedItem(name);
|
||||
if (field && "value" in field) field.value = value;
|
||||
if (field instanceof HTMLInputElement || field instanceof HTMLSelectElement || field instanceof HTMLTextAreaElement) {
|
||||
field.value = String(value);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
document.querySelector("#form-error")?.focus();
|
||||
document.querySelector<HTMLElement>("#form-error")?.focus();
|
||||
}
|
||||
if (state === "sent" || state === "screenshot_sent") safeStorage.removeItem(key);
|
||||
form?.addEventListener("submit", () => {
|
||||
const btn = form.querySelector("button[type=submit]");
|
||||
const btn = form.querySelector<HTMLButtonElement>("button[type=submit]");
|
||||
if (btn) { btn.disabled = true; btn.textContent = "Отправка..."; }
|
||||
// A05: Save draft before submit, handle sessionStorage unavailable
|
||||
const draft = {};
|
||||
const draft: Record<string, string> = {};
|
||||
try {
|
||||
for (const [name, value] of new FormData(form)) {
|
||||
if (typeof value === "string" && name !== "website") draft[name] = value;
|
||||
|
||||
@@ -47,7 +47,7 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr
|
||||
</section>
|
||||
</>}
|
||||
</Layout>
|
||||
<script is:inline>
|
||||
<script>
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-copy-coordinates]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const value = button.dataset.copyCoordinates || "";
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
.activity-timeline{width:min(1180px,calc(100% - 64px));margin:20px auto 0;padding:25px 28px;border-radius:14px;background:#fff;border:1px solid #d9e1da}.activity-timeline header{display:flex;align-items:end;justify-content:space-between;gap:20px}.activity-timeline h2{font:400 29px Georgia,serif;margin:5px 0 0}.activity-timeline header p{margin:0;color:#71817e;font-size:10px}
|
||||
.timeline-chart{height:145px;display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-top:18px;padding-top:8px;border-bottom:1px solid #a9bbb1;background:repeating-linear-gradient(to top,transparent 0,transparent 35px,#dce5de 36px)}.timeline-slot{position:relative;display:grid;grid-template-rows:1fr auto;place-items:center}.timeline-line{position:relative;align-self:end;width:2px;height:var(--height);min-height:10px;background:#3f7779}.timeline-line i{position:absolute;left:0;top:0;width:12px;height:18px;margin:-4px 0 0 -5px;border-radius:7px 7px 9px 9px;background:linear-gradient(#ff785a 0 35%,var(--lime) 35%);box-shadow:0 0 0 4px #c9f45b18}.timeline-slot strong{position:absolute;top:2px;font:400 14px Georgia,serif;color:#365c5e}.timeline-slot small{position:absolute;bottom:-20px;color:#71817e;font-size:8px;white-space:nowrap}.activity-timeline+*{margin-top:42px}
|
||||
.timeline-chart{height:145px;display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-top:18px;padding-top:8px;border-bottom:1px solid #a9bbb1;background:repeating-linear-gradient(to top,transparent 0,transparent 35px,#dce5de 36px)}.timeline-slot{position:relative;display:grid;grid-template-rows:1fr auto;place-items:center}.timeline-line{position:relative;align-self:end;width:2px;height:var(--meter-level);min-height:10px;background:#3f7779}.timeline-line i{position:absolute;left:0;top:0;width:12px;height:18px;margin:-4px 0 0 -5px;border-radius:7px 7px 9px 9px;background:linear-gradient(#ff785a 0 35%,var(--lime) 35%);box-shadow:0 0 0 4px #c9f45b18}.timeline-slot strong{position:absolute;top:2px;font:400 14px Georgia,serif;color:#365c5e}.timeline-slot small{position:absolute;bottom:-20px;color:#71817e;font-size:8px;white-space:nowrap}.activity-timeline+*{margin-top:42px}
|
||||
@media(max-width:720px){.activity-timeline{width:calc(100% - 28px);padding:20px 15px}.activity-timeline header{display:block}.activity-timeline header p{margin-top:6px}.timeline-slot small{font-size:7px}}
|
||||
|
||||
@@ -31,7 +31,8 @@ footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32
|
||||
|
||||
/* RF4 field-guide iconography and float activity gauge */
|
||||
.fish-icon{display:inline-block;flex:none;vertical-align:-.2em}.brand-mark .fish-icon{transform:rotate(-12deg)}.topbar nav a.active .fish-icon{color:#6b9229}.spot-meta span{display:flex;align-items:center;gap:4px}.bait-line>.fish-icon,.best-lure .fish-icon{color:#e19a43;transform:rotate(14deg)}.card-arrow{font-size:0}.detail-head>a{transition:background .2s ease,color .2s ease}.detail-head>a:hover{background:var(--lime);color:var(--deep)}
|
||||
.float-gauge{width:122px;height:142px;position:relative;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;padding-bottom:8px;isolation:isolate}.float-gauge:before,.float-gauge:after{content:"";position:absolute;left:3px;right:3px;border:1px solid #ffffff1d;border-radius:50%}.float-gauge:before{height:28px;bottom:25px;box-shadow:0 0 0 10px #ffffff08}.float-gauge:after{height:15px;bottom:32px}.float-gauge__line{position:absolute;left:50%;top:0;width:1px;height:105px;background:linear-gradient(#ffffff55,#ffffff12);z-index:-1}.float-gauge__water{position:absolute;left:11px;right:11px;bottom:39px;height:calc(var(--level)*.62);max-height:62px;background:linear-gradient(180deg,#c9f45b08,#c9f45b36);border-top:1px solid var(--lime);clip-path:polygon(0 6px,18% 0,36% 6px,54% 0,72% 6px,90% 0,100% 4px,100% 100%,0 100%)}.float-gauge__bob{position:absolute;left:50%;bottom:calc(39px + var(--level)*.62);width:18px;height:54px;transform:translate(-50%,50%);border:1px solid #e6f8ad;border-radius:50% 50% 42% 42%;background:linear-gradient(180deg,var(--lime) 0 35%,#f4f7ed 36% 58%,#e47e3b 59%);box-shadow:0 5px 18px #c9f45b38}.float-gauge__bob i{position:absolute;left:50%;top:-24px;width:1px;height:25px;background:#dfe9dc}.float-gauge strong{position:relative;z-index:2;font:400 31px/1 Georgia,serif;text-shadow:0 2px 8px var(--deep)}.float-gauge small{position:relative;z-index:2;color:#a9b8b5;font-size:9px;text-transform:uppercase}.best-lure>div{grid-template-columns:25px 1fr auto}
|
||||
.float-gauge{width:122px;height:142px;position:relative;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;padding-bottom:8px;isolation:isolate}.float-gauge:before,.float-gauge:after{content:"";position:absolute;left:3px;right:3px;border:1px solid #ffffff1d;border-radius:50%}.float-gauge:before{height:28px;bottom:25px;box-shadow:0 0 0 10px #ffffff08}.float-gauge:after{height:15px;bottom:32px}.float-gauge__line{position:absolute;left:50%;top:0;width:1px;height:105px;background:linear-gradient(#ffffff55,#ffffff12);z-index:-1}.float-gauge__water{position:absolute;left:11px;right:11px;bottom:39px;height:calc(var(--meter-level)*.62);max-height:62px;background:linear-gradient(180deg,#c9f45b08,#c9f45b36);border-top:1px solid var(--lime);clip-path:polygon(0 6px,18% 0,36% 6px,54% 0,72% 6px,90% 0,100% 4px,100% 100%,0 100%)}.float-gauge__bob{position:absolute;left:50%;bottom:calc(39px + var(--meter-level)*.62);width:18px;height:54px;transform:translate(-50%,50%);border:1px solid #e6f8ad;border-radius:50% 50% 42% 42%;background:linear-gradient(180deg,var(--lime) 0 35%,#f4f7ed 36% 58%,#e47e3b 59%);box-shadow:0 5px 18px #c9f45b38}.float-gauge__bob i{position:absolute;left:50%;top:-24px;width:1px;height:25px;background:#dfe9dc}.float-gauge strong{position:relative;z-index:2;font:400 31px/1 Georgia,serif;text-shadow:0 2px 8px var(--deep)}.float-gauge small{position:relative;z-index:2;color:#a9b8b5;font-size:9px;text-transform:uppercase}.best-lure>div{grid-template-columns:25px 1fr auto}
|
||||
.meter-level-0{--meter-level:0%}.meter-level-5{--meter-level:5%}.meter-level-10{--meter-level:10%}.meter-level-15{--meter-level:15%}.meter-level-20{--meter-level:20%}.meter-level-25{--meter-level:25%}.meter-level-30{--meter-level:30%}.meter-level-35{--meter-level:35%}.meter-level-40{--meter-level:40%}.meter-level-45{--meter-level:45%}.meter-level-50{--meter-level:50%}.meter-level-55{--meter-level:55%}.meter-level-60{--meter-level:60%}.meter-level-65{--meter-level:65%}.meter-level-70{--meter-level:70%}.meter-level-75{--meter-level:75%}.meter-level-80{--meter-level:80%}.meter-level-85{--meter-level:85%}.meter-level-90{--meter-level:90%}.meter-level-95{--meter-level:95%}.meter-level-100{--meter-level:100%}
|
||||
@media(max-width:720px){.float-gauge{width:100px}}
|
||||
@media(prefers-reduced-motion:no-preference){.pulse-orb span{animation:ripple-pulse var(--motion-signal) ease-out infinite}.float-gauge__bob{animation:bob calc(var(--motion-signal) + 400ms) ease-in-out infinite}}@keyframes ripple-pulse{50%{box-shadow:0 0 0 15px #c9f45b08}}@keyframes bob{50%{translate:0 -3px}}
|
||||
.report-form fieldset{border:0;padding:0;margin:0}.report-form legend{width:100%;margin-bottom:18px;font:400 24px Georgia,serif}.report-form legend span,.optional-fields summary span{float:right;font:11px Inter,sans-serif;text-transform:uppercase;letter-spacing:.08em;color:#7c8c89}.field-help{color:#657572;font-size:12px}.optional-fields{margin:26px 0;border-block:1px solid #d6dfd7;padding:18px 0}.optional-fields summary{cursor:pointer;font:400 20px Georgia,serif}.optional-fields[open] summary{margin-bottom:20px}.report-form input:user-invalid,.report-form select:user-invalid{border-color:#b84c3c}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--canvas: light-dark(#f2f5ee, #071719);
|
||||
--surface-elevated: light-dark(#ffffff, #0d2325);
|
||||
--surface-raised: light-dark(#fbfcf9, #10282a);
|
||||
--surface-control: light-dark(#f3f6f1, #0a1d1f);
|
||||
--text-primary: light-dark(#092226, #edf5ef);
|
||||
--text-secondary: light-dark(#526662, #b8c7c3);
|
||||
--text-tertiary: light-dark(#647572, #93a7a2);
|
||||
--text-on-dark: #f5f8f3;
|
||||
--border-strong: light-dark(#cbd6ce, #3a5658);
|
||||
--border-subtle: light-dark(#d6dfd7, #294244);
|
||||
--shadow-color: light-dark(#14333812, #00000052);
|
||||
--paper: var(--canvas);
|
||||
--surface: var(--surface-elevated);
|
||||
--surface-soft: var(--surface-raised);
|
||||
--surface-field: var(--surface-control);
|
||||
--ink: var(--text-primary);
|
||||
--text: var(--text-primary);
|
||||
--text-muted: var(--text-secondary);
|
||||
--text-subtle: var(--text-tertiary);
|
||||
--line: var(--border-strong);
|
||||
--border: var(--border-strong);
|
||||
--border-soft: var(--border-subtle);
|
||||
--focus: light-dark(#7da529, #c9f45b);
|
||||
--success: light-dark(#426315, #c9ec8a);
|
||||
--success-soft: light-dark(#e2efcf, #243e25);
|
||||
--warning: light-dark(#73550c, #f3ce7a);
|
||||
--warning-soft: light-dark(#fff0c7, #46391d);
|
||||
--danger: light-dark(#842f25, #ffafa3);
|
||||
--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); }
|
||||
.brand-name span, .topbar nav a, .live-badge, .eyebrow, .overline,
|
||||
.intro-copy > p, .active-filters a, .result-count, .spot-rank,
|
||||
.spot-topline, .spot-meta, .bait-line div span, .spot-stats span,
|
||||
.data-note, .principles p, .principles article > span, .official-note,
|
||||
.privacy, .field-help, .record-row > span, .record-row > time,
|
||||
.report-form label, .screenshot-retry label, .back, .note,
|
||||
.moderation-summary > p, .moderation-summary dt,
|
||||
.moderation-provenance summary, .legal-page p, .legal-page li {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.topbar nav a.active, .eyebrow b, .state h2, .admin-kpis strong,
|
||||
.data-passport, .data-passport dd, .legal-page h1, .legal-page h2 {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.active-filters span, .spot-card, .record-table, .report-form,
|
||||
.periods, .detail-grid aside, .screenshot-retry, .moderation-card,
|
||||
.admin-kpis > a, .admin-kpis > article, .admin-dashboard-grid > section,
|
||||
.source-health-grid article, .state, .load-more, .signal-more {
|
||||
background: var(--surface-elevated);
|
||||
border-color: var(--border-subtle);
|
||||
}
|
||||
|
||||
.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__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); }
|
||||
.report-form input, .report-form textarea, .report-form select,
|
||||
.moderation-actions textarea { background: var(--surface-control); color: var(--text-primary); border-color: var(--border-strong); }
|
||||
.record-head, .no-proof { background: var(--surface-raised); }
|
||||
.record-row, .periods > div, .catch-list article, .detail-grid aside li,
|
||||
.bait-line, .spot-stats, .moderation-summary dl div,
|
||||
.moderation-actions, .how-it-works, .signal-heading {
|
||||
border-color: var(--border-subtle);
|
||||
}
|
||||
|
||||
.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 dt { color: light-dark(#776f56, #b9ae82); }
|
||||
.signal-card .missing-note { background: light-dark(#efe5bd, #453c20); color: light-dark(#6b550f, #f0d889); }
|
||||
.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)); }
|
||||
.notice.success { background: var(--success-soft); color: var(--success); }
|
||||
.notice.warning, .data-quality { background: var(--warning-soft); color: var(--warning); }
|
||||
.notice.error { background: var(--danger-soft); color: var(--danger); }
|
||||
.moderation-summary blockquote { background: var(--surface-raised); color: var(--text-secondary); }
|
||||
.moderation-actions .reject { background: var(--danger-soft); color: var(--danger); }
|
||||
.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); }
|
||||
.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 small, .activity-legend { color: var(--text-tertiary); }
|
||||
@media print {
|
||||
:root { color-scheme: light; }
|
||||
}
|
||||
@@ -264,9 +264,19 @@
|
||||
"source_pages": [
|
||||
"https://rf4map.ru/fishes"
|
||||
],
|
||||
"status": "queued",
|
||||
"status": "approved",
|
||||
"first_seen_at": "2026-09-12T14:59:53.013377+00:00",
|
||||
"last_seen_at": "2026-09-12T14:59:53.013377+00:00"
|
||||
"last_seen_at": "2026-09-12T14:59:53.013377+00:00",
|
||||
"sha256": "13bfec46ff5751b591c879744f66efd4daa7db3b9a5612501a59b41ff262fab1",
|
||||
"local_path": "files/13/13bfec46ff5751b591c879744f66efd4daa7db3b9a5612501a59b41ff262fab1.png",
|
||||
"content_type": "image/png",
|
||||
"bytes": 2359,
|
||||
"width": 48,
|
||||
"height": 48,
|
||||
"fetched_at": "2026-09-13T08:41:24.487986+00:00",
|
||||
"entity_key": "rf4map-bait-2",
|
||||
"reviewed_at": "2026-09-13T08:41:46.261842+00:00",
|
||||
"review_note": "Visual review: Natural Squid 23-02 pale squid-shaped soft lure; transparent PNG; RF4MAP catalog provenance"
|
||||
},
|
||||
{
|
||||
"source_page": "https://rf4map.ru/fishes",
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@
|
||||
Permissions-Policy "camera=(), microphone=(), geolocation=()"
|
||||
X-Frame-Options "DENY"
|
||||
Cross-Origin-Opener-Policy "same-origin"
|
||||
Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; connect-src 'self'; img-src 'self' data: https://{$FILES_DOMAIN}; font-src 'self'; media-src 'self'; manifest-src 'self'; script-src 'self' 'unsafe-inline'; script-src-attr 'none'; style-src 'self' 'unsafe-inline'; style-src-attr 'unsafe-inline'; upgrade-insecure-requests"
|
||||
Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; connect-src 'self'; img-src 'self' data: https://{$FILES_DOMAIN}; font-src 'self'; media-src 'self'; manifest-src 'self'; script-src 'self' 'unsafe-inline'; script-src-attr 'none'; style-src 'self'; style-src-attr 'none'; upgrade-insecure-requests"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
-1
@@ -36,6 +36,18 @@
|
||||
- [x] **B14 · Атласные переходы сущностей.** Боковые списки рыб и водоёмов на detail-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания.
|
||||
- [x] **B15 · Целостность медиакаталога.** Локальный audit сводит статусы очереди и проверяет наличие файлов, SHA-256, фактические dimensions/MIME, каноническое соответствие approved-записей и бесхозные файлы. Проверка не обращается в сеть и может использоваться как pre-publication gate.
|
||||
|
||||
### Тёмная тема
|
||||
|
||||
Реализовывать последовательно: сначала семантическая палитра и системный режим, затем ручное управление и полировка компонентов. Тёмная тема должна сохранять полевую эстетику RF4 Spotter, а не быть механической инверсией светлой.
|
||||
|
||||
- [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] **D03 · Переключатель темы.** В header добавлен доступный трёхпозиционный выбор «Системная / Светлая / Тёмная»; на узких экранах он сохраняет три понятные иконки и доступные названия. Кнопки работают с клавиатуры, имеют видимый focus и синхронизируют `aria-pressed`.
|
||||
- [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; источники и статусы не должны различаться только цветом.
|
||||
- [ ] **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 и краткий отчёт.
|
||||
|
||||
- [ ] **Q01 · Документы источников — реестр готов, нужны первичные подтверждения.** Создан единый production-gate с атрибуцией, общим лимитом 30 минут, хранением и процедурой отзыва для RF4DB, RF4-STAT, RF4MAP, RF4 Posts и официального RF4. До открытой публикации приложить устойчивые ссылки/копии первичных разрешений, контакты, даты и отдельно подтвердить право на изображения; пустое поле блокирует соответствующий источник.
|
||||
- [ ] **Q02 · Управляемое удаление источника.** Добавить обнаружение изменённых/удалённых опубликованных записей без отдельного частого обхода: статус, журнал решения и безопасное исключение из активности после проверки.
|
||||
- [ ] **Q03 · Целостность ссылок.** Проверять исходные ссылки только во время разрешённого планового обращения к площадке, разделяя `missing`, `temporary_error` и `blocked`; не создавать дополнительный сетевой цикл.
|
||||
@@ -54,7 +66,7 @@
|
||||
- [x] **Q11 · Декомпозиция API.** Catalog, activity/spots, records/community/status/import-history, submissions и весь admin API вынесены в отдельные `APIRouter`. `main.py` оставляет composition root, middleware, health/readiness и временные совместимые экспорты rate-limit для тестового контракта; URL и OpenAPI сохранены.
|
||||
- [ ] **Q12 · Query-plan gate.** В рамках Q07 снять `EXPLAIN (ANALYZE, BUFFERS)` для activity, records, spot detail и public spot pages на реалистичном наборе данных. Существующие индексы миграции `0011_query_indexes` не дублировать; индекс с `fish_id`, SQL-агрегацию или materialized view добавлять только по измеренному плану и p95.
|
||||
- [x] **Q13 · Production bootstrap в CI.** Отдельный workflow запускает `deploy/test-production-bootstrap.sh` вручную или раз в неделю, а не на каждом push. Вывод bootstrap всегда сохраняется 14 дней; при падении добавляются Compose status и Playwright diagnostics.
|
||||
- [ ] **Q14 · Полная CSP — origin-policy внедрена.** Production ограничивает default/connect/form/font/media/manifest текущим доменом, изображения — self/data/`FILES_DOMAIN`, запрещает inline event handlers, eval, wildcard и HTTP. Инвентаризация зафиксировала динамический JSON-LD, page scripts, scoped styles и CSS variables; из-за них `unsafe-inline` временно остаётся только для script/style элементов и style attributes. Далее вынести page scripts, решить nonce/hash JSON-LD и убрать исключения поэтапно с bootstrap-проверкой report/admin/OG/screenshots.
|
||||
- [ ] **Q14 · Полная CSP — в работе.** Production ограничивает default/connect/form/font/media/manifest текущим доменом, изображения — self/data/`FILES_DOMAIN`, запрещает inline handlers, eval, wildcard и HTTP. Page scripts и scoped styles гарантированно выпускаются внешними `_astro`-ассетами; динамические шкалы переведены на CSS-классы. `style-src` теперь только `'self'`, `style-src-attr` и `script-src-attr` — `'none'`. Остаётся внедрить nonce/hash для динамического JSON-LD, убрать последнее `unsafe-inline` из `script-src` и выполнить bootstrap-проверку report/admin/OG/screenshots.
|
||||
- [x] **Q15 · Частичная деградация главной.** SSR независимо получает activity, community signals и оба справочника через settled-результаты. Отказ секции показывает собственный `StatePanel`, сохраняет остальные данные и HTTP 200 с `X-RF4-Partial`/`Cache-Control: no-store`; только отказ всех четырёх частей возвращает 503, `Retry-After` и noindex. Client-side loading и optimistic UI не добавлялись.
|
||||
- [x] **Q16 · Контракт OpenAPI.** `apps/api/openapi.json` детерминированно генерируется из FastAPI; CI проверяет его актуальность после backend suite. Изменение artifact обязательно рассматривается вместе с реализацией, а ручное редактирование не используется.
|
||||
- [x] **Q17 · Эксплуатационные документы.** Зафиксированы ADR по Astro/FastAPI/PostgreSQL, локальному cache, scheduler/cooldown и разделению PostgreSQL/MinIO. Incident runbook покрывает заполнение диска, отказ PostgreSQL/MinIO, зависшие импорты, ошибки миграций, компрометацию секретов и критерии закрытия без опасных reset/recreate операций.
|
||||
|
||||
@@ -4,18 +4,14 @@
|
||||
|
||||
## Временные inline-зависимости
|
||||
|
||||
- динамический JSON-LD в `Layout.astro`;
|
||||
- inline bootstrap в `report.astro` и `spots/[id].astro`;
|
||||
- page scripts главной и трёх admin-экранов;
|
||||
- scoped `<style>` Astro-компонентов;
|
||||
- вычисляемые CSS custom properties для шкалы активности и timeline.
|
||||
- динамический JSON-LD в `Layout.astro` остаётся единственным намеренно inline script;
|
||||
|
||||
Текущая CSP поэтому временно допускает `unsafe-inline` отдельно для script/style elements и style attributes. Inline event handlers запрещены `script-src-attr 'none'`; `unsafe-eval`, wildcard, HTTP и сторонние connect/script origin отсутствуют.
|
||||
Vite собирает даже малые page scripts и scoped Astro styles в same-origin `_astro` assets (`assetsInlineLimit: 0`). Динамические шкалы используют дискретные CSS-классы вместо style attributes, поэтому `style-src` ограничен `'self'`, а `style-src-attr` — `'none'`. Временный `unsafe-inline` остаётся только для динамического JSON-LD в `script-src`. Inline event handlers запрещены `script-src-attr 'none'`; `unsafe-eval`, wildcard, HTTP и сторонние connect/script origin отсутствуют.
|
||||
|
||||
## Путь к nonce/hash
|
||||
|
||||
1. Перенести page scripts в импортируемые клиентские модули, конфигурацию передавать через безопасные `data-*`.
|
||||
1. Page scripts перенесены в собираемые клиентские модули; конфигурация формы передаётся через безопасный `data-state`. Нулевой inline limit запрещает Vite встраивать малые модули обратно в HTML.
|
||||
2. JSON-LD либо хэшировать на уровне SSR-заголовка, либо выдавать nonce из web-runtime; статический nonce запрещён.
|
||||
3. Оставить scoped styles в скомпилированных `_astro` assets; вычисляемые значения заменить классами/`data-*` или nonce style.
|
||||
4. Удалить `unsafe-inline` из `script-src`, затем из `style-src`; `style-src-attr` убрать последним.
|
||||
3. Scoped styles находятся в скомпилированных `_astro` assets, вычисляемые шкалы используют классы с шагом 5%; inline-разрешения для styles и attributes удалены.
|
||||
4. Удалить последнее `unsafe-inline` из `script-src` после решения JSON-LD.
|
||||
5. Проверить report, spots, admin, JSON-LD и signed screenshots в production bootstrap. Не добавлять `blob:`, `*` или произвольные CDN для устранения ошибок.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Тёмная тема: палитра и ограничения
|
||||
|
||||
Базовая ревизия выполнена 13 сентября 2026 года. Старый CSS содержит много жёстких цветов, а исторический `--deep` используется одновременно как фирменный тёмный фон и как текст на светлых карточках. Поэтому менять его значение для dark mode нельзя: роли разделяются постепенно без визуальной инверсии бренда.
|
||||
|
||||
## Семантические роли
|
||||
|
||||
- `--canvas` — фон страницы;
|
||||
- `--surface-elevated`, `--surface-raised`, `--surface-control` — карточки, hover и поля;
|
||||
- `--text-primary`, `--text-secondary`, `--text-tertiary`, `--text-on-dark` — иерархия текста;
|
||||
- `--border-strong`, `--border-subtle`, `--shadow-color` — разделители и глубина;
|
||||
- существующие status/source-токены сохраняют смысл, но получают контрастные dark-значения там, где используются как текст.
|
||||
|
||||
По умолчанию сайт следует `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 не требуются.
|
||||
|
||||
## Дальнейшая миграция
|
||||
|
||||
Файл `theme.css` временно содержит точечные dark overrides для основных публичных и административных поверхностей. На этапе D05 жёсткие цвета последовательно заменяются семантическими токенами в исходных component styles; source/status различия сохраняют текст или форму и никогда не полагаются только на оттенок.
|
||||
Reference in New Issue
Block a user