diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx
index f60b666..f82b69c 100644
--- a/web/src/App.test.tsx
+++ b/web/src/App.test.tsx
@@ -1,8 +1,56 @@
-import { render, screen } from "@testing-library/react";
-import { expect, it } from "vitest";
+import { screen } from "@testing-library/react";
+import { render } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { createMemoryRouter } from "react-router";
+import { beforeEach, describe, expect, it } from "vitest";
import { App } from "./App.tsx";
+import { clearToken, getAuth, setToken } from "./auth/token.ts";
+import { routes } from "./routes.tsx";
+import { json, mockFetch } from "./test/fetchMock.ts";
-it("показывает заголовок", () => {
- render();
- expect(screen.getByRole("heading", { name: "Хаб Jira" })).toBeInTheDocument();
+const at = (path: string) => createMemoryRouter(routes, { initialEntries: [path], basename: "/app" });
+
+describe("оболочка", () => {
+ beforeEach(() => { setToken("tok"); mockFetch(() => json(200, {})); });
+
+ it("/app ведёт на доску", async () => {
+ render();
+ expect(await screen.findByTestId("page-board")).toBeInTheDocument();
+ });
+
+ it("переход «Задачи» и обратно", async () => {
+ const router = at("/app/board");
+ render();
+ await userEvent.click(screen.getByRole("link", { name: "Задачи" }));
+ expect(router.state.location.pathname).toBe("/app/issues");
+ expect(await screen.findByTestId("page-issues")).toBeInTheDocument();
+ });
+
+ it("неизвестный путь — «не найдено» со ссылками", async () => {
+ render();
+ expect(await screen.findByRole("heading", { name: "Страница не найдена" })).toBeInTheDocument();
+ });
+
+ it("«Сменить токен» показывает экран входа", async () => {
+ render();
+ await userEvent.click(screen.getByRole("button", { name: "Сменить токен" }));
+ expect(getAuth().token).toBe("");
+ expect(screen.getByRole("heading", { name: "Вход в хаб" })).toBeInTheDocument();
+ });
+
+ it("исключение в странице — сообщение и «Перезагрузить», панель на месте", async () => {
+ const Boom = () => { throw new Error("сломалось"); };
+ const router = createMemoryRouter([{ path: "/", element: , children: [{ path: "x", element: }] }],
+ { initialEntries: ["/x"] });
+ render();
+ expect(await screen.findByText(/сломалось/)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Перезагрузить" })).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Доска" })).toBeInTheDocument();
+ });
+
+ it("без токена — только экран входа", () => {
+ clearToken();
+ render();
+ expect(screen.queryByRole("link", { name: "Доска" })).not.toBeInTheDocument();
+ });
});
diff --git a/web/src/App.tsx b/web/src/App.tsx
index e570ce5..1ad4257 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -1,3 +1,23 @@
-export function App() {
- return
Хаб Jira
;
+import { QueryClientProvider } from "@tanstack/react-query";
+import { useState } from "react";
+import { createBrowserRouter, RouterProvider } from "react-router";
+import { makeQueryClient } from "./api/queryClient.ts";
+import { AuthGate } from "./auth/AuthGate.tsx";
+import { routes } from "./routes.tsx";
+import { Layout } from "./shell/Layout.tsx";
+import "./styles/theme.css";
+import "./styles/base.css";
+import "./styles/controls.css";
+
+type Router = ReturnType;
+
+export function App({ router }: { router?: Router }) {
+ const [client] = useState(makeQueryClient);
+ const [r] = useState(() => router ?? createBrowserRouter(routes, { basename: "/app" }));
+ return (
+
+
+
+ );
}
+App.Layout = Layout;
diff --git a/web/src/board/BoardPage.tsx b/web/src/board/BoardPage.tsx
new file mode 100644
index 0000000..202112f
--- /dev/null
+++ b/web/src/board/BoardPage.tsx
@@ -0,0 +1,2 @@
+// временная страница (заменяется в Task 11)
+export function BoardPage() { return ; }
diff --git a/web/src/issues/IssuePage.tsx b/web/src/issues/IssuePage.tsx
new file mode 100644
index 0000000..e04fb22
--- /dev/null
+++ b/web/src/issues/IssuePage.tsx
@@ -0,0 +1,2 @@
+// временная страница (заменяется в Task 14)
+export function IssuePage() { return ; }
diff --git a/web/src/issues/IssuesPage.tsx b/web/src/issues/IssuesPage.tsx
new file mode 100644
index 0000000..2826f58
--- /dev/null
+++ b/web/src/issues/IssuesPage.tsx
@@ -0,0 +1,2 @@
+// временная страница (заменяется в Task 13)
+export function IssuesPage() { return ; }
diff --git a/web/src/main.tsx b/web/src/main.tsx
index a1b5c62..2fd256e 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -1,6 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App.tsx";
+import { applyTheme, readTheme } from "./shell/theme.ts";
+
+applyTheme(readTheme());
createRoot(document.getElementById("root")!).render(
diff --git a/web/src/routes.tsx b/web/src/routes.tsx
new file mode 100644
index 0000000..c120fcf
--- /dev/null
+++ b/web/src/routes.tsx
@@ -0,0 +1,19 @@
+import { Navigate, type RouteObject } from "react-router";
+import { BoardPage } from "./board/BoardPage.tsx";
+import { IssuePage } from "./issues/IssuePage.tsx";
+import { IssuesPage } from "./issues/IssuesPage.tsx";
+import { Layout } from "./shell/Layout.tsx";
+import { NotFound } from "./shell/NotFound.tsx";
+
+// адреса относительно basename "/app": /app → /app/board
+export const routes: RouteObject[] = [{
+ path: "/",
+ element: ,
+ children: [
+ { index: true, element: },
+ { path: "board", element: },
+ { path: "issues", element: },
+ { path: "issues/:key", element: },
+ { path: "*", element: },
+ ],
+}];
diff --git a/web/src/shell/ErrorBoundary.tsx b/web/src/shell/ErrorBoundary.tsx
new file mode 100644
index 0000000..0a8e2d8
--- /dev/null
+++ b/web/src/shell/ErrorBoundary.tsx
@@ -0,0 +1,20 @@
+import { Component, type ErrorInfo, type ReactNode } from "react";
+
+interface State { error: Error | null }
+
+// неожиданная ошибка отрисовки страницы: сообщение и перезагрузка вместо пустого экрана; панель разделов остаётся
+export class ErrorBoundary extends Component<{ children: ReactNode }, State> {
+ state: State = { error: null };
+ static getDerivedStateFromError(error: Error): State { return { error }; }
+ componentDidCatch(error: Error, info: ErrorInfo) { console.error("ошибка страницы", error, info.componentStack); }
+ render() {
+ if (!this.state.error) return this.props.children;
+ return (
+
+
Страница сломалась
+
{this.state.error.message}
+
+
+ );
+ }
+}
diff --git a/web/src/shell/Layout.tsx b/web/src/shell/Layout.tsx
new file mode 100644
index 0000000..f08a4b3
--- /dev/null
+++ b/web/src/shell/Layout.tsx
@@ -0,0 +1,26 @@
+import { useQueryClient } from "@tanstack/react-query";
+import { NavLink, Outlet, useLocation } from "react-router";
+import { clearToken } from "../auth/token.ts";
+import { ErrorBoundary } from "./ErrorBoundary.tsx";
+import "./shell.css";
+
+export function Layout() {
+ const qc = useQueryClient();
+ const { pathname } = useLocation();
+ return (
+ <>
+
+ Хаб Jira
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/web/src/shell/NotFound.tsx b/web/src/shell/NotFound.tsx
new file mode 100644
index 0000000..8a830de
--- /dev/null
+++ b/web/src/shell/NotFound.tsx
@@ -0,0 +1,10 @@
+import { Link } from "react-router";
+
+export function NotFound() {
+ return (
+
+
Страница не найдена
+
Разделы приложения: доска, задачи.
+
+ );
+}
diff --git a/web/src/shell/shell.css b/web/src/shell/shell.css
new file mode 100644
index 0000000..3ea927e
--- /dev/null
+++ b/web/src/shell/shell.css
@@ -0,0 +1,8 @@
+/* оболочка: верхняя панель разделов */
+.appbar{display:flex;align-items:center;gap:16px;padding:10px 16px;border-bottom:1px solid var(--line);background:var(--panel)}
+.appbar .brand{font-weight:600}
+.appbar nav{display:flex;gap:4px}
+.appbar nav a{color:var(--fg);text-decoration:none;padding:6px 10px;border-radius:6px}
+.appbar nav a.active{background:var(--accent);color:var(--accent-fg)}
+.appbar .spacer{flex:1 1 auto}
+.crash{max-width:640px;margin:48px auto}
diff --git a/web/src/shell/theme.test.ts b/web/src/shell/theme.test.ts
new file mode 100644
index 0000000..ffec89c
--- /dev/null
+++ b/web/src/shell/theme.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest";
+import { applyTheme, readTheme, saveTheme } from "./theme.ts";
+
+describe("тема", () => {
+ it("читает graph-theme, неизвестное значение — системная", () => {
+ localStorage.setItem("graph-theme", "dark");
+ expect(readTheme()).toBe("dark");
+ localStorage.setItem("graph-theme", "purple");
+ expect(readTheme()).toBe("");
+ });
+ it("applyTheme ставит и снимает data-theme, saveTheme пишет ключ", () => {
+ applyTheme("light");
+ expect(document.documentElement.getAttribute("data-theme")).toBe("light");
+ applyTheme("");
+ expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
+ saveTheme("dark");
+ expect(localStorage.getItem("graph-theme")).toBe("dark");
+ });
+});
diff --git a/web/src/shell/theme.ts b/web/src/shell/theme.ts
new file mode 100644
index 0000000..3918743
--- /dev/null
+++ b/web/src/shell/theme.ts
@@ -0,0 +1,20 @@
+// тема интерфейса: "" — системная, light, dark; ключ graph-theme, как у прежней доски (links.html, строки 1000–1003)
+export const THEMES = ["", "light", "dark"] as const;
+export type Theme = (typeof THEMES)[number];
+export const THEME_NAMES: Record = { "": "системная", light: "светлая", dark: "тёмная" };
+const KEY = "graph-theme";
+
+export function readTheme(): Theme {
+ let v: string | null = null;
+ try { v = localStorage.getItem(KEY); } catch { /* закрытое хранилище */ }
+ return (THEMES as readonly string[]).includes(v ?? "") ? (v as Theme) : "";
+}
+
+export function saveTheme(t: Theme): void {
+ try { localStorage.setItem(KEY, t); } catch { /* закрытое хранилище */ }
+}
+
+export function applyTheme(t: Theme): void {
+ if (t) document.documentElement.setAttribute("data-theme", t);
+ else document.documentElement.removeAttribute("data-theme");
+}
diff --git a/web/src/styles/base.css b/web/src/styles/base.css
new file mode 100644
index 0000000..c9878e9
--- /dev/null
+++ b/web/src/styles/base.css
@@ -0,0 +1,11 @@
+/* 2. Основа */
+html,body{min-height:100%}
+body{background:var(--bg);color:var(--fg);font-family:var(--font-body);font-size:15px;line-height:1.45;margin:0}
+h1{font-size:20px;font-weight:600;margin:0}
+[hidden]{display:none!important}
+.row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
+.spacer{flex:1 1 auto}
+.hint{font-size:13px;color:var(--muted)}
+.warn{color:var(--p-high)}
+.page{padding-inline:16px;padding-block:0 32px}
+.login{max-width:560px;margin:48px auto}.login form{display:flex;gap:8px}.login input{flex:1;font:inherit;height:34px;padding:0 10px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg)}.login .err{color:var(--p-crit)}
diff --git a/web/src/styles/controls.css b/web/src/styles/controls.css
new file mode 100644
index 0000000..fe53af7
--- /dev/null
+++ b/web/src/styles/controls.css
@@ -0,0 +1,42 @@
+.link{font:inherit;font-size:14px;color:var(--accent);cursor:pointer;background:none;border:0;padding:0;text-decoration:underline dotted;white-space:nowrap}
+.link:hover,.link:focus-visible{text-decoration-style:solid;outline:none}
+.link:disabled{color:var(--muted);cursor:default;text-decoration:none}
+/* 4. Кнопки и выпадающие списки */
+.btn{font:inherit;font-size:14px;line-height:1.2;color:var(--fg);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:0 12px;cursor:pointer;height:34px;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;white-space:nowrap;gap:5px}
+.btn:hover,.btn:focus-visible{border-color:var(--accent);outline:none}
+.btn[aria-pressed="true"]{background:var(--accent);color:var(--accent-fg);border-color:var(--accent)}
+.btn .n{font-weight:600;color:var(--p-crit)}
+.btn .n.zero{font-weight:500;color:var(--muted)}
+.btn[aria-pressed="true"] .n{color:inherit}
+.btn.sm{height:28px;padding:0 8px;font-size:13px}
+.more-btn{padding:0 10px;font-size:20px;line-height:1;letter-spacing:.05em}
+.dd{position:relative;display:inline-block}
+.dd-btn{max-width:360px;gap:8px;justify-content:space-between}
+.dd-btn .lbl{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.dd-btn .car{font-size:10px;opacity:.7}
+.dd-menu{position:absolute;top:calc(100% + 4px);left:0;z-index:20;min-width:280px;max-width:420px;background:var(--panel);border:1px solid var(--line);border-radius:8px;box-shadow:0 10px 28px rgba(0,0,0,.18);padding:8px;display:flex;flex-direction:column;gap:6px}
+.dd-menu input[type=search]{font:inherit;font-size:14px;color:var(--fg);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:6px 8px;width:100%;box-sizing:border-box}
+.dd-menu input[type=search]:focus-visible{border-color:var(--accent);outline:none}
+.dd-list{max-height:260px;overflow:auto;display:flex;flex-direction:column}
+.dd-item{display:flex;align-items:center;gap:8px;padding:5px 6px;border-radius:5px;cursor:pointer;font-size:14px}
+.dd-item:hover{background:var(--soft)}
+.dd-item input{accent-color:var(--accent);margin:0;width:14px;height:14px;flex:0 0 auto}
+.dd-item .n{margin-left:auto;font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}
+.dd-item.none{color:var(--muted);font-style:italic}
+.dd-item.zero{opacity:.5}
+.dd-foot{display:flex;gap:6px;justify-content:space-between;border-top:1px solid var(--line);padding-top:6px}
+.dd-foot .btn{height:30px;padding:0 10px;font-size:13.5px}
+.dd-empty{font-size:12.5px;color:var(--muted);padding:6px}
+.dd-sep{border:0;border-top:1px solid var(--line);margin:4px 0}
+/* меню «⋯»: открывается к левому краю, пункты с галочкой — переключатели */
+.dd-menu.menu{right:0;left:auto;min-width:230px;padding:6px}
+.menu button{font:inherit;font-size:14px;text-align:left;background:none;border:0;border-radius:5px;padding:7px 10px;color:var(--fg);cursor:pointer;width:100%;display:flex;justify-content:space-between;align-items:center;gap:10px}
+.menu button:hover,.menu button:focus-visible{background:var(--soft);outline:none}
+.menu button[aria-pressed="true"]::after{content:"✓";color:var(--accent);font-weight:600}
+.menu hr{border:0;border-top:1px solid var(--line);margin:4px 0}
+.menu .danger{color:var(--p-crit)}
+.confirm{display:flex;flex-direction:column;gap:6px;padding:6px 10px;font-size:14px}
+.confirm .row{flex-wrap:nowrap}
+.confirm .btn{height:28px;padding:0 10px;width:auto;flex:1 1 0}
+.confirm .btn.yes{background:var(--p-crit);color:#fff;border-color:var(--p-crit)}
+
diff --git a/web/src/styles/theme.css b/web/src/styles/theme.css
new file mode 100644
index 0000000..a2044ca
--- /dev/null
+++ b/web/src/styles/theme.css
@@ -0,0 +1,19 @@
+/* 1. Цвета и темы: светлая по умолчанию, тёмная по системе или по выбору в меню */
+:root{
+ --bg:#f3f4f1; --fg:#1f2321; --muted:#6b7069; --line:#d6d8d0; --card:#ffffff; --panel:#ffffff; --soft:#eaece6;
+ --accent:#1f6f8b; --accent-fg:#ffffff; --edge:#9a9f97; --edge-strong:#1f6f8b; --guide:#c2410c;
+ --k-decision:#b4542a; --k-analytics:#8a6d1f; --k-dev:#1f6f8b; --k-ui:#5b4b9a; --k-test:#2f7d4f; --k-org:#6b7069;
+ --p-crit:#b23a3a; --p-high:#c7772a; --p-mid:#6b7069; --p-low:#9a9f97;
+ --font-body:"IBM Plex Sans",system-ui,-apple-system,"Segoe UI",sans-serif;
+ --font-mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
+}
+@media (prefers-color-scheme: dark){ :root:not([data-theme="light"]){
+ --bg:#151817; --fg:#e8e6df; --muted:#9a9f97; --line:#303532; --card:#1f2422; --panel:#1b201e; --soft:#252a28;
+ --accent:#6fb3cc; --accent-fg:#101413; --edge:#6c726b; --edge-strong:#6fb3cc; --guide:#f08a5a;
+ --k-decision:#e08a5a; --k-analytics:#d4b35a; --k-dev:#6fb3cc; --k-ui:#a493d8; --k-test:#6cc08f; --k-org:#9a9f97;
+ --p-crit:#e36b6b; --p-high:#e3a059; --p-mid:#9a9f97; --p-low:#6c726b; color-scheme:dark }}
+:root[data-theme="dark"]{
+ --bg:#151817; --fg:#e8e6df; --muted:#9a9f97; --line:#303532; --card:#1f2422; --panel:#1b201e; --soft:#252a28;
+ --accent:#6fb3cc; --accent-fg:#101413; --edge:#6c726b; --edge-strong:#6fb3cc; --guide:#f08a5a;
+ --k-decision:#e08a5a; --k-analytics:#d4b35a; --k-dev:#6fb3cc; --k-ui:#a493d8; --k-test:#6cc08f; --k-org:#9a9f97;
+ --p-crit:#e36b6b; --p-high:#e3a059; --p-mid:#9a9f97; --p-low:#6c726b; color-scheme:dark }