style(web): вход, новый проект, служебные экраны; старые стили убраны (#65)

Экраны входа, «Новый проект», ProjectGate, «не найдено» и ошибка отрисовки
переведены на компоненты системы. controls.css удалён, псевдонимы токенов
убраны, глобальные .row/.spacer/.hint/.warn/.login разобраны. Разделы в
верхней панели не активны вне доски и задач. Тонкая настройка дорожек доски,
тест на ошибку списка, стабилизирован тест проектов.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 01:19:09 +04:00
parent 047835b223
commit 2a472cacbf
29 changed files with 163 additions and 137 deletions

View File

@ -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`.

View File

@ -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(<App router={at("/app/nope")} />);
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(<App router={router} />);
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); }
});

View File

@ -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<typeof createBrowserRouter>;

View File

@ -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();
});

View File

@ -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 (
<div className="login">
<h2>Вход в хаб</h2>
<p>Приложению нужен клиентский токен хаба. Его показывает команда <code>uv run jira-hub tokens</code> в каталоге
jira-hub на этом Mac. Токен сохранится в этом браузере.</p>
{error && <p className="err" role="alert">{error}</p>}
<form onSubmit={submit}>
<input type="password" autoComplete="off" spellCheck={false} aria-label="Клиентский токен хаба"
placeholder="клиентский токен" value={value} onChange={(e) => setValue(e.target.value)} />
<button className="btn" type="submit" disabled={busy}>Войти</button>
</form>
<Panel padding="lg" className="login__panel">
<h2 className="login__title">Вход в хаб</h2>
<p className="login__text">Приложению нужен клиентский токен хаба. Его показывает команда <code>uv run jira-hub tokens</code> в каталоге
jira-hub на этом Mac. Токен сохранится в этом браузере.</p>
{error && <Notice tone="error">{error}</Notice>}
<form className="login__form" onSubmit={submit}>
<Field label="Клиентский токен хаба">
<TextInput size="lg" type="password" autoComplete="off" spellCheck={false}
placeholder="клиентский токен" value={value} onChange={(e) => setValue(e.target.value)} />
</Field>
<Button variant="primary" size="lg" type="submit" loading={busy}>Войти</Button>
</form>
</Panel>
</div>
);
}

9
web/src/auth/login.css Normal file
View File

@ -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); } }

View File

@ -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}}

View File

@ -10,8 +10,8 @@ export function ExportBox({ text, onClose }: { text: string; onClose(): void })
let ta: HTMLTextAreaElement | null = null;
return (
<Panel className="export">
<div className="row">
<span className="hint">JSON доски: все задачи, связи, раскладка, дорожки и фильтры. Загружается обратно пунктом «Загрузить файл…» в меню «Ещё». Если файл не скачался, скопируйте текст.</span>
<div className="export__row">
<span className="export__hint">JSON доски: все задачи, связи, раскладка, дорожки и фильтры. Загружается обратно пунктом «Загрузить файл…» в меню «Ещё». Если файл не скачался, скопируйте текст.</span>
<Button onClick={() => void copy(ta)}>{copied ? "Скопировано" : "Копировать"}</Button>
<Button onClick={onClose}>Закрыть</Button>
</div>

View File

@ -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
<span ref={nameRef} className="lane-name" title="Перетащить, чтобы поменять дорожки местами" onPointerDown={down}>{g.name}</span>
<span className="lane-count">{plural(count, "задача", "задачи", "задач")}</span>
<span className="lane-tools">
<button type="button" className="up" title="Выше" disabled={index === 0} onClick={() => onMove(g.name, index - 1)}><IconChevronDown size={16} /></button>
<button type="button" title="Выше" disabled={index === 0} onClick={() => onMove(g.name, index - 1)}><IconChevronUp size={16} /></button>
<button type="button" title="Ниже" disabled={index === last} onClick={() => onMove(g.name, index + 1)}><IconChevronDown size={16} /></button>
</span>
</div>

View File

@ -19,7 +19,7 @@ export function IssueFilters({ filters, set, onReset, q, setQ, showClosed, opts,
q: string; setQ(v: string): void; showClosed: boolean; opts: Record<Name, Opt[]>; 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 (

View File

@ -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(<IssuesPage project="AWPCA" />, { 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(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });

View File

@ -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)}

View File

@ -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}

View File

@ -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): <dialog> держит фокус; Esc и «Отмена» — onClose, ошибка хаба — рядом с «Создать»
// окно «Новый проект» (как IssueModal): <dialog> держит фокус; Esc и «Отмена» — onClose, ошибка хаба — над кнопками
export function NewProjectDialog({ onClose, onCreated }: { onClose(): void; onCreated(p: Project, batchId: number): void }) {
const ref = useRef<HTMLDialogElement>(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 (
<dialog ref={ref} className="project-dialog" aria-label="Новый проект"
onCancel={(e) => { e.preventDefault(); if (!create.isPending) onClose(); }}>
<form onSubmit={submit}>
<h2>Новый проект</h2>
<label>Метка<input value={label} onChange={(e) => setLabel(e.target.value)} required /></label>
<label>Название<input value={name} onChange={(e) => setName(e.target.value)} required /></label>
<label>Номер доски Jira
<input type="number" min={1} value={board} onChange={(e) => setBoard(e.target.value)} placeholder="scrum или kanban определится сам" />
</label>
<div className="actions">
<button className="btn" type="submit" disabled={create.isPending}>Создать</button>
<button className="btn" type="button" onClick={onClose} disabled={create.isPending}>Отмена</button>
{text && <span className="warn" role="alert">{text}</span>}
<Dialog size="sm" label="Новый проект" className="project-dialog" onClose={() => { if (!create.isPending) onClose(); }}>
<form className="project-form" onSubmit={submit}>
<h2 className="project-form__title">Новый проект</h2>
<Field label="Метка"><TextInput value={label} onChange={(e) => setLabel(e.target.value)} required /></Field>
<Field label="Название"><TextInput value={name} onChange={(e) => setName(e.target.value)} required /></Field>
<Field label="Номер доски Jira">
<TextInput type="number" min={1} value={board} onChange={(e) => setBoard(e.target.value)} placeholder="scrum или kanban определится сам" />
</Field>
{text && <Notice tone="error">{text}</Notice>}
<div className="project-form__actions">
<Button variant="secondary" disabled={create.isPending} onClick={onClose}>Отмена</Button>
<Button variant="primary" type="submit" loading={create.isPending}>Создать</Button>
</div>
</form>
</dialog>
</Dialog>
);
}

View File

@ -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 <p className="hint">Загрузка проектов…</p>;
case "error": return <p className="warn" role="alert">Проекты не загрузились: {state.error.message}</p>;
case "loading": return <div className="project-gate"><Notice tone="info">Загрузка проектов…</Notice></div>;
case "error": return <div className="project-gate"><Notice tone="error">Проекты не загрузились: {state.error.message}</Notice></div>;
case "unknown":
return <div className="project-gate" data-testid="project-unknown"><p className="warn">проект {state.label} не найден</p><p className="hint">Выберите проект в списке «Проект» вверху.</p></div>;
return <div className="project-gate" data-testid="project-unknown">
<Notice tone="warning" title={`проект ${state.label} не найден`}>Выберите проект в списке «Проект» вверху.</Notice></div>;
case "none":
return <div className="project-gate"><p className="hint">Проектов нет.</p>
<button className="btn" type="button" onClick={openNew}>+ Новый проект…</button></div>;
return <div className="project-gate">
<Notice tone="info" title="Проектов нет." action={<Button variant="primary" onClick={openNew}>+ Новый проект…</Button>} /></div>;
case "ok": return <>{children(state.project)}</>;
}
}

View File

@ -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); }

View File

@ -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();
});

View File

@ -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" }] }) });

View File

@ -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 (

View File

@ -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 (
<div className="crash" role="alert">
<h2>Страница сломалась</h2>
<p>{this.state.error.message}</p>
<button className="btn" type="button" onClick={() => window.location.reload()}>Перезагрузить</button>
<div className="crash">
<Notice tone="error" title={<h2>Страница сломалась</h2>}
action={<Button variant="primary" onClick={() => window.location.reload()}>Перезагрузить</Button>}>
{this.state.error.message}
</Notice>
</div>
);
}

View File

@ -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<ProjectCtx>(() => ({ state, projects: projects.data ?? [], select, openNew: () => setNewOpen(true) }),
[state, projects.data, select]);
@ -79,7 +79,7 @@ export function Layout() {
return (
<ProjectContext value={ctx}>
<AppBar section={section} project={okLabel} />
<main className={"page" + (section === "/board" ? " page--board" : "")}>
<main className={"page" + (section !== "/issues" ? " page--board" : "")}>
<ErrorBoundary key={at}>{page}</ErrorBoundary>
</main>
{issueKey && <CloseAbortContext value={abortClose}><IssueModal issueKey={issueKey} onClose={close} /></CloseAbortContext>}

View File

@ -1,10 +1,12 @@
import { Link } from "react-router";
import { Notice } from "../ui/index.ts";
export function NotFound() {
return (
<div className="crash">
<h2>Страница не найдена</h2>
<p>Разделы приложения: <Link to="/board">доска</Link>, <Link to="/issues">задачи</Link>.</p>
<Notice tone="warning" title={<h2>Страница не найдена</h2>}>
Разделы приложения: <Link to="/board">Доска связей</Link>, <Link to="/issues">Задачи</Link>.
</Notice>
</div>
);
}

View File

@ -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}

View File

@ -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}}

View File

@ -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)}

View File

@ -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);
}

View File

@ -3,7 +3,7 @@ import { Link } from "react-router";
type Option<T extends string> = { value: T; label: string; href?: string };
export function Segmented<T extends string>({ label, value, options, onChange }: {
label: string; value: T; options: Option<T>[]; onChange?(v: T): void;
label: string; value: T | null; options: Option<T>[]; onChange?(v: T): void;
}) {
if (options.some((o) => o.href)) {
return (

View File

@ -15,6 +15,7 @@ export const IconUpload = make(<><path d="M12 16V4"/><path d="M7 9l5-5 5 5"/><pa
export const IconMore = make(<><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></>);
export const IconClose = make(<><path d="M6 6l12 12M18 6L6 18"/></>);
export const IconChevronDown = make(<><path d="M6 9l6 6 6-6"/></>);
export const IconChevronUp = make(<><path d="M6 15l6-6 6 6"/></>);
export const IconSearch = make(<><circle cx="11" cy="11" r="6.5"/><path d="M20 20l-4.2-4.2"/></>);
export const IconFilter = make(<><path d="M4 5h16l-6 8v6l-4-2v-4z"/></>);
export const IconSun = make(<><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></>);

View File

@ -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%;