);
}
-// строки модулей, у которых есть данные для проекта
-export function StatusBlock({ project, compact }: { project: string | null; compact: boolean }) {
+// блок состояния модулей панели: по строке на модуль, у которого есть данные для проекта (у вики без корня строки нет)
+export function ModuleStatusBlock({ project, variant }: { project: string | null; variant: "expanded" | "collapsed" }) {
const p = useProjects().data?.find((x) => x.label === project);
if (!p) return null;
- return <>{WITH_STATUS.filter((m) => m.status.freshness(p).available).map((m) => )}>;
+ return <>{WITH_STATUS.filter((m) => m.status.freshness(p).available).map((m) => )}>;
}
-// I-1: на телефоне подсказок нет — событие пакета строкой под верхней панелью
-function ModuleNotice({ m }: { m: (typeof WITH_STATUS)[number] }) {
+// узкий экран: события пакетов модулей — строкой под верхней строкой; контейнер role="status" в DOM всегда (T10.1 #94)
+function ModuleEvent({ m }: { m: WithStatus }) {
const act = useModuleActivity(m.name);
const ev = act.event ? eventText(act.event, m.status.refresh?.label ?? "") : null;
- return ev ?
{ev.text}
: null;
+ return ev ?
{ev.text}
: null;
}
-export function TopbarNotice() {
- return <>{WITH_STATUS.map((m) => )}>;
+export function ModuleEventStrip() {
+ return
{WITH_STATUS.map((m) => )}
;
}
diff --git a/web/src/shell/NotFound.tsx b/web/src/shell/NotFound.tsx
index 7a98bc6..6d56a88 100644
--- a/web/src/shell/NotFound.tsx
+++ b/web/src/shell/NotFound.tsx
@@ -1,9 +1,7 @@
import { Fragment } from "react";
import { Link } from "react-router";
-import { MODULES } from "../modules/index.ts";
import { Notice } from "../ui/index.ts";
-
-const NAV = MODULES.flatMap((m) => m.nav);
+import { NAV } from "./nav.tsx";
export function NotFound() {
return (
diff --git a/web/src/shell/OverviewPage.tsx b/web/src/shell/OverviewPage.tsx
new file mode 100644
index 0000000..b21db96
--- /dev/null
+++ b/web/src/shell/OverviewPage.tsx
@@ -0,0 +1,10 @@
+import { PageHeader } from "../ui/index.ts";
+
+// ВРЕМЕННАЯ заглушка «Обзора» (Task 4): Task 5 заменяет её страницей со сводками модулей
+export function OverviewPage() {
+ return (
+
+
+
+ );
+}
diff --git a/web/src/shell/Sidebar.test.tsx b/web/src/shell/Sidebar.test.tsx
index c3ab326..865728d 100644
--- a/web/src/shell/Sidebar.test.tsx
+++ b/web/src/shell/Sidebar.test.tsx
@@ -45,19 +45,19 @@ describe("боковая панель", () => {
it("разделы — ссылки «Доска связей» и «Задачи», активная с aria-current", async () => {
hub();
- open("/app/issues?project=AWPCA");
+ open("/app/jira/issues?project=AWPCA");
await screen.findByRole("link", { name: "Доска связей" });
- await waitFor(() => expect(screen.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/board?project=AWPCA"));
+ await waitFor(() => expect(screen.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/jira/board?project=AWPCA"));
expect(screen.getByRole("link", { name: "Задачи" })).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("link", { name: "Доска связей" })).not.toHaveAttribute("aria-current");
});
it("раздел «Лента» ведёт на /feed с проектом и отмечается как текущий", async () => {
hub({ "GET /changes": () => json(200, { since: "", until: "", truncated: false, changes: [] }) });
- open("/app/feed?project=AWPCA");
+ open("/app/jira/feed?project=AWPCA");
expect(await screen.findByTestId("page-feed")).toBeInTheDocument();
const nav = within(bar()).getByRole("navigation", { name: "Разделы" });
- await waitFor(() => expect(within(nav).getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/feed?project=AWPCA"));
+ await waitFor(() => expect(within(nav).getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/jira/feed?project=AWPCA"));
expect(within(nav).getByRole("link", { name: "Лента" })).toHaveAttribute("aria-current", "page");
expect(within(nav).getByRole("link", { name: "Задачи" })).not.toHaveAttribute("aria-current");
});
@@ -70,16 +70,17 @@ describe("боковая панель", () => {
expect(within(nav).getByRole("link", { name: "Доска связей" })).not.toHaveAttribute("aria-current");
expect(within(nav).getByRole("link", { name: "Задачи" })).not.toHaveAttribute("aria-current");
const main = within(screen.getByRole("main"));
- expect(main.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/board");
- expect(main.getByRole("link", { name: "Задачи" })).toHaveAttribute("href", "/app/issues");
- expect(main.getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/feed"); // M-2
+ expect(main.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/jira/board");
+ expect(main.getByRole("link", { name: "Задачи" })).toHaveAttribute("href", "/app/jira/issues");
+ expect(main.getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/jira/feed"); // M-2
+ expect(main.getByRole("link", { name: "Обзор" })).toHaveAttribute("href", expect.stringMatching(/^\/app\/?$/));
expect(screen.getByRole("alert")).toHaveTextContent("Страница не найдена");
});
it("«Обновить из Jira» на /issues ставит пакет, недоступна, по done — «пакет #7 выполнен» и GET /issues повторно", async () => {
let done = false;
const h = hub({ "GET /batches/7": () => json(200, { id: 7, author: "viewer", status: done ? "done" : "running", remaining_seconds: 3, counts: {}, commands: [{ type: "refresh", status: done ? "done" : "running" }] }) });
- open("/app/issues?project=AWPCA");
+ open("/app/jira/issues?project=AWPCA");
await screen.findByTestId("page-issues");
await waitFor(() => expect(h.count("GET", "/issues")).toBeGreaterThan(0));
const issuesBefore = h.count("GET", "/issues");
@@ -97,39 +98,60 @@ describe("боковая панель", () => {
it("агент офлайн — «агент офлайн»; на связи — «агент на связи»", async () => {
hub({ "GET /agents": () => json(200, { agents: [{ module: "jira", online: false, last_seen_at: "2026-10-14T09:30:00+00:00", warning: null, update_hint: null }] }) });
- open("/app/board?project=AWPCA");
+ open("/app/jira/board?project=AWPCA");
expect(await within(bar()).findByText("агент офлайн")).toBeInTheDocument();
});
it("агент на связи", async () => {
hub();
- open("/app/board?project=AWPCA");
+ open("/app/jira/board?project=AWPCA");
expect(await within(bar()).findByText("агент на связи")).toBeInTheDocument();
});
it("«данные на ДД.ММ ЧЧ:ММ · все проекты» берутся из jira_synced_at проекта", async () => {
hub();
- open("/app/issues?project=AWPCA");
+ open("/app/jira/issues?project=AWPCA");
expect(await within(bar()).findByText(/^данные на \d\d\.\d\d \d\d:\d\d · все проекты$/)).toBeInTheDocument();
});
it("обновления ещё не было", async () => {
hub({ "GET /projects": () => json(200, { projects: [{ ...A, jira_synced_at: null }] }) });
- open("/app/issues?project=AWPCA");
+ open("/app/jira/issues?project=AWPCA");
expect(await within(bar()).findByText("обновления ещё не было")).toBeInTheDocument();
});
it("блок состояния не грузит GET /wiki/status; у проекта без корня вики строки «Вики» нет", async () => {
const h = hub();
- open("/app/board?project=AWPCA");
+ open("/app/jira/board?project=AWPCA");
await within(bar()).findByRole("button", { name: /^Обновить из Jira/ });
expect(within(bar()).queryByRole("button", { name: /^Обновить вики/ })).toBeNull();
expect(h.count("GET", "/wiki/status")).toBe(0);
});
+ it("группы по модулям: «Обзор» первым, затем заголовки «Jira» и «Вики» со своими разделами", async () => {
+ hub();
+ open("/app/jira/board?project=AWPCA");
+ const nav = within(bar()).getByRole("navigation", { name: "Разделы" });
+ const links = (await within(nav).findAllByRole("link")).map((l) => l.textContent);
+ expect(links).toEqual(["Обзор", "Доска связей", "Задачи", "Лента", "Вики"]);
+ const jira = within(nav).getByRole("group", { name: "Jira" });
+ expect(within(jira).getAllByRole("link").map((l) => l.textContent)).toEqual(["Доска связей", "Задачи", "Лента"]);
+ expect(within(nav).getByRole("group", { name: "Вики" })).toBeInTheDocument();
+ await waitFor(() => expect(within(nav).getByRole("link", { name: "Обзор" })).toHaveAttribute("href", expect.stringMatching(/^\/app\/?\?project=AWPCA$/)));
+ });
+
+ it("свёрнутая: метка проекта целиком в title (M-4), у групп скрытые названия", async () => {
+ localStorage.setItem("sdlc-hub-sidebar", "collapsed");
+ hub({ "GET /projects": () => json(200, { projects: [{ ...A, label: "STOCKS_DRRK_LONG" }] }) });
+ open("/app/jira/board?project=STOCKS_DRRK_LONG");
+ const tag = await within(bar()).findByRole("button", { name: "Проект STOCKS_DRRK_LONG — развернуть панель" });
+ expect(tag).toHaveAttribute("title", "Проект STOCKS_DRRK_LONG");
+ expect(within(bar()).getByRole("group", { name: "Jira" })).toBeInTheDocument();
+ });
+
it("меню «Ещё» → «Сменить токен» показывает экран входа", async () => {
hub();
- open("/app/board?project=AWPCA");
+ open("/app/jira/board?project=AWPCA");
await userEvent.click(await screen.findByRole("button", { name: "Ещё" }));
await userEvent.click(screen.getByRole("button", { name: "Сменить токен" }));
expect(getAuth().token).toBe("");
@@ -138,13 +160,13 @@ describe("боковая панель", () => {
it("переключатель темы — в панели", async () => {
hub();
- open("/app/board?project=AWPCA");
+ open("/app/jira/board?project=AWPCA");
expect(await within(bar()).findByRole("switch", { name: "Тёмная тема" })).toBeInTheDocument();
});
it("доска не рисует свою кнопку «Обновить из Jira»: в приложении она одна", async () => {
hub();
- open("/app/board?project=AWPCA");
+ open("/app/jira/board?project=AWPCA");
await screen.findByTestId("page-board");
await screen.findByText(/задач из/);
expect(screen.getAllByRole("button", { name: /^Обновить из Jira/ })).toHaveLength(1);
@@ -152,21 +174,21 @@ describe("боковая панель", () => {
it("разделы — в навигации «Разделы» боковой панели", async () => {
hub();
- open("/app/board?project=AWPCA");
+ open("/app/jira/board?project=AWPCA");
const nav = within(bar()).getByRole("navigation", { name: "Разделы" });
expect(await within(nav).findByRole("link", { name: "Доска связей" })).toHaveAttribute("aria-current", "page");
});
it("«Свернуть панель» сворачивает и запоминает в sdlc-hub-sidebar; после перезапуска свёрнута, разделы доступны", async () => {
hub();
- open("/app/board?project=AWPCA");
+ open("/app/jira/board?project=AWPCA");
const btn = await within(bar()).findByRole("button", { name: "Свернуть панель" });
expect(btn).toHaveAttribute("aria-expanded", "true");
await userEvent.click(btn);
expect(localStorage.getItem("sdlc-hub-sidebar")).toBe("collapsed");
expect(bar()).toHaveClass("is-collapsed");
cleanup();
- open("/app/board?project=AWPCA");
+ open("/app/jira/board?project=AWPCA");
expect(bar()).toHaveClass("is-collapsed");
expect(within(bar()).getByRole("button", { name: "Развернуть панель" })).toHaveAttribute("aria-expanded", "false");
expect(within(bar()).getByRole("link", { name: "Задачи" })).toHaveAttribute("title", "Задачи");
@@ -178,7 +200,7 @@ describe("боковая панель", () => {
it("свёрнутая: «Обновить из Jira» значком ставит пакет, событие — в role=status и подсказке", async () => {
localStorage.setItem("sdlc-hub-sidebar", "collapsed");
const h = hub();
- open("/app/issues?project=AWPCA");
+ open("/app/jira/issues?project=AWPCA");
const btn = await within(bar()).findByRole("button", { name: /^Обновить из Jira/ });
await userEvent.click(btn);
await waitFor(() => expect(btn).toBeDisabled());
@@ -190,7 +212,7 @@ describe("боковая панель", () => {
it("свёрнутая: метка проекта видна, щелчок по ней разворачивает панель", async () => {
localStorage.setItem("sdlc-hub-sidebar", "collapsed");
hub();
- open("/app/board?project=AWPCA");
+ open("/app/jira/board?project=AWPCA");
const tag = await within(bar()).findByRole("button", { name: "Проект AWPCA — развернуть панель" });
await userEvent.click(tag);
expect(bar()).not.toHaveClass("is-collapsed");
@@ -204,24 +226,25 @@ describe("боковая панель на узком экране", () => {
});
afterEach(() => { vi.unstubAllGlobals(); });
- it("сверху строка с «Меню», названием и «Обновить из Jira»; панели нет", async () => {
+ it("сверху строка с «Меню», меткой проекта и сводной точкой; кнопки обновления и панели нет", async () => {
hub();
- open("/app/board?project=AWPCA");
+ open("/app/jira/board?project=AWPCA");
const top = await screen.findByRole("banner", { name: "Строка приложения" });
expect(within(top).getByRole("button", { name: "Меню" })).toHaveAttribute("aria-expanded", "false");
- expect(within(top).getByText("SDLC Hub")).toBeInTheDocument();
- expect(await within(top).findByRole("button", { name: /^Обновить из Jira/ })).toBeInTheDocument();
+ expect(await within(top).findByText("AWPCA")).toBeInTheDocument();
+ expect(await within(top).findByRole("img", { name: /агент на связи/ })).toBeInTheDocument();
+ expect(within(top).queryByRole("button", { name: /^Обновить/ })).toBeNull();
expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull();
});
it("«Меню» открывает панель с фокусом на первом разделе; Escape закрывает и возвращает фокус", async () => {
hub();
- open("/app/board?project=AWPCA");
+ open("/app/jira/board?project=AWPCA");
const menu = await screen.findByRole("button", { name: "Меню" });
await userEvent.click(menu);
expect(menu).toHaveAttribute("aria-expanded", "true");
const nav = within(bar()).getByRole("navigation", { name: "Разделы" });
- expect(within(nav).getByRole("link", { name: "Доска связей" })).toHaveFocus();
+ expect(within(nav).getByRole("link", { name: "Обзор" })).toHaveFocus();
expect(within(bar()).queryByRole("button", { name: "Свернуть панель" })).toBeNull();
await userEvent.keyboard("{Escape}");
expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull();
@@ -230,16 +253,16 @@ describe("боковая панель на узком экране", () => {
it("выбор раздела закрывает панель и открывает раздел", async () => {
hub();
- const router = open("/app/board?project=AWPCA");
+ const router = open("/app/jira/board?project=AWPCA");
await userEvent.click(await screen.findByRole("button", { name: "Меню" }));
await userEvent.click(within(bar()).getByRole("link", { name: "Задачи" }));
- expect(router.state.location.pathname).toBe("/app/issues");
+ expect(router.state.location.pathname).toBe("/app/jira/issues");
expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull();
});
it("щелчок по подложке и «Закрыть меню» закрывают панель", async () => {
hub();
- open("/app/board?project=AWPCA");
+ open("/app/jira/board?project=AWPCA");
await userEvent.click(await screen.findByRole("button", { name: "Меню" }));
await userEvent.click(screen.getByTestId("sidebar-backdrop"));
expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull();
@@ -248,14 +271,35 @@ describe("боковая панель на узком экране", () => {
expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull();
});
- it("I-1: событие пакета видно под строкой, не только в подсказке", async () => {
+ it("I-1: событие пакета видно строкой под верхней строкой в постоянном живом регионе", async () => {
hub();
- open("/app/board?project=AWPCA");
- const top = await screen.findByRole("banner", { name: "Строка приложения" });
- await userEvent.click(await within(top).findByRole("button", { name: /^Обновить из Jira/ }));
- const strip = await screen.findByTestId("topbar-event");
+ open("/app/jira/board?project=AWPCA");
+ await screen.findByRole("banner", { name: "Строка приложения" });
+ const strip = screen.getByTestId("topbar-events");
+ expect(strip).toHaveAttribute("role", "status"); // T10.1: контейнер на месте до появления текста
+ expect(strip).toBeEmptyDOMElement();
+ await userEvent.click(screen.getByRole("button", { name: "Меню" }));
+ await userEvent.click(await within(bar()).findByRole("button", { name: /^Обновить из Jira/ }));
await waitFor(() => expect(strip).toHaveTextContent(/пакет #7 выполняется/));
expect(strip).not.toHaveClass("visually-hidden");
- expect(strip).toHaveAttribute("aria-hidden", "true"); // озвучивает role=status кнопки, второй раз не нужно
+ });
+
+ it("drawer is modal: фокус в панели, страница инертна; Escape во вложенном меню закрывает только меню (M-1)", async () => {
+ hub();
+ open("/app/jira/board?project=AWPCA");
+ const menu = await screen.findByRole("button", { name: "Меню" });
+ await userEvent.click(menu);
+ expect(within(bar()).getByRole("link", { name: "Обзор" })).toHaveFocus();
+ expect(screen.getByRole("main")).toHaveAttribute("inert");
+ expect(screen.getByRole("banner", { name: "Строка приложения" })).toHaveAttribute("inert");
+ await userEvent.click(within(bar()).getByRole("button", { name: "Ещё" }));
+ expect(within(bar()).getByRole("button", { name: "Сменить токен" })).toBeInTheDocument();
+ await userEvent.keyboard("{Escape}");
+ expect(within(bar()).queryByRole("button", { name: "Сменить токен" })).toBeNull();
+ expect(bar()).toBeInTheDocument(); // панель осталась
+ await userEvent.keyboard("{Escape}");
+ expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull();
+ await waitFor(() => expect(menu).toHaveFocus());
+ expect(screen.getByRole("main")).not.toHaveAttribute("inert");
});
});
diff --git a/web/src/shell/Sidebar.tsx b/web/src/shell/Sidebar.tsx
index 3e0440b..562ca66 100644
--- a/web/src/shell/Sidebar.tsx
+++ b/web/src/shell/Sidebar.tsx
@@ -1,9 +1,12 @@
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
+import type { NavItem } from "../modules/types.ts";
import { Link } from "react-router";
+import type { Ref } from "react";
import { clearToken } from "../auth/token.ts";
-import { MODULES } from "../modules/index.ts";
-import { StatusBlock, TopbarNotice } from "./activity/StatusBlock.tsx";
+import { useActivitySummary } from "./activity/activity.ts";
+import { ModuleEventStrip, ModuleStatusBlock } from "./ModuleStatusBlock.tsx";
+import { GROUPS, OVERVIEW } from "./nav.tsx";
import { withProject } from "../projects/current.ts";
import { ProjectPicker } from "../projects/ProjectPicker.tsx";
import { IconButton, IconClose, IconMenu, IconSidebar, Menu, MenuItem, ThemeSwitch } from "../ui/index.ts";
@@ -11,8 +14,6 @@ import { readSidebar, saveSidebar } from "./sidebarPref.ts";
export type Section = string;
-const SECTIONS = MODULES.flatMap((m) => m.nav);
-
// узкий экран (<640 px): панель не в потоке, а выезжает по кнопке «Меню» из закреплённой строки (design 4)
const NARROW = "(max-width: 639px)";
const subscribe = (cb: () => void) => {
@@ -31,21 +32,36 @@ export function Sidebar(p: Props) {
return narrow ? : ;
}
+const SUMMARY_TEXT = { error: "ошибка", busy: "идёт обновление", online: "агент на связи", offline: "агент офлайн" } as const;
+
+// сводная точка состояния модулей: цвет не единственный признак — состояние в aria-label и title
+function SummaryDot() {
+ const summary = useActivitySummary();
+ const text = `Состояние модулей: ${SUMMARY_TEXT[summary]}`;
+ return ;
+}
+
function NarrowShell(p: Props) {
const [open, setOpen] = useState(false);
const menuBtn = useRef(null);
// объект стабилен, пока панель открыта: эффект фокуса и Escape в Panel не перезапускается на каждой отрисовке
- const drawer = useMemo(() => ({ close: () => { setOpen(false); menuBtn.current?.focus(); }, navigated: () => setOpen(false) }), []);
+ const drawer = useMemo(() => ({ close: () => setOpen(false), navigated: () => setOpen(false) }), []);
+ // фокус возвращается на «Меню» после закрытия: пока панель открыта, строка инертна и фокус на неё не встал бы
+ const wasOpen = useRef(false);
+ useEffect(() => {
+ if (wasOpen.current && !open) menuBtn.current?.focus();
+ wasOpen.current = open;
+ }, [open]);
return (
<>
- SDLC Hub
+ {p.project && {p.project}}
-
+
-
+
{open && <>
@@ -54,21 +70,31 @@ function NarrowShell(p: Props) {
);
}
-// содержимое панели: название и свёртка (в выезжающей — «Закрыть меню»), проект, разделы, внизу состояние данных, тема, меню «Ещё»
+// содержимое панели: название и свёртка (в выезжающей — «Закрыть меню»), проект, «Обзор» и разделы по модулям, внизу состояние
+// модулей, тема, меню «Ещё»; прокручивается середина, низ с меню «Ещё» остаётся на месте (M-5 #73)
function Panel({ section, project, drawer }: Props & { drawer?: { close(): void; navigated(): void } }) {
const qc = useQueryClient();
const [stored, setCollapsed] = useState(readSidebar);
const collapsed = !drawer && stored;
const fold = (c: boolean) => { setCollapsed(c); saveSidebar(c); };
- const href = (to: Section) => (project ? to + withProject("", project) : to);
+ const href = (to: string) => (project ? to + withProject("", project) : to);
const first = useRef(null);
useEffect(() => {
if (!drawer) return;
first.current?.focus();
const esc = (e: KeyboardEvent) => { if (e.key === "Escape" && !e.defaultPrevented) drawer.close(); };
document.addEventListener("keydown", esc);
- return () => document.removeEventListener("keydown", esc);
+ // выезжающая панель модальна: страница и строка приложения недоступны с клавиатуры и для чтения с экрана
+ const behind = [...document.querySelectorAll(".shell > :not(.sidebar):not(.sidebar-backdrop)")];
+ behind.forEach((el) => el.setAttribute("inert", ""));
+ return () => { document.removeEventListener("keydown", esc); behind.forEach((el) => el.removeAttribute("inert")); };
}, [drawer]);
+ const link = (s: NavItem, ref?: Ref) => (
+
+ {s.icon}{s.label}
+
+ );
return (