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" : "")} />
- } aria-expanded={open} aria-controls="issue-filters-grid" onClick={toggle}>
+ } aria-expanded={open} aria-controls={open ? "issue-filters-grid" : undefined} onClick={toggle}>
Фильтры{chosen.length > 0 && {chosen.length}}
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 IconCalendar = 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); }