# 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 `; в теме коммита — `(#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 ` / `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` добавить файлы: ```python for name, data in (("logo.svg", b""), ("p.png", b"\x89PNG"), ("f.ico", b"\x00"), ("f.woff2", b"wOF2"), ("index-abc123.js.map", b"{}")): (web / "assets" / name).write_bytes(data) ``` и в конец файла: ```python @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/"):` на: ```python 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: Коммит** ```bash 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` добавить: ```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`: ```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`: ```ts vi.spyOn(Storage.prototype, "removeItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); }); ``` и после `expect(t.getToken()).toBe("mem");`: ```ts 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): ```tsx it("401 в запросе страницы — экран входа «токен не подошёл», страница скрыта (T3.6)", async () => { setToken("stale"); mockFetch(() => json(401, { error: "unauthorized" })); function Page() { useBoard(); return

страница приложения

; } renderWithProviders(); 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((r) => { release = r; }); mockFetch((_m, url) => (url.pathname === "/board" ? slow : json(200, {}))); function Page() { useBoard(); return

страница приложения

; } const { client } = renderWithProviders(); 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 {}` на: ```ts 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`: ```ts setError(e instanceof AuthError ? BAD_TOKEN : e instanceof ApiError ? `Хаб отверг проверку токена: ${e.detail ?? e.error}` // T3.2: 4xx — текст хаба : HUB_DOWN); ``` - [ ] **Step 11: Проверка** `make check` — зелёный. - [ ] **Step 12: Коммит** ```bash 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` на: ```ts 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` принимает клиент: ```tsx const hook = (client = makeQueryClient()) => ({ client, ...renderHook(() => useBoardSource(dicts, { pollMs: 10 }), { wrapper: ({ children }: { children: ReactNode }) => {children} }) }); ``` (константу `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();`) и ```tsx 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 }`; - новые тесты: ```tsx 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 }` и ```ts case "hub-down": return { text: s.fromFile ? "хаб не отвечает — на доске снимок из файла" : s.hasData ? "хаб не отвечает — показаны прежние данные" : "хаб не отвечает: запустите хаб (jira-hub serve) и нажмите «Перечитать»", warn: true, }; ``` - [ ] **Step 5: `useBoardSource.ts`** — изменения (остальное без изменений): ```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(`/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: Коммит** ```bash 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`** ```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, тест «полоса дат…»: ```tsx const wrap = document.querySelector(".wrap") as HTMLElement; expect((document.querySelector(".dates-in") as HTMLElement).style.width).toBe(`${wrap.offsetWidth}px`); ``` - T11.3 и M-3, новые тесты: ```tsx 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(".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(); Object.defineProperty(window, "scrollY", { configurable: true, value: 640 }); fireEvent.scroll(window); expect(readBoardScroll()).toMatchObject({ y: 640 }); first.unmount(); renderWithProviders(); 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()`; импорт `readBoardScroll, saveBoardScroll` из `../scrollMemory.ts`. Если `onMoveLane` получает не 3, а индекс последней дорожки другой длины — проверять `vis.lanes.length - 1` той же выборки. - [ ] **Step 3: Тест компаратора** — создать `Card.test.ts`: ```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`: ```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( }>{() =>

меню

}
); fireEvent.click(screen.getByText("открыть")); expect(onOpen).toHaveBeenCalledTimes(1); expect(screen.getByText("меню")).toBeInTheDocument(); fireEvent.click(screen.getByText("открыть")); expect(onOpen).toHaveBeenCalledTimes(1); }); }); ``` `DatesBar.test.tsx`: ```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(); 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`: ```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(, { 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`: ```ts // 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`: ```ts // 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);`: ```ts 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): ```ts 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`, убрать неиспользуемые: ```ts // высота закреплённой шапки — ящик «Проблемы» и полоса дат липнут под ней (строки 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"`; список `
` (T12.4: флажки — это группа, не listbox без option). - [ ] **Step 14: e2e прокрутки** — в `web/e2e/board.spec.ts`: ```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: Коммит** ```bash 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`, как в соседних тестах): ```tsx it("T13.1: значение фильтра из адреса вне вариантов — в списке с пометкой", async () => { hub(() => [row(1)]); renderWithProviders(, { 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(() => {}); // снимок не приходит до конца теста return json(200, { issues: [row(1)] }); }); renderWithProviders(, { 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`: ```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 = …` и ```ts // 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) => )}`. - [ ] **Step 5: `IssuePage.tsx`** — таблицу команд обернуть: ```tsx {i.commands.length ? ( …как сейчас…
) :

команд по задаче не было

} ``` `issues.css` — добавить `.issue a{color:var(--accent)}` (O-10). - [ ] **Step 6: `NoteForm.tsx`** — `import { useEffect, useId, useMemo, useState } from "react";`, `const listId = useId();`, `` и `` (T14.3). - [ ] **Step 7: Проверка** `make check` — зелёный. - [ ] **Step 8: Коммит** ```bash 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`: ```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`: ```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())`): ```ts 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`: ```ts export function colWidths(): Record { const v = store.json(fileKey("colw"), {}); if (!isObj(v)) return {}; return Object.fromEntries(Object.entries(v).filter(([, w]) => num(w))) as Record; // T9.1 } ``` - `facets.ts`, `initialWho` (T7.1, `whoItems` один раз; условие — то же, что у `whoBox`): ```ts export function initialWho(model: BoardModel, f: Filters, remembered: unknown): Set { 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` добавить последним элементом: ```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: Коммит** ```bash 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`: ```ts // шрифты входят в сборку: приложение не ходит в интернет (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` удалить строку ``. - [ ] **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: тест «переход «Задачи» и обратно» дополнить: ```tsx await userEvent.click(screen.getByRole("link", { name: "Доска" })); expect(router.state.location.pathname).toBe("/app/board"); expect(await screen.findByTestId("page-board")).toBeInTheDocument(); ``` - T4.2: тест «неизвестный путь…» дополнить: ```tsx expect(screen.getByRole("link", { name: "доска" })).toHaveAttribute("href", "/app/board"); expect(screen.getByRole("link", { name: "задачи" })).toHaveAttribute("href", "/app/issues"); ``` - T4.3: в тесте «исключение в странице…» `` заменить на `` (`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`: ```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`: ```bash 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`, тест «высота карточек…», вместо сравнения с формулой проверять, что содержимое помещается и высоты на сетке: ```ts const rows = await page.evaluate(() => [...document.querySelectorAll(".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: Коммит** ```bash 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: Коммит** ```bash git add CLAUDE.md README.md openspec/changes/web-app-followups/design.md git commit -m "docs: web-app-followups — CLAUDE.md, README, рулинги исполнения (#29)" ```