sdlc-hub/docs/superpowers/plans/2026-10-15-web-app-followups.md
2026-10-15 00:55:25 +04:00

66 KiB
Raw Blame History

Web-app follow-ups (#29) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: закрыть все 46 пунктов jira-hub#29 (отложенные находки change web-app): видимое поведение доски, входа и страниц задач, шрифты без интернета, уязвимость dev-зависимости, React-код и тесты.

Architecture: правки внутри существующего веб-приложения web/ (React 19 + TS + Vite, TanStack Query, React Router) и одной функции хаба h_app. Новых модулей два: web/src/board/scrollMemory.ts (прокрутка доски между страницами) и web/src/api/queryClient.test.ts. API хаба, контракт с агентом, ключи localStorage и логика доски не меняются.

Tech Stack: Python 3.13 + pytest (хаб); TypeScript, React 19, TanStack Query 5, React Router 8, Vitest 5 + Testing Library (jsdom), Playwright (e2e на тестовом хабе :8791), npm.

Spec: openspec/changes/web-app-followups/ (proposal.md, design.md — решения 1–14 и таблица пунктов, specs/web-app, specs/hub-api).

Global Constraints

  • Язык комментариев, сообщений коммитов и текстов интерфейса — русский; идентификаторы — английские.
  • Коммит заканчивается строкой Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>; в теме коммита — (#29).
  • Новые npm-зависимости — точные версии: npm install --before=2026-10-01 --save-exact <пакет>; package-lock.json в git.
  • tsconfig: strict, erasableSyntaxOnly — без parameter properties в классах (поля объявлять явно, как в ApiError).
  • Ключи localStorage (jira-hub-token, graph-<имя>…) не меняются.
  • Живой хаб :8790 и ~/.local/share/jira-hub не трогать; e2e и ручные проверки — только тестовый хаб :8791 (bash web/scripts/test-hub.sh).
  • Поведение доски на тех же данных совпадает с golden-эталонами links.html@3931a88; эталон не подгоняется под код.
  • Каждый пункт #29 в отчёте задачи — с маркером и статусом fixed <commit> / rejected <причина>.
  • Зелёным считается только весь набор: make check (pytest, tsc, eslint, vitest); для задач 4 и 7 — ещё cd web && npm run e2e.
  • Перед изменением — codegraph_explore с projectPath = абсолютный путь worktree по своей области.

Review Focus

  1. Запрос ушёл со старым токеном, пользователь успел войти с новым, и только потом пришёл 401 — новый токен остаётся, страница не сменяется экраном входа (Task 2, тест «запоздавший 401 от прежнего токена…»).
  2. Пользователь ушёл с доски в карточку задачи, пока пакет «Обновить из Jira» выполняется, и вернулся — пакет снова опрашивается, ровно одним опросом, кнопка недоступна (Task 3, тест «уход и возврат во время пакета»).
  3. Прокрутка восстанавливается не позицией на момент ухода, сжатой браузером под короткую страницу карточки, а той, что была на доске (Task 4: запоминание по событию scroll; e2e «M-3: прокрутка доски после возврата…»).
  4. Адрес списка задач с фильтром, значение которого есть на доске, а снимок доски ещё грузится, — без мигания пометки «(нет на доске)» (Task 5, тест «пока снимок доски грузится…»).
  5. Сборка без интернета: в собранном index.html и CSS нет http(s):// ссылок (Task 7, шаг 2).

Task 1: Хаб — нулевой байт в пути, типы файлов сборки, дубль импорта (T1.1, T1.4, T16.1)

Модель исполнителя: haiku (готовый код).

Files:

  • Modify: src/jira_hub/api.py (функция h_app)
  • Modify: tests/test_web_app.py
  • Modify: tests/test_api.py:161 (локальный import urllib.request, urllib.error)

Interfaces:

  • Consumes: api.Hub(db_path, backups_dir=None, release_dir=None, web_dir=None), api.ApiError(status, error, detail).

  • Produces: ничего нового для других задач.

  • Step 1: Падающие тесты — в tests/test_web_app.py, в фикстуре app_hub после строки с font.bin добавить файлы:

    for name, data in (("logo.svg", b"<svg/>"), ("p.png", b"\x89PNG"), ("f.ico", b"\x00"), ("f.woff2", b"wOF2"),
                       ("index-abc123.js.map", b"{}")):
        (web / "assets" / name).write_bytes(data)

и в конец файла:

@pytest.mark.parametrize("name,ctype", [("logo.svg", "image/svg+xml"), ("p.png", "image/png"), ("f.ico", "image/x-icon"),
                                        ("f.woff2", "font/woff2"), ("index-abc123.js.map", "application/json")])
def test_asset_content_types(app_hub, name, ctype):
    status, r, _ = _get(app_hub, "/app/assets/" + name)
    assert status == 200 and r.getheader("Content-Type") == ctype


@pytest.mark.parametrize("path", ["/app/assets/%00", "/app/assets/a%00.js", "/app/assets/%00/../index-abc123.js"])
def test_nul_in_asset_path_is_404(app_hub, path):
    status, _, _ = _get(app_hub, path)
    assert status == 404
  • Step 2: Запустить uv run pytest tests/test_web_app.py -q — ожидается FAIL: test_nul_in_asset_path_is_404 получает 500 (ValueError: embedded null byte из Path.resolve). Тесты типов проходят сразу — это покрытие (T1.4).

  • Step 3: Реализация — в h_app заменить блок if rest.startswith("assets/"): на:

    if rest.startswith("assets/"):
        if "\x00" in rest:                       # T1.1: нулевой байт — 404, а не 500 из resolve()
            raise ApiError(404, "not_found", "/app/" + rest.replace("\x00", "%00"))
        assets = (hub.web_dir / "assets").resolve()
        try:
            f = (hub.web_dir / rest).resolve()
        except (OSError, ValueError):
            raise ApiError(404, "not_found", "/app/" + rest)
        if not f.is_relative_to(assets) or not f.is_file():
            raise ApiError(404, "not_found", "/app/" + rest)
        return 200, Raw(ASSET_TYPES.get(f.suffix, "application/octet-stream"), f.read_bytes(), ASSET_CACHE)
  • Step 4: T16.1 — в tests/test_api.py удалить строку import urllib.request, urllib.error внутри теста (модули уже импортированы в начале файла).

  • Step 5: Проверка uv run pytest -q — весь набор зелёный.

  • Step 6: Коммит

git add src/jira_hub/api.py tests/test_web_app.py tests/test_api.py
git commit -m "fix(hub): нулевой байт в пути /app/assets — 404; тесты типов файлов сборки (T1.1, T1.4, T16.1, #29)"

Task 2: Клиент и вход — токен в AuthError, 4xx на входе, тесты 401 (T3.2–T3.6)

Модель исполнителя: sonnet (интеграция: клиент, queryClient, useBoardSource, экран входа).

Files:

  • Modify: web/src/api/client.ts
  • Modify: web/src/api/queryClient.ts
  • Modify: web/src/board/useBoardSource.ts (функция report)
  • Modify: web/src/auth/LoginScreen.tsx
  • Create: web/src/api/queryClient.test.ts
  • Modify: web/src/api/client.test.ts, web/src/auth/token.test.ts, web/src/auth/LoginScreen.test.tsx

Interfaces:

  • Consumes: getToken(), clearToken(reason), setToken() из auth/token.ts.

  • Produces:

    • class AuthError extends Error { readonly token: string; constructor(message: string, token: string) } — токен, с которым ушёл запрос;
    • function dropTokenOn(e: AuthError): void (в api/client.ts) — очищает токен ("bad", если токен был, иначе "none"), только если e.token === getToken(). Все места, где по AuthError вызывался clearToken, вызывают dropTokenOn.
  • Step 1: Найти все места grep -rn "instanceof AuthError" web/src — ожидается api/queryClient.ts, board/useBoardSource.ts, auth/LoginScreen.tsx и тесты. Других вызовов clearToken по AuthError быть не должно.

  • Step 2: Падающие тесты клиента — в web/src/api/client.test.ts добавить:

  it("AuthError хранит токен запроса; dropTokenOn не трогает новый токен (T3.5)", async () => {
    mockFetch(() => json(401, { error: "unauthorized" }));
    const e = await api.get("/board").catch((err: unknown) => err) as AuthError;
    expect(e).toBeInstanceOf(AuthError);
    expect(e.token).toBe("tok");
    setToken("new-tok");
    dropTokenOn(e);
    expect(getAuth()).toEqual({ token: "new-tok", reason: "none" });
    setToken("tok");
    dropTokenOn(e);
    expect(getAuth()).toEqual({ token: "", reason: "bad" });
  });

(импорт: import { clearToken, getAuth, setToken } from "../auth/token.ts"; и dropTokenOn из ./client.ts).

  • Step 3: Тесты queryClient — создать web/src/api/queryClient.test.ts:
import { beforeEach, describe, expect, it } from "vitest";
import { getAuth, setToken } from "../auth/token.ts";
import { AuthError, HubDownError } from "./client.ts";
import { makeQueryClient } from "./queryClient.ts";

const fail = (e: Error) => makeQueryClient().fetchQuery({ queryKey: ["x"], queryFn: () => Promise.reject(e) }).catch(() => undefined);

describe("queryClient", () => {
  beforeEach(() => setToken("tok"));

  it("401 с текущим токеном — экран входа с причиной «bad»", async () => {
    await fail(new AuthError("токен не подошёл", "tok"));
    expect(getAuth()).toEqual({ token: "", reason: "bad" });
  });
  it("401 с прежним токеном не стирает текущий (T3.5)", async () => {
    await fail(new AuthError("токен не подошёл", "old"));
    expect(getAuth()).toEqual({ token: "tok", reason: "none" });
  });
  it("прочие ошибки токен не трогают", async () => {
    await fail(new HubDownError("хаб не отвечает"));
    expect(getAuth().token).toBe("tok");
  });
  it("повторов нет: запрос с ошибкой выполняется один раз", async () => {
    let n = 0;
    await makeQueryClient().fetchQuery({ queryKey: ["y"], queryFn: () => { n += 1; return Promise.reject(new HubDownError("x")); } }).catch(() => undefined);
    expect(n).toBe(1);
  });
});
  • Step 4: T3.4 — в web/src/auth/token.test.ts, в тест «закрытое хранилище…», добавить перед import:
    vi.spyOn(Storage.prototype, "removeItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); });

и после expect(t.getToken()).toBe("mem");:

    t.clearToken("bad");
    expect(t.getAuth()).toEqual({ token: "", reason: "bad" });
  • Step 5: Экран входа — в web/src/auth/LoginScreen.test.tsx заменить тавтологичный тест «после 401 в приложении экран входа поясняет причину» (T3.3, T3.6) на сквозные и добавить тест 4xx (T3.2):
  it("401 в запросе страницы — экран входа «токен не подошёл», страница скрыта (T3.6)", async () => {
    setToken("stale");
    mockFetch(() => json(401, { error: "unauthorized" }));
    function Page() { useBoard(); return <p>страница приложения</p>; }
    renderWithProviders(<AuthGate><Page /></AuthGate>);
    expect(await screen.findByText(/Токен не подошёл/)).toBeInTheDocument();
    expect(screen.queryByText("страница приложения")).not.toBeInTheDocument();
    expect(getAuth()).toEqual({ token: "", reason: "bad" });
  });

  it("запоздавший 401 от прежнего токена после входа с новым не выбрасывает на вход (T3.5)", async () => {
    setToken("old-token");
    let release!: (r: Response) => void;
    const slow = new Promise<Response>((r) => { release = r; });
    mockFetch((_m, url) => (url.pathname === "/board" ? slow : json(200, {})));
    function Page() { useBoard(); return <p>страница приложения</p>; }
    const { client } = renderWithProviders(<AuthGate><Page /></AuthGate>);
    act(() => setToken("new-token"));
    release(json(401, { error: "unauthorized" }));
    await waitFor(() => expect(client.getQueryState(qk.board)?.status).toBe("error"));   // 401 точно обработан
    expect(screen.getByText("страница приложения")).toBeInTheDocument();
    expect(getAuth()).toEqual({ token: "new-token", reason: "none" });
  });

  it("хаб отверг проверку токена (4xx) — текст хаба, не «хаб не отвечает» (T3.2)", async () => {
    mockFetch(() => json(400, { error: "bad_request", detail: "заголовок Authorization без Bearer" }));
    renderWithProviders(app);
    await userEvent.type(screen.getByLabelText("Клиентский токен хаба"), "tok");
    await userEvent.click(screen.getByRole("button", { name: "Войти" }));
    expect(await screen.findByText(/заголовок Authorization без Bearer/)).toBeInTheDocument();
    expect(screen.queryByText(/Хаб не отвечает/)).not.toBeInTheDocument();
  });

Импорты: act — из @testing-library/react, qk, useBoard — из ../api/queries.ts, setToken — из ./token.ts.

  • Step 6: Запустить cd web && npx vitest run src/api src/auth — ожидается FAIL: нет dropTokenOn, у AuthError нет token, 4xx показывает «Хаб не отвечает», запоздавший 401 стирает новый токен.

  • Step 7: Реализация client.ts — заменить export class AuthError extends Error {} на:

export class AuthError extends Error {
  readonly token: string;   // токен, с которым ушёл запрос: запоздавший 401 прежнему токену не выбрасывает на вход (T3.5)
  constructor(message: string, token: string) {
    super(message);
    this.token = token;
  }
}

// 401 выводит на экран входа, только если запрос ушёл с текущим токеном (R-3): новый токен уже проверен GET /settings
export function dropTokenOn(e: AuthError): void {
  if (e.token !== getToken()) return;
  clearToken(e.token ? "bad" : "none");
}

импорт: import { clearToken, getToken, TOKEN_RE } from "../auth/token.ts";. В call(): throw new AuthError("нет токена", token), throw new AuthError("недопустимые символы в токене", token), if (resp.status === 401) throw new AuthError("токен не подошёл", token);.

  • Step 8: queryClient.ts — const onError = (e: unknown) => { if (e instanceof AuthError) dropTokenOn(e); };, импорт import { AuthError, dropTokenOn } from "./client.ts";, импорт из ../auth/token.ts удалить.

  • Step 9: useBoardSource.ts — в report: if (e instanceof AuthError) dropTokenOn(e); вместо clearToken("bad"); импорт dropTokenOn из ../api/client.ts, импорт clearToken удалить, если больше не нужен.

  • Step 10: LoginScreen.tsx — импорт ApiError из ../api/client.ts; в catch:

      setError(e instanceof AuthError ? BAD_TOKEN
        : e instanceof ApiError ? `Хаб отверг проверку токена: ${e.detail ?? e.error}`   // T3.2: 4xx — текст хаба
        : HUB_DOWN);
  • Step 11: Проверка make check — зелёный.

  • Step 12: Коммит

git add web/src/api web/src/auth web/src/board/useBoardSource.ts
git commit -m "fix(web): запоздавший 401 прежнего токена не выбрасывает на вход, 4xx на входе — текст хаба (T3.2–T3.6, #29)"

Task 3: Источник доски — строка пакета, снимок из файла, подхват только refresh (T10.1, T10.3–T10.6)

Модель исполнителя: sonnet (интеграция, тайминги опроса).

Files:

  • Modify: web/src/board/status.ts, web/src/board/status.test.ts
  • Modify: web/src/board/useBoardSource.ts
  • Modify: web/src/board/useBoardSource.test.tsx

Interfaces:

  • Consumes: dropTokenOn (Task 2), api.get, qk.board, Batch.commands?: BatchCommand[].

  • Produces: BoardStatus вариант { kind: "hub-down"; hasData: boolean; fromFile: boolean } (поле fromFile обязательное); statusText для fromFile: true — «хаб не отвечает — на доске снимок из файла».

  • Step 1: Тест текста — в status.test.ts заменить строку с hub-down на:

    expect(statusText({ kind: "hub-down", hasData: true, fromFile: false }).text).toBe("хаб не отвечает — показаны прежние данные");
    expect(statusText({ kind: "hub-down", hasData: true, fromFile: true }).text).toBe("хаб не отвечает — на доске снимок из файла");
  • Step 2: Тесты источника — в useBoardSource.test.tsx:
    • хелпер hook принимает клиент:
const hook = (client = makeQueryClient()) => ({ client, ...renderHook(() => useBoardSource(dicts, { pollMs: 10 }),
  { wrapper: ({ children }: { children: ReactNode }) => <QueryClientProvider client={client}>{children}</QueryClientProvider> }) });
(константу `wrapper` удалить; импорт `qk` из `../api/queries.ts`);
  • в тесте «M-1: поздний ответ…» (T10.5) заменить await waitFor(() => expect(h.count("GET /board")).toBe(1)); await sleep(20); на const { result, client } = hook(); (вместо прежнего const { result } = hook();) и
    await waitFor(() => expect(client.getQueryData(qk.board)).toBeDefined());   // ответ хаба точно применён к кэшу
  • в тесте «M-2» после expect(h.count("GET /board")).toBe(2); добавить expect(h.count("GET /batches")).toBe(1); // T10.1: после своего пакета список пакетов не перечитывается;
  • в тесте «хаб остановлен при «Перечитать»» ожидание — { kind: "hub-down", hasData: true, fromFile: false };
  • новые тесты:
  it("T10.3: «Перечитать» и CSV во время пакета не стирают строку пакета", async () => {
    hub({ "GET /batches/:id": () => batch("running") });
    const { result } = hook();
    await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
    await act(() => result.current.refreshFromJira());
    await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch", id: 7 }));
    act(() => result.current.reload());
    expect(result.current.status).toMatchObject({ kind: "batch", id: 7 });
    act(() => { result.current.loadFile("jira-export.csv", CSV); });
    expect(result.current.status).toMatchObject({ kind: "batch", id: 7 });
  });

  it("T10.4: JSON-снимок без данных хаба — «на доске снимок из файла»", async () => {
    hub({ "GET /board": () => { throw new TypeError("Failed to fetch"); } });
    const { result } = hook();
    await waitFor(() => expect(result.current.status).toMatchObject({ kind: "hub-down", fromFile: false }));
    act(() => { result.current.loadFile("old-board.json", fx("old-board.json")); });
    expect(result.current.source).toBe("file");
    expect(result.current.status).toEqual({ kind: "hub-down", hasData: true, fromFile: true });
  });

  it("T10.4: пакет завершился при показанном CSV, хаб не ответил — «на доске снимок из файла»", async () => {
    let polls = 0, down = false;
    hub({
      "GET /board": () => { if (down) throw new TypeError("Failed to fetch"); return json(200, SNAP); },
      "GET /batches/:id": () => { if (++polls >= 3) down = true; return polls < 3 ? batch("running") : batch("done"); },
    });
    const { result } = hook();
    await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
    await act(() => result.current.refreshFromJira());
    act(() => { result.current.loadFile("jira-export.csv", CSV); });
    await waitFor(() => expect(result.current.status).toEqual({ kind: "hub-down", hasData: true, fromFile: true }));
    expect(result.current.source).toBe("file");
  });

  it("уход со страницы и возврат во время пакета — пакет подхвачен снова, один опрос (Review Focus 2)", async () => {
    let listed: unknown[] = [];
    const h = hub({ "GET /batches": () => json(200, { batches: listed }), "GET /batches/:id": () => batch("running") });
    const first = hook();
    await waitFor(() => expect(first.result.current.model.nodes.length).toBe(10));
    await act(() => first.result.current.refreshFromJira());
    listed = [{ id: 7, author: "viewer", status: "running" }];
    first.unmount();
    const second = hook(first.client);
    await waitFor(() => expect(second.result.current.status).toMatchObject({ kind: "batch", id: 7 }));
    expect(second.result.current.refreshing).toBe(true);
    expect(h.maxInFlight("GET /batches/7")).toBe(1);
  });

  it("T10.6: незавершённый пакет viewer без refresh не подхватывается", async () => {
    const h = hub({
      "GET /batches": () => json(200, { batches: [{ id: 7, author: "viewer", status: "running" }] }),
      "GET /batches/:id": () => batch("running", { type: "ensure_fields" }),
    });
    const { result } = hook();
    await waitFor(() => expect(h.count("GET /batches/7")).toBe(1));
    await sleep(50);
    expect(result.current.refreshing).toBe(false);
    expect(h.count("GET /batches/7")).toBe(1);   // только проверка типа, опроса нет
  });
  • Step 3: Запустить cd web && npx vitest run src/board/status.test.ts src/board/useBoardSource.test.tsx — ожидается FAIL: нет fromFile, строка пакета стирается, пакет ensure_fields подхватывается, лишний GET /batches.

  • Step 4: status.ts — вариант | { kind: "hub-down"; hasData: boolean; fromFile: boolean } и

    case "hub-down": return {
      text: s.fromFile ? "хаб не отвечает — на доске снимок из файла"
        : s.hasData ? "хаб не отвечает — показаны прежние данные" : "хаб не отвечает: запустите хаб (jira-hub serve) и нажмите «Перечитать»",
      warn: true,
    };
  • Step 5: useBoardSource.ts — изменения (остальное без изменений):
  // свежие значения для эффектов, которые не должны перезапускаться от их смены
  const sourceRef = useRef(source);
  useEffect(() => { sourceRef.current = source; }, [source]);
  const activeRef = useRef(active);
  useEffect(() => { activeRef.current = active; }, [active]);
  const skipPickup = useRef(false);   // T10.1: снимок, перечитанный после своего пакета, — пакет уже завершён

  const report = useCallback((e: unknown, hasData: boolean, fromFile = false) => {
    if (e instanceof AuthError) dropTokenOn(e);
    else if (e instanceof HubDownError) setEvent({ kind: "hub-down", hasData, fromFile });
    else setEvent({ kind: "error", message: e instanceof Error ? e.message : String(e) });
  }, []);

  useEffect(() => {
    if (!board.error || board.error instanceof AuthError) return;
    skipPickup.current = false;
    report(board.error, !!board.data, sourceRef.current.kind === "file");
  }, [board.error, board.errorUpdatedAt, board.data, report]);

  // M-3 #20: после каждой успешной загрузки снимка — подхватить незавершённый пакет обновления от viewer
  useEffect(() => {
    if (!board.dataUpdatedAt) return;
    if (skipPickup.current) { skipPickup.current = false; return; }
    if (activeRef.current) return;
    let gone = false;
    (async () => {
      const r = await api.get<{ batches: Batch[] }>("/batches", { limit: 20 });
      const b = r.batches.find((x) => x.author === "viewer" && (x.status === "queued" || x.status === "running"));
      if (!b || gone) return;
      const full = await api.get<Batch>(`/batches/${b.id}`);   // T10.6: в списке нет команд — тип смотрим в пакете (R-2)
      if (gone || full.commands?.[0]?.type !== "refresh") return;
      failures.current = 0;
      setActive((cur) => cur ?? { id: b.id, agentOffline: false });
    })().catch(() => { /* не критично: подхватим при следующей загрузке */ });
    return () => { gone = true; };
  }, [board.dataUpdatedAt]);

в finish: перед void qc.invalidateQueries(...) — skipPickup.current = true;; в reload: setEvent((cur) => (cur?.kind === "batch" ? cur : null)); вместо setEvent(null); в loadFile, ветка CSV: setEvent((cur) => (cur?.kind === "batch" ? cur : null)); вместо setEvent(null); в loadFile, ветка «хаб не ответил — снимок из файла»: setEvent({ kind: "hub-down", hasData: r.model.nodes.length > 0, fromFile: true });. ESLint react-hooks/exhaustive-deps не должен ругаться: refs в зависимости не входят.

  • Step 6: Проверка make check; затем cd web && for i in $(seq 1 10); do npx vitest run src/board/useBoardSource.test.tsx || break; done — 10 зелёных прогонов подряд.

  • Step 7: Коммит

git add web/src/board/status.ts web/src/board/status.test.ts web/src/board/useBoardSource.ts web/src/board/useBoardSource.test.tsx
git commit -m "fix(web): строка пакета переживает «Перечитать», снимок из файла при недоступном хабе, подхват только refresh (T10.1, T10.3–T10.6, #29)"

Task 4: Холст и страница доски — прокрутка, memo карточек, обработчики React (M-3, T11.1–T11.4, T12.1–T12.4)

Модель исполнителя: sonnet (интеграция с перетаскиванием и раскладкой; e2e обязателен).

Files:

  • Create: web/src/board/scrollMemory.ts
  • Modify: web/src/board/components/BoardCanvas.tsx, Card.tsx, DatesBar.tsx, Dropdown.tsx, MultiSelect.tsx
  • Modify: web/src/board/useBoardPrefs.ts, web/src/board/BoardPage.tsx
  • Modify: web/src/board/components/BoardCanvas.test.tsx, web/src/board/BoardPage.test.tsx
  • Create: web/src/board/components/Card.test.ts, web/src/board/components/Dropdown.test.tsx, web/src/board/components/DatesBar.test.tsx
  • Modify: web/e2e/board.spec.ts

Interfaces:

  • Produces:

    • scrollMemory.ts: interface BoardScroll { x: number; y: number; left: number }, readBoardScroll(): BoardScroll | null, saveBoardScroll(s: BoardScroll | null): void;
    • Card.tsx: export function cardPropsEqual(a: CardProps, b: CardProps): boolean — компаратор memo.
  • Step 1: scrollMemory.ts

// M-3: прокрутка доски на время жизни вкладки (R-1) — переход в карточку задачи и обратно её не сбрасывает,
// перезагрузка страницы сбрасывает (значение живёт в памяти модуля)
export interface BoardScroll { x: number; y: number; left: number }
let saved: BoardScroll | null = null;
export const readBoardScroll = (): BoardScroll | null => saved;
export const saveBoardScroll = (s: BoardScroll | null): void => { saved = s; };
  • Step 2: Падающие тесты холста — в BoardCanvas.test.tsx:
    • T11.4, тест «сужение выборки…»: последнюю проверку заменить на await waitFor(() => expect(board().querySelectorAll(".card[data-key]")).toHaveLength(narrow.nodes.length)); (тест — async, waitFor из @testing-library/react);
    • T11.4, тест «полоса дат…»:
    const wrap = document.querySelector(".wrap") as HTMLElement;
    expect((document.querySelector(".dates-in") as HTMLElement).style.width).toBe(`${wrap.offsetWidth}px`);
  • T11.3 и M-3, новые тесты:
  it("T11.3: перетаскивание названия дорожки вниз — onMoveLane с новым местом", () => {
    // в jsdom может не быть PointerEvent и захвата указателя — минимальные замены только для теста
    if (typeof window.PointerEvent === "undefined") Object.defineProperty(window, "PointerEvent", { configurable: true, value: class extends MouseEvent {} });
    if (typeof Element.prototype.setPointerCapture !== "function") Object.defineProperty(Element.prototype, "setPointerCapture", { configurable: true, value: () => {} });
    const p = draw();
    const name = [...board().querySelectorAll<HTMLElement>(".lane-name")].find((e) => e.textContent === "Гудвилл")!;
    fireEvent.pointerDown(name, { pointerId: 1, clientY: 5 });
    fireEvent.pointerMove(name, { pointerId: 1, clientY: 100000 });
    fireEvent.pointerUp(name, { pointerId: 1, clientY: 100000 });
    expect(p.onMoveLane).toHaveBeenCalledWith("Гудвилл", 3);
  });

  it("M-3: прокрутка запоминается по событию scroll и восстанавливается при новом монтировании", () => {
    const to = vi.spyOn(window, "scrollTo").mockImplementation(() => {});
    const first = renderWithProviders(<BoardCanvas {...baseProps()} />);
    Object.defineProperty(window, "scrollY", { configurable: true, value: 640 });
    fireEvent.scroll(window);
    expect(readBoardScroll()).toMatchObject({ y: 640 });
    first.unmount();
    renderWithProviders(<BoardCanvas {...baseProps()} />);
    expect(to).toHaveBeenCalledWith(0, 640);
    Object.defineProperty(window, "scrollY", { configurable: true, value: 0 });
    vi.restoreAllMocks();
  });
в `web/src/test/setup.ts` в `afterEach` после `cleanup()` добавить `saveBoardScroll(null);` (импорт из
`../board/scrollMemory.ts`) — память прокрутки не переходит между тестами;
вынести создание пропсов из `draw` в `baseProps(over = {})` (тот же объект, что сейчас собирает `draw`), `draw`
вызывает `renderWithProviders(<BoardCanvas {...baseProps(over)} />)`; импорт `readBoardScroll, saveBoardScroll` из
`../scrollMemory.ts`. Если `onMoveLane` получает не 3, а индекс последней дорожки другой длины — проверять
`vis.lanes.length - 1` той же выборки.
  • Step 3: Тест компаратора — создать Card.test.ts:
import { describe, expect, it } from "vitest";
import { cardPropsEqual, type CardProps } from "./Card.tsx";

const node = { key: "A-1" } as CardProps["node"];
const base: CardProps = { node, box: { x: 1, y: 2, w: 240, h: 120 }, rel: undefined, multiSprint: false, sprintSel: new Set() };

describe("cardPropsEqual (T11.1)", () => {
  it("новый объект box с теми же числами — карточка не перерисовывается", () => {
    expect(cardPropsEqual(base, { ...base, box: { x: 1, y: 2, w: 240, h: 120 } })).toBe(true);
  });
  it("сдвиг, другая карточка или другой выбор спринтов — перерисовка", () => {
    expect(cardPropsEqual(base, { ...base, box: { x: 9, y: 2, w: 240, h: 120 } })).toBe(false);
    expect(cardPropsEqual(base, { ...base, node: { key: "A-2" } as CardProps["node"] })).toBe(false);
    expect(cardPropsEqual(base, { ...base, sprintSel: new Set(["S"]) })).toBe(false);
  });
});
  • Step 4: Тесты Dropdown и DatesBar — Dropdown.test.tsx:
import { fireEvent, render, screen } from "@testing-library/react";
import { StrictMode } from "react";
import { describe, expect, it, vi } from "vitest";
import { Dropdown } from "./Dropdown.tsx";

describe("Dropdown (T12.1)", () => {
  it("onOpen вызывается один раз на открытие, в том числе в StrictMode", () => {
    const onOpen = vi.fn();
    render(<StrictMode><Dropdown onOpen={onOpen} button={({ toggle }) => <button onClick={toggle}>открыть</button>}>{() => <p>меню</p>}</Dropdown></StrictMode>);
    fireEvent.click(screen.getByText("открыть"));
    expect(onOpen).toHaveBeenCalledTimes(1);
    expect(screen.getByText("меню")).toBeInTheDocument();
    fireEvent.click(screen.getByText("открыть"));
    expect(onOpen).toHaveBeenCalledTimes(1);
  });
});

DatesBar.test.tsx:

import { fireEvent, render } from "@testing-library/react";
import { createRef } from "react";
import { describe, expect, it, vi } from "vitest";
import type { DateCol } from "../layout/layout.ts";
import { DatesBar } from "./DatesBar.tsx";

describe("DatesBar (T11.2)", () => {
  it("отложенный кадр растяжки отменяется при размонтировании", () => {
    const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 42);
    const caf = vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
    const cols: DateCol[] = [{ date: "2026-10-20", x0: 0, w: 300, cls: "", count: 1 }];
    const r = render(<DatesBar cols={cols} width={300} scrollLeft={0} wrapW={300} inRef={createRef()} onColWidth={vi.fn()} />);
    const split = r.container.querySelector(".dsplit") as HTMLElement;
    fireEvent.pointerDown(split, { clientX: 300 });
    fireEvent.pointerMove(window, { clientX: 340 });
    expect(raf).toHaveBeenCalled();
    r.unmount();
    expect(caf).toHaveBeenCalledWith(42);
  });
});
  • Step 5: Тесты страницы — в BoardPage.test.tsx:
  it("T12.3: --head-h снимается при уходе со страницы", async () => {
    hub();
    const r = page();
    await screen.findByText(/^Хаб · данные на/);
    r.unmount();
    expect(document.documentElement.style.getPropertyValue("--head-h")).toBe("");
  });

  it("T12.2: переключатель пишет флаг в хранилище один раз, в том числе в StrictMode", async () => {
    hub();
    renderWithProviders(<StrictMode><BoardPage /></StrictMode>, { route: "/board", path: "/board" });
    await screen.findByText(/^Хаб · данные на/);
    const set = vi.spyOn(Storage.prototype, "setItem");
    fireEvent.click(screen.getByRole("button", { name: /критический путь/ }));
    expect(set.mock.calls.filter(([k]) => k === "graph-crit")).toEqual([["graph-crit", "1"]]);
  });

  it("T12.4: список исполнителей — группа флажков с подписью", async () => {
    hub();
    page();
    await userEvent.click(await screen.findByRole("button", { name: /Все исполнители/ }));
    expect(screen.getByRole("group", { name: "Все исполнители" })).toBeInTheDocument();
    expect(screen.queryByRole("listbox")).toBeNull();
  });

(renderWithProviders уже возвращает unmount; page вернуть его результат; vi — из vitest, StrictMode — из react: React в StrictMode вызывает функцию-updater дважды, поэтому до исправления запись будет двойной.)

  • Step 6: Запустить cd web && npx vitest run src/board — ожидается FAIL новых тестов.

  • Step 7: Реализация Card.tsx — перед export const Card:

// T11.1: при перетаскивании одной карточки place() создаёт новые box у всех — сравниваем box по значению
export function cardPropsEqual(a: CardProps, b: CardProps): boolean {
  const sameBox = a.box === b.box || (!!a.box && !!b.box && a.box.x === b.box.x && a.box.y === b.box.y && a.box.w === b.box.w && a.box.h === b.box.h);
  return sameBox && a.node === b.node && a.rel === b.rel && a.multiSprint === b.multiSprint && a.sprintSel === b.sprintSel &&
    a.onPointerDown === b.onPointerDown && a.onAutoSize === b.onAutoSize;
}

и второй аргумент memo(function Card(...) {...}, cardPropsEqual).

  • Step 8: BoardCanvas.tsx, прокрутка — импорт readBoardScroll, saveBoardScroll из ../scrollMemory.ts, useEffect из react; после useLayoutEffect с syncScroll:
  // M-3: прокрутка доски переживает переход в карточку задачи и обратно (R-1); восстанавливаем, когда карточки расставлены
  const restore = useRef(readBoardScroll());
  useLayoutEffect(() => {
    const s = restore.current;
    if (!s || !placed) return;
    restore.current = null;
    window.scrollTo(s.x, s.y);
    if (wrapRef.current) wrapRef.current.scrollLeft = s.left;
    syncScroll();
  }, [placed, syncScroll]);
  // запоминаем по событиям прокрутки, а не при размонтировании: к моменту ухода браузер может уже сжать прокрутку
  // под короткую страницу карточки
  useEffect(() => {
    const w = wrapRef.current;
    const save = () => saveBoardScroll({ x: window.scrollX, y: window.scrollY, left: w?.scrollLeft ?? 0 });
    window.addEventListener("scroll", save, { passive: true });
    w?.addEventListener("scroll", save, { passive: true });
    return () => { window.removeEventListener("scroll", save); w?.removeEventListener("scroll", save); };
  }, []);
  • Step 9: DatesBar.tsx — импорт useEffect; после const raf = useRef(0);:
  useEffect(() => () => { if (raf.current) cancelAnimationFrame(raf.current); }, []);   // T11.2
  • Step 10: Dropdown.tsx — const toggle = () => { if (!open) onOpen?.(); setOpen(!open); }; // T12.1: побочное действие вне updater

  • Step 11: useBoardPrefs.ts — toggle и toggleHidden без побочных действий в updater (T12.2):

  const toggle = useCallback((name: st.FlagName | "snap" | "table" | "problems") => {
    const isView = name === "snap" || name === "table" || name === "problems";
    const on = isView ? !prefs[name] : !prefs.filters[name];
    st.writeFlag(name, on);
    setPrefs((p) => (isView ? { ...p, [name]: on } : { ...p, filters: { ...p.filters, [name]: on } }));
  }, [prefs]);
  const toggleHidden = useCallback((what: "kind" | "edge", value: string) => {
    const key = what === "kind" ? "hideKind" : "hideEdge";
    const next = new Set(prefs.filters[key]);
    if (next.has(value)) next.delete(value); else next.add(value);
    const filters = { ...prefs.filters, [key]: next };
    st.writeHidden(filters.hideKind, filters.hideEdge);
    setPrefs((p) => ({ ...p, filters: { ...p.filters, [key]: next } }));
  }, [prefs]);
  • Step 12: BoardPage.tsx — useRef/useLayoutEffect шапки заменить на ref-колбэк с очисткой (T12.3; React 19 поддерживает возврат функции очистки из ref-колбэка); импорт useCallback, убрать неиспользуемые:
  // высота закреплённой шапки — ящик «Проблемы» и полоса дат липнут под ней (строки 1016–1019); при уходе — снимаем
  const headRef = useCallback((el: HTMLDivElement | null) => {
    if (!el) return;
    const root = document.documentElement.style;
    const set = () => root.setProperty("--head-h", (getComputedStyle(el).position === "sticky" ? el.offsetHeight : 0) + "px");
    set();
    const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(set);
    ro?.observe(el);
    return () => { ro?.disconnect(); root.removeProperty("--head-h"); };
  }, []);
  • Step 13: MultiSelect.tsx — кнопка aria-haspopup="dialog"; список <div className="dd-list" role="group" aria-label={allLabel}> (T12.4: флажки — это группа, не listbox без option).

  • Step 14: e2e прокрутки — в web/e2e/board.spec.ts:

test("M-3: прокрутка доски после возврата из карточки задачи", async ({ page }) => {
  await page.setViewportSize({ width: 1600, height: 600 });
  await login(page);
  await expect(page.locator(".board .card[data-key]").first()).toBeVisible();
  const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; });
  expect(y).toBeGreaterThan(100);
  // щелчок без прокрутки к элементу: иначе Playwright сам сдвинет страницу перед переходом
  await page.evaluate(() => (document.querySelector(".board .card[data-key] a.open-card") as HTMLElement).click());
  await expect(page).toHaveURL(/\/app\/issues\//);
  await page.getByRole("link", { name: "Доска", exact: true }).click();
  await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2);
});
  • Step 15: Проверка make check и cd web && npm run e2e — зелёные.

  • Step 16: Коммит

git add web/src/board web/e2e/board.spec.ts
git commit -m "feat(web): прокрутка доски после возврата, memo карточек, обработчики без побочных действий в updater (M-3, T11.1–T11.4, T12.1–T12.4, #29)"

Task 5: Задачи и карточка — фильтр вне вариантов, ссылки, команды, заметка (T13.1, O-10, O-11, T14.2, T14.3)

Модель исполнителя: haiku (готовый код).

Files:

  • Modify: web/src/issues/options.ts, web/src/issues/IssuesPage.tsx, web/src/issues/IssuePage.tsx, web/src/issues/NoteForm.tsx, web/src/issues/issues.css
  • Modify: web/src/issues/IssuesPage.test.tsx, web/src/issues/IssuePage.test.tsx

Interfaces:

  • Produces: withCurrent(opts: Opt[], value: string | undefined, loaded: boolean): Opt[] в options.ts; type Opt = { value: string; label: string } экспортируется.

  • Step 1: Падающие тесты — в IssuesPage.test.tsx (хелперы файла — mockFetch, json, снимок доски из ../board/__fixtures__/hub-basic.board.json, как в соседних тестах):

  it("T13.1: значение фильтра из адреса вне вариантов — в списке с пометкой", async () => {
    hub(() => [row(1)]);
    renderWithProviders(<IssuesPage />, { route: "/issues?sprint=AWPCA_Спринт 0", path: "/issues" });
    const sel = await screen.findByLabelText("Спринт");
    await waitFor(() => expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 0 (нет на доске)" })).toBeInTheDocument());
    expect(sel).toHaveValue("AWPCA_Спринт 0");
  });

  it("T13.1: пока снимок доски грузится, пометки «(нет на доске)» нет", async () => {
    mockFetch((_m, url) => {
      if (url.pathname === "/settings") return json(200, settings);
      if (url.pathname === "/board") return new Promise<Response>(() => {});   // снимок не приходит до конца теста
      return json(200, { issues: [row(1)] });
    });
    renderWithProviders(<IssuesPage />, { route: "/issues?sprint=AWPCA_Спринт 2", path: "/issues" });
    await screen.findByRole("link", { name: "AURORA-9001" });
    const sel = await screen.findByLabelText("Спринт");
    expect(within(sel).queryByRole("option", { name: /нет на доске/ })).toBeNull();
    expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 2" })).toBeInTheDocument();
  });

(импорт within из @testing-library/react.) В IssuePage.test.tsx:

  it("O-11: задача без команд — текст вместо пустой таблицы", async () => {
    mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { commands: [] })));
    open("/issues/AURORA-9001");
    expect(await screen.findByText("команд по задаче не было")).toBeInTheDocument();
    expect(screen.queryByRole("columnheader", { name: "Команда" })).toBeNull();
  });

  it("T14.3: id подсказок «статуса по факту» уникален и связан с полем", async () => {
    mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
    open("/issues/AURORA-9001");
    const input = await screen.findByLabelText("Статус по факту");
    const id = input.getAttribute("list")!;
    expect(id).not.toBe("fact-statuses");
    expect(document.getElementById(id)?.tagName).toBe("DATALIST");
  });

T14.2 — в тестах «поздний ответ прежней карточки…» и «ответ «Сохранить» после ухода…» заменить await new Promise((r) => setTimeout(r, 20));:

  • в первом — на await waitFor(() => expect(client.getQueryData(qk.issue("AURORA-9001"))).toBeDefined()); (клиент создать в тесте const client = makeQueryClient(); и передать open("/issues/AURORA-9001", client));

  • во втором — на await waitFor(() => expect(client.isMutating()).toBe(0));. Пауза 450 мс в IssuesPage.test.tsx («T13.2: внешняя смена адреса…») не трогается: это проверка, что за время задержки поиска (300 мс) адрес не перезаписан, — ей нужно реальное время.

  • Step 2: Запустить cd web && npx vitest run src/issues — FAIL новых тестов.

  • Step 3: options.ts — export type Opt = … и

// T13.1: значение фильтра из адреса, которого нет среди вариантов (закрытый спринт, ушедший исполнитель), видно в списке;
// пока снимок доски не загружен, пометку не ставим (R-4)
export const withCurrent = (opts: Opt[], value: string | undefined, loaded: boolean): Opt[] =>
  !value || opts.some((o) => o.value === value) ? opts
    : [{ value, label: loaded ? `${value} (нет на доске)` : value }, ...opts];
  • Step 4: IssuesPage.tsx — в SELECTS.map: {withCurrent(opts[name], filters[name], !!board.data).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}.

  • Step 5: IssuePage.tsx — таблицу команд обернуть:

          {i.commands.length ? (
            <table className="grid">…как сейчас…</table>
          ) : <p className="hint">команд по задаче не было</p>}

issues.css — добавить .issue a{color:var(--accent)} (O-10).

  • Step 6: NoteForm.tsx — import { useEffect, useId, useMemo, useState } from "react";, const listId = useId();, <input list={listId} …/> и <datalist id={listId}> (T14.3).

  • Step 7: Проверка make check — зелёный.

  • Step 8: Коммит

git add web/src/issues
git commit -m "fix(web): фильтр из адреса вне вариантов, ссылки цветом темы, «команд по задаче не было», тесты без пауз (T13.1, O-10, O-11, T14.2, T14.3, #29)"

Task 6: Модель, хранилище, эталоны (T2.1, T5.1, T6.3, T7.1, T9.1, T9.3, T9.4)

Модель исполнителя: sonnet (перегенерация эталонов и разбор расхождений).

Files:

  • Modify: web/src/test/canon.ts, web/src/test/canon.test.ts
  • Modify: web/src/board/analysis/facets.ts (initialWho)
  • Modify: web/src/board/storage.ts (colWidths), web/src/board/storage.test.ts
  • Modify: web/src/board/model/fromJson.ts (порядок импортов)
  • Modify: web/src/board/model/util.test.ts
  • Modify: web/src/board/__fixtures__/scenarios.json; перегенерировать web/src/board/__fixtures__/*.expected.json

Interfaces: без новых экспортов.

  • Step 1: T2.1 — тест в canon.test.ts:
  it("порядок Set не зависит от локали: сравнение по кодам символов", () => {
    expect(canon(new Set(["б", "Б", "b", "B"]))).toEqual(["B", "b", "Б", "б"]);
  });

реализация в canon.ts: сортировка .sort((a, b) => { const x = JSON.stringify(a), y = JSON.stringify(b); return x < y ? -1 : x > y ? 1 : 0; }).

  • Step 2: T6.3 — в util.test.ts:
  it("notStarted, unassigned, whoKey, fmtStamp, emptyModel", () => {
    expect(notStarted("Backlog")).toBe(true);
    expect(notStarted("К выполнению")).toBe(true);
    expect(notStarted("В работе")).toBe(false);
    expect(unassigned("")).toBe(true);
    expect(unassigned("Не назначен")).toBe(true);
    expect(unassigned("Иванов")).toBe(false);
    expect(whoKey("  Иванов  ")).toBe("Иванов");
    expect(whoKey("unassigned")).toBe("");
    expect(fmtStamp("не дата")).toBe("не дата");
    expect(fmtStamp("2026-10-14T09:05:00")).toMatch(/^14\.10 09:05$/);
    expect(emptyModel("пусто")).toMatchObject({ nodes: [], links: [], emptyText: "пусто", missing: 0 });
  });
  • Step 3: T9.1 и T9.3 — в storage.test.ts (новый describe("операции хранилища"), beforeEach(() => localStorage.clear())):
  it("colWidths пропускает только конечные числа (T9.1)", () => {
    localStorage.setItem("graph-colw:board", JSON.stringify({ "2026-10-20": 400, "2026-10-21": "x", "2026-10-22": null }));
    expect(colWidths()).toEqual({ "2026-10-20": 400 });
  });
  it("toggleLane сворачивает и разворачивает", () => {
    toggleLane("Гудвилл");
    expect(foldedSet()).toEqual(new Set(["Гудвилл"]));
    toggleLane("Гудвилл");
    expect(foldedSet()).toEqual(new Set());
  });
  it("moveLane переставляет и сохраняет имена вне текущей выборки", () => {
    localStorage.setItem("graph-lanes:board", JSON.stringify(["A", "B", "C", "Скрытая"]));
    expect(moveLane(["A", "B", "C"], "A", 2)).toBe(true);
    expect(laneOrder()).toEqual(["B", "C", "A", "Скрытая"]);
    expect(moveLane(["A", "B", "C"], "A", 5)).toBe(false);
    expect(moveLane(["A", "B", "C"], "нет", 0)).toBe(false);
  });
  it("resetLayout: обычный режим не трогает «по датам», режим дат чистит и ширины колонок", () => {
    for (const k of ["pos", "posdate", "lanes", "fold", "colw"]) localStorage.setItem(`graph-${k}:board`, "{}");
    resetLayout(false);
    expect(Object.keys(localStorage).sort()).toEqual(["graph-colw:board", "graph-posdate:board"]);
    resetLayout(true);
    expect(Object.keys(localStorage)).toEqual([]);
  });
  it("exportFileName: имя файла без .csv и недопустимых символов, дата из now", () => {
    expect(exportFileName({ fileName: "Jira (1).csv" } as BoardModel, "2026-10-14T12:00:00Z")).toBe("links-Jira_1_-2026-10-14.json");
    expect(exportFileName({ fileName: "" } as BoardModel, "2026-10-14T12:00:00Z")).toBe("links-board-2026-10-14.json");
  });
  it("applyJsonSettings: mode=all — пустой выбор спринтов", () => {
    applyJsonSettings({ layout: {}, layoutByDate: {}, laneOrder: [], collapsedLanes: null, dateColumns: null,
      filters: { mode: "all", sprints: ["S 2"] } } as unknown as JsonImport);
    expect(localStorage.getItem("graph-sprint:board")).toBe("[]");
  });
  it("закрытое хранилище: чтение — значения по умолчанию, запись и удаление без исключений", () => {
    const boom = () => { throw new DOMException("closed", "SecurityError"); };
    vi.spyOn(Storage.prototype, "getItem").mockImplementation(boom);
    vi.spyOn(Storage.prototype, "setItem").mockImplementation(boom);
    vi.spyOn(Storage.prototype, "removeItem").mockImplementation(boom);
    expect(laneOrder()).toEqual([]);
    expect(() => { toggleLane("A"); resetLayout(true); setColWidth("2026-10-20", 400); }).not.toThrow();
  });

Если ожидаемое имя в exportFileName расходится с фактическим, свериться с links.html@3931a88 (git show 3931a88:src/jira_hub/web/links.html | grep -n "links-"): мерило — старая страница, тест правится по ней. Если JsonImport требует других полей, взять форму из types.ts.

  • Step 4: Запустить cd web && npx vitest run src/test src/board/storage.test.ts src/board/model — FAIL: colWidths пропускает строки и null, порядок canon локальный.

  • Step 5: Реализация

    • storage.ts:
export function colWidths(): Record<string, number> {
  const v = store.json<unknown>(fileKey("colw"), {});
  if (!isObj(v)) return {};
  return Object.fromEntries(Object.entries(v).filter(([, w]) => num(w))) as Record<string, number>;   // T9.1
}
  • facets.ts, initialWho (T7.1, whoItems один раз; условие — то же, что у whoBox):
export function initialWho(model: BoardModel, f: Filters, remembered: unknown): Set<string> {
  if (!model.nodes.length) return new Set();
  const { total } = whoItems(model, f);
  const names = Object.keys(total);
  if (!names.length || (names.length === 1 && names[0] === "")) return new Set();
  return new Set(strList(remembered).filter((w) => total[w] !== undefined));
}
  • fromJson.ts (T9.4): порядок импортов — внешние/../constants.ts, ../storage.ts, ../types.ts, затем ./links.ts, ./util.ts (по пути, по алфавиту).

  • Step 6: T5.1 — в scenarios.json добавить последним элементом:

  {"name": "saved-short", "filters": {}, "saved": {"AURORA-9001": {"x": 30, "ry": 50, "w": 240, "h": 120}, "AURORA-9002": {"x": 320, "ry": 50, "w": 240, "h": 136}, "AURORA-9004": {"x": 30, "ry": 50, "w": 240, "h": 200}, "AURORA-9008": {"x": 30, "ry": 50, "w": 280, "h": 320}}}
  • Step 7: Перегенерировать эталоны cd web && npm run golden, затем git diff --stat src/board/__fixtures__/. Ожидается: в hub-basic.expected.json новый сценарий saved-short с числовыми clamp у четырёх карточек; прочие изменения — только порядок элементов из Set после смены сортировки canon (T2.1). Любое другое изменение эталона — стоп и разбор: это расхождение генератора, не кода.

  • Step 8: Проверка make check — зелёный; layout.test.ts проходит сценарий saved-short (grep -c '"[0-9]"' src/board/__fixtures__/hub-basic.expected.json больше прежнего).

  • Step 9: Коммит

git add web/src/test web/src/board
git commit -m "test(web): сценарий saved-short для обрезки заголовков, canon без локали, тесты хранилища и util (T2.1, T5.1, T6.3, T7.1, T9.1, T9.3, T9.4, #29)"

Task 7: Окружение и e2e — шрифты, source-map-js, порт, тесты оболочки (T2.2, O-1, T4.1–T4.5, T15.1, T15.2, T15.4, T15.6)

Модель исполнителя: sonnet (зависимости, сборка, e2e).

Files:

  • Modify: web/package.json, web/package-lock.json, web/index.html, web/src/main.tsx
  • Modify: web/src/App.tsx, web/src/App.test.tsx, web/src/shell/theme.test.ts
  • Modify: web/playwright.config.ts, web/e2e/hub.ts, web/e2e/geometry.spec.ts, web/e2e/board.spec.ts
  • Modify: web/scripts/test-hub.sh

Interfaces:

  • Produces: export const HUB_PORT = 8791; export const HUB_URL = \http://127.0.0.1:${HUB_PORT}`;вplaywright.config.ts(рядом сHUB_HOME); e2e/hub.tsберётHUB_URL` оттуда.

  • Step 1: Шрифты (T2.2) — cd web && npm install --before=2026-10-01 --save-exact @fontsource/ibm-plex-sans @fontsource/ibm-plex-mono; в main.tsx до импорта App:

// шрифты входят в сборку: приложение не ходит в интернет (T2.2)
import "@fontsource/ibm-plex-sans/400.css";
import "@fontsource/ibm-plex-sans/500.css";
import "@fontsource/ibm-plex-sans/600.css";
import "@fontsource/ibm-plex-mono/500.css";

из index.html удалить строку <link rel="stylesheet" href="https://fonts.googleapis.com/…">.

  • Step 2: Проверка сборки без внешних адресов — npm run build && grep -rEn "https?://" ../src/jira_hub/web/app/index.html ../src/jira_hub/web/app/assets/*.css — ожидается пусто; ls ../src/jira_hub/web/app/assets/*.woff2 — файлы есть.

  • Step 3: source-map-js (O-1) — в package.json добавить "overrides": { "source-map-js": "1.2.2" }, npm install --before=2026-10-01, npm audit — ожидается 0 high. Если high остаётся — откатить overrides, в отчёте O-1 rejected с выводом npm audit.

  • Step 4: Тесты оболочки — App.test.tsx:

    • T4.4: один импорт import { render, screen } from "@testing-library/react";;
    • T4.1: тест «переход «Задачи» и обратно» дополнить:
    await userEvent.click(screen.getByRole("link", { name: "Доска" }));
    expect(router.state.location.pathname).toBe("/app/board");
    expect(await screen.findByTestId("page-board")).toBeInTheDocument();
  • T4.2: тест «неизвестный путь…» дополнить:
    expect(screen.getByRole("link", { name: "доска" })).toHaveAttribute("href", "/app/board");
    expect(screen.getByRole("link", { name: "задачи" })).toHaveAttribute("href", "/app/issues");
  • T4.3: в тесте «исключение в странице…» <App.Layout /> заменить на <Layout /> (import { Layout } from "./shell/Layout.tsx";); в App.tsx удалить App.Layout = Layout; и импорт Layout.

  • T4.5: theme.test.ts — afterEach(() => document.documentElement.removeAttribute("data-theme")); (импорт afterEach).

  • Step 5: Порт одной константой (T15.4) — playwright.config.ts:

export const HUB_PORT = 8791;
export const HUB_URL = `http://127.0.0.1:${HUB_PORT}`;
…
  use: { baseURL: HUB_URL, … },
  webServer: { …, url: `${HUB_URL}/app/`, env: { JIRA_HUB_HOME: HUB_HOME, PORT: String(HUB_PORT) } },

e2e/hub.ts: import { HUB_HOME, HUB_URL } from "../playwright.config.ts";, const BASE = HUB_URL;.

  • Step 6: Занятый порт (T15.6) — в scripts/test-hub.sh перед rm -rf:
if lsof -nP -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then
  echo "отказ: порт $PORT занят — остановите прежний тестовый хаб (lsof -iTCP:$PORT -sTCP:LISTEN)" >&2; exit 3
fi

Проверка: запустить bash scripts/test-hub.sh в фоне, второй запуск — выход 3 с этим текстом; остановить первый.

  • Step 7: geometry без тавтологии (T15.1) — в geometry.spec.ts, тест «высота карточек…», вместо сравнения с формулой проверять, что содержимое помещается и высоты на сетке:
  const rows = await page.evaluate(() => [...document.querySelectorAll<HTMLElement>(".board .card[data-key]")].map((e) => ({
    key: e.dataset.key!, h: e.offsetHeight, fits: e.scrollHeight <= e.clientHeight + 1,
  })));
  expect(rows.length).toBeGreaterThan(0);
  for (const r of rows) {
    expect(r.fits, `${r.key}: содержимое не помещается`).toBe(true);
    expect(r.h % 8, `${r.key}: высота не на сетке 8`).toBe(0);
  }
  expect(new Set(rows.map((r) => r.h)).size, "все карточки одной высоты — высота не по содержимому").toBeGreaterThan(1);
  • Step 8: растяжка (T15.2) — в board.spec.ts, тест «растяжка за уголок…»: import { CW } from "../src/board/constants.ts";; await expect.poll(async () => (await card.boundingBox())!.width).toBeGreaterThan(w0 + 60); и await expect.poll(async () => Math.round((await card.boundingBox())!.width)).toBe(CW);.

  • Step 9: Проверка make check; cd web && for i in $(seq 1 10); do npm run e2e || break; done — 10 зелёных прогонов подряд (Review Focus 5 — шаг 2).

  • Step 10: Коммит

git add web/package.json web/package-lock.json web/index.html web/src web/playwright.config.ts web/e2e web/scripts/test-hub.sh
git commit -m "build(web): шрифты в сборке, source-map-js 1.2.2, порт e2e одной константой, тесты оболочки и геометрии (T2.2, O-1, T4.1–T4.5, T15.1, T15.2, T15.4, T15.6, #29)"

Task 8: Документация и ручная проверка (закрытие #29)

Модель исполнителя: haiku (документы и сверка).

Files:

  • Modify: CLAUDE.md (строка «Спеки: … текущего change нет …»)

  • Modify: README.md (раздел веб-приложения — шрифты в сборке, без интернета)

  • Modify: openspec/changes/web-app-followups/design.md (раздел «Decisions made without discussion» — рулинги исполнения)

  • Step 1: CLAUDE.md — заменить «текущего change нет (последний — openspec/changes/archive/2026-10-15-web-app/)» на «текущий change — openspec/changes/web-app-followups/».

  • Step 2: README.md — в разделе о веб-приложении одна строка: «Шрифты (IBM Plex) входят в сборку: приложение работает без доступа в интернет».

  • Step 3: Ручной проход на тестовом хабе — bash web/scripts/test-hub.sh (фон), cd web && npm run build, открыть http://127.0.0.1:8791/app/board в Playwright (токен — web/.e2e-hub/tokens.json, поле client): прокрутка после «ⓘ» и «Доска»; «Перечитать» во время «Обновить из Jira» (учебный агент — e2e/hub.ts agentServeRefresh) не стирает строку пакета; /app/issues?sprint=AWPCA_Спринт 0 — пометка «(нет на доске)»; карточка задачи без команд — «команд по задаче не было», ссылки цветом темы в светлой и тёмной теме; во вкладке Network нет запросов вне 127.0.0.1:8791. Тестовый хаб остановить.

  • Step 4: Проверка make check, openspec validate web-app-followups --strict.

  • Step 5: Коммит

git add CLAUDE.md README.md openspec/changes/web-app-followups/design.md
git commit -m "docs: web-app-followups — CLAUDE.md, README, рулинги исполнения (#29)"