diff --git a/web/e2e/board.spec.ts b/web/e2e/board.spec.ts index 61c3c35..921b8bf 100644 --- a/web/e2e/board.spec.ts +++ b/web/e2e/board.spec.ts @@ -49,7 +49,7 @@ test("«Обновить из Jira» с учебным агентом доход await agentServeRefresh(); await expect(page.getByRole("status")).toContainText(/пакет #\d+ выполнен/, { timeout: 15_000 }); await expect(btn).toBeEnabled(); - await expect(page.getByText(/^Хаб · данные на /)).toBeVisible(); + await expect(page.getByText(/^данные на /)).toBeVisible(); await expect(page.locator(".board .card[data-key]")).not.toHaveCount(0); }); diff --git a/web/src/board/BoardPage.test.tsx b/web/src/board/BoardPage.test.tsx index bdce9f7..0cfeb27 100644 --- a/web/src/board/BoardPage.test.tsx +++ b/web/src/board/BoardPage.test.tsx @@ -39,7 +39,7 @@ describe("страница доски", () => { it("строит доску из хаба: источник, статистика, выбор самого наполненного спринта", async () => { hub(); page(); - expect(await screen.findByText(/^Хаб · данные на/)).toBeInTheDocument(); + expect(await screen.findByText(/задач из/)).toBeInTheDocument(); expect(screen.getByText(/задач из 9/)).toBeInTheDocument(); expect(screen.getByRole("button", { name: /AWPCA_Спринт 2/ })).toBeInTheDocument(); }); @@ -47,7 +47,7 @@ describe("страница доски", () => { it("снимок запрашивается по проекту страницы", async () => { const fetch = hub(); page(); - await screen.findByText(/^Хаб · данные на/); + await screen.findByText(/задач из/); const urls = fetch.mock.calls.map(([u]) => new URL(String(u), "http://hub.test")).filter((u) => u.pathname === "/board"); expect(urls[0].searchParams.get("project")).toBe("AWPCA"); }); @@ -120,7 +120,7 @@ describe("страница доски", () => { fireEvent.change(input, { target: { files: [new File([fx("jira-export.csv")], "jira-export.csv", { type: "text/csv" })] } }); expect(await screen.findByText("из файла: jira-export.csv")).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Вернуться к данным хаба" })); - expect(await screen.findByText(/^Хаб · данные на/)).toBeInTheDocument(); + expect(await screen.findByText(/задач из/)).toBeInTheDocument(); expect(screen.queryByText(/^из файла:/)).not.toBeInTheDocument(); }); @@ -171,7 +171,7 @@ describe("страница доски", () => { page(); await screen.findByText(/задач из 9/); expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument(); - expect(screen.getByText(/^Хаб · данные на/)).toBeInTheDocument(); + expect(screen.queryByText(/^Хаб · данные на/)).not.toBeInTheDocument(); // данные хаба пометки не имеют (спека, решение 13) expect(screen.queryByRole("status")).not.toBeInTheDocument(); }); @@ -184,7 +184,7 @@ describe("страница доски", () => { it("T12.3: --head-h снимается при уходе со страницы", async () => { hub(); const r = page(); - await screen.findByText(/^Хаб · данные на/); + await screen.findByText(/задач из/); r.unmount(); expect(document.documentElement.style.getPropertyValue("--head-h")).toBe(""); }); @@ -192,7 +192,7 @@ describe("страница доски", () => { it("T12.2: переключатель пишет флаг в хранилище один раз, в том числе в StrictMode", async () => { hub(); renderWithProviders(, { route: "/board", path: "/board" }); - await screen.findByText(/^Хаб · данные на/); + await screen.findByText(/задач из/); const set = vi.spyOn(Storage.prototype, "setItem"); fireEvent.click(screen.getByRole("button", { name: "Критический путь" })); expect(set.mock.calls.filter(([k]) => k === "graph-crit")).toEqual([["graph-crit", "1"]]); @@ -210,12 +210,12 @@ describe("страница доски", () => { hub(); localStorage.setItem("graph-pos:old.json", '{"AURORA-9001":{"x":1,"ry":40}}'); const r = page("STOCKS_DRRK"); - await screen.findByText(/^Хаб · данные на/); + await screen.findByText(/задач из/); expect(localStorage.getItem("graph-pos:board@STOCKS_DRRK")).toBeNull(); expect(localStorage.getItem("graph-pos:board")).toBeNull(); r.unmount(); page("AWPCA"); - await screen.findByText(/^Хаб · данные на/); + await screen.findByText(/задач из/); expect(localStorage.getItem("graph-pos:board")).toBe('{"AURORA-9001":{"x":1,"ry":40}}'); }); diff --git a/web/src/board/BoardPage.tsx b/web/src/board/BoardPage.tsx index ee9f1e6..e0295b6 100644 --- a/web/src/board/BoardPage.tsx +++ b/web/src/board/BoardPage.tsx @@ -42,7 +42,7 @@ export function BoardPage({ project }: { project: string }) { }, []); if (settings.isPending) return

Загрузка настроек хаба…

; - if (settings.error) return

Настройки хаба не загрузились: {settings.error.message}

; + if (settings.error) return
Настройки хаба не загрузились: {settings.error.message}
; if (d && d.missing.length) { return (
diff --git a/web/src/board/board.css b/web/src/board/board.css index a25a74c..f7ea935 100644 --- a/web/src/board/board.css +++ b/web/src/board/board.css @@ -7,7 +7,7 @@ .board-src:empty{display:none} .board-src__label{font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary);max-width:420px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .board-event{font-size:var(--fs-body);line-height:var(--lh-body);color:var(--color-text-secondary)} -.board-event--warn{color:var(--color-status-warning)} +.board-event--warn{color:color-mix(in srgb, var(--color-status-warning) 55%, var(--color-text-primary))} .menu__label{display:inline-flex;align-items:center;gap:var(--space-2)} .confirm{display:flex;flex-direction:column;gap:var(--space-2);padding:var(--space-2) var(--space-3);font-size:var(--fs-body);line-height:var(--lh-body);max-width:280px} .confirm__row{display:flex;gap:var(--space-2)} diff --git a/web/src/board/components/BoardHeader.tsx b/web/src/board/components/BoardHeader.tsx index c2d3fb6..50297f0 100644 --- a/web/src/board/components/BoardHeader.tsx +++ b/web/src/board/components/BoardHeader.tsx @@ -68,7 +68,7 @@ export function BoardHeader(p: BoardHeaderProps) { из файла: {p.fileName} } onClick={p.onReload} /> - ) : {p.label}} + ) : null} {/* данные хаба пометки не имеют: хаб показывает верхняя панель (design.md, решение 13) */} {st && {st.text}}
diff --git a/web/src/issues/IssueFilters.tsx b/web/src/issues/IssueFilters.tsx index a544973..65af002 100644 --- a/web/src/issues/IssueFilters.tsx +++ b/web/src/issues/IssueFilters.tsx @@ -32,7 +32,7 @@ export function IssueFilters({ filters, set, onReset, q, setQ, showClosed, opts, options={[{ value: "open", label: "Открытые" }, { value: "all", label: "Все" }]} onChange={(v) => set("closed", v === "all" ? "1" : "")} /> - diff --git a/web/src/main.tsx b/web/src/main.tsx index 8a7a891..7326e39 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -5,6 +5,7 @@ import "@fontsource/mulish/400.css"; import "@fontsource/mulish/600.css"; import "@fontsource/mulish/700.css"; import "@fontsource/ibm-plex-mono/500.css"; +import "@fontsource/ibm-plex-mono/600.css"; import { App } from "./App.tsx"; import { applyTheme, readTheme } from "./shell/theme.ts"; diff --git a/web/src/shell/DataStatus.tsx b/web/src/shell/DataStatus.tsx index 5d01fcc..dcc4457 100644 --- a/web/src/shell/DataStatus.tsx +++ b/web/src/shell/DataStatus.tsx @@ -25,7 +25,7 @@ export function DataStatus({ project }: { project: string | null }) { title="Поставить в хаб пакет «обновление по базовой JQL» и дождаться его: агент перечитает задачи из Jira"> Обновить из Jira - {rt && {rt.text}} + {rt?.text} ); } diff --git a/web/src/shell/refreshStatus.test.ts b/web/src/shell/refreshStatus.test.ts index fc520fe..a4ed1a8 100644 --- a/web/src/shell/refreshStatus.test.ts +++ b/web/src/shell/refreshStatus.test.ts @@ -4,7 +4,7 @@ import { refreshText } from "./refreshStatus.ts"; describe("refreshText", () => { it("пакет: офлайн, очередь, остаток, повтор", () => { expect(refreshText({ kind: "batch", id: 7, phase: "queued", remaining: 12, agentOffline: true, retrying: false })) - .toEqual({ text: "агент офлайн — пакет выполнится, когда он выйдет на связь; пакет #7 в очереди, осталось ~12 с…", tone: "info" }); + .toEqual({ text: "агент офлайн — пакет выполнится, когда он выйдет на связь; пакет #7 в очереди, осталось ~12 с…", tone: "warning" }); expect(refreshText({ kind: "batch", id: 7, phase: "running", remaining: 0, agentOffline: false, retrying: true })) .toEqual({ text: "пакет #7 выполняется… (хаб не отвечает, повтор через 3 с)", tone: "warning" }); }); diff --git a/web/src/shell/refreshStatus.ts b/web/src/shell/refreshStatus.ts index 5401f8f..629ee23 100644 --- a/web/src/shell/refreshStatus.ts +++ b/web/src/shell/refreshStatus.ts @@ -20,7 +20,7 @@ export function refreshText(e: RefreshEvent): { text: string; tone: RefreshTone const off = e.agentOffline ? "агент офлайн — пакет выполнится, когда он выйдет на связь; " : ""; const left = e.remaining ? `, осталось ~${e.remaining} с` : ""; const retry = e.retrying ? ` (хаб не отвечает, повтор через ${POLL_MS / 1000} с)` : ""; - return { text: `${off}пакет #${e.id} ${e.phase === "queued" ? "в очереди" : "выполняется"}${left}…${retry}`, tone: e.retrying ? "warning" : "info" }; + return { text: `${off}пакет #${e.id} ${e.phase === "queued" ? "в очереди" : "выполняется"}${left}…${retry}`, tone: e.retrying || e.agentOffline ? "warning" : "info" }; } case "batch-done": return { text: `пакет #${e.id} выполнен`, tone: "success" }; case "batch-failed": return { text: `пакет #${e.id} не выполнен: ${e.error}`, tone: "error" }; diff --git a/web/src/shell/shell.css b/web/src/shell/shell.css index 9bbd6ee..52b4e30 100644 --- a/web/src/shell/shell.css +++ b/web/src/shell/shell.css @@ -1,5 +1,6 @@ /* оболочка: верхняя панель разделов (design.md, решение 9) */ .appbar{ + position:relative; display:flex;align-items:center;flex-wrap:wrap;gap:8px 16px;min-height:64px;padding:8px 24px;box-sizing:border-box; border-bottom:1px solid var(--color-border-default);background:var(--color-bg-surface); } @@ -7,16 +8,21 @@ .appbar__spacer{flex:1 1 auto} .data-status{display:flex;align-items:center;flex-wrap:wrap;gap:4px 12px;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary)} .data-status__agent{display:inline-flex;align-items:center;gap:6px;white-space:nowrap} -.data-status__dot{width:8px;height:8px;border-radius:50%;background:var(--color-status-error)} +.data-status__dot{width:8px;height:8px;border-radius:50%;background:var(--color-status-warning)} .data-status__dot.is-online{background:var(--color-status-success)} .data-status__sync{white-space:nowrap} -.data-status__event{font-weight:600} +/* I-3: живой регион всегда в DOM; ≥1100 текст события — отдельной строкой у нижней границы панели (кнопка не двигается, высота панели не меняется) */ +.data-status__event{font-weight:600;min-width:0} +.data-status__event:empty{position:absolute} .data-status__event--info{color:var(--color-text-secondary)} .data-status__event--success{color:var(--color-status-success)} -.data-status__event--warning{color:var(--color-status-warning)} +.data-status__event--warning{color:color-mix(in srgb, var(--color-status-warning) 55%, var(--color-text-primary))} .data-status__event--error{color:var(--color-status-error)} .crash{max-width:640px;margin:48px auto} .crash h2{margin:0;font:inherit} +@media (min-width:1100px){ + .data-status__event{position:absolute;right:24px;bottom:1px;max-width:min(560px,45vw);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:14px} +} @media (max-width:1099px){ .appbar .data-status{order:10;flex:1 0 100%} .appbar .ui-theme,.appbar .more{order:4} /* T10.1: тема и «…» остаются в первой строке, статус данных — второй */ diff --git a/web/src/ui/icons.tsx b/web/src/ui/icons.tsx index e6ad481..eb25186 100644 --- a/web/src/ui/icons.tsx +++ b/web/src/ui/icons.tsx @@ -30,7 +30,5 @@ export const IconCritical = make(<>); export const IconNeighbors = make(<>); export const IconLink = make(<>); -export const IconGrid = make(<>); -export const IconReset = make(<>); export const IconLegend = make(<>); export const IconOpen = make(<>); diff --git a/web/src/ui/ui.css b/web/src/ui/ui.css index 7abe2a2..1dce05b 100644 --- a/web/src/ui/ui.css +++ b/web/src/ui/ui.css @@ -26,6 +26,14 @@ .ui-btn:disabled { cursor: not-allowed; color: var(--color-text-disabled); } .ui-btn--primary:disabled, .ui-btn--danger:disabled { background: var(--color-bg-track); border-color: var(--color-border-default); } .ui-btn[aria-busy="true"] { cursor: progress; } +/* загрузка: вид своего варианта, а не серая «выключенная» кнопка (AURA); повторный щелчок невозможен — disabled */ +.ui-btn--primary[aria-busy="true"]:disabled { background: var(--color-action-primary); border-color: transparent; color: var(--color-text-on-primary); } +.ui-btn--danger[aria-busy="true"]:disabled { background: var(--color-action-danger); border-color: transparent; color: var(--color-text-on-primary); } +.ui-btn--secondary[aria-busy="true"]:disabled { color: var(--color-text-primary); } +.ui-btn--ghost[aria-busy="true"]:disabled { color: var(--color-action-primary); } +.ui-btn--primary[aria-busy="true"] .ui-spinner, .ui-btn--danger[aria-busy="true"] .ui-spinner { + border-color: color-mix(in srgb, var(--color-text-on-primary) 35%, transparent); border-top-color: var(--color-text-on-primary); +} .ui-spinner { width: 16px; height: 16px; border-radius: var(--radius-round); border: 2px solid var(--color-border-strong); border-top-color: var(--color-action-primary); @@ -72,7 +80,7 @@ .ui-badge--neutral { background: var(--color-bg-track); border-color: var(--color-border-default); color: var(--color-text-secondary); } .ui-badge--success { background: var(--color-status-success-bg); border-color: var(--color-status-success); color: var(--color-status-success); } .ui-badge--error { background: var(--color-status-error-bg); border-color: var(--color-status-error); color: var(--color-status-error); } -.ui-badge--warning { background: var(--color-status-warning-bg); border-color: var(--color-status-warning-border); color: var(--color-status-warning); } +.ui-badge--warning { background: var(--color-status-warning-bg); border-color: var(--color-status-warning-border); color: color-mix(in srgb, var(--color-status-warning) 55%, var(--color-text-primary)); } .ui-badge--info { background: var(--color-bg-subtle); border-color: var(--color-border-strong); color: var(--color-text-primary); } .ui-chip { @@ -99,6 +107,7 @@ .ui-notice__text { font-size: var(--fs-caption); line-height: var(--lh-caption); color: var(--color-text-primary); } .ui-notice__action { flex: none; } .ui-notice a { color: var(--color-action-primary); } +.ui-notice--warning a { color: var(--color-text-primary); text-decoration: underline; } .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); }