From 90ee980149ced6b10f1b0027010bbc12c2f81e88 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 14:30:53 +0400 Subject: [PATCH] =?UTF-8?q?fix(web-modules):=20=D0=BB=D0=B8=D0=BF=D0=BA?= =?UTF-8?q?=D0=B0=D1=8F=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B0=20=C2=AB=D0=97?= =?UTF-8?q?=D0=B0=D0=B4=D0=B0=D1=87=C2=BB,=20=D0=BF=D0=BE=D1=80=D1=8F?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=20=D0=B2=D0=B8=D0=BA=D0=B8=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=88=D0=B8=D1=80=D0=B8=D0=BD=D0=B5=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D0=B9=D0=BD=D0=B5=D1=80=D0=B0,=20=C2=AB,=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=81=D1=80=D0=BE=D1=87=D0=B5=D0=BD=D0=BE=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- openspec/changes/web-modules/design.md | 7 ++-- web/src/modules/jira/board/BoardPage.test.tsx | 2 +- web/src/modules/jira/issues/IssueTable.tsx | 2 +- .../modules/jira/issues/IssuesPage.test.tsx | 5 +-- web/src/modules/jira/issues/issues.css | 5 ++- web/src/modules/wiki/WikiPage.test.tsx | 23 +++++++++++-- web/src/modules/wiki/WikiPage.tsx | 32 ++++++++++++------- web/src/modules/wiki/download.ts | 5 +-- 8 files changed, 57 insertions(+), 24 deletions(-) diff --git a/openspec/changes/web-modules/design.md b/openspec/changes/web-modules/design.md index 9f0ebd1..99c3fd6 100644 --- a/openspec/changes/web-modules/design.md +++ b/openspec/changes/web-modules/design.md @@ -146,8 +146,11 @@ type HubModule = { | 42 | 24.10.2026 | Task 5: поле поиска вики на «Обзоре» не ведёт на пустой запрос (Enter при пустой строке ничего не делает); `q` в «Вики» — только начальное значение поля, выбор страницы убирает `q` из адреса | пустой поиск = страница «Вики» без `q`, для неё есть ссылка «Вики»; `hrefOf` страницы по контракту Task 4 не несёт поиск | пользователь, вставший на `?q=` и открывший страницу, теряет в адресе строку поиска (в поле она остаётся) | | 43 | 24.10.2026 | Task 6: номер доски проверяется в окне сам (`/^\d+$/`, ≥ 1, текст «нужно целое число»), поле — `type="text"` с `inputMode="numeric"`, форма `noValidate` | `type="number"` даёт браузерную подсказку вместо нашего текста и пропускает `51.5`/`1e3` по-разному в браузерах; T4.1 | поле без стрелок счётчика; невалидное значение ловится только по «Создать» | | 44 | 24.10.2026 | Task 6: в ядре `useTrackModuleBatch()` (activity.ts) — отслеживание пакета по имени модуля; окно создания вынесено в `shell/NewProject.tsx`; `useTrackBatch` остаётся для пакетов без известного модуля | пакеты нового проекта привязываются каждому своему модулю по `batches[].module` (M-14, T5.3 #68); Layout не обязан знать модули | пакет неизвестного модуля молча не отслеживается | -| 45 | 24.10.2026 | Task 7: узкий экран «Вики» (страница выше дерева) — через `matchMedia("(max-width: 759.98px)")` и порядок в разметке (`WikiPage`), а не CSS `order`; дерево при поиске скрыто (`hidden`), а не размонтировано | порядок Tab совпадает с видимым; раскрытые вручную узлы переживают поиск (T10.2, T10.6); порог окна — приближение порога контейнера страницы | CSS `order` и размонтирование дерева | +| 45 | 24.10.2026 | Task 7: узкий экран «Вики» (страница выше дерева) — через `matchMedia("(max-width: 759.98px)")` и порядок в разметке (`WikiPage`), а не CSS `order`; дерево при поиске скрыто (`hidden`), а не размонтировано | порядок Tab совпадает с видимым; раскрытые вручную узлы переживают поиск (T10.2, T10.6); порог окна — приближение порога контейнера страницы (заменено решением 51: мерится ширина контейнера) | CSS `order` и размонтирование дерева | | 46 | 24.10.2026 | Task 7: заголовки текста страницы вики сдвинуты ниже заголовка страницы: `#`/`##` → h3, `###` → h4 … не глубже h6 | порядок h1 → h2 → h3 без скачков и без второго h1 (T10.3) | оставить h1–h4 как есть | -| 47 | 24.10.2026 | Task 7: `.issue-table-wrap{overflow-x:auto}` на любой ширине (раньше — только `@container page < 1052px`) | на 1440 px девять столбцов шире панели; цена — липкая шапка таблицы липнет к панели, а не к окну | вернуть условие и сжать столбцы | +| 47 | 24.10.2026 | Task 7: `.issue-table-wrap{overflow-x:auto}` на любой ширине (раньше — только `@container page < 1052px`) | на 1440 px девять столбцов шире панели; цена указана неверно: обёртка-скроллер выключает липкость шапки совсем — см. решение 50 | вернуть условие и сжать столбцы | | 48 | 24.10.2026 | Task 7: проверка T5.2 «ядро не грузит /board и /wiki/status» идёт на странице вне модулей (`/app/nowhere`), а не на `/wiki?project=…` | страница «Вики» сама запрашивает `/wiki/status` (это модуль, не ядро) | проверять на странице вики с исключением статуса | | 49 | 24.10.2026 | Task 7: `scrollbar-gutter: stable` — на `html` (прокручивается окно, отдельной области страницы нет); «Обновить из Jira» в шапках — общий `modules/jira/RefreshButton.tsx` | страницы разной высоты не двигаются по ширине; одна реализация кнопки на три страницы | прокручиваемый контейнер страницы; кнопка в каждой странице | +| 50 | 24.10.2026 | Task 7, fix T7.I1: обёртка таблицы «Задач» при высоте окна ≥ 700 px получает `max-height: calc(100vh - 220px)` и `overflow-y:auto`, шапка липнет к обёртке; на низких окнах ограничения нет — шапка уходит со страницей | при `overflow-x:auto` обёртка — скроллер, и `position:sticky` шапки внутри неё без собственной вертикальной прокрутки не работает (решение 47 ошибочно считало, что шапка липнет к панели); на длинных списках шапка должна оставаться на виду; цена — вложенная вертикальная прокрутка (на телефонах высотой < 700 px её нет) | снять sticky/z-index и принять уходящую шапку | +| 51 | 24.10.2026 | Task 7, fix T7.I2: порядок «страница/дерево» в `WikiPage` определяется `ResizeObserver` на `.wiki__body` с порогом `≤ 759.98` px — тем же, что `@container page` в `wiki.css`; `matchMedia` убран | окно и контейнер страницы (рядом боковая панель) расходятся, на окне ≈ 760–1050 px CSS уже в одну колонку, а DOM ещё нет; до первого замера (и без `ResizeObserver`) — широкая раскладка; ключи панелей сохраняют состояние дерева | вернуть `matchMedia` с запасом по ширине панели | +| 52 | 24.10.2026 | Task 7, fix T7.I2: `useWikiDownload` возвращает объект через `useMemo` | перерисовка `WikiPage` (замер ширины) пересоздавала `{start,status}` → набор компонентов Markdown → ссылки-вложения перемонтировались (тест M-15 обнаружил); так же ломали бы их ввод в поиск и опрос статуса | не трогать хук и убрать состояние замера | diff --git a/web/src/modules/jira/board/BoardPage.test.tsx b/web/src/modules/jira/board/BoardPage.test.tsx index 41ed29a..7fdea35 100644 --- a/web/src/modules/jira/board/BoardPage.test.tsx +++ b/web/src/modules/jira/board/BoardPage.test.tsx @@ -175,7 +175,7 @@ describe("страница доски", () => { expect(screen.getByRole("button", { name: "Перечитать" })).toBeInTheDocument(); // меню действительно открыто expect(screen.queryByRole("button", { name: /^Тема/ })).not.toBeInTheDocument(); }); - it("заголовок страницы — h1, в шапке label; строка состояния — только события доски", async () => { + it("заголовок страницы — h1; строка состояния — только события доски", async () => { hub(); page(); await screen.findByText(/задач из 9/); diff --git a/web/src/modules/jira/issues/IssueTable.tsx b/web/src/modules/jira/issues/IssueTable.tsx index 3f60e1e..ecacc71 100644 --- a/web/src/modules/jira/issues/IssueTable.tsx +++ b/web/src/modules/jira/issues/IssueTable.tsx @@ -17,7 +17,7 @@ function rowClick(e: MouseEvent, key: string, onOpen: (key: function DueCell({ due, late }: { due: string | null; late: boolean }) { return ( - {due ? fmtDate(due) : ""}{late && просрочено} + {due ? fmtDate(due) : ""}{late && , просрочено} ); } diff --git a/web/src/modules/jira/issues/IssuesPage.test.tsx b/web/src/modules/jira/issues/IssuesPage.test.tsx index 743e407..86ffda9 100644 --- a/web/src/modules/jira/issues/IssuesPage.test.tsx +++ b/web/src/modules/jira/issues/IssuesPage.test.tsx @@ -365,11 +365,12 @@ describe("список задач", () => { renderWithProviders(, { route: "/issues?closed=1", path: "/issues" }); await screen.findByRole("link", { name: "AURORA-9001" }); const cell = await waitFor(() => { - const c = within(screen.getByRole("row", { name: /AURORA-9001/ })).getByText("просрочено").closest("td")!; + const c = within(screen.getByRole("row", { name: /AURORA-9001/ })).getByText(", просрочено").closest("td")!; expect(c).toHaveClass("overdue"); return c; }); - expect(within(cell).getByText("просрочено")).toHaveClass("visually-hidden"); + expect(within(cell).getByText(", просрочено")).toHaveClass("visually-hidden"); + expect(cell.textContent).toBe("01.01.2000, просрочено"); expect(cell).toHaveAttribute("title", "просрочено"); expect(cell).toHaveTextContent("01.01.2000"); }); diff --git a/web/src/modules/jira/issues/issues.css b/web/src/modules/jira/issues/issues.css index b3f2465..47a8f62 100644 --- a/web/src/modules/jira/issues/issues.css +++ b/web/src/modules/jira/issues/issues.css @@ -9,8 +9,11 @@ .issue-filters__grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--space-3) var(--space-4)} @container page (max-width:851.98px){.issue-filters__grid{grid-template-columns:repeat(2,minmax(0,1fr))}} @container page (max-width:607.98px){.issue-filters__grid{grid-template-columns:minmax(0,1fr)}.issue-filters__search{max-width:none}} -/* таблица прокручивается внутри своей панели на любой ширине (девять столбцов шире контейнера и на 1440 px); обёртка не шире контейнера страницы. Цена: липкая шапка таблицы липнет к панели, а не к окну (R-5 Task 7) */ +/* таблица прокручивается внутри своей панели на любой ширине (девять столбцов шире контейнера и на 1440 px); обёртка не шире контейнера страницы. + Обёртка — скроллер, поэтому липкая шапка липнет к ней, а не к окну: чтобы шапка оставалась на виду, высота обёртки ограничена высотой окна + и длинный список прокручивается внутри панели (на низких окнах — меньше 700 px — ограничения нет, шапка уходит вместе со страницей) */ .issue-table-wrap{overflow-x:auto;max-width:100%;min-width:0} +@media (min-height:700px){.issue-table-wrap{max-height:calc(100vh - 220px);overflow-y:auto}} .issue-table th:first-child{border-top-left-radius:calc(var(--radius-xl) - 1px)} .issue-table th:last-child{border-top-right-radius:calc(var(--radius-xl) - 1px)} .issue-table tbody tr:last-child td:first-child{border-bottom-left-radius:calc(var(--radius-xl) - 1px)} diff --git a/web/src/modules/wiki/WikiPage.test.tsx b/web/src/modules/wiki/WikiPage.test.tsx index f10e74d..a4f78e6 100644 --- a/web/src/modules/wiki/WikiPage.test.tsx +++ b/web/src/modules/wiki/WikiPage.test.tsx @@ -109,14 +109,31 @@ describe("страница «Вики»", () => { levels.slice(1).forEach((l, i) => { expect(l).toBeGreaterThan(1); expect(l - (levels[i] ?? 0)).toBeLessThanOrEqual(1); }); }); - it("на узком экране страница выше дерева", async () => { - vi.stubGlobal("matchMedia", (q: string) => ({ matches: q.includes("max-width"), media: q, addEventListener() {}, removeEventListener() {} })); + // jsdom без вёрстки: ResizeObserver подменён, ширину страницы сообщает тест + const withWidth = (width: number) => { + class RO { cb: (e: unknown[]) => void; constructor(cb: (e: unknown[]) => void) { this.cb = cb; } observe() { this.cb([{ contentRect: { width } }]); } disconnect() {} unobserve() {} } + vi.stubGlobal("ResizeObserver", RO); + }; + + it("на узкой странице (ширина контейнера ≤ 759.98) страница выше дерева", async () => { + withWidth(700); try { hub(); open("/wiki?project=AWPCA&page=DOC-2"); const article = await screen.findByRole("article"); const tree = await screen.findByRole("tree"); - expect(article.compareDocumentPosition(tree) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + await waitFor(() => expect(article.compareDocumentPosition(tree) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()); + } finally { vi.unstubAllGlobals(); } + }); + + it("на странице шире порога дерево выше страницы", async () => { + withWidth(900); + try { + hub(); + open("/wiki?project=AWPCA&page=DOC-2"); + const article = await screen.findByRole("article"); + const tree = await screen.findByRole("tree"); + expect(tree.compareDocumentPosition(article) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); } finally { vi.unstubAllGlobals(); } }); diff --git a/web/src/modules/wiki/WikiPage.tsx b/web/src/modules/wiki/WikiPage.tsx index 51e6d94..2383ecb 100644 --- a/web/src/modules/wiki/WikiPage.tsx +++ b/web/src/modules/wiki/WikiPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useNavigate, useSearchParams } from "react-router"; import { ApiError } from "../../api/client.ts"; import { Button, IconRefresh, Notice, PageHeader, Panel } from "../../ui/index.ts"; @@ -13,15 +13,23 @@ import "./wiki.css"; const Q_DELAY = 300; // поиск — после паузы ввода -// узкий экран: страница выше дерева (порядок в разметке, чтобы совпадал с порядком обхода Tab); порог = порог одной колонки в wiki.css -const NARROW = "(max-width: 759.98px)"; -const subscribeNarrow = (cb: () => void) => { - if (typeof matchMedia !== "function") return () => {}; - const m = matchMedia(NARROW); - m.addEventListener?.("change", cb); - return () => m.removeEventListener?.("change", cb); -}; -const isNarrow = () => typeof matchMedia === "function" && matchMedia(NARROW).matches; +// узкая страница: она выше дерева (порядок в разметке — чтобы совпадал с порядком обхода Tab). Мерится ширина самой страницы +// (.wiki__body = контейнер `page` в wiki.css), порог тот же, что у @container page (max-width:759.98px): DOM и сетка переключаются вместе +const NARROW_PX = 759.98; +function useNarrowBody() { + const [el, setEl] = useState(null); + const [narrow, setNarrow] = useState(false); + useEffect(() => { + if (!el || typeof ResizeObserver !== "function") return; + const ro = new ResizeObserver((entries) => { + const w = entries[entries.length - 1]?.contentRect.width; + if (typeof w === "number") setNarrow(w <= NARROW_PX); + }); + ro.observe(el); + return () => ro.disconnect(); + }, [el]); + return [narrow, setEl] as const; +} export function WikiPage({ project }: { project: string }) { const [params] = useSearchParams(); @@ -44,7 +52,7 @@ export function WikiPage({ project }: { project: string }) { }, [q]); const search = useWikiSearch(project, dq); const searching = q.trim() !== ""; - const narrow = useSyncExternalStore(subscribeNarrow, isNarrow, () => false); + const [narrow, bodyRef] = useNarrowBody(); if (status.isPending) return

Загрузка…

; if (noRoot) return
; @@ -79,7 +87,7 @@ export function WikiPage({ project }: { project: string }) { {!synced ? ( {running ? "Вики синхронизируется…" : "Вики ещё не синхронизирована. Нажмите «Обновить вики»."} ) : ( -
+
{narrow ? <>{content}{nav} : <>{nav}{content}}
)} diff --git a/web/src/modules/wiki/download.ts b/web/src/modules/wiki/download.ts index acfcd86..5229217 100644 --- a/web/src/modules/wiki/download.ts +++ b/web/src/modules/wiki/download.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { api, ApiError } from "../../api/client.ts"; import type { WikiAttachmentState } from "./api/queries.ts"; @@ -72,5 +72,6 @@ export function useWikiDownload(project: string, scope: string) { }, [put]); const status = useCallback((id: string): DownloadStatus => map[id] ?? IDLE, [map]); - return { start, status }; + // объект стабилен между перерисовками родителя: от него зависит набор компонентов Markdown, смена ссылки перемонтирует вложения + return useMemo(() => ({ start, status }), [start, status]); }