` (роль поменял Task 4) добавить
+ `ref={confirmRef}`.
+
+- [ ] **Step 3: Карточка**
+
+Заменить `web/src/issues/IssueCard.tsx` целиком:
+
+```tsx
+import { useLayoutEffect, useRef, useState } from "react";
+import { ApiError } from "../api/client.ts";
+import { useIssue, useSettings } from "../api/queries.ts";
+import type { IssueComment, IssueDetail } from "../api/types.ts";
+import { JIRA_URL } from "../board/constants.ts";
+import { fmtDate, fmtStamp } from "../board/model/util.ts";
+import { factClass, groupLinks, isOverdue, todayISO } from "./card.ts";
+import { useCommentsHidden } from "./commentsPref.ts";
+import { IssueLink } from "./IssueLink.tsx";
+import { Notes } from "./Notes.tsx";
+import { dueOf } from "./options.ts";
+import "./issues.css";
+
+const DESC_LINES = 8;
+
+export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClose: () => void }) {
+ const issue = useIssue(key);
+ const settings = useSettings();
+ const close =
✕ ;
+ if (issue.isPending) return
;
+ if (issue.error instanceof ApiError && issue.error.status === 404) {
+ return
Задачи {key} нет в зеркале хаба.
Закрыть ;
+ }
+ if (issue.isError && !issue.data) {
+ return (
+
+
{issue.error.message}{" "}
+ void issue.refetch()}>Повторить
+
Закрыть
+
+ );
+ }
+ const i = issue.data!;
+ const labels = settings.data?.status_labels as Record
| undefined;
+ const fact = i.note?.fact_status ?? null;
+ const cls = factClass(fact, labels);
+ const due = dueOf(i);
+ const facts = [
+ i.assignee && {i.assignee} ,
+ due && срок {fmtDate(due)}{isOverdue(due, fact, labels, todayISO()) && <> просрочено >} ,
+ i.sprint && {i.sprint} ,
+ i.epic_key && эпик: {i.epic_summary ?? i.epic_key} ,
+ ].filter(Boolean);
+ return (
+
+
+
+
{i.key}
+ {i.status &&
{i.status} }
+ {fact &&
{fact} }
+
+
Открыть в Jira ↗
+ {close}
+
+ {i.summary || i.key}
+ {facts.length > 0 && {facts.flatMap((f, n) => (n ? [" · ", f] : [f]))}
}
+ {issue.isError && (
+ {issue.error.message}{" "}
+ void issue.refetch()}>Повторить
+ )}
+
+
+
+ } />
+ {i.description && }
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record }) {
+ const groups = groupLinks(issue, linkTypes);
+ return (
+
+ Связи
+ {!groups.length && связей нет
}
+ {groups.map(([id, title, links]) => (
+
+
{title}
+
+ {links.map((l) => (
+
+ {l.tag && <>{l.tag} {" "}>}
+ {" "}
+ {l.summary === null && l.status === null
+ ? нет в зеркале
+ : <>{l.summary} {l.status && <>{" "}{l.status} >}>}
+
+ ))}
+
+
+ ))}
+
+ );
+}
+
+function Description({ text }: { text: string }) {
+ const ref = useRef(null);
+ const [open, setOpen] = useState(false);
+ const [overflow, setOverflow] = useState(false);
+ useLayoutEffect(() => {
+ const el = ref.current;
+ if (el && !open) setOverflow(el.scrollHeight > el.clientHeight + 1);
+ }, [text, open]);
+ const long = text.split("\n").length > DESC_LINES || overflow;
+ return (
+
+ Описание
+ {text}
+ {(long || open) && setOpen(!open)}>{open ? "свернуть" : "развернуть"} }
+
+ );
+}
+
+function Comments({ items }: { items: IssueComment[] }) {
+ const [hidden, setHidden] = useCommentsHidden();
+ return (
+
+
+
Комментарии ({items.length})
+ {items.length > 0 && setHidden(!hidden)}>{hidden ? "Показать" : "Скрыть"} }
+
+ {!items.length && комментариев нет
}
+ {items.length > 0 && !hidden && (
+
+ )}
+
+ );
+}
+
+function OtherFields({ issue: i }: { issue: IssueDetail }) {
+ const rows: [string, string | null][] = [
+ ["Тип", i.issue_type], ["Приоритет", i.priority], ["Автор", i.reporter], ["Метки", i.labels.join(", ") || null],
+ ["Плановое начало", i.planned_start], ["Обновлена в Jira", i.jira_updated ? fmtStamp(i.jira_updated) : null],
+ ["Сосед по связям", i.neighbor ? "да" : null],
+ ];
+ return (
+
+ Прочие поля
+ {rows.filter(([, v]) => v).map(([k, v]) =>
{k} {v} )}
+
+ );
+}
+
+function Commands({ issue: i }: { issue: IssueDetail }) {
+ if (!i.commands.length) {
+ return Команды моста команд по задаче не было
;
+ }
+ return (
+
+
+ Команды моста ({i.commands.length})
+
+ Команда Пакет Тип Статус Завершена Ошибка
+ {i.commands.map((c) => (
+ #{c.id} #{c.batch_id} {c.type} {c.status}
+ {c.finished_at ? fmtStamp(c.finished_at) : ""} {c.error}
+ ))}
+
+
+
+ );
+}
+```
+
+Тест O-11 ищет `screen.queryByRole("columnheader", { name: "Команда" })` = `null` — при пустых командах таблицы нет.
+
+- [ ] **Step 4: Стили карточки**
+
+В `web/src/issues/issues.css` заменить строки от `.issue a{color:var(--accent)}` до
+`.issue .links{padding-left:18px}` включительно на:
+
+```css
+.issue a{color:var(--accent)}
+.issue-head{position:sticky;top:0;z-index:1;background:var(--bg);padding:14px 0 10px;border-bottom:1px solid var(--line);margin-bottom:14px}
+.issue-head .row1{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
+.issue-head .key{font-family:var(--font-mono);font-size:13px;color:var(--muted)}
+.issue-head .spacer{flex:1 1 auto}
+.issue-head h2{margin:6px 0 4px;font-size:20px;line-height:1.3}
+.issue-head .facts{margin:0;color:var(--muted);font-size:14px}
+.issue .overdue{color:var(--p-crit);font-weight:600}
+.issue .close{font-size:18px;line-height:1;background:none;border:0;color:var(--muted);cursor:pointer;padding:4px 6px}
+.badge{display:inline-block;font-size:12px;line-height:18px;padding:0 8px;border-radius:9px;background:var(--soft);color:var(--fg);white-space:nowrap}
+.badge.fact{background:transparent;border:1px solid var(--accent);color:var(--accent)}
+.badge.fact.fact-done{border-color:var(--k-test);color:var(--k-test)}
+.badge.fact.fact-cancelled{border-color:var(--muted);color:var(--muted);text-decoration:line-through}
+.issue .cols{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:24px;align-items:start}
+.issue .side{display:flex;flex-direction:column;gap:16px;min-width:0}
+.issue .main{min-width:0}
+@media (max-width:900px){
+ .issue .cols{display:flex;flex-direction:column;gap:16px}
+ .issue .side{display:contents}
+ .issue .side .note{order:-1}
+ .issue .side .other{order:1}
+ .issue .side .commands{order:2}
+}
+.issue section{margin-bottom:18px}
+.issue h3{font-size:13px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin:0 0 8px}
+.issue .sec-head{display:flex;align-items:center;justify-content:space-between;gap:8px}
+.issue .group{margin:0 0 10px}
+.issue .group h4{font-size:13px;font-weight:600;margin:0 0 4px}
+.issue .links{list-style:none;margin:0;padding:0}
+.issue .links li{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px;padding:3px 0}
+.issue .links .tag{font-size:12px;color:var(--muted);min-width:76px}
+.issue .links .sum{flex:1 1 200px;min-width:0}
+.issue .links .absent{color:var(--muted);font-style:italic}
+.issue .description{white-space:pre-wrap;font:inherit;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:10px;margin:0 0 4px}
+.issue .description.clamped{display:-webkit-box;-webkit-line-clamp:8;-webkit-box-orient:vertical;overflow:hidden}
+.issue .comments ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}
+.issue .comments .who{font-size:12px;color:var(--muted);margin-bottom:2px}
+.issue .comments .body{white-space:pre-wrap;font:inherit;margin:0}
+.issue .fields{display:grid;grid-template-columns:1fr;gap:6px;margin:0}
+.issue .fields dt{font-size:12px;color:var(--muted)}
+.issue .fields dd{margin:0;white-space:pre-wrap}
+.issue .commands summary{cursor:pointer;font-size:13px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
+.issue .commands table{margin-top:8px;font-size:13px}
+```
+
+- [ ] **Step 5: Тесты проходят, весь набор зелёный**
+
+Run: `cd web && npx vitest run src/issues`
+Expected: PASS (`IssueCard.test.tsx`, `IssueModal.test.tsx`, `card.test.ts`, `modal.test.ts`, `IssuesPage.test.tsx`).
+
+Run: `cd web && npm test && npm run lint && npx tsc -b`
+Expected: всё зелёное.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add web/src/issues
+git commit -m "feat(web): раскладка карточки — шапка состояния, связи по смыслу, комментарии, заметки в режиме чтения (#37)
+
+Co-Authored-By: Claude Opus 5.5 "
+```
+
+### Task 6: Тестовый хаб, e2e и документация
+
+**Модель исполнителя:** sonnet (e2e и проверка в браузере).
+
+**Files:**
+- Create: `web/scripts/seed-extras.json`
+- Modify: `web/scripts/seed-hub.py`
+- Modify: `web/e2e/issues.spec.ts`, `web/e2e/board.spec.ts:56-81`
+- Modify: `README.md` (раздел веб-приложения, строка 161), `ARCHITECTURE.md` (если описывает страницы `web/`)
+
+**Interfaces:**
+- Consumes: Tasks 1–5 (комментарии в зеркале, окно, раскладка).
+
+- [ ] **Step 1: Дополнения тестового хаба**
+
+Создать `web/scripts/seed-extras.json`:
+
+```json
+{
+ "issues": {
+ "AURORA-9001": {
+ "description": "Цель: схема свода для КФО.\nКонтекст: ТЗ 6.4.\nШаг 1 — таблицы.\nШаг 2 — связи.\nШаг 3 — индексы.\nШаг 4 — миграции.\nШаг 5 — проверки.\nШаг 6 — документация.\nШаг 7 — ревью.\nШаг 8 — выкатка.\nШаг 9 — приёмка.\nКонец описания.",
+ "comments": [
+ {"id": "501", "author": "Петров Пётр Петрович", "created": "2026-10-13T10:00:00.000+0300", "updated": "2026-10-13T10:00:00.000+0300", "body": "Первый комментарий e2e\nвторая строка"},
+ {"id": "502", "author": "Иванов Иван Иванович", "created": "2026-10-14T11:30:00.000+0300", "updated": "2026-10-14T12:00:00.000+0300", "body": "Схему согласовали с аналитиком."},
+ {"id": "503", "author": null, "created": "2026-10-15T09:15:00.000+0300", "updated": "2026-10-15T09:15:00.000+0300", "body": "[мост] служебный комментарий"}
+ ]
+ }
+ },
+ "commands": [
+ {"type": "ensure_fields", "params": {"key": "AURORA-9001", "fields": {"duedate": "2026-10-20"}}, "status": "done"},
+ {"type": "ensure_status", "params": {"key": "AURORA-9001", "status": "В работе"}, "status": "failed", "error": "нет перехода в «В работе»"}
+ ]
+}
+```
+
+В `web/scripts/seed-hub.py`:
+- в строку документации добавить предложение «Дополнения только для тестового хаба (описание, комментарии, команды) — из
+ seed-extras.json рядом со скриптом.»;
+- после `snap = json.loads(...)` добавить:
+
+```python
+extras_path = Path(__file__).with_name("seed-extras.json")
+extras = json.loads(extras_path.read_text(encoding="utf-8")) if extras_path.exists() else {}
+extra_issues = extras.get("issues", {})
+```
+
+- строку `issues = [...]` заменить на:
+
+```python
+issues = [{k: v for k, v in i.items() if k not in ("fact_status", "work_kind", "neighbor")} | {"id": i["key"]}
+ | extra_issues.get(i["key"], {}) for i in snap["issues"]]
+```
+
+- перед строкой `(paths.home() / "tokens.json").write_text(...)` добавить:
+
+```python
+if extras.get("commands"): # завершённые команды по задачам — для раздела «Команды моста» карточки
+ with db.transaction(conn):
+ batch = conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('Тестовые команды', 'seed', ?)",
+ (db.now(),)).lastrowid
+ for seq, c in enumerate(extras["commands"], 1):
+ conn.execute("INSERT INTO command(batch_id, seq, type, params, status, finished_at, error) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
+ (batch, seq, c["type"], json.dumps(c["params"], ensure_ascii=False), c["status"], db.now(),
+ c.get("error")))
+```
+
+Проверка: `JIRA_HUB_HOME=web/.e2e-hub bash web/scripts/test-hub.sh` в фоне, затем
+`curl -s -H "Authorization: Bearer $(python3 -c "import json;print(json.load(open('web/.e2e-hub/tokens.json'))['client'])")" http://127.0.0.1:8791/issues/AURORA-9001 | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d['comments']), len(d['commands']), d['description'].count(chr(10)))"`
+Expected: `3 2 11`. Остановить тестовый хаб.
+
+- [ ] **Step 2: e2e списка задач**
+
+Заменить `web/e2e/issues.spec.ts` целиком:
+
+```ts
+import { expect, test } from "@playwright/test";
+import { login } from "./hub.ts";
+
+test("фильтр → карточка окном → заметка сохраняется и переживает перезагрузку", async ({ page }) => {
+ await login(page, "/app/issues");
+ await page.getByLabel("Исполнитель").selectOption("Петров Пётр Петрович");
+ await expect(page).toHaveURL(/assignee=/);
+ await page.getByRole("link", { name: "AURORA-9002", exact: true }).click();
+ const d = page.getByRole("dialog");
+ await expect(d.getByRole("heading", { name: "API свода" })).toBeVisible();
+ await d.getByRole("button", { name: "Править" }).click();
+ await d.getByLabel("Осталось").fill("e2e: дописать тесты");
+ await d.getByRole("button", { name: "Сохранить" }).click();
+ await expect(d.getByText("e2e: дописать тесты")).toBeVisible();
+ await page.reload();
+ await expect(page.getByRole("dialog").getByText("e2e: дописать тесты")).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+ await expect(page).toHaveURL(/\/app\/issues\?assignee=/);
+ await expect(page.getByLabel("Исполнитель")).toHaveValue("Петров Пётр Петрович");
+});
+
+test("окно поверх списка: прокрутка списка на месте при открытии и после закрытия", async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 320 });
+ await login(page, "/app/issues");
+ await expect(page.locator("table.grid tbody tr")).toHaveCount(10);
+ const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; });
+ expect(y).toBeGreaterThan(50);
+ await page.locator("table.grid tbody tr").last().getByRole("link").click();
+ await expect(page.getByRole("dialog")).toBeVisible();
+ expect(await page.evaluate(() => window.scrollY)).toBe(y);
+ await page.keyboard.press("Escape");
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+ expect(await page.evaluate(() => window.scrollY)).toBe(y);
+});
+
+test("переход по связи и «назад»; ✕ и щелчок по фону; после закрытия «назад» не открывает карточку", async ({ page }) => {
+ await login(page); // доска
+ await page.getByRole("link", { name: "Задачи", exact: true }).click();
+ await page.getByRole("link", { name: "AURORA-9001", exact: true }).click();
+ const d = page.getByRole("dialog");
+ await d.getByRole("link", { name: "AURORA-9002", exact: true }).click();
+ await expect(page).toHaveURL(/\/app\/issues\/AURORA-9002$/);
+ await expect(d.getByRole("heading", { name: "API свода" })).toBeVisible();
+ await page.goBack();
+ await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible();
+ await d.getByRole("link", { name: "AURORA-9002", exact: true }).click();
+ await d.getByRole("button", { name: "Закрыть" }).click();
+ await expect(page).toHaveURL(/\/app\/issues$/);
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+ await page.getByRole("link", { name: "AURORA-9003", exact: true }).click();
+ await expect(page.getByRole("dialog")).toBeVisible();
+ await page.mouse.click(5, 300); // фон слева от окна
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+ await page.goBack();
+ await expect(page).toHaveURL(/\/app\/board$/);
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+});
+
+test("прямой адрес и перезагрузка: окно поверх списка, ✕ ведёт в список", async ({ page }) => {
+ await login(page, "/app/issues/AURORA-9001");
+ const d = page.getByRole("dialog");
+ await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible();
+ await expect(page.getByTestId("page-issues")).toBeAttached();
+ await page.reload();
+ await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible();
+ await page.getByRole("dialog").getByRole("button", { name: "Закрыть" }).click();
+ await expect(page).toHaveURL(/\/app\/issues$/);
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+});
+
+test("карточка: описание свёрнуто, комментарии новые сверху и скрываются с запоминанием, команды свёрнуты", async ({ page }) => {
+ await login(page, "/app/issues/AURORA-9001");
+ const d = page.getByRole("dialog");
+ await expect(d.getByRole("heading", { name: "Комментарии (3)" })).toBeVisible();
+ await expect(d.getByTestId("comments").getByRole("listitem").first()).toContainText("[мост] служебный комментарий");
+ await expect(d.getByRole("button", { name: "развернуть" })).toBeVisible();
+ await expect(d.getByText("Конец описания.")).toBeHidden();
+ await d.getByRole("button", { name: "развернуть" }).click();
+ await expect(d.getByText("Конец описания.")).toBeVisible();
+ await expect(d.getByRole("cell", { name: "ensure_fields" })).toBeHidden();
+ await d.getByText("Команды моста (2)").click();
+ await expect(d.getByRole("cell", { name: "ensure_fields" })).toBeVisible();
+ await d.getByRole("button", { name: "Скрыть" }).click();
+ await expect(d.getByText("Первый комментарий e2e")).toHaveCount(0);
+ await page.reload();
+ await expect(page.getByRole("dialog").getByRole("button", { name: "Показать" })).toBeVisible();
+ await expect(page.getByRole("dialog").getByText("Первый комментарий e2e")).toHaveCount(0);
+});
+
+test("несохранённая заметка: Esc спрашивает, «Остаться» оставляет окно, «Уйти» закрывает", async ({ page }) => {
+ await login(page, "/app/issues");
+ await page.getByRole("link", { name: "AURORA-9003", exact: true }).click();
+ const d = page.getByRole("dialog");
+ await d.getByRole("button", { name: "Править" }).click();
+ await d.getByLabel("Сделано").fill("черновик");
+ await page.keyboard.press("Escape");
+ await expect(d.getByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeVisible();
+ await d.getByRole("button", { name: "Остаться" }).click();
+ await expect(d.getByLabel("Сделано")).toHaveValue("черновик");
+ await page.keyboard.press("Escape");
+ await d.getByRole("button", { name: "Уйти" }).click();
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+ await expect(page).toHaveURL(/\/app\/issues$/);
+});
+
+test("узкий экран: окно на всю ширину, заметки выше связей, без горизонтальной прокрутки", async ({ page }) => {
+ await page.setViewportSize({ width: 600, height: 800 });
+ await login(page, "/app/issues/AURORA-9001");
+ const d = page.getByRole("dialog");
+ await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible();
+ expect((await d.boundingBox())!.width).toBeGreaterThan(590);
+ const notesY = (await d.getByRole("heading", { name: "Служебные заметки" }).boundingBox())!.y;
+ const linksY = (await d.getByRole("heading", { name: "Связи", exact: true }).boundingBox())!.y;
+ expect(notesY).toBeLessThan(linksY);
+ expect(await d.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
+});
+```
+
+- [ ] **Step 3: e2e доски**
+
+В `web/e2e/board.spec.ts` заменить тесты «ⓘ открывает карточку задачи в приложении» и «M-3: прокрутка доски после
+возврата из карточки задачи» на:
+
+```ts
+test("ⓘ открывает карточку окном поверх доски", async ({ page }) => {
+ await page.locator('.board .card[data-key="AURORA-9001"]').getByRole("link", { name: "Открыть карточку AURORA-9001" }).click();
+ await expect(page).toHaveURL(/\/app\/issues\/AURORA-9001$/);
+ await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible();
+ await expect(page.getByTestId("page-board")).toBeAttached();
+ await expect(page.getByRole("link", { name: "Доска", exact: true })).toHaveClass(/active/);
+});
+
+test("M-3: прокрутка и данные доски после окна карточки и после другого раздела", async ({ page }) => {
+ await page.setViewportSize({ width: 1600, height: 600 });
+ 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 сам сдвинет страницу)
+ const key = await page.evaluate(() => {
+ const vh = window.innerHeight;
+ const a = [...document.querySelectorAll(".board .card[data-key] a.open-card")]
+ .find((e) => { const r = e.getBoundingClientRect(); return r.top > 200 && r.bottom < vh - 20; });
+ return a?.closest(".card")?.dataset.key ?? null;
+ });
+ expect(key).not.toBeNull();
+ let boardLoads = 0;
+ page.on("request", (r) => { if (new URL(r.url()).pathname === "/board") boardLoads++; });
+ await page.locator(`.board .card[data-key="${key}"] a.open-card`).click();
+ await expect(page.getByRole("dialog")).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(page).toHaveURL(/\/app\/board$/);
+ expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2);
+ expect(boardLoads).toBe(0); // доска не перезагружалась
+ await page.getByRole("link", { name: "Задачи", exact: true }).click();
+ await page.getByRole("link", { name: "Доска", exact: true }).click();
+ await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2);
+});
+
+test("заметка из окна видна на доске под ним", async ({ page }) => {
+ await page.locator('.board .card[data-key="AURORA-9002"] a.open-card').click();
+ const d = page.getByRole("dialog");
+ await d.getByRole("button", { name: "Править" }).click();
+ await d.getByLabel("Вид работы").selectOption("тестирование");
+ await d.getByRole("button", { name: "Сохранить" }).click();
+ await expect(d.getByRole("button", { name: "Править" })).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(page.locator('.board .card[data-key="AURORA-9002"] .kind')).toHaveText("тестирование");
+});
+```
+
+- [ ] **Step 4: Прогон e2e**
+
+Run: `cd web && npm run e2e`
+Expected: все тесты зелёные. Затем три прогона подряд: `cd web && for n in 1 2 3; do npm run e2e || break; done`
+Expected: три зелёных прогона (устойчивость). Падение — чинить причину, не добавлять ожидания «на всякий случай».
+
+- [ ] **Step 5: Документация**
+
+В `README.md` в строке со списком адресов (`Адреса: … карточка \`/app/issues/{key}\` …`) заменить фрагмент
+«карточка `/app/issues/{key}`» на «карточка `/app/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc,
+щелчок по фону; прямой адрес открывает окно поверх списка задач)». В том же разделе, после абзаца про «Задачи», добавить
+абзац:
+
+```markdown
+Карточка задачи: в шапке — статус Jira, статус по факту, исполнитель, срок (просроченный — красным), спринт и эпик;
+связи сгруппированы «Зависит от» / «Блокирует» / «Связана с» / «Прочие» с темой и статусом связанной задачи;
+комментарии из Jira (агент v10+) новые сверху, «Скрыть» запоминается в браузере; заметки — режим чтения, «Править»
+открывает форму. Комментарии появляются после первого обновления из Jira агентом v10.
+```
+
+В `ARCHITECTURE.md`:
+- строку таблицы `| \`mirror\` | зеркало задач и связей, \`apply_state\`, заметки, фильтры, снимок доски \`board\` | \`db\` |`
+ заменить на `| \`mirror\` | зеркало задач, связей и комментариев, \`apply_state\` (с проверкой \`check_comments\`), заметки, фильтры, снимок доски \`board\` | \`db\` |`;
+- в абзаце про `web/` фрагмент «и «Задачи» (`/app/issues`, `/app/issues/{key}`).» заменить на «и «Задачи»
+ (`/app/issues`); карточка `/app/issues/{key}` — окно поверх страницы, с которой её открыли: `shell/Layout.tsx` рисует
+ страницу по адресу из `location.state.background` (`useRoutes` с явным адресом) и `issues/IssueModal.tsx` поверх неё.».
+
+- [ ] **Step 6: Полная проверка**
+
+Run: `make check`
+Expected: pytest и vitest зелёные, lint чистый.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add web/scripts/seed-extras.json web/scripts/seed-hub.py web/e2e README.md ARCHITECTURE.md
+git commit -m "test(web): e2e окна карточки, тестовый хаб с комментариями и командами; README (#37)
+
+Co-Authored-By: Claude Opus 5.5 "
+```
+
+## После плана
+
+- Финальное ревью ветки (opus); ручной проход сценариев в браузере на тестовом хабе :8791 со скриншотами (делает
+ сессия, не контроллер).
+- PR в aep/jira-hub со ссылкой на #37 и на PR агента; merge — по команде владельца, после merge и публикации агента v10.
diff --git a/openspec/changes/issue-card-modal/design.md b/openspec/changes/issue-card-modal/design.md
index 961beeb..47aea88 100644
--- a/openspec/changes/issue-card-modal/design.md
+++ b/openspec/changes/issue-card-modal/design.md
@@ -44,8 +44,8 @@
### Хаб
-5. **Таблица `issue_comment`** `(key, comment_id, author, created, updated, body, PRIMARY KEY (key, comment_id))`, индекс
- по `key`. Схема v3; миграция v2 → v3 создаёт таблицу (копия базы `schema-v2` — существующим механизмом).
+5. **Таблица `issue_comment`** `(key, comment_id, author, created, updated, body, PRIMARY KEY (key, comment_id))`; выборка
+ по `key` идёт по первичному ключу, отдельный индекс не нужен. Схема v3; миграция v2 → v3 создаёт таблицу (копия базы `schema-v2` — существующим механизмом).
6. **Запись.** `apply_state`: для задачи с полем `comments` (список) комментарии этой задачи удаляются и вставляются
присланные — в одной транзакции с задачей; задача без поля `comments` своих комментариев не теряет (агент до v10).
7. **Проверка формы** в `h_agent_state` до записи: `comments` — список объектов; `id` — непустая строка; `body` — строка;
@@ -60,16 +60,27 @@
### Веб-приложение: окно
10. **Фоновая страница.** Ссылки на карточку (ключ в списке, «ⓘ» на доске, связи в карточке) передают в
- `location.state` `{background, depth}`: `background` — адрес страницы под окном (`pathname + search`), `depth` —
- сколько карточек открыто поверх неё (1 при открытии со страницы, +1 при переходе по связи). `Layout` отрисовывает
- маршрут `background` (если он есть), а при адресе `/issues/:key` — окно поверх него. Страница под окном не
- пересоздаётся: её состояние (фильтры, загруженные порции, прокрутка, раскладка доски, опрос пакета) сохраняется.
-11. **Прямой адрес** `/app/issues/{key}` без `state` (новая вкладка, закладка, перезагрузка): под окном — список задач без
- фильтров, `depth = 0`.
-12. **Закрытие** (✕, Esc, щелчок по фону): при `depth ≥ 1` — `navigate(-depth)`, браузер возвращается к фоновой
- странице, и «назад» после закрытия ведёт на шаг раньше неё; при `depth = 0` — `navigate("/issues", {replace: true})`.
+ `location.state` `{background, depth, direct}`: `background` — адрес страницы под окном (`pathname + search`),
+ `depth` — сколько записей истории занято карточками поверх неё (1 при открытии со страницы, +1 при переходе по
+ связи), `direct` — окно открыто прямым адресом (переносится в переходы по связям). Маршрут верхнего уровня —
+ `{path: "*", element: }`; `Layout` всегда рисует страницы через `useRoutes(PAGE_ROUTES, pageLocation)` с
+ явным адресом: `background` при открытом окне, иначе текущий адрес. Явный адрес нужен всегда: так дерево
+ компонентов страницы не меняется при открытии окна (страница не пересоздаётся), а `useSearchParams`/`useLocation`
+ внутри фоновой страницы видят её собственный адрес, и фильтры списка не сбрасываются. Состояние страницы (фильтры,
+ загруженные порции, прокрутка, раскладка доски, опрос пакета) сохраняется. `ErrorBoundary` страницы получает ключ по
+ адресу страницы, у окна — свой.
+11. **Прямой адрес** `/app/issues/{key}` без `state` (новая вкладка, закладка): `Layout` заменяет запись истории тем же
+ адресом со `state = {background: /issues, depth: 0, direct: true}`; под окном — список задач без фильтров.
+ Перезагрузка страницы с открытым окном сохраняет `state` (браузер хранит его в записи истории).
+12. **Закрытие** (✕, Esc, щелчок по фону): `depth = 0` — `navigate("/issues", {replace: true})`; `depth ≥ 1` —
+ `navigate(-depth)`, браузер возвращается к записи страницы под окном, и «назад» после закрытия ведёт на шаг раньше
+ неё; если окно было открыто прямым адресом (`direct`), запись прямого адреса, на которую вернулись, заменяется на
+ `/issues` (`replace`). Закрытие проходит через блокировщик заметок (п. 14). Вкладки «Доска»/«Задачи» в шапке
+ подсвечиваются по адресу страницы под окном.
13. **Элемент ``** с `showModal()`: фокус удерживается в окне, фон недоступен, Esc вызывает `cancel` (обрабатываем
- сами — закрытие по п. 12 с проверкой заметок). Щелчок по фону — щелчок по самому `` вне области содержимого.
+ сами — закрытие по п. 12 с проверкой заметок; если `cancel` не отменяемый — браузер закрыл окно сам без активации
+ пользователя, — по событию `close` окно открывается снова и закрытие идёт тем же путём). Щелчок по фону — щелчок по
+ самому `` вне области содержимого.
Прокрутка страницы под окном на время открытия блокируется (`overflow: hidden` у `body`), содержимое окна
прокручивается внутри, шапка окна закреплена. Ширина до 1000 px, высота до 90vh; на экранах уже 700 px — на всю
ширину.
@@ -112,10 +123,11 @@
### Проверки
-25. **Тестовый хаб**: фикстура `hub-basic` дополняется у одной задачи описанием длиннее 8 строк, тремя комментариями и
- двумя командами; у второй — входящими и исходящими связями всех групп, включая тип вне `link_types` и связь на
- задачу вне зеркала. Эталоны доски от этого не меняются (комментарии и описание в модель доски не входят) — проверяется
- golden-тестом.
+25. **Тестовый хаб**: `web/scripts/seed-extras.json` — дополнения к фикстуре доски только для тестового хаба: у AURORA-9001
+ описание длиннее 8 строк, три комментария и две команды (пакет автора `seed`, команды завершены). `seed-hub.py`
+ применяет их после фикстуры. Связи не добавляются (они меняют доску и её e2e); группа «Прочие» и связь на задачу вне
+ зеркала проверяются в vitest, а связь на задачу вне зеркала (`lower-1`) уже есть в фикстуре. Фикстура доски и её
+ golden-эталоны не меняются.
26. **e2e** (Playwright на :8791): открытие из списка (фильтр и прокрутка списка после закрытия на месте), по «ⓘ» с доски
(прокрутка и раскладка доски на месте), переход по связи и «назад», Esc / ✕ / фон, «назад» после закрытия не
открывает карточку, прямой адрес и перезагрузка с открытым окном, подтверждение при несохранённой заметке, ширина