diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a65633a..b82c633 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -32,8 +32,10 @@ и `storage.ts` (ключи `localStorage` прежней страницы; у проекта AWPCA — прежние, у остальных с суффиксом `@<метка>`; набор ключей — состояние модуля, выставляется при монтировании страницы), отрисовка — `web/src/board/components`. Вид — дизайн-система AURA: токены цвета, шрифта, отступов и теней (светлая и тёмная темы) — `web/src/styles/tokens.css`, единственное место с литералами цвета; базовые компоненты (`Button`, `Notice`, `Dialog`, `Field`, `Panel`, меню и др.) — -`web/src/ui/`, остальные стили подключаются к токенам по семантическим именам. Обновление данных из Jira (кнопка в -верхней панели, отслеживание пакета) — `web/src/shell/refresh.ts` (провайдер в `App.tsx`); доска отдаёт наружу только +`web/src/ui/`, остальные стили подключаются к токенам по семантическим именам. Оболочка — `web/src/shell/Layout.tsx`: grid из боковой +панели `Sidebar.tsx` (закреплена у левого края, сворачивается в полосу значков — ключ `jira-hub-sidebar`; на экранах +уже 640 px — строка с «Меню» и выезжающая панель) и страницы; прокрутка — у окна. Обновление данных из Jira (кнопка в +боковой панели, отслеживание пакета) — `web/src/shell/refresh.ts` (провайдер в `App.tsx`); доска отдаёт наружу только источник и события снимка. Цепочки: `api → queue → commands/mirror/settings/projects → db`; `db → projects` (только миграция, ленивый импорт); `projects → settings`; `db → backup → paths`, `db → paths`; `files → paths`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`. diff --git a/web/e2e/board.spec.ts b/web/e2e/board.spec.ts index 921b8bf..ae2d6b2 100644 --- a/web/e2e/board.spec.ts +++ b/web/e2e/board.spec.ts @@ -82,10 +82,10 @@ test("M-3: прокрутка и данные доски после окна к await expect(page).toHaveURL(/\/app\/board\?project=AWPCA$/); expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2); expect(boardLoads).toBe(0); // доска не перезагружалась - // ссылки меню вверху страницы: обычный click прокрутил бы доску к ним до перехода и затёр запомненное, поэтому dispatchEvent - await page.getByRole("link", { name: "Задачи", exact: true }).dispatchEvent("click"); + // боковая панель закреплена (#69): ссылки разделов видны и после прокрутки, щелчок не двигает доску + await page.getByRole("link", { name: "Задачи", exact: true }).click(); await expect(page).toHaveURL(/\/app\/issues\?project=AWPCA$/); - await page.getByRole("link", { name: "Доска связей", exact: true }).dispatchEvent("click"); + await page.getByRole("link", { name: "Доска связей", exact: true }).click(); await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2); }); diff --git a/web/e2e/hub.ts b/web/e2e/hub.ts index 86eff72..5566bad 100644 --- a/web/e2e/hub.ts +++ b/web/e2e/hub.ts @@ -9,7 +9,8 @@ export async function login(page: Page, path = "/app/board") { await page.goto(path); await page.getByLabel("Клиентский токен хаба").fill(tokens().client); await page.getByRole("button", { name: "Войти" }).click(); - await expect(page.getByRole("link", { name: "Доска связей" })).toBeVisible(); + // оболочка: на широком экране — ссылки разделов в боковой панели, на узком — кнопка «Меню» (#69) + await expect(page.getByRole("link", { name: "Доска связей" }).or(page.getByRole("button", { name: "Меню" }))).toBeVisible(); } async function call(path: string, body: unknown) { diff --git a/web/e2e/layout.spec.ts b/web/e2e/layout.spec.ts new file mode 100644 index 0000000..9e6e4b4 --- /dev/null +++ b/web/e2e/layout.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from "@playwright/test"; +import { login } from "./hub.ts"; + +// оболочка с боковой панелью (#69) +test("панель на месте после прокрутки доски до низа", 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); + const side = page.getByRole("complementary", { name: "Панель приложения" }); + await expect(side.getByRole("link", { name: "Задачи" })).toBeInViewport(); + await expect(side.getByRole("button", { name: "Обновить из Jira" })).toBeInViewport(); + expect((await side.boundingBox())!.y).toBe(0); +}); + +test("свёрнутая панель переживает перезагрузку, доска шире", async ({ page }) => { + await login(page); + const wrap = page.locator(".wrap"); + await expect(wrap).toBeVisible(); + const before = (await wrap.boundingBox())!.width; + await page.getByRole("button", { name: "Свернуть панель" }).click(); + await page.reload(); + await expect(page.getByRole("button", { name: "Развернуть панель", exact: true })).toBeVisible(); + await expect.poll(async () => (await wrap.boundingBox())!.width).toBeGreaterThan(before + 150); + await page.getByRole("link", { name: "Задачи" }).click(); + await expect(page).toHaveURL(/\/app\/issues\?project=AWPCA$/); + await page.getByRole("button", { name: "Развернуть панель", exact: true }).click(); + await expect(page.getByRole("button", { name: "Свернуть панель" })).toBeVisible(); +}); + +test("390 px: «Меню» открывает панель, «Задачи» закрывает её и открывает список; без горизонтальной прокрутки", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 800 }); + await login(page); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + await page.getByRole("button", { name: "Меню" }).click(); + const side = page.getByRole("complementary", { name: "Панель приложения" }); + await expect(side).toBeVisible(); + await side.getByRole("link", { name: "Задачи" }).click(); + await expect(page).toHaveURL(/\/app\/issues\?project=AWPCA$/); + await expect(side).toHaveCount(0); + await expect(page.getByTestId("page-issues")).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); +}); diff --git a/web/e2e/screens.spec.ts b/web/e2e/screens.spec.ts index a0f876c..a3aa7f3 100644 --- a/web/e2e/screens.spec.ts +++ b/web/e2e/screens.spec.ts @@ -80,12 +80,31 @@ for (const theme of ["light", "dark"] as const) { test("new-project", async ({ page }) => { await login(page); + if (w < 640) await page.getByRole("button", { name: "Меню" }).click(); // на узком экране проект — в выезжающей панели await page.getByLabel("Проект", { exact: true }).selectOption({ label: "+ Новый проект…" }); await expect(page.getByRole("dialog", { name: "Новый проект" })).toBeVisible(); await settle(page); await shot(page, "new-project"); }); + test("board-collapsed", async ({ page }) => { + test.skip(w < 640, "свёртка — только на широком экране"); + await page.addInitScript(() => { localStorage.setItem("jira-hub-sidebar", "collapsed"); }); + await login(page); + await expect(page.locator(".board .card[data-key]").first()).toBeVisible(); + await settle(page); + await shot(page, "board-collapsed"); + }); + + test("menu", async ({ page }) => { + test.skip(w >= 640, "выезжающая панель — только на узком экране"); + await login(page); + await page.getByRole("button", { name: "Меню" }).click(); + await expect(page.getByRole("complementary", { name: "Панель приложения" })).toBeVisible(); + await settle(page); + await page.screenshot({ path: `${OUT}/${theme}-${w}-menu.png` }); + }); + test("refresh", async ({ page }) => { await login(page); await page.getByRole("button", { name: "Обновить из Jira" }).click(); diff --git a/web/src/shell/shell.css b/web/src/shell/shell.css index 2711319..b8c5751 100644 --- a/web/src/shell/shell.css +++ b/web/src/shell/shell.css @@ -9,7 +9,7 @@ .sidebar__head{display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);min-height:var(--control-h)} .sidebar__brand{font-size:16px;line-height:24px;font-weight:700;color:var(--color-text-primary);white-space:nowrap;padding-left:var(--space-2)} .sidebar__project-tag{ - max-width:48px;padding:2px var(--space-1);border:1px solid var(--color-border-default);border-radius:var(--radius-xs); + max-width:100%;padding:2px;border:1px solid var(--color-border-default);border-radius:var(--radius-xs); background:var(--color-bg-subtle);color:var(--color-text-secondary);font:inherit;font-size:var(--fs-caption);line-height:var(--lh-caption); font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer; }