go-learn/docs/INTERFACES.md
sab.code.lab 1c85091186 chore: восстановление репозитория из снапшота v0.3.1
Прежняя git-история утрачена при переносе проекта на машину владельца
(снапшот без .git). Хэши коммитов в docs/reports/* относятся к утраченной
истории.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 09:34:02 +02:00

1127 lines
65 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# INTERFACES — контракты между модулями
Обновлено: 2026-08-05 | Статус: контракт core реализован (этап 1)
> Правило: изменение контракта — сначала запись сюда, потом код. Код, нарушающий
> контракт без обновления файла, — дефект. Удалил сущность из контракта — докажи
> grep'ом, что её никто не читает.
## Единицы и система координат (объявлено один раз, I.6)
- Координаты доски: целые, 0-based, `(x, y)`, x — слева направо, y — сверху вниз.
- Размеры досок: 9, 13, 19. Коми: 6.5 (19×19), 5.5 (9×9 и 13×13) — решение владельца 2026-08-05.
- Подсчёт: китайские правила, площадь (Tromp-Taylor).
- Формат обмена партиями: SGF FF[4].
## Контракт движка правил (packages/core)
Зафиксировано в этапе 1 до написания кода. Все структуры неизменяемы (`readonly`),
операции возвращают новые объекты. Движок — чистый TS: без импортов DOM/worker API,
без `Date.now()`/`Math.random()`; время и случайность — только через инжектируемые
`Clock`/`Rng` (сам core в этапе 1 их не использует, типы объявлены для этапов 34).
### Базовые типы
```ts
export type BoardSize = 9 | 13 | 19;
export type Color = 'black' | 'white';
export type CellState = 'empty' | 'black' | 'white';
export interface Point {
readonly x: number; // 0-based, слева направо
readonly y: number; // 0-based, сверху вниз
}
export type Move =
| { readonly kind: 'play'; readonly color: Color; readonly point: Point }
| { readonly kind: 'pass'; readonly color: Color }
| { readonly kind: 'resign'; readonly color: Color };
```
### Позиция и правила
```ts
export interface BoardState {
readonly size: BoardSize;
/** Строго типизированная структура: обычный Array с readonly-типом поверх,
* длина size*size, индекс клетки = y*size + x (три состояния клетки). */
readonly grid: ReadonlyArray<CellState>;
readonly toPlay: Color;
/** Сколько камней снял каждый цвет за партию. */
readonly captures: { readonly black: number; readonly white: number };
/** Точка простого ко (запрет немедленного обратного захвата) или null. */
readonly koPoint: Point | null;
/** Хэши «позиция + toPlay» всех предыдущих позиций (позиционное суперко). */
readonly positionHashes: ReadonlyArray<string>;
/** true после двух пасов подряд или resign. */
readonly over: boolean;
}
/** Машиночитаемая причина отказа — для ветвления UI (подсказки, звуки). */
export type MoveError =
'out-of-bounds' | 'occupied' | 'suicide' | 'ko' | 'superko' | 'game-over' | 'wrong-turn';
export type MoveResult =
| { readonly ok: true; readonly state: BoardState; readonly captured: ReadonlyArray<Point> }
| {
readonly ok: false;
readonly state: BoardState;
readonly reason: MoveError;
readonly error: string; // человекочитаемое сообщение (ru)
};
export function createBoard(size: BoardSize): BoardState;
/** Неизменяемая постановка хода: возвращает новый BoardState.
* При ok=false state — исходное состояние без изменений.
* Правила: захват групп без дамэ; самоубийство запрещено, кроме хода,
* который захватывает чужую группу; простое ко (koPoint); позиционное
* суперко — запрет повтора любой предыдущей позиции (хэш позиция+toPlay);
* pass/resign всегда легальны (если партия не окончена); два паса подряд
* или resign → over: true. */
export function applyMove(state: BoardState, move: Move): MoveResult;
/** Группа в точке: камни и число дамэ; null — клетка пуста или вне доски. */
export function groupAt(
state: BoardState,
point: Point,
): { readonly stones: ReadonlyArray<Point>; readonly liberties: number } | null;
/** Ключ точки для множеств ("x,y") — формат для dead в scorePosition. */
export function pointKey(point: Point): string;
```
### Подсчёт (китайские правила, площадь, Tromp-Taylor)
```ts
export interface ScoreOptions {
readonly komi: number;
/** Мёртвые камни (ключи pointKey, "x,y"): снимаются с доски перед подсчётом. */
readonly dead: ReadonlySet<string>;
}
export interface ScoreResult {
readonly black: number; // площадь чёрных: камни + территория
readonly white: number; // площадь белых: камни + территория (без коми)
readonly komi: number;
/** black white komi. */
readonly margin: number;
/** margin > 0 → 'black', margin < 0 → 'white', margin === 0 → 'draw'. */
readonly winner: Color | 'draw';
/** Нейтральные пункты (дамэ, сэки): никому не засчитываются. */
readonly neutral: ReadonlyArray<Point>;
}
export function scorePosition(state: BoardState, options: ScoreOptions): ScoreResult;
/** Эвристика «мёртвая группа»: группа без двух глаз в чужой территории.
* Грубая; ручная корректировка — за UI. Возвращает список групп. */
export function suggestDeadGroups(state: BoardState): ReadonlyArray<ReadonlyArray<Point>>;
/** Коми по умолчанию: 19×19 → 6.5, 9×9 и 13×13 → 5.5. */
export function defaultKomi(size: BoardSize): number;
```
### История партии (неизменяемое дерево)
```ts
export interface Label {
readonly point: Point;
readonly text: string;
}
export interface NodeMarkup {
readonly labels: ReadonlyArray<Label>; // LB
readonly triangles: ReadonlyArray<Point>; // TR
readonly squares: ReadonlyArray<Point>; // SQ
readonly circles: ReadonlyArray<Point>; // CR
}
export interface GameNode {
readonly id: number;
readonly move: Move | null; // null — корень
readonly state: BoardState; // позиция ПОСЛЕ хода узла
readonly comment: string; // '' — нет
readonly markup: NodeMarkup;
readonly children: ReadonlyArray<GameNode>;
}
export interface GameMeta {
readonly blackName: string; // '' — нет
readonly whiteName: string;
readonly result: string | null; // RE, напр. "B+R", "W+3.5", "Draw"
}
export interface GameTree {
readonly size: BoardSize;
readonly komi: number;
readonly root: GameNode;
readonly currentId: number;
readonly nextId: number; // id для следующего узла
readonly meta: GameMeta;
}
export interface CreateGameOptions {
readonly size: BoardSize;
readonly komi?: number; // по умолчанию defaultKomi(size)
readonly blackName?: string;
readonly whiteName?: string;
}
export function createGame(options: CreateGameOptions): GameTree;
export type AppendResult =
| { readonly ok: true; readonly tree: GameTree; readonly nodeId: number }
| {
readonly ok: false;
readonly tree: GameTree;
readonly reason: MoveError | 'node-not-found';
readonly error: string;
};
/** Ход от текущего узла; если у текущего уже есть children — это ветвление.
* Нелегальный ход не меняет дерево (ok=false, tree — исходное).
* При resign дополнительно проставляет meta.result ("B+R"/"W+R"). */
export function appendMove(tree: GameTree, move: Move): AppendResult;
/** Переход к узлу по id; если узла нет — возвращает дерево без изменений. */
export function goToNode(tree: GameTree, nodeId: number): GameTree;
```
### SGF FF[4]
```ts
/** Ошибка парсинга SGF; offset — смещение в исходном тексте. */
export class SgfError extends Error {
readonly offset: number;
}
/** GM[1] (иное → SgfError), SZ (9/13/19), KM, PB/PW, RE, C, B/W
* (пустое значение = pass), вариации, LB/TR/SQ/CR.
* Эскейпинг значений: `\]` → `]`, `\\` → `\`, прочее `\x` → `x`.
* Нелегальный ход в SGF → SgfError с offset этого хода. */
export function parseSgf(text: string): GameTree;
/** Сериализация с эскейпингом `]` и `\` в значениях.
* Roundtrip: parseSgf(serializeSgf(tree)) даёт эквивалентное дерево. */
export function serializeSgf(tree: GameTree): string;
```
### Инжектируемые часы и RNG
```ts
export interface Rng {
next(): number; // равномерное [0, 1)
}
export interface Clock {
now(): number; // миллисекунды
}
/** Детерминированный PRNG (mulberry32): одинаковый seed → одинаковая
* последовательность. Единственный разрешённый источник случайности
* в тестах и движке. */
export function createSeededRng(seed: number): Rng;
```
### Дополнение этапа 6 (ADR-0003)
```ts
/** Произвольная стартовая позиция для задач. Валидация: точки в пределах
* доски, без дублей, каждая группа с ≥1 дамэ; нарушение → Error.
* captures = 0, koPoint = null, positionHashes = [хэш позиции]. */
export function buildPosition(
size: BoardSize,
black: ReadonlyArray<Point>,
white: ReadonlyArray<Point>,
toPlay: Color,
): BoardState;
```
## Контракт отрисовки доски (packages/board)
Статус: контракт board реализован (этап 2). Зафиксирован: 2026-08-05. Canvas 2D;
вся геометрия и ввод — чистые функции, тестируемые без DOM. Канва исполняет
draw-команды; DOM-слой — тонкий адаптер.
### Геометрия (чистая, без DOM)
```ts
export interface BoardGeometry {
readonly size: BoardSize;
readonly pixelSize: number; // логический квадрат канвы в CSS px
readonly padding: number; // поля (под координаты, если включены)
readonly cell: number; // шаг сетки в CSS px
}
export function computeGeometry(
size: BoardSize,
pixelSize: number,
showCoordinates: boolean,
): BoardGeometry;
/** Центр пересечения в CSS px. */
export function pointToPixel(geo: BoardGeometry, p: Point): { x: number; y: number };
/** Обратное преобразование; null — курсор дальше половины клетки от любого пересечения. */
export function pixelToPoint(geo: BoardGeometry, px: number, py: number): Point | null;
/** Хоси: 19×19 — 9 точек (3-3..15-15 в 1-based), 13×13 — 5, 9×9 — 5. */
export function hoshiPoints(size: BoardSize): readonly Point[];
```
### Рендер (чистая функция состояния)
`render` не хранит состояния и не читает ничего, кроме аргументов: каждый кадр
рисуется целиком из `state` + `options`. Порядок слоёв: фон доски → заливка
территории → сетка → хоси → координаты → разметка (SQ/TR/CR) → камни →
метка последнего хода → номера ходов → фантомный камень → подписи LB.
```ts
export interface BoardTheme {
readonly boardBackground: string;
readonly lineColor: string;
readonly blackStone: string;
readonly whiteStone: string;
readonly blackStoneEdge: string;
readonly whiteStoneEdge: string;
readonly coordinateColor: string;
readonly markupColor: string;
readonly lastMoveMarker: string;
readonly phantomOpacity: number; // 0..1
readonly territoryBlack: string; // заливка с альфой
readonly territoryWhite: string;
}
/** Заливка территории при подсчёте: ключи pointKey ("x,y") по цветам. */
export interface TerritoryMap {
readonly black: ReadonlySet<string>;
readonly white: ReadonlySet<string>;
}
export interface RenderOptions {
readonly theme?: Partial<BoardTheme>; // дефолт — тёмная тема
readonly showCoordinates?: boolean; // дефолт true
readonly showMoveNumbers?: boolean; // дефолт false; номера — из истории ходов
readonly moveNumbers?: ReadonlyMap<string, number>; // ключ "x,y" → номер хода
readonly lastMove?: Point | null;
readonly phantom?: { readonly point: Point; readonly color: Color } | null;
readonly markup?: NodeMarkup; // из @go-learn/core
readonly territory?: TerritoryMap; // заливка при подсчёте
readonly dead?: ReadonlySet<string>; // ключи "x,y"; помеченные мёртвые — приглушаются
}
export function render(
ctx: CanvasRenderingContext2D,
state: BoardState,
options?: RenderOptions,
): void;
```
Координаты: буквы без «I» (AH, JT) снизу, числа слева (1 внизу). Шрифт —
system-ui, размер от cell.
### Тач-ввод с подтверждением (чистый редьюсер)
Первый тап/клик — фантом (pending); второй тап по той же точке или `confirm`
ход (`commit`); тап по другой точке — перенос фантома; `cancel`сброс.
Редьюсер не знает о DOM; DOM-адаптер переводит pointer-события в InputEvent.
```ts
export type InputEvent =
| { readonly type: 'tap'; readonly px: number; readonly py: number }
| { readonly type: 'confirm' }
| { readonly type: 'cancel' };
export interface InputState {
readonly pending: Point | null;
}
export interface InputResult {
readonly state: InputState;
readonly commit: Point | null; // не-null — пользователь подтвердил ход
}
export function reduceInput(state: InputState, event: InputEvent, geo: BoardGeometry): InputResult;
```
### DOM-адаптер (тонкий, без логики; юнит-тестами не покрывается)
```ts
/** Выставляет canvas.width/height = cssSize × devicePixelRatio и
* ctx.setTransform(dpr, …). Возвращает dpr. */
export function setupCanvas(canvas: HTMLCanvasElement, cssSize: number): number;
/** Подписывается на pointer-события канвы и вызывает onEvent с InputEvent.
* Возвращает функцию отписки. */
export function attachPointerInput(
canvas: HTMLCanvasElement,
onEvent: (event: InputEvent) => void,
): () => void;
```
### Класс данных пакета
packages/board импортирует типы из @go-learn/core, но не импортирует DOM-зависимый
код в geometry/render/input (CanvasRenderingContext2D — только как тип параметра).
Date.now()/Math.random() запрещены и здесь.
## Контракт ИИ ур. 13 (packages/ai)
Статус: контракт ai реализован (этап 3). Зафиксирован: 2026-08-05.
Эвристический бот, синхронный чистый
вычислитель: без DOM, без воркер-API, без собственного состояния. Случайность —
только через переданный seeded `Rng` из @go-learn/core (детерминизм =
воспроизводимые партии). Легальность ходов проверяется через applyMove из core —
никакого дублирования правил.
```ts
import type { BoardState, Move, Point, Rng } from '@go-learn/core';
export type AiLevel = 1 | 2 | 3;
/** Вероятность применения приоритетов «спасти своё атари» / «забрать чужое»
* на каждом уровне (ур.1 ≈ 0.3, ур.3 ≈ 0.9 — по спеке). */
export const LEVEL_ATARI_PROBABILITY: Readonly<Record<AiLevel, number>>;
export interface AiMoveRequest {
readonly state: BoardState; // ходит state.toPlay
readonly level: AiLevel;
readonly rng: Rng; // seeded снаружи
readonly lastOpponentMove?: Point | null; // для приоритета «вблизи хода противника»
}
export type AiReason =
| 'save-atari' // спас свою группу в атари
| 'capture-atari' // забрал чужую группу в атари
| 'nearby' // взвешенный ход вблизи lastOpponentMove (Chebyshev ≤ 2)
| 'random' // случайный легальный
| 'pass'; // нет кандидатов
export interface AiMoveResult {
readonly move: Move; // kind 'play' | 'pass'; resign на ур. 13 не генерируется
readonly reason: AiReason;
}
/** Вызов при state.over === true — программная ошибка: кидает Error. */
export function chooseMove(request: AiMoveRequest): AiMoveResult;
```
Порядок приоритетов (спека этапа 3):
1. **save-atari** — своя группа с 1 дамэ → ход, после которого у группы >1 дамэ
(включая контрзахват). Применяется с вероятностью p(level); при «промахе»
ролла — переход к следующему приоритету.
2. **capture-atari** — чужая группа с 1 дамэ → легальный ход в последнее дамэ.
Та же вероятность p(level).
3. **nearby** — взвешенный случайный среди легальных кандидатов в радиусе
Chebyshev ≤ 2 от lastOpponentMove (если он передан и кандидаты есть).
4. **random** — случайный из оставшихся легальных кандидатов.
5. **pass** — только если кандидатов не осталось.
Фильтр кандидатов (применяется ко всем приоритетам): легальный ход, не
заполняющий свой глаз — простая эвристика: все ортогональные соседи точки —
свои камни, и ход ничего не захватывает (захват не может быть заполнением глаза).
## Контракт MCTS ИИ ур. 46 (packages/ai, этап 4)
Статус: контракт MCTS + протокол воркера реализован (этап 4). Зафиксирован: 2026-08-05 (этап 4). Движок MCTS — чистый вычислитель в packages/ai:
без DOM и воркер-API (воркер-обёртка — отдельный тонкий модуль). Однопоточный код
(SharedArrayBuffer требует COOP/COEP — на шаред-хостинге может не быть). Часы и
случайность инжектируются (Clock/Rng из core) — реплеи MCTS воспроизводимы.
### Движок MCTS (чистый)
```ts
export type MctsLevel = 4 | 5 | 6;
/** Бюджет по времени: ур.4 — 300 мс, ур.5 — 800 мс (дефолт между уровнями,
* спека задаёт только крайние), ур.6 — 1500 мс. */
export const LEVEL_TIME_BUDGET_MS: Readonly<Record<MctsLevel, number>>;
export interface MctsRequest {
readonly state: BoardState; // ходит state.toPlay
readonly komi: number; // для оценки плейаута (scorePosition)
readonly level: MctsLevel;
readonly rng: Rng; // seeded снаружи
readonly clock: Clock; // бюджет по времени
readonly maxSimulations?: number; // override для тестов/отладки: игнорирует
// тайм-бюджет, крутит ровно N симуляций
readonly shouldStop?: () => boolean; // отмена: проверяется между симуляциями
}
export interface CandidateScore {
readonly move: Move; // play или pass
readonly winRate: number; // 0..1 по визитам
readonly visits: number;
}
export interface MctsResult {
readonly move: Move; // лучший по визитам (UCT — внутри дерева)
readonly topMoves: readonly CandidateScore[]; // до 3 лучших — режим подсказки этапа 5
readonly simulations: number;
readonly elapsedMs: number; // по инжектированным часам
}
/** Вызов при state.over === true — программная ошибка: кидает Error. */
export function chooseMoveMcts(request: MctsRequest): MctsResult;
```
Правила движка:
- Дерево: UCT (c = 1.4), ленивая экспансия легальных детей (легальность — только
applyMove из core), pass — легальный узел.
- Плейаут — «лёгкая» политика (НЕ chooseMove этапа 3 — тот сканирует доску
целиком и слишком дорог): дешёвая реакция на атари вокруг последнего хода,
иначе случайные точки с фильтром своего глаза и лимитом попыток; конец —
два паса или кап ходов size²×2. Оценка — scorePosition с komi запроса.
- Таргет 9×9 и 13×13; на 19×19 работает, но честно слабее (предупреждение — UI
этапа 5, движок обязан просто укладываться в бюджет).
- Бюджет: остановка по clock.now() >= deadline ИЛИ shouldStop(), что раньше;
при maxSimulations — ровно N итераций (детерминизм тестов).
- При shouldStop() до первой завершённой симуляции — ход всё равно возвращается
(лучший из оценённых или легальный random с reason-эквивалентом; обязан быть
легальным).
### Протокол postMessage воркера (этап 4)
Сериализация позиции — СПИСОКОМ ХОДОВ от начала партии (компактно, воркер
пересобирает BoardState через applyMove и заодно валидирует; расхождение —
error-ответ). Типы сообщений:
```ts
export interface WorkerChooseRequest {
readonly type: 'choose';
readonly requestId: number;
readonly size: BoardSize;
readonly komi: number;
readonly moves: readonly Move[]; // вся партия от пустой доски
readonly level: MctsLevel;
readonly seed: number; // seed воркера (партия + requestId — забота UI)
readonly timeBudgetMs: number;
}
export interface WorkerCancelRequest {
readonly type: 'cancel';
readonly requestId: number;
}
export type WorkerRequest = WorkerChooseRequest | WorkerCancelRequest;
export type WorkerResponse =
| {
readonly type: 'result';
readonly requestId: number;
readonly move: Move;
readonly topMoves: readonly CandidateScore[];
readonly simulations: number;
readonly elapsedMs: number;
}
| { readonly type: 'error'; readonly requestId: number; readonly message: string };
```
- Воркер одноразовый на партию или переиспользуемый — решение UI этапа 5;
протокол stateless: каждый choose несёт полный список ходов.
- cancel: воркер прерывает вычисление активного requestId и отвечает result с
лучшим найденным ходом (НЕ молчит, НЕ error).
- Модуль packages/ai/src/worker.ts — тонкая обёртка: парсинг → пересборка
позиции → chooseMoveMcts → ответ. Абстракция порта:
`interface MessagePort { postMessage(msg: WorkerResponse): void; }`
протокол тестируется через фейк-порт без реального Worker.
## Форматы данных уроков и задач (этап 6)
Статус: реализовано (этап 6, 2026-08-06). Уточнение: goal проверяется
после каждого хода игрока, ДО авто-ответа соперника (фикс интеграции,
tsm-liberties-escape).
Зафиксировано: 2026-08-06 (этап 6). Зона реализации — apps/web (страницы,
контент, lib); packages/* не меняются, кроме одной аддитивной функции
buildPosition в core (ADR-0003). Весь контент проекта — оригинальный,
авторство команды проекта, фиксируется в CREDITS.md (ADR-0001, I.16).
### Разделы курса (фиксированный список, порядок = порядок прохождения)
1. «Дамэ и захват» 2. «Атари, лестница, сеть» 3. «Самоубийство и ко»
2. «Глаза: жизнь и смерть» 5. «Подсчёт и конец партии» 6. «Связь и разрезание»
3. «Углы, стороны, центр» 8. «Базовые дзёсэки» 9. «Форма» 10. «Простое ёсэ».
### Урок (Markdown)
- Файлы: `apps/web/src/content/lessons/<id>.md`, коллекция `lessons`
(Astro content layer, glob loader); `<id>` — kebab-case, он же slug страницы.
- Frontmatter (обязателен целиком):
```yaml
---
title: string # название урока
section: string # один из 10 разделов выше, точное совпадение
order: number # порядок внутри раздела, 1-based
summary: string # 12 предложения для карточки урока
---
```
- Тело — Markdown-теория, обязана читаться без JS (критерий этапа 7 уже
держим в уме). Интерактивные шаги вставляются прямо в текст элементом
`<go-task data-task-id="<task-id>">запасной текст без JS</go-task>`;
порядок элементов в теле = порядок шагов урока. Никаких remark-плагинов:
элемент — обычный raw HTML в Markdown, виджет гидрируется клиентским
скриптом страницы урока.
- Минимум контента: по уроку на каждый из 10 разделов; в уроках с
интерактивом — ≥1 `<go-task>` (kind: 'lesson').
### Задача (интерактивный шаг урока и цумэ-го)
- Файлы: `apps/web/src/content/tasks/*.json`, один файл — массив TaskV1
(например, по теме). Коллекция `tasks` (glob loader, JSON).
```ts
export interface TaskV1 {
readonly id: string; // уникальный kebab-case, префикс kind: 'lsn-*' | 'tsm-*'
readonly kind: 'lesson' | 'tsumego';
readonly title: string;
readonly topics: readonly string[]; // темы для фильтров цумэ-го
readonly difficulty: 1 | 2 | 3 | 4 | 5;
readonly size: BoardSize; // доска задачи (цумэ-го — обычно 9)
readonly toPlay: PlayerColor; // чей ход первым в задаче
readonly setupBlack: readonly Point[]; // стартовые камни чёрных (ADR-0003)
readonly setupWhite: readonly Point[]; // стартовые камни белых
readonly sgf: string; // дерево вариантов от стартовой позиции + TR/CR цели
readonly goal: GoalSpec;
readonly hint?: string; // текстовая подсказка (кнопка «Подсказка»)
}
export type GoalSpec =
| { readonly type: 'capture-target' } // камни с TR сняты с доски
| { readonly type: 'liberties'; readonly count: number } // группа цели (TR) ≥ count дамэ
| { readonly type: 'connect' } // все CR-камни оказались в одной группе
| { readonly type: 'two-eyes' }; // у группы цели (TR) ≥2 формальных глаза
```
- Позиция задачи: поля `setupBlack`/`setupWhite` + `buildPosition` из core
(ADR-0003). SGF задачи: дерево вариантов от стартовой позиции; цель
размечена `TR` (для
capture-target / liberties / two-eyes) или `CR` (для connect; MA
парсером не поддерживается, ADR-0003). Дерево
вариантов в том же SGF — допустимые линии «ход игрока → ответ соперника →
…»; варианты — контент для автоответов и подсветки, НЕ критерий успеха.
- Критерий успеха — ТОЛЬКО goal-предикат, вычисленный движком на текущей
позиции (apps/web/src/lib/goals.ts, чистые функции над экспортами
@go-learn/core). После каждого хода игрока предикат проверяется заново;
выполнен → задача решена, даже если линия шла не по дереву (I.6: условие
успеха проверяется движком, не хардкодом координат).
- Ход игрока вне вариантов дерева → статус «неверно», ход авто-отменяется,
attempts++. Ход из дерева → авто-ответ соперника (дочерний узел варианта),
затем проверка goal. Кнопки виджета: «Сброс», «Подсказка» (hint или
первый ход ведущего варианта).
- Формальный глаз (упрощение v1): пустая область из 13 смежных пустых
пунктов, у которой все ортогональные соседи за её пределами — камни одного
цвета (или край доски). two-eyes = у группы цели ≥2 различных таких глаза.
Ложный глаз в v1 не отличаем — зафиксированное упрощение; уроки про ложный
глаз используют capture-target. Уточнение — только через ADR.
- Цумэ-го: ≥30 задач kind: 'tsumego', разброс difficulty 15 (в каждой
градации ≥3), темы из topics уроков (атари, лестница, сеть, захват, связь,
разрезание, глаза, жизнь и смерть, ёсэ, дзёсэки).
### Прогресс курса (localStorage, этап 6)
Ключ `go-learn:progress`, тот же конверт (version + FNV-1a checksum), что и
у настроек; правила чтения/миграции/записи — те же (битое → дефолты +
recovered, запись только через save-функции).
```ts
export interface ProgressV1 {
readonly lessons: Readonly<Record<string, LessonProgress>>; // key = lesson id
readonly tasks: Readonly<Record<string, TaskProgress>>; // key = task id
readonly streak: StreakV1;
}
export interface LessonProgress {
readonly done: boolean;
readonly tasksSolved: readonly string[];
}
export interface TaskProgress {
readonly solved: boolean;
readonly attempts: number;
}
export interface StreakV1 {
readonly lastDay: string; // 'YYYY-MM-DD' последней активности
readonly current: number; // дней подряд
}
```
API (apps/web/src/lib/storage.ts, дополнение):
```ts
export function loadProgress(): { progress: ProgressV1; recovered: boolean };
export function saveProgress(progress: ProgressV1): void;
export const DEFAULT_PROGRESS: ProgressV1;
/** Чистая функция серии дней: вчера → current+1, сегодня → без изменений,
* иначе → 1. today — 'YYYY-MM-DD' локальной даты. */
export function touchStreak(progress: ProgressV1, today: string): ProgressV1;
```
Roundtrip-тест обязателен (save → load идентичность; битый
JSON/checksum/version → дефолты + recovered: true). Серия дней обновляется
при первом решении задачи за день (через touchStreak).
### Страницы этапа 6 (apps/web)
- `/learn` — программа по разделам (фиксированный порядок), карточки уроков
(title, summary, пометка «пройден»).
- `/learn/<id>` — урок: Markdown-теория + гидрированные go-task виджеты
(канва packages/board, ввод reduceInput, статус-строка, Сброс/Подсказка);
без JS видна вся теория и запасной текст задач.
- `/tsumego` — список задач, фильтры: тема, сложность, статус
(все/решенные/нерешенные); переход к задаче — `/tsumego` с раскрытым
виджетом или отдельная подстраница (выбор исполнителя, зафиксировать в
plan-phase-6).
- `/progress` — «Мой прогресс»: уроки пройдено X/Y, задачи решено X/Y
(отдельно уроки/цумэ-го), серия дней; recovered-пометка при битом
хранилище.
- Навигация Layout.astro: Курс, Цумэ-го, Прогресс, Играть. Все новые строки
UI — только через ui/strings.ts.
## Схема localStorage (этапы 56)
Статус: реализовано (этап 5) — apps/web/src/lib/storage.ts; сигнатуры
loadSettings/saveSettings дополнены необязательным параметром StorageLike
(инжекция для тестов и отсутствия localStorage), поведение по контракту.
Зафиксировано: 2026-08-05 (этап 5). Класс данных: настройки и прогресс не
покидают браузер, во внешние сервисы не отправляются (ADR-0001, I.16).
Битое состояние не роняет приложение: старт с дефолтов с пометкой recovered.
### Конверт (все ключи `go-learn:*`)
```ts
export interface StorageEnvelope<T> {
readonly version: number; // версия схемы payload
readonly checksum: string; // FNV-1a (hex) от JSON.stringify(payload)
readonly payload: T;
}
```
- Чтение: нет ключа → дефолты (recovered: false); битый JSON / несовпадение
checksum / неизвестная version → дефолты + recovered: true (в UI — тихая
пометка, без алертов).
- Миграция: при появлении V2 — функция migrate(payload, fromVersion) до
applyDefaults; односторонняя, в момент загрузки.
- Запись — только через save-функции (они же считают checksum); прямой
localStorage.setItem вне storage-модуля запрещён.
### Ключ `go-learn:settings`, version: 1
```ts
export interface SettingsV1 {
readonly size: BoardSize; // дефолт 9
readonly level: 1 | 2 | 3 | 4 | 5 | 6; // дефолт 2
readonly playerColor: 'black' | 'white' | 'random'; // дефолт 'black'
readonly komi: number | 'auto'; // дефолт 'auto' → defaultKomi(size)
readonly showCoordinates: boolean; // дефолт true
readonly showMoveNumbers: boolean; // дефолт false
readonly hintsEnabled: boolean; // дефолт false; смысл — ур. 4+
}
```
API модуля хранения (apps/web/src/lib/storage.ts):
```ts
export function loadSettings(): { settings: SettingsV1; recovered: boolean };
export function saveSettings(settings: SettingsV1): void;
export function checksumOf(json: string): string; // FNV-1a hex
export const DEFAULT_SETTINGS: SettingsV1;
```
Roundtrip-тест обязателен: save → load даёт идентичные настройки; подпорченный
JSON/checksum/version → дефолты + recovered: true.
## Клиент ИИ для UI (apps/web, этап 5)
Статус: реализовано (этап 5) — apps/web/src/lib/ai-client.ts (+ entry
apps/web/src/ai-worker.ts). Дополнение к контракту: у AiClient есть
`readonly fallback: boolean` — флаг перехода на синхронный фолбэк
(предусмотрен текстом контракта: «флаг в ответе/состоянии для пометки в UI»).
Зафиксировано: 2026-08-05. Тонкий модуль над протоколом воркера этапа 4.
```ts
export interface AiClient {
/** moves — вся партия от пустой доски (протокол stateless). */
choose(request: {
size: BoardSize;
komi: number;
moves: readonly Move[];
level: 1 | 2 | 3 | 4 | 5 | 6;
seed: number;
}): Promise<AiAnswer>;
/** Отмена активного запроса: terminate + пересоздание воркера
* (реальный Worker не примет cancel во время синхронного счёта —
* известная проблема, PROJECT_STATE). Promise старого запроса
* отклоняется с AiCancelledError. */
cancel(): void;
}
export interface AiAnswer {
readonly move: Move;
readonly topMoves?: readonly CandidateScore[]; // есть на ур. 46
readonly simulations?: number;
}
export class AiCancelledError extends Error {}
export function createAiClient(): AiClient;
```
- Ур. 13 — синхронный chooseMove этапа 3 (мгновенно, воркер не нужен),
ур. 46 — Web Worker (chooseMoveMcts через worker.ts); seed формирует
клиент (seed партии + requestId).
- Fail-safe: Worker недоступен/упал → синхронный фолбэк в основном потоке
(ур. 46 подморозит UI на бюджет) + письменная пометка в UI; не молчим.
- Режим подсказки (ур. 4+, hintsEnabled): topMoves последнего ответа —
разметка на доске + оценка; дельта — изменение winRate лучшего хода между
запросами (упрощение зафиксировано в plan-phase-5).
## Полировка: .htaccess, a11y, бюджет веса (этап 7)
Зафиксировано: 2026-08-06 (этап 7). Зона — apps/web (pages, ui, lib,
public/, Layout, strings) + wiring-тесты; packages/* не меняются, новых
зависимостей нет.
### .htaccess (apps/web/public/.htaccess → dist/.htaccess)
Директивы фиксированы (Apache shared hosting, ADR-0001):
- `AddType application/wasm .wasm` — иначе WASM не стартанёт (нужен этапу 9,
ставим сейчас).
- mod_deflate: gzip для text/html, text/css, application/javascript,
application/json, image/svg+xml.
- mod_expires: `/_astro/` — 1 год + `immutable` (имена файлов с хэшем);
`text/html``no-cache, must-revalidate` (через ExpiresDefault «access
0 seconds» + заголовок Cache-Control); прочее — 1 час.
- `Options -Indexes`, `DirectoryIndex index.html`.
- HTTPS-редиректа в файле НЕТ и быть не должно (комментарий в файле:
только панелью хостинга; RewriteCond %{HTTPS} за SSL-прокси = вечный
редирект, gotcha-https-redirect-proxy).
- Wiring-тест: после astro build файл dist/.htaccess существует и содержит
AddType wasm, deflate, expires, `Options -Indexes`; НЕ содержит
`RewriteCond %{HTTPS}`.
### Доступность (a11y)
- Клавиатурное управление доской (страница /play и виджет go-task): канва
focusable (`tabindex="0"`, aria-label из strings). Стрелки двигают
курсор-крестик по пересечениям (старт — центр доски), Enter/Space —
эквивалент tap по курсору через reduceInput (первое нажатие — фантом,
второе — ход; как тач), Escape — cancel. Курсор виден на доске
(маркер через существующие RenderOptions/фантом).
- Чистый редьюсер курсора — apps/web/src/lib/keyboard.ts:
`reduceCursor(cursor: Point, key: 'up'|'down'|'left'|'right', size: BoardSize): Point`
(кламп по краям) + юнит-тесты. DOM-подписка keydown — в play-page.ts и
task-widget.ts (тонкая, без логики).
- Статусные строки (статус партии, статус задачи, сообщения прогресса) —
`role="status" aria-live="polite"`.
- `@media (prefers-reduced-motion: reduce)`: все transition/animation
отключены (глобальный блок в Layout).
- Контраст основного текста и кнопок ≥ 4.5:1 к фону (проверить --text и
--muted на --bg/--surface; при недоборе осветлить --muted, фикс в одном
месте — :root). Видимый :focus-visible outline (accent).
- Все новые строки (подсказки клавиатуры, aria-labels) — через strings.ts.
### Отметка «урок пройден» (дополнение этапа 6)
На странице урока — кнопка «Отметить пройденным» / состояние «Пройден»:
запись `lessons[id].done = true` через saveProgress (+ touchStreak),
чтение — loadProgress. Чистый помощник
`markLessonDone(progress, lessonId, today): ProgressV1` в storage.ts +
тест. Это закрывает теоретические уроки 79 (без go-task).
### Бюджет веса страницы урока
≤ 300 КБ по сети на страницу урока (спека этапа 7). Wiring-тест: для
каждого dist/learn/*.html сумма байт = html + все локальные
script src / link href ассеты из _astro → ≤ 300·1024 (gzip-вес
оцениваем по сырым байтам × — берём сырые, с запасом). При превышении —
сначала уменьшать страницу (ленивая гидратация, разделение чанков), не
ослаблять бюджет без ADR.
## Личный кабинет и синхронизация прогресса (этап 8)
Зафиксировано: 2026-08-06 (этап 8). Активирован отдельной командой
владельца; модель учётки — email + пароль по решению владельца
(ADR-0004, отступление от «анонимного токена» спеки). До-триаж раздела V
литании — там же. Зоны: server/ (PHP API) и apps/web (кабинет + sync).
Новых npm-зависимостей нет; PHP — без фреймворка, синтаксис ≥ PHP 8.0
(хостинги), локальная проверка на PHP 8.2.
### Хранилище (SQLite, PDO)
- БД SQLite вне web root; путь — config.php (не в репо; в репо —
server/config.example.php с плейсхолдерами). PRAGMA journal_mode=WAL,
busy_timeout=5000, foreign_keys=ON.
- Схема (миграция init при первом обращении, idempotent):
users(id INTEGER PK, email TEXT UNIQUE COLLATE NOCASE,
pass_hash TEXT, created_at TEXT ISO);
progress(user_id INTEGER PK REFERENCES users, payload TEXT JSON,
updated_at TEXT ISO);
rate_log(ip TEXT, action TEXT, ts INTEGER); индекс (ip, action, ts).
- Prepared statements ВЕЗДЕ (никакой конкатенации). Лимит тела запроса
256 КБ (413 сверх). JSON-only ответы, charset utf-8.
### API (server/api/_.php; на хостинге — /api/_)
- `POST /api/register.php` {email, password} → 201 {ok:true};
422 (невалидный email / пароль < 8); 409 (email занят).
password_hash (PASSWORD_DEFAULT), password_verify.
- `POST /api/login.php` {email, password} 200 {ok:true} + сессия;
401 (неверная пара). Сессия PHP native, cookie HttpOnly,
SameSite=Lax, Secure при HTTPS.
- `POST /api/logout.php` 200 {ok:true}, сессия уничтожена.
- `GET /api/me.php` 200 {email} / 401.
- `GET /api/progress.php` 200 {payload: ProgressV2, updatedAt} /
204 (прогресса нет) / 401.
- `PUT /api/progress.php` {payload} 200 {updatedAt} / 401 / 413 / 422
(payload не объект). Хранит payload как есть (opaque), updated_at
серверное время.
- `GET /api/health.php` 200 {"status":"ok"} (V.6, без текста
исключений наружу).
- Мутирующие эндпоинты требуют заголовок `X-GoLearn-Client: web`
(CSRF-мерa вместе с SameSite=Lax); иначе 403.
- Rate limiting по IP из rate_log: login/register 10 попыток за 10 мин
на IP 429 {error:"rate_limited"}.
- Общий код server/lib/ (db.php, http.php, auth.php, ratelimit.php).
- Заголовки безопасности на все ответы API: X-Content-Type-Options:
nosniff, Cache-Control: no-store.
### Прогресс V2 и синхронизация (apps/web)
- ProgressV2 = ProgressV1 + `readonly updatedAt: string` (ISO локального
изменения). Миграция V1V2 в storage.ts (migrate до applyDefaults по
контракту конверта): updatedAt = момент миграции; roundtrip-тест
V1V2 обязателен. Каждый saveProgress обновляет updatedAt (Clock
инжектируется параметром, не Date.now в чистых функциях).
- sync-client.ts: `syncProgress(): Promise<SyncResult>` GET; 401
'unauthorized'; недоступен/404 'unavailable' (тихая деградация, UI
помечает «синхронизация недоступна», НЕ алерт); сервер новее
(updated_at > local.updatedAt) → принять серверный payload
('pulled'); локальный новее → PUT ('pushed'); равны → 'noop'.
- Страница /account («Кабинет»): формы регистрации/входа (email+пароль),
состояние «вы вошли как …», кнопки «Синхронизировать», «Выйти»;
статус синхронизации через role="status". Вход выполняется fetch'ем
на /api/*.php относительно текущего origin. В навигацию — «Кабинет».
- Текст-оговорка в UI: восстановления пароля нет (нет отправки почты,
ADR-0004) — предупреждение при регистрации.
- Строки — strings.ts; клавиатура/a11y — как на прочих страницах.
### Тесты этапа 8
- server/tests/smoke.sh: поднимает `php -S 127.0.0.1:8099` на server/,
прогон curl: register 201 → повтор 409 → login 200 → cookie →
PUT progress → GET progress (совпадение payload) → неверный пароль
401 → rate limit 429 (11 попыток) → health 200 {"status":"ok"} →
POST без X-GoLearn-Client → 403. Очищает временную БД.
- vitest (apps/web): migrate V1→V2, roundtrip, sync-merge логика
(pulled/pushed/noop/unavailable) как чистая функция mergeDecision +
fetch-моки.
## Кабинет 2.0: регистрация отдельной страницей, восстановление пароля, капча (этап 8.1)
Зафиксировано: 2026-08-06. Решения владельца (ask_user): своя автономная
капча; OAuth (VK ID / Яндекс ID) — позже отдельным этапом, дизайн —
docs/design/oauth-vk-yandex.md.
### UX регистрации/входа
- /account.html — страница ВХОДА: форма входа; под кнопкой «Войти»
ссылка «Нет аккаунта? Зарегистрируйтесь» → /register.html; ниже —
ссылка «Забыли пароль?» → /password-reset.html.
- /register.html — отдельная страница регистрации: email, пароль (≥8),
повтор пароля, капча (картинка + поле + кнопка «обновить капчу»),
предупреждение о невозможности восстановления УБРАТЬ (появилось
восстановление); после успеха — авто-вход и редирект на /account.html.
- /password-reset.html — два режима по query: без token — форма «email»
(запрос письма); с ?token= — форма «новый пароль ×2». Статусы через
role="status", без алертов.
- Поведение авторизованного на /account — как в этапе 8 (sync/выйти).
### Капча (автономная, без внешних сервисов и GD)
- `GET /api/captcha.php` → 200 image/svg+xml: 5 символов (алфавит без
неоднозначных: без 0/O, 1/I/l), каждый glyph — случайный поворот/
сдвиг/размер, 35 шумовых кривых; в сессию пишется
sha256(strtolower(code)) + expires (10 мин). SVG, НЕ GD: на части
хостингов GD нет, а SVG рендерит любой браузер.
- Проверка — одноразовая: любая попытка (успех/ошибка) сжигает код;
новая попытка = новая капча (клиент обновляет картинку после любой
ошибки регистрации). register.php принимает поле captcha; неверная/
истёкшая/отсутствующая → 422 {error:"captcha"}.
- Капча только на register (login защищён rate limit).
### Восстановление пароля
- Таблица password_resets(id INTEGER PK, email TEXT, token_hash TEXT,
expires_at INTEGER); аддитивно в init-миграцию.
- `POST /api/password-reset/request.php` {email} → ВСЕГДА 200 {ok:true}
(не раскрываем существование учётки). Если email есть: токен
bin2hex(random_bytes(32)), в БД — sha256(token), expires = +1 час;
письмо mail() со ссылкой {base_url}/password-reset.html?token=…
(base_url и mail_from — config.php). Сбой mail() → 503
{error:"mail_unavailable"} (это отказ сервиса, не enumeration).
Rate limit: 5 за 10 мин на IP → 429.
- `POST /api/password-reset/confirm.php` {token, password} → 200
{ok:true} (пароль обновлён password_hash, токен удалён, просроченные
токены чистятся) | 400 {error:"token_invalid"} | 422 (пароль <8).
Rate limit: 10 за 10 мин на IP. Оба с X-GoLearn-Client.
- Письмо plain text, From: mail_from, тема «Восстановление пароля
Го»; в тексте ссылка и срок 1 час. Доставляемость на shared-хостинге
хрупкая (SPF/DKIM панели) риск в PROJECT_STATE, проверка шаг в
deploy.md.
### Тесты этапа 8.1
- smoke.sh += : captcha.php 200 image/svg+xml + Set-Cookie сессии;
register без/с неверной капчей 422 captcha; полный цикл с верной
капчей (код извлекается из сессии тестовым хуком: при env
GOLEARN_TEST=1 captcha.php дополнительно отдаёт заголовок
X-Captcha-Debug с кодом ТОЛЬКО в тестовом режиме, в проде env
отсутствует); reset request на несуществующий email 200; request на
существующий 200 (mail() в тесте: GOLEARN_TEST=1 пишет письмо в
файл /tmp вместо mail()); confirm неверный токен 400; confirm
верный 200 login новым паролем 200; request ×6 429.
- vitest: страницы register/password-reset собраны, формы на месте
(wiring); существующие гейты не падают.
## Дизайн-перенос: светлая тема «васи» от Kimi WebSites (этап 10)
Источник: переносной пакет дизайна (пользовательский экспорт Kimi WebSites),
эталон сохранён в `docs/design/kimi-export/` (style.css + 8 HTML-страниц
только как визуальная референс-копия, не подключается к сборке). Этап
РЕДИЗАЙН: меняются разметка страниц и стили, НЕ меняются логика
(`apps/web/src/lib/**`, `apps/web/src/ui/*.ts`, `packages/**` кроме темы
доски), контент (`src/content/**`), API и тесты.
### Дизайн-токены (CSS custom properties, :root в Layout.astro)
Точно как в `docs/design/kimi-export/style.css`, КРОМЕ трёх сознательных
отклонений ради контраста WCAG AA 4.5:1 (замерено):
- `--ink-faint: #6e675c` (было #a29885 2.67:1 на бумаге, недопустимо
для мелкого текста подсказок).
- `--terra: #a84e2c` (было #c25a33 4.09:1; кикеры мелкий bold-текст).
- `--ok: #46734a` (было #4a7a4e 4.24:1 на --ok-soft в бейдже «пройден»).
Остальные токены без изменений: paper #faf7f1, paper-2 #f2ede3,
paper-3 #e9e2d3, surface #ffffff, ink #23201b, ink-soft #6b6154,
accent #3d4f7c, accent-strong #2e3d63, accent-soft #e9ecf5,
terra-soft #f6e8e0, line #e3dbc9, board #e6c48a, board-line #8a6a3f,
stone-black #211e1a, stone-white #f6f2e8, err #b0492f, err-soft #f8e8e2,
focus #2e3d63, star = terra, radius/radius-sm/radius-pill, shadow-sm/md/lg,
measure 62ch, font-head/body/mono, ease. Старые токены (--bg, --surface-2,
--text, --muted, --border, тёмные --accent*) УДАЛЯЮТСЯ тёмная тема уходит
полностью.
### Каркас Layout.astro
- Глобальные стили = адаптированная копия `kimi-export/style.css` целиком
(база, шапка, кнопки, бейджи, карточки, формы, подвал, page-head, hero,
курс, урок, цумэ-го, игра, прогресс, кабинет, reveal, адаптив,
reduced-motion) плюс сохранённые правила нашего каркаса:
`[hidden]{display:none !important}`, стили `go-task` (переведены на
светлые токены: фон surface, рамка line, статус solved ok).
- Шапка: logo с канji в круге + «Иго · школа Го», nav с пунктами
Курс/Цумэ-го/Играть/Прогресс/Кабинет (ссылки /learn.html, /tsumego.html,
/play.html, /progress.html, /account.html), menu-toggle для мобильных
(переключает .main-nav.is-open, маленький inline-script в Layout,
aria-expanded синхронизируется).
- Проп `section?: 'learn' | 'tsumego' | 'play' | 'progress' | 'account'`
Layout ставит `aria-current="page"` соответствующему пункту nav. Проп
опционален (обратная совместимость: страницы без пропа собираются).
- Подвал: privacy-note «◎ Прогресс на вашем устройстве; кабинет нужен
только для синхронизации между устройствами.», nav (те же разделы),
© 2026 Иго.
- Шрифты: Google Fonts PT Sans 400/700 + PT Serif 400/700 + PT Mono с
preconnect и display=swap; фолбэки уже в токенах font-*. Канji
рендерится системным шрифтом.
- В head добавить meta description на страницах (уже частично есть).
### Тема доски (packages/board/src/render.ts DEFAULT_THEME)
Светлая, под токены: boardBackground #e6c48a, lineColor #8a6a3f,
blackStone #211e1a, whiteStone #f6f2e8, blackStoneEdge #00000000 не
трогаем семантику: edge для чёрного #4a453c, для белого #b9ac93,
coordinateColor #8a6a3f, markupColor #a84e2c (terra), lastMoveMarker
#3d4f7c (accent). Если render.test.ts опирается на DEFAULT_THEME —
тесты поправить под новые константы (это допустимое изменение тестов:
константы темы часть дизайна).
### Изображения
`apps/web/public/assets/hero.jpg` (1024×576, 53КБ), `ink.jpg`
(1400×933, 118КБ, loading="lazy"), `stones.jpg` (800×533, 24КБ,
loading="lazy") уже в репозитории. Используются только на главной
(hero-figure, ink-band, home-strip). Все с осмысленным alt.
### Страницы — соответствие паттернам дизайна
- index.astro: hero (kicker, h1 с em, lead, btn-row, hero-meta с ЧЕСТНЫМИ
числами: 10 разделов, реальное кол-во задач цумэ-го из коллекции,
6 уровней ИИ), features (3 карточки), ink-band, home-strip с цитатой.
Тексты из STRINGS/существующих, новые короткие допускаются.
- learn.astro: page-head + course-grid карточек lesson-card (номер,
название, описание, бейдж времени если есть, слот «пройден»).
Существующая JS-логика отметок сохраняется.
- learn/[id].astro: page-head (kicker «урок N из 10»), article.lesson-body
(контент из markdown), lesson-nav внизу. Кнопка «Отметить пройденным»
сохраняет поведение.
- tsumego.astro: page-head + form.card.filters (существующие select'ы и их
id/JS не меняются) + сетка задач. Существующие <details> задачи можно
оставить (стилизовать как card) — структура островка не ломается.
- play.astro: page-head + play-layout (board-wrap с канвасом/SVG +
aside.play-panel карточки: настройки, пленники, status-line, подсказки,
управление). Все id/data-атрибуты и ui/play-page.ts не меняются.
- progress.astro: page-head + stats-grid stat-card + progress-track/fill.
Существующие data-атрибуты и JS сохраняются.
- account.astro / register.astro / password-reset.astro: паттерн
.card.auth-card (register — с captcha-row; password-reset не было в
дизайне — используем тот же auth-card). Все формы, id, data-атрибуты,
скрипты sync-client — без изменений.
### Приёмка этапа 10
- `npm run gates` зелёный (467+ тестов; меняться могут только
константные ожидания темы доски и wiring-ожидания разметки, если
проверяли старые классы, — правки минимальны и точечны).
- Контраст ключевых пар ≥ 4.5:1 (проверка скриптом/расчётом — пары из
списка выше).
- Вес: каждая страница ≤ 300КБ собственных ресурсов (HTML+CSS+JS+img
без lazy); index ≤ 300КБ с hero, lazy-картинки не считаются.
- Визуальная проверка превью: новая версия сайта отдельно от рабочей.
## Аудит контента, страница «Источники», дисклеймер (этап 11)
### Железное правило (требование владельца)
Никаких выдуманных источников и фактов. Каждый источник — только URL,
который агент РЕАЛЬНО открыл инструментом и из которого приведена цитата,
подтверждающая утверждение. Не удалось подтвердить — пишем «не найдено».
Выдуманный URL/цитата = брак всей работы агента.
### Формат отчёта аудита (docs/audit/NN.md)
По каждому проверяемому утверждению урока:
- **Утверждение** (дословно или близко к тексту урока, файл/секция).
- **Вердикт**: ПОДТВЕРЖДЕНО | НЕ НАЙДЕНО | РАСХОЖДЕНИЕ.
- **Источник**: URL + дословная цитата (12 строки) + название ресурса.
Только для ПОДТВЕРЖДЕНО и РАСХОЖДЕНИЕ.
- При РАСХОЖДЕНИИ — как должно быть по источнику.
Авторитетность (по убыванию): официальные правила (AGA/BGA/федерации),
Sensei's Library, gobase.org, Wikipedia (как вторичный), книги
известных авторов в открытом доступе. Блоги/форумы — не источники.
### Страница «Источники» (/istochniki.html)
- Обычная страница Layout (page-head, container), title «Источники».
- Структура: по темам курса — проверяемый факт одной строкой + ссылка на
источник (название ресурса). Без оценок и прозы — «голые факты».
- Отдельный честный раздел: «Что пока не удалось подтвердить» — список
утверждений курса без найденного источника (если такие есть).
- Ссылка на страницу — из футера (nav) и со страницы курса.
### Дисклеймер
- На странице /learn.html под вводным абзацем — примечание:
«Курс создан с помощью ИИ и находится в открытом редактировании:
сверяйте спорные места с источниками» (ссылка на /istochniki.html).
- В футере nav добавить пункт «Источники».
- Стиль — .progress-note / текстовое примечание, без кричащих баннеров.
### Приёмка
- Оркестратор выборочно перепроверяет ≥5 URL из отчётов через
web_open_url — все обязаны открываться и содержать цитату.
- Гейты зелёные; страница источников ≤ бюджета веса; новая версия превью.