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

65 KiB
Raw Permalink Blame History

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).

Базовые типы

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 };

Позиция и правила

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)

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;

История партии (неизменяемое дерево)

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]

/** Ошибка парсинга 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

export interface Rng {
  next(): number; // равномерное [0, 1)
}

export interface Clock {
  now(): number; // миллисекунды
}

/** Детерминированный PRNG (mulberry32): одинаковый seed → одинаковая
 *  последовательность. Единственный разрешённый источник случайности
 *  в тестах и движке. */
export function createSeededRng(seed: number): Rng;

Дополнение этапа 6 (ADR-0003)

/** Произвольная стартовая позиция для задач. Валидация: точки в пределах
 *  доски, без дублей, каждая группа с ≥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)

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.

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.

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-адаптер (тонкий, без логики; юнит-тестами не покрывается)

/** Выставляет 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 — никакого дублирования правил.

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 (чистый)

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-ответ). Типы сообщений:

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 (обязателен целиком):
---
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).
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-функции).

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, дополнение):

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:*)

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

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):

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.

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/htmlno-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 локального изменения). Миграция V1→V2 в storage.ts (migrate до applyDefaults по контракту конверта): updatedAt = момент миграции; roundtrip-тест V1→V2 обязателен. Каждый 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 не меняются) + сетка задач. Существующие
    задачи можно оставить (стилизовать как 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 — все обязаны открываться и содержать цитату.
  • Гейты зелёные; страница источников ≤ бюджета веса; новая версия превью.