diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4292119..a65633a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -30,6 +30,11 @@ `/app/board`. Данные — только через HTTP API с клиентским токеном; `mirror.board` отдаёт зеркало без преобразований, перевод в модель доски — в `web/src/board/model`. Логика доски — чистые модули `web/src/board/{model,analysis,layout}` и `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`); доска отдаёт наружу только +источник и события снимка. Цепочки: `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/src/App.test.tsx b/web/src/App.test.tsx index 611e12c..6e08d5b 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createMemoryRouter } from "react-router"; import { beforeEach, describe, expect, it } from "vitest"; @@ -36,8 +36,9 @@ describe("оболочка", () => { it("неизвестный путь — «не найдено» со ссылками", async () => { render(); expect(await screen.findByRole("heading", { name: "Страница не найдена" })).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "доска" })).toHaveAttribute("href", "/app/board"); - expect(screen.getByRole("link", { name: "задачи" })).toHaveAttribute("href", "/app/issues"); + const main = within(screen.getByRole("main")); + expect(main.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/board"); + expect(main.getByRole("link", { name: "Задачи" })).toHaveAttribute("href", "/app/issues"); }); it("«Сменить токен» показывает экран входа", async () => { @@ -57,6 +58,7 @@ describe("оболочка", () => { render(); expect(await screen.findByText(/сломалось/)).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Перезагрузить" })).toBeInTheDocument(); + expect(screen.getAllByRole("alert")).toHaveLength(1); expect(screen.getByRole("link", { name: "Доска связей" })).toBeInTheDocument(); } finally { PAGE_ROUTES.splice(PAGE_ROUTES.indexOf(boom), 1); } }); diff --git a/web/src/App.tsx b/web/src/App.tsx index 34645c9..cb4bce8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,7 +7,6 @@ import { RefreshProvider } from "./shell/RefreshProvider.tsx"; import { routes } from "./routes.tsx"; import "./styles/tokens.css"; import "./styles/base.css"; -import "./styles/controls.css"; type Router = ReturnType; diff --git a/web/src/auth/LoginScreen.test.tsx b/web/src/auth/LoginScreen.test.tsx index edec09a..f082226 100644 --- a/web/src/auth/LoginScreen.test.tsx +++ b/web/src/auth/LoginScreen.test.tsx @@ -19,6 +19,7 @@ describe("вход", () => { expect(screen.getByRole("heading", { name: "Вход в хаб" })).toBeInTheDocument(); expect(screen.queryByText("страница приложения")).not.toBeInTheDocument(); expect(screen.getByText(/uv run jira-hub tokens/)).toBeInTheDocument(); + expect(screen.getByLabelText("Клиентский токен хаба")).toHaveAttribute("type", "password"); }); it("верный токен сохраняется и открывает приложение", async () => { @@ -37,7 +38,7 @@ describe("вход", () => { const input = screen.getByLabelText("Клиентский токен хаба"); await userEvent.type(input, "wrong"); await userEvent.click(screen.getByRole("button", { name: "Войти" })); - expect(await screen.findByText(/Токен не подошёл/)).toBeInTheDocument(); + expect(await screen.findByRole("alert")).toHaveTextContent(/Токен не подошёл/); expect(input).toHaveValue("wrong"); expect(localStorage.getItem("jira-hub-token")).toBeNull(); }); diff --git a/web/src/auth/LoginScreen.tsx b/web/src/auth/LoginScreen.tsx index c8c3553..eb1c29a 100644 --- a/web/src/auth/LoginScreen.tsx +++ b/web/src/auth/LoginScreen.tsx @@ -1,8 +1,10 @@ import { useQueryClient } from "@tanstack/react-query"; import { useState, type FormEvent } from "react"; import { ApiError, AuthError, checkToken } from "../api/client.ts"; +import { Button, Field, Notice, Panel, TextInput } from "../ui/index.ts"; import { setToken, TOKEN_RE } from "./token.ts"; import { useAuth } from "./useAuth.ts"; +import "./login.css"; const BAD_TOKEN = "Токен не подошёл. Проверьте его по uv run jira-hub tokens: токен могли пересоздать."; const BAD_CHARS = "В токене недопустимые символы: скопируйте строку после «клиентский токен:» целиком, без пробелов и кириллицы."; @@ -36,15 +38,19 @@ export function LoginScreen() { return (
-

Вход в хаб

-

Приложению нужен клиентский токен хаба. Его показывает команда uv run jira-hub tokens в каталоге - jira-hub на этом Mac. Токен сохранится в этом браузере.

- {error &&

{error}

} -
- setValue(e.target.value)} /> - -
+ +

Вход в хаб

+

Приложению нужен клиентский токен хаба. Его показывает команда uv run jira-hub tokens в каталоге + jira-hub на этом Mac. Токен сохранится в этом браузере.

+ {error && {error}} +
+ + setValue(e.target.value)} /> + + +
+
); } diff --git a/web/src/auth/login.css b/web/src/auth/login.css new file mode 100644 index 0000000..023dfdc --- /dev/null +++ b/web/src/auth/login.css @@ -0,0 +1,9 @@ +/* экран входа: Panel 480 px по центру канвы (design.md, решение 20) */ +.login { display: flex; justify-content: center; padding: 64px var(--space-4) var(--space-8); } +.login__panel { box-sizing: border-box; width: min(480px, 100%); display: flex; flex-direction: column; gap: var(--space-4); } +.login__title { margin: 0; font-size: var(--fs-section); line-height: var(--lh-section); font-weight: 700; color: var(--color-text-primary); } +.login__text { margin: 0; font-size: var(--fs-body); line-height: var(--lh-body); color: var(--color-text-secondary); } +.login__text code { font-family: var(--font-mono); font-size: var(--fs-caption); color: var(--color-text-primary); } +.login__form { display: flex; flex-direction: column; gap: var(--space-4); } +.login__form .ui-btn { align-self: stretch; } +@media (max-width: 639px) { .login { padding-top: var(--space-6); } } diff --git a/web/src/board/board.css b/web/src/board/board.css index b1c6658..894231a 100644 --- a/web/src/board/board.css +++ b/web/src/board/board.css @@ -49,17 +49,9 @@ svg.guides{z-index:3} svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3} .empty{padding:40px 16px;color:var(--color-text-secondary);text-align:center} #hubState{white-space:normal;min-width:0} -.login{max-width:560px;margin:48px auto;padding:20px 24px;background:var(--color-bg-surface);border:1px solid var(--color-border-default);border-radius:var(--radius-xl)} -.login h2{margin:0 0 8px;font-size:var(--fs-lg);line-height:var(--lh-lg);font-weight:700} -.login p{margin:0 0 12px;font-size:var(--fs-body);color:var(--color-text-secondary)} -.login p.err{color:var(--color-status-error)} -.login code{font-family:var(--font-mono);font-size:13px;color:var(--color-text-primary)} -.login form{display:flex;gap:8px;margin:0 0 12px} -.login input{flex:1;min-width:0;font:inherit;font-size:var(--fs-body);height:var(--control-h);box-sizing:border-box;padding:0 10px;color:var(--color-text-primary);background:var(--color-bg-canvas);border:1px solid var(--color-border-default);border-radius:var(--radius-sm)} -.login input:focus-visible{border-color:var(--color-border-selected);outline:none} .lane{position:absolute;left:0;right:0;border-top:1px solid var(--color-border-default)} .lane:nth-child(even of .lane){background:var(--lane-alt)} -.lane.folded{background:var(--color-bg-hover)} +.lane.folded{background:var(--color-bg-track);border-bottom:1px solid var(--color-border-strong);border-top-color:var(--color-border-strong)} .lane.target{box-shadow:inset 0 3px 0 var(--color-action-primary)} .lane.target-after{box-shadow:inset 0 -3px 0 var(--color-action-primary)} .lane.dragging{opacity:.6} @@ -78,7 +70,6 @@ svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3} .lane-tools button{display:inline-flex;align-items:center;justify-content:center;color:var(--color-text-secondary);background:var(--color-bg-surface);border:1px solid var(--color-border-strong);border-radius:var(--radius-xs);width:22px;height:22px;cursor:pointer;padding:0} .lane-tools button:hover{border-color:var(--color-action-primary);color:var(--color-action-primary)} .lane-tools button:disabled{opacity:.35;cursor:default} -.lane-tools .up svg{transform:rotate(180deg)} /* режим «по датам»: колонки по плановым датам окончания, шапка колонки над дорожками; карточки двигаются внутри своей колонки */ .dcol{position:absolute;top:0;border-left:1px solid var(--color-border-default);box-sizing:border-box;pointer-events:none} .dcol:first-of-type{border-left:0} @@ -128,7 +119,7 @@ svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3} .card .rsz{position:absolute;right:0;bottom:0;width:14px;height:14px;cursor:nwse-resize;background:linear-gradient(135deg,transparent 50%,var(--color-border-strong) 50%);border-bottom-right-radius:11px;z-index:2} .card:hover .rsz{background:linear-gradient(135deg,transparent 50%,var(--color-action-primary) 50%)} .head{display:flex;align-items:center;justify-content:space-between;gap:6px;min-width:0} -.key{font-family:var(--font-mono);font-size:13px;font-weight:600;color:var(--color-action-primary);text-decoration:none;flex:0 0 auto} +.card .key{font-family:var(--font-mono);font-size:13px;font-weight:600;color:var(--color-action-primary);text-decoration:none;flex:0 0 auto} .sprint-tag{font-size:11px;line-height:1;padding:4px 7px;border-radius:var(--radius-round);background:var(--color-bg-track);color:var(--color-text-secondary);max-width:55%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:0 1 auto} .sprint-tag.sel{background:var(--color-action-primary);color:var(--color-text-on-primary)} .sprint-tag.none{background:transparent;border:1px dashed var(--color-border-strong)} @@ -163,6 +154,8 @@ svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3} /* 8. Экспорт JSON и таблица связей (включаются из меню «Ещё») */ .export{display:flex;flex-direction:column;gap:var(--space-2);margin:0 0 var(--space-2)} +.export__row{display:flex;align-items:center;flex-wrap:wrap;gap:var(--space-2)} +.export__hint{font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary)} .export textarea{font-family:var(--font-mono);font-size:11.5px;width:100%;box-sizing:border-box;height:180px;border:1px solid var(--color-border-default);border-radius:var(--radius-sm);background:var(--color-bg-surface);color:var(--color-text-primary);padding:8px;resize:vertical} .link-table{margin-top:var(--space-3)} .tbl{max-width:100%;overflow-x:auto;margin-top:var(--space-2)} @@ -173,7 +166,7 @@ td.k{font-family:var(--font-mono);font-size:12px;white-space:nowrap;color:var(-- /* 9. Движение и узкие экраны */ @media (prefers-reduced-motion:no-preference){.card{transition:border-color .15s}} -@media (prefers-reduced-motion:reduce){.card .grip,.fold::before{transition:none}} +@media (prefers-reduced-motion:reduce){.card .grip,.fold svg{transition:none}} @media (max-width:900px){.stage{flex-direction:column}.drawer{flex:none;max-width:100%;max-height:none;position:static}} @media (max-width:640px){.sticky{position:static}.dates-bar{position:static}} diff --git a/web/src/board/components/ExportBox.tsx b/web/src/board/components/ExportBox.tsx index 9e9a731..eb1e071 100644 --- a/web/src/board/components/ExportBox.tsx +++ b/web/src/board/components/ExportBox.tsx @@ -10,8 +10,8 @@ export function ExportBox({ text, onClose }: { text: string; onClose(): void }) let ta: HTMLTextAreaElement | null = null; return ( -
- JSON доски: все задачи, связи, раскладка, дорожки и фильтры. Загружается обратно пунктом «Загрузить файл…» в меню «Ещё». Если файл не скачался, скопируйте текст. +
+ JSON доски: все задачи, связи, раскладка, дорожки и фильтры. Загружается обратно пунктом «Загрузить файл…» в меню «Ещё». Если файл не скачался, скопируйте текст.
diff --git a/web/src/board/components/Lane.tsx b/web/src/board/components/Lane.tsx index 40d5895..e9711f8 100644 --- a/web/src/board/components/Lane.tsx +++ b/web/src/board/components/Lane.tsx @@ -1,5 +1,5 @@ import { useRef, useState, type PointerEvent } from "react"; -import { IconChevronDown } from "../../ui/icons.tsx"; +import { IconChevronDown, IconChevronUp } from "../../ui/icons.tsx"; import type { LaneGeom } from "../layout/layout.ts"; import { plural } from "../model/util.ts"; @@ -44,7 +44,7 @@ export function Lane({ g, index, last, count, lanes, boardTop, targetClass, onTo {g.name} {plural(count, "задача", "задачи", "задач")} - +
diff --git a/web/src/issues/IssueFilters.tsx b/web/src/issues/IssueFilters.tsx index d1a9594..a544973 100644 --- a/web/src/issues/IssueFilters.tsx +++ b/web/src/issues/IssueFilters.tsx @@ -19,7 +19,7 @@ export function IssueFilters({ filters, set, onReset, q, setQ, showClosed, opts, q: string; setQ(v: string): void; showClosed: boolean; opts: Record; hasBoard: boolean; }) { const [open, setOpen] = useState(readOpen); - const toggle = () => setOpen((o) => { saveOpen(!o); return !o; }); + const toggle = () => { saveOpen(!open); setOpen(!open); }; // T8.1: запись в хранилище вне функции-обновителя const labelOf = (name: Name, value: string) => opts[name].find((o) => o.value === value)?.label ?? value; const chosen = SELECTS.filter(([n]) => filters[n]); return ( diff --git a/web/src/issues/IssuesPage.test.tsx b/web/src/issues/IssuesPage.test.tsx index b4d6d25..0781803 100644 --- a/web/src/issues/IssuesPage.test.tsx +++ b/web/src/issues/IssuesPage.test.tsx @@ -51,6 +51,25 @@ describe("список задач", () => { await waitFor(() => expect(issuesCalls(fetch).at(-1)!.searchParams.get("assignee")).toBe("Петров Пётр Петрович")); }); + it("ошибка списка — сообщение хаба и «Повторить», повтор перезапрашивает список (T8.4)", async () => { + let fail = true; + const fetch = mockFetch((_m, url) => { + if (url.pathname === "/settings") return json(200, settings); + if (url.pathname === "/board") return json(200, SNAP); + if (url.pathname === "/issues") return fail ? json(500, { error: "boom" }) : json(200, { issues: [row(1)] }); + return json(404, {}); + }); + renderWithProviders(, { route: "/issues", path: "/issues" }); + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("хаб ответил 500"); + expect(screen.queryByText("По этим фильтрам задач нет")).toBeNull(); + fail = false; + await userEvent.click(within(alert).getByRole("button", { name: "Повторить" })); + expect(await screen.findByRole("link", { name: "AURORA-9001" })).toBeInTheDocument(); + expect(screen.queryByRole("alert")).toBeNull(); + expect(issuesCalls(fetch)).toHaveLength(2); + }); + it("«Показать ещё» до неполной порции", async () => { const fetch = hub((p) => (p.get("offset") === "0" ? Array.from({ length: 50 }, (_, i) => row(i + 1)) : Array.from({ length: 17 }, (_, i) => row(i + 51)))); renderWithProviders(, { route: "/issues", path: "/issues" }); diff --git a/web/src/issues/issues.css b/web/src/issues/issues.css index 812ae35..6b59e4a 100644 --- a/web/src/issues/issues.css +++ b/web/src/issues/issues.css @@ -37,7 +37,7 @@ .issue--state{padding-top:var(--space-6)} .issue-head{position:sticky;top:0;z-index:1;background:var(--color-bg-surface);padding:var(--space-6) 0 var(--space-4);border-bottom:1px solid var(--color-border-default);margin-bottom:var(--space-5)} .issue-head .row1{display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap} -.issue-head .key{font-family:var(--font-mono);font-size:13px;color:var(--color-text-secondary)} +.issue-head .key{font-family:var(--font-mono);font-size:13px;font-weight:400;color:var(--color-text-secondary)} .issue-head .spacer{flex:1 1 auto} .issue-head h2{margin:var(--space-3) 0 var(--space-1);font-size:var(--fs-section);line-height:var(--lh-section);font-weight:700;overflow-wrap:anywhere} .issue-head .facts{margin:0;display:flex;flex-wrap:wrap;align-items:center;gap:0 var(--space-1);color:var(--color-text-secondary)} @@ -85,8 +85,8 @@ .issue .comments>ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column} .issue .comments>ul>li{padding:var(--space-3) 0;border-top:1px solid var(--color-border-default)} .issue .comments>ul>li:first-child{border-top:0;padding-top:0} -.issue .comments .c-head{display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);margin-bottom:var(--space-1);font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary)} -.issue .comments .c-head .ui-btn{margin-block:-8px;padding:0 var(--space-2);margin-right:calc(-1*var(--space-2));font-size:var(--fs-caption)} +.issue .comments .c-head{display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);min-height:var(--control-h);font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary)} +.issue .comments .c-head .ui-btn{padding:0 var(--space-2);margin-right:calc(-1*var(--space-2));font-size:var(--fs-caption)} .issue .comments pre.body{white-space:pre-wrap;font:inherit;margin:0;overflow-wrap:anywhere} .issue .fields{display:grid;grid-template-columns:1fr;gap:var(--space-3);margin:0} .issue .fields dt{font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary)} @@ -102,12 +102,12 @@ /* служебные заметки (design 20) */ .note{display:flex;flex-direction:column;gap:var(--space-3)} .note fieldset{display:flex;flex-direction:column;gap:var(--space-3);border:0;margin:0;padding:0;min-width:0} -.note .row{gap:var(--space-2)} +.note .row{display:flex;align-items:center;flex-wrap:wrap;gap:var(--space-2)} .note .note-confirm{display:flex;flex-direction:column;gap:var(--space-2);padding:var(--space-3);font-size:var(--fs-body);border:1px solid var(--color-border-strong);border-radius:var(--radius-lg);background:var(--color-bg-surface)} .note.view .hint{margin-top:var(--space-3);font-size:var(--fs-caption);line-height:var(--lh-caption)} /* окно карточки задачи (design 13): размер и рамка — Dialog size="lg"; прокрутка и шапка — здесь */ -.issue-modal{padding:0} +.ui-dialog.issue-modal{padding:0} .issue-modal > .issue{padding:0 var(--space-6) var(--space-6)} @media (max-width:700px){ .issue-modal > .issue{padding:0 var(--space-4) var(--space-4)} diff --git a/web/src/issues/wiki/wiki.css b/web/src/issues/wiki/wiki.css index 133d5d5..b6b9699 100644 --- a/web/src/issues/wiki/wiki.css +++ b/web/src/issues/wiki/wiki.css @@ -10,13 +10,13 @@ div.wiki :is(h5,h6){font-size:13px} div.wiki :is(ul,ol){margin:0 0 6px;padding-left:22px} div.wiki li>:is(ul,ol){margin:2px 0 0} div.wiki table{display:block;max-width:100%;min-width:0;overflow-x:auto;border-collapse:collapse;margin:0 0 6px;font-size:13px} -div.wiki :is(th,td){border:1px solid var(--line);padding:3px 8px;text-align:left;vertical-align:top;overflow-wrap:break-word} +div.wiki :is(th,td){border:1px solid var(--color-border-default);padding:3px 8px;text-align:left;vertical-align:top;overflow-wrap:break-word} /* глобальные table/th из board.css (min-width 520px, прописные 11px) здесь не действуют (I-1) */ -div.wiki th{background:var(--soft);font-weight:600;font-size:inherit;text-transform:none;letter-spacing:normal;color:inherit} +div.wiki th{background:var(--color-bg-hover);font-weight:600;font-size:inherit;text-transform:none;letter-spacing:normal;color:inherit} div.wiki code,div.wiki pre{font-family:var(--font-mono);font-size:12.5px} -div.wiki code{background:var(--soft);border-radius:3px;padding:0 3px} -div.wiki pre{background:var(--soft);border-radius:4px;padding:6px 8px;margin:0 0 6px;overflow-x:auto;white-space:pre;overflow-wrap:normal} -div.wiki blockquote{margin:0 0 6px;padding:0 0 0 10px;border-left:3px solid var(--line);color:var(--muted)} -div.wiki hr{border:0;border-top:1px solid var(--line);margin:8px 0} -div.wiki .mention{color:var(--accent)} +div.wiki code{background:var(--color-bg-hover);border:1px solid var(--color-border-default);border-radius:var(--radius-xs);padding:0 3px} +div.wiki pre{background:var(--color-bg-hover);border:1px solid var(--color-border-default);border-radius:var(--radius-sm);padding:6px 8px;margin:0 0 6px;overflow-x:auto;white-space:pre;overflow-wrap:normal} +div.wiki blockquote{margin:0 0 6px;padding:0 0 0 10px;border-left:3px solid var(--color-border-default);color:var(--color-text-secondary)} +div.wiki hr{border:0;border-top:1px solid var(--color-border-default);margin:8px 0} +div.wiki .mention{color:var(--color-action-primary)} pre.wiki-source{white-space:pre-wrap;font:inherit;margin:0} diff --git a/web/src/projects/NewProjectDialog.tsx b/web/src/projects/NewProjectDialog.tsx index 1c3b955..a215548 100644 --- a/web/src/projects/NewProjectDialog.tsx +++ b/web/src/projects/NewProjectDialog.tsx @@ -1,15 +1,14 @@ -import { useEffect, useRef, useState, type FormEvent } from "react"; +import { useState, type FormEvent } from "react"; import { ApiError } from "../api/client.ts"; import { useCreateProject } from "../api/queries.ts"; import type { Project } from "../api/types.ts"; +import { Button, Dialog, Field, Notice, TextInput } from "../ui/index.ts"; import "./projects.css"; -// окно «Новый проект» (как IssueModal): держит фокус; Esc и «Отмена» — onClose, ошибка хаба — рядом с «Создать» +// окно «Новый проект» (как IssueModal): держит фокус; Esc и «Отмена» — onClose, ошибка хаба — над кнопками export function NewProjectDialog({ onClose, onCreated }: { onClose(): void; onCreated(p: Project, batchId: number): void }) { - const ref = useRef(null); const [label, setLabel] = useState(""), [name, setName] = useState(""), [board, setBoard] = useState(""); const create = useCreateProject(); - useEffect(() => { const d = ref.current; if (d && !d.open) d.showModal(); }, []); const submit = (e: FormEvent) => { e.preventDefault(); @@ -21,21 +20,20 @@ export function NewProjectDialog({ onClose, onCreated }: { onClose(): void; onCr const text = err ? (err instanceof ApiError ? err.detail ?? err.message : err.message) : null; return ( - { e.preventDefault(); if (!create.isPending) onClose(); }}> -
-

Новый проект

- - - -
- - - {text && {text}} + { if (!create.isPending) onClose(); }}> + +

Новый проект

+ setLabel(e.target.value)} required /> + setName(e.target.value)} required /> + + setBoard(e.target.value)} placeholder="scrum или kanban определится сам" /> + + {text && {text}} +
+ +
-
+
); } diff --git a/web/src/projects/ProjectGate.tsx b/web/src/projects/ProjectGate.tsx index 3f6eb87..4c02fce 100644 --- a/web/src/projects/ProjectGate.tsx +++ b/web/src/projects/ProjectGate.tsx @@ -1,18 +1,21 @@ import type { ReactNode } from "react"; import type { Project } from "../api/types.ts"; +import { Button, Notice } from "../ui/index.ts"; import { useProjectContext } from "./ProjectContext.ts"; +import "./projects.css"; // страница доски/списка показывается, только когда текущий проект определён; иначе — причина и что делать export function ProjectGate({ children }: { children: (p: Project) => ReactNode }) { const { state, openNew } = useProjectContext(); switch (state.kind) { - case "loading": return

Загрузка проектов…

; - case "error": return

Проекты не загрузились: {state.error.message}

; + case "loading": return
Загрузка проектов…
; + case "error": return
Проекты не загрузились: {state.error.message}
; case "unknown": - return

проект {state.label} не найден

Выберите проект в списке «Проект» вверху.

; + return
+ Выберите проект в списке «Проект» вверху.
; case "none": - return

Проектов нет.

-
; + return
+ + Новый проект…} />
; case "ok": return <>{children(state.project)}; } } diff --git a/web/src/projects/projects.css b/web/src/projects/projects.css index 51cb6b9..8870ab4 100644 --- a/web/src/projects/projects.css +++ b/web/src/projects/projects.css @@ -1,10 +1,7 @@ -/* выбор проекта и окно «Новый проект» */ -.appbar .project-picker{max-width:280px;min-width:160px} -.project-gate{display:flex;flex-direction:column;gap:12px;align-items:flex-start;margin:24px 0} -.project-dialog{border:1px solid var(--line);border-radius:10px;background:var(--panel);color:var(--fg);padding:16px 20px;min-width:340px} -.project-dialog::backdrop{background:var(--color-overlay)} -.project-dialog form{display:flex;flex-direction:column;gap:10px} -.project-dialog h2{margin:0 0 4px;font-size:18px} -.project-dialog label{display:flex;flex-direction:column;gap:2px;font-size:13px;color:var(--muted)} -.project-dialog input{font:inherit;font-size:14px;height:32px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg);padding:0 8px} -.project-dialog .actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px} +/* выбор проекта, служебные состояния выбора и окно «Новый проект» */ +.appbar .project-picker { max-width: 280px; min-width: 160px; } +.project-gate { max-width: 640px; margin: var(--space-6) 0; } +.project-gate .ui-notice { align-items: center; } +.project-form { display: flex; flex-direction: column; gap: var(--space-4); } +.project-form__title { margin: 0; font-size: var(--fs-section); line-height: var(--lh-section); font-weight: 700; color: var(--color-text-primary); } +.project-form__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); } diff --git a/web/src/projects/projects.test.tsx b/web/src/projects/projects.test.tsx index 546e7b6..081d404 100644 --- a/web/src/projects/projects.test.tsx +++ b/web/src/projects/projects.test.tsx @@ -83,6 +83,7 @@ describe("проект в приложении", () => { const h = hub(); open("/app/issues?project=NOPE"); expect(await screen.findByText(/проект NOPE не найден/)).toBeInTheDocument(); + expect(screen.getByTestId("project-unknown")).toHaveTextContent("Выберите проект в списке «Проект» вверху."); expect(screen.getByRole("combobox", { name: "Проект" })).toBeInTheDocument(); expect(h.get("/issues")).toHaveLength(0); }); @@ -161,7 +162,7 @@ describe("проект в приложении", () => { await userEvent.click(within(dlg).getByRole("button", { name: "Создать" })); // инвалидация: повторный GET /projects падает await waitFor(() => expect(router.state.location.search).toBe("?project=STOCKS_DRRK")); expect(h.get("/projects").length).toBeGreaterThan(1); - expect(screen.getByRole("combobox", { name: "Проект" })).toHaveValue("STOCKS_DRRK"); + await waitFor(() => expect(screen.getByRole("combobox", { name: "Проект" })).toHaveValue("STOCKS_DRRK")); // O-4: адрес меняется раньше отрисовки expect(await screen.findByTestId("page-board")).toBeInTheDocument(); }); diff --git a/web/src/shell/AppBar.test.tsx b/web/src/shell/AppBar.test.tsx index 2e3ef54..65a1ae8 100644 --- a/web/src/shell/AppBar.test.tsx +++ b/web/src/shell/AppBar.test.tsx @@ -52,6 +52,19 @@ describe("верхняя панель", () => { expect(screen.getByRole("link", { name: "Доска связей" })).not.toHaveAttribute("aria-current"); }); + it("на странице «не найдено» ни один раздел не активен; ссылки в сообщении ведут на разделы", async () => { + hub(); + open("/app/nowhere?project=AWPCA"); + expect(await screen.findByRole("heading", { name: "Страница не найдена" })).toBeInTheDocument(); + const nav = within(bar()).getByRole("navigation", { name: "Раздел" }); + 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(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" }] }) }); diff --git a/web/src/shell/AppBar.tsx b/web/src/shell/AppBar.tsx index 6f69d4f..e624093 100644 --- a/web/src/shell/AppBar.tsx +++ b/web/src/shell/AppBar.tsx @@ -7,8 +7,8 @@ import { DataStatus } from "./DataStatus.tsx"; export type Section = "/board" | "/issues"; -// верхняя панель (design.md, решение 9): название, проект, разделы, состояние данных, тема, меню «Ещё» -export function AppBar({ section, project }: { section: Section; project: string | null }) { +// верхняя панель (design.md, решение 9); section = null на путях вне разделов — активной ссылки нет (T5.1): название, проект, разделы, состояние данных, тема, меню «Ещё» +export function AppBar({ section, project }: { section: Section | null; project: string | null }) { const qc = useQueryClient(); const href = (to: Section) => (project ? to + withProject("", project) : to); return ( diff --git a/web/src/shell/ErrorBoundary.tsx b/web/src/shell/ErrorBoundary.tsx index 0a8e2d8..13878d5 100644 --- a/web/src/shell/ErrorBoundary.tsx +++ b/web/src/shell/ErrorBoundary.tsx @@ -1,4 +1,5 @@ import { Component, type ErrorInfo, type ReactNode } from "react"; +import { Button, Notice } from "../ui/index.ts"; interface State { error: Error | null } @@ -10,10 +11,11 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, State> { render() { if (!this.state.error) return this.props.children; return ( -
-

Страница сломалась

-

{this.state.error.message}

- +
+ Страница сломалась} + action={}> + {this.state.error.message} +
); } diff --git a/web/src/shell/Layout.tsx b/web/src/shell/Layout.tsx index 3d8ee03..02e0219 100644 --- a/web/src/shell/Layout.tsx +++ b/web/src/shell/Layout.tsx @@ -70,8 +70,8 @@ export function Layout() { }, [okLabel, fromUrl, issueKey, onPage, at, pageLocation.search, navigate]); const [newOpen, setNewOpen] = useState(false); - const section = at === "/issues" ? "/issues" : "/board"; - const select = useCallback((label: string) => { void navigate({ pathname: section, search: withProject("", label) }); }, [navigate, section]); + const section = at === "/issues" || at === "/board" ? at : null; + const select = useCallback((label: string) => { void navigate({ pathname: section ?? "/board", search: withProject("", label) }); }, [navigate, section]); const ctx = useMemo(() => ({ state, projects: projects.data ?? [], select, openNew: () => setNewOpen(true) }), [state, projects.data, select]); @@ -79,7 +79,7 @@ export function Layout() { return ( -
+
{page}
{issueKey && } diff --git a/web/src/shell/NotFound.tsx b/web/src/shell/NotFound.tsx index 8a830de..b7805c4 100644 --- a/web/src/shell/NotFound.tsx +++ b/web/src/shell/NotFound.tsx @@ -1,10 +1,12 @@ import { Link } from "react-router"; +import { Notice } from "../ui/index.ts"; export function NotFound() { return (
-

Страница не найдена

-

Разделы приложения: доска, задачи.

+ Страница не найдена}> + Разделы приложения: Доска связей, Задачи. +
); } diff --git a/web/src/shell/shell.css b/web/src/shell/shell.css index 32371c8..de3e5ea 100644 --- a/web/src/shell/shell.css +++ b/web/src/shell/shell.css @@ -16,6 +16,7 @@ .data-status__event--warning{color:var(--color-status-warning)} .data-status__event--error{color:var(--color-status-error)} .crash{max-width:640px;margin:48px auto} +.crash h2{margin:0;font:inherit} @media (max-width:1099px){ .appbar .data-status{order:10;flex:1 0 100%} .appbar .appbar__spacer{flex:1 1 0} diff --git a/web/src/styles/base.css b/web/src/styles/base.css index 2ca943d..54a8a42 100644 --- a/web/src/styles/base.css +++ b/web/src/styles/base.css @@ -3,13 +3,8 @@ html,body{min-height:100%} body{font:var(--fs-body)/var(--lh-body) var(--font-ui);background:var(--color-bg-canvas);color:var(--color-text-primary);margin:0} h1{font-size:var(--fs-section);line-height:var(--lh-section);font-weight:700;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:24px;padding-block:0 32px;max-width:1440px;margin-inline:auto} .page--board{max-width:none} @media (max-width:639px){.page{padding-inline:16px}} -.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)} :focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:2px} @media (prefers-reduced-motion: reduce){*{transition:none!important;animation:none!important}} diff --git a/web/src/styles/controls.css b/web/src/styles/controls.css deleted file mode 100644 index a80ddc3..0000000 --- a/web/src/styles/controls.css +++ /dev/null @@ -1,35 +0,0 @@ -.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;padding:8px;display:flex;flex-direction:column;gap:6px} -.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} -.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:var(--color-text-on-primary);border-color:var(--p-crit)} - diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css index dc10dcd..4590601 100644 --- a/web/src/styles/tokens.css +++ b/web/src/styles/tokens.css @@ -43,7 +43,7 @@ --p-low: #a3a39b; --edge: #a3a39b; --guide: #c2410c; - --lane-alt: #f1f1ee; + --lane-alt: #f5f5f2; --edge-strong: var(--color-action-primary); --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; --space-5: 20px; --space-6: 24px; --space-7: 28px; --space-8: 32px; @@ -55,10 +55,6 @@ --lh-caption: 16px; --lh-body: 20px; --lh-lg: 24px; --lh-section: 28px; --control-h: 32px; --control-h-lg: 40px; --control-radius: 10px; --motion: 180ms ease; - /* Псевдонимы на переход: удаляются в Task 10 */ - --bg: var(--color-bg-canvas); --fg: var(--color-text-primary); --muted: var(--color-text-secondary); - --line: var(--color-border-default); --card: var(--color-bg-surface); --panel: var(--color-bg-surface); - --soft: var(--color-bg-hover); --accent: var(--color-action-primary); --accent-fg: var(--color-text-on-primary); } @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { @@ -104,7 +100,7 @@ --p-low: #6c726b; --edge: #6c726b; --guide: #f08a5a; - --lane-alt: #212121; + --lane-alt: #252525; --edge-strong: var(--color-action-primary); } } @@ -151,6 +147,6 @@ --p-low: #6c726b; --edge: #6c726b; --guide: #f08a5a; - --lane-alt: #212121; + --lane-alt: #252525; --edge-strong: var(--color-action-primary); } diff --git a/web/src/ui/Segmented.tsx b/web/src/ui/Segmented.tsx index 149afe4..fe46d09 100644 --- a/web/src/ui/Segmented.tsx +++ b/web/src/ui/Segmented.tsx @@ -3,7 +3,7 @@ import { Link } from "react-router"; type Option = { value: T; label: string; href?: string }; export function Segmented({ label, value, options, onChange }: { - label: string; value: T; options: Option[]; onChange?(v: T): void; + label: string; value: T | null; options: Option[]; onChange?(v: T): void; }) { if (options.some((o) => o.href)) { return ( diff --git a/web/src/ui/icons.tsx b/web/src/ui/icons.tsx index fc1d3a3..e6ad481 100644 --- a/web/src/ui/icons.tsx +++ b/web/src/ui/icons.tsx @@ -15,6 +15,7 @@ export const IconUpload = make(<>); export const IconClose = make(<>); export const IconChevronDown = make(<>); +export const IconChevronUp = make(<>); export const IconSearch = make(<>); export const IconFilter = make(<>); export const IconSun = make(<>); diff --git a/web/src/ui/ui.css b/web/src/ui/ui.css index 4fa6ec0..88d7933 100644 --- a/web/src/ui/ui.css +++ b/web/src/ui/ui.css @@ -98,6 +98,7 @@ .ui-notice__title { font-size: var(--fs-body); line-height: var(--lh-body); font-weight: 600; color: var(--color-text-primary); } .ui-notice__text { font-size: var(--fs-caption); line-height: var(--lh-caption); color: var(--color-text-secondary); } .ui-notice__action { flex: none; } +.ui-notice a { color: var(--color-action-primary); } .ui-notice--success { background: var(--color-status-success-bg); border-color: var(--color-status-success); } .ui-notice--success .ui-notice__icon { background: var(--color-status-success); } .ui-notice--error { background: var(--color-status-error-bg); border-color: var(--color-status-error); } @@ -170,11 +171,28 @@ .ui-dialog { width: 100vw; max-width: 100vw; height: 100vh; max-height: 100vh; margin: 0; border-radius: 0; } } -/* меню: пункты — кнопки; «выбрано» — цветом текста и галочкой */ +/* выпадающая панель и меню: пункты — кнопки; «выбрано» — цветом текста и галочкой */ +.dd { position: relative; display: inline-block; } .dd-menu { + position: absolute; top: calc(100% + var(--space-1)); left: 0; z-index: 20; min-width: 280px; max-width: 420px; + display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-2); background: var(--color-bg-surface); border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); box-shadow: var(--shadow-popover); color: var(--color-text-primary); font-family: var(--font-ui); } +.dd-menu.menu { right: 0; left: auto; min-width: 230px; gap: 0; padding: var(--space-1); } +.dd-list { max-height: 260px; overflow: auto; display: flex; flex-direction: column; } +.dd-item { + display: flex; align-items: center; gap: var(--space-2); padding: var(--space-1) var(--space-2); border-radius: var(--radius-sm); + font-size: var(--fs-body); line-height: var(--lh-body); cursor: pointer; +} +.dd-item:hover { background: var(--color-bg-hover); } +.dd-item input { flex: none; width: 16px; height: 16px; margin: 0; accent-color: var(--color-action-primary); cursor: pointer; } +.dd-item input:focus-visible { outline: 2px solid var(--color-focus-ring); outline-offset: 2px; } +.dd-item .n { margin-left: auto; font-size: var(--fs-caption); color: var(--color-text-secondary); font-variant-numeric: tabular-nums; } +.dd-item.none { color: var(--color-text-secondary); font-style: italic; } +.dd-item.zero { opacity: .5; } +.dd-empty { padding: var(--space-2); font-size: var(--fs-caption); line-height: var(--lh-caption); color: var(--color-text-secondary); } +.dd-foot { display: flex; gap: var(--space-2); justify-content: space-between; border-top: 1px solid var(--color-border-default); padding-top: var(--space-2); } .dd--start > .dd-menu.menu { left: 0; right: auto; } .menu button { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); width: 100%;