` (роль поменял 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.summary || i.key}
{facts.length > 0 && {facts.flatMap((f, n) => (n ? [" · ", f] : [f]))}
}
{issue.isError && (
{issue.error.message}{" "}
void issue.refetch()}>Повторить
)}
);
}
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.