[]; onChange?(v: T): void;
+}) {
+ if (options.some((o) => o.href)) {
+ return (
+
+ );
+ }
+ return (
+
+ {options.map((o) => (
+
+ ))}
+
+ );
+}
diff --git a/web/src/ui/ThemeSwitch.tsx b/web/src/ui/ThemeSwitch.tsx
new file mode 100644
index 0000000..2a94ad5
--- /dev/null
+++ b/web/src/ui/ThemeSwitch.tsx
@@ -0,0 +1,19 @@
+import { useState } from "react";
+import { applyTheme, effectiveDark, readTheme, saveTheme, type Theme } from "../shell/theme.ts";
+import { IconMoon, IconSun } from "./icons.tsx";
+
+export function ThemeSwitch() {
+ const [theme, setTheme] = useState(readTheme);
+ const dark = effectiveDark(theme);
+ const toggle = () => {
+ const next: Theme = dark ? "light" : "dark";
+ saveTheme(next);
+ applyTheme(next);
+ setTheme(next);
+ };
+ return (
+
+ );
+}
diff --git a/web/src/ui/icons.tsx b/web/src/ui/icons.tsx
new file mode 100644
index 0000000..fc1d3a3
--- /dev/null
+++ b/web/src/ui/icons.tsx
@@ -0,0 +1,35 @@
+import type { ReactNode } from "react";
+
+// иконки дизайн-системы: React-SVG 24x24, штрих currentColor 1.5; без dangerouslySetInnerHTML
+export type IconProps = { size?: 16 | 20 };
+
+function make(children: ReactNode) {
+ return ({ size = 20 }: IconProps) => (
+
+ );
+}
+
+export const IconRefresh = make(<>>);
+export const IconUpload = make(<>>);
+export const IconMore = make(<>>);
+export const IconClose = make(<>>);
+export const IconChevronDown = make(<>>);
+export const IconSearch = make(<>>);
+export const IconFilter = make(<>>);
+export const IconSun = make(<>>);
+export const IconMoon = make(<>>);
+export const IconExternal = make(<>>);
+export const IconWarning = make(<>>);
+export const IconCheck = make(<>>);
+export const IconError = make(<>>);
+export const IconInfo = make(<>>);
+export const IconHandoff = make(<>>);
+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/index.ts b/web/src/ui/index.ts
new file mode 100644
index 0000000..f4b616f
--- /dev/null
+++ b/web/src/ui/index.ts
@@ -0,0 +1,10 @@
+import "./ui.css";
+
+export * from "./icons.tsx";
+export { Button, IconButton, type ButtonProps } from "./Button.tsx";
+export { Segmented } from "./Segmented.tsx";
+export { ThemeSwitch } from "./ThemeSwitch.tsx";
+export { Badge, Chip } from "./Badge.tsx";
+export { Notice } from "./Notice.tsx";
+export { Panel } from "./Panel.tsx";
+export { PageHeader } from "./PageHeader.tsx";
diff --git a/web/src/ui/ui.css b/web/src/ui/ui.css
new file mode 100644
index 0000000..d7a9340
--- /dev/null
+++ b/web/src/ui/ui.css
@@ -0,0 +1,120 @@
+/* базовые компоненты дизайн-системы AURA: только переменные из styles/tokens.css, веса 400/600/700 */
+.ui-btn, .ui-icon-btn, .ui-seg__item, .ui-theme, .ui-chip__x {
+ font-family: var(--font-ui); cursor: pointer;
+ transition: background var(--motion), border-color var(--motion), color var(--motion);
+}
+.ui-btn:focus-visible, .ui-icon-btn:focus-visible, .ui-seg__item:focus-visible, .ui-theme:focus-visible, .ui-chip__x:focus-visible {
+ outline: 2px solid var(--color-focus-ring); outline-offset: 2px;
+}
+.ui-btn {
+ display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2);
+ height: var(--control-h); padding: 0 var(--space-4); border: 1px solid transparent; border-radius: var(--radius-round);
+ font-size: var(--fs-body); line-height: var(--lh-body); font-weight: 600; white-space: nowrap;
+}
+.ui-btn--lg { height: var(--control-h-lg); padding: 0 var(--space-5); }
+.ui-btn--primary { background: var(--color-action-primary); color: var(--color-text-on-primary); }
+.ui-btn--primary:hover:not(:disabled) { background: var(--color-action-primary-hover); }
+.ui-btn--secondary { background: transparent; border-color: var(--color-border-strong); color: var(--color-text-primary); }
+.ui-btn--secondary:hover:not(:disabled) { border-color: var(--color-action-primary); }
+.ui-btn--secondary[aria-pressed="true"] {
+ background: var(--color-bg-selected); border-color: var(--color-action-primary); color: var(--color-action-primary);
+}
+.ui-btn--danger { background: var(--color-action-danger); color: var(--color-text-on-primary); }
+.ui-btn--danger:hover:not(:disabled) { background: var(--color-action-danger-hover); }
+.ui-btn--ghost { background: transparent; color: var(--color-action-primary); }
+.ui-btn--ghost:hover:not(:disabled) { background: var(--color-bg-hover); color: var(--color-action-primary-hover); }
+.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; }
+.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);
+ animation: ui-spin 800ms linear infinite;
+}
+@keyframes ui-spin { to { transform: rotate(360deg); } }
+@media (prefers-reduced-motion: reduce) { .ui-spinner { animation-duration: 2400ms; } }
+
+.ui-icon-btn {
+ display: inline-flex; align-items: center; justify-content: center; width: var(--control-h); height: var(--control-h);
+ padding: 0; border: 1px solid transparent; border-radius: var(--control-radius); background: transparent; color: var(--color-text-secondary);
+}
+.ui-icon-btn:hover:not(:disabled) { background: var(--color-bg-hover); border-color: var(--color-border-strong); color: var(--color-text-primary); }
+.ui-icon-btn:disabled { cursor: not-allowed; color: var(--color-text-disabled); }
+
+.ui-seg {
+ display: inline-flex; gap: var(--space-1); padding: var(--space-1); border-radius: var(--radius-round); background: var(--color-bg-track);
+}
+.ui-seg__item {
+ display: inline-flex; align-items: center; height: 24px; padding: 0 var(--space-4); border: 1px solid transparent;
+ border-radius: var(--radius-round); background: transparent; color: var(--color-text-secondary);
+ font-size: var(--fs-body); line-height: var(--lh-body); font-weight: 400; text-decoration: none; white-space: nowrap;
+}
+.ui-seg__item:hover { color: var(--color-text-primary); }
+.ui-seg__item[aria-checked="true"], .ui-seg__item[aria-current="page"] {
+ background: var(--color-bg-surface); border-color: var(--color-border-selected); color: var(--color-action-primary); font-weight: 600;
+}
+
+.ui-theme {
+ display: inline-flex; align-items: center; width: 44px; height: 24px; padding: 2px; border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius-round); background: var(--color-bg-track); color: var(--color-text-secondary);
+}
+.ui-theme__knob {
+ display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: var(--radius-round);
+ background: var(--color-bg-surface); box-shadow: inset 0 0 0 1px var(--color-border-default); transition: transform var(--motion);
+}
+.ui-theme[aria-checked="true"] { border-color: var(--color-border-selected); color: var(--color-action-primary); }
+.ui-theme[aria-checked="true"] .ui-theme__knob { transform: translateX(20px); }
+
+.ui-badge {
+ display: inline-flex; align-items: center; height: 20px; padding: 0 var(--space-2); border: 1px solid transparent;
+ border-radius: var(--radius-round); font-size: var(--fs-caption); line-height: var(--lh-caption); font-weight: 600; white-space: nowrap;
+}
+.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--info { background: var(--color-bg-subtle); border-color: var(--color-border-strong); color: var(--color-text-primary); }
+
+.ui-chip {
+ display: inline-flex; align-items: center; gap: var(--space-1); height: 24px; padding: 0 var(--space-1) 0 var(--space-3);
+ border: 1px solid var(--color-border-selected); border-radius: var(--radius-round); background: var(--color-bg-selected);
+ color: var(--color-action-primary); font-size: var(--fs-caption); line-height: var(--lh-caption); font-weight: 600;
+}
+.ui-chip__x {
+ display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; padding: 0; border: 0;
+ border-radius: var(--radius-round); background: transparent; color: inherit;
+}
+.ui-chip__x:hover { background: var(--color-bg-hover); }
+
+.ui-notice {
+ display: flex; align-items: flex-start; gap: var(--space-3); padding: var(--space-3) var(--space-4);
+ border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); background: var(--color-bg-subtle);
+}
+.ui-notice__icon {
+ display: inline-flex; flex: none; align-items: center; justify-content: center; width: 20px; height: 20px;
+ border-radius: var(--radius-round); color: var(--color-text-on-primary); background: var(--color-text-secondary);
+}
+.ui-notice__body { flex: 1; min-width: 0; }
+.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--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); }
+.ui-notice--error .ui-notice__icon { background: var(--color-status-error); }
+.ui-notice--warning { background: var(--color-status-warning-bg); border-color: var(--color-status-warning-border); }
+.ui-notice--warning .ui-notice__icon { background: var(--color-status-warning); }
+
+.ui-panel {
+ background: var(--color-bg-surface); border: 1px solid var(--color-border-default); border-radius: var(--radius-xl);
+}
+.ui-panel--md { padding: var(--space-4); }
+.ui-panel--lg { padding: var(--space-6); }
+.ui-panel--none { padding: 0; }
+
+.ui-page-header { display: flex; flex-direction: column; gap: var(--space-4); margin-bottom: var(--space-4); }
+.ui-page-header__row { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: var(--space-4); }
+.ui-page-header__text { min-width: 0; }
+.ui-page-header__title { margin: 0; font-size: var(--fs-section); line-height: var(--lh-section); font-weight: 700; color: var(--color-text-primary); }
+.ui-page-header__subtitle { margin: var(--space-1) 0 0; font-size: var(--fs-body); line-height: var(--lh-body); color: var(--color-text-secondary); }
+.ui-page-header__actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
diff --git a/web/src/ui/ui.test.tsx b/web/src/ui/ui.test.tsx
new file mode 100644
index 0000000..02ba47f
--- /dev/null
+++ b/web/src/ui/ui.test.tsx
@@ -0,0 +1,87 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { Button, Chip, IconButton, IconRefresh, Notice, Segmented, ThemeSwitch } from "./index.ts";
+
+function stubMatchMedia(dark: boolean) {
+ vi.stubGlobal("matchMedia", (q: string) => ({ matches: dark && q.includes("dark"), media: q, addEventListener() {}, removeEventListener() {} }));
+}
+
+describe("Button", () => {
+ it("loading: aria-busy, недоступна, иконка не показана", () => {
+ render(}>Обновить);
+ const b = screen.getByRole("button", { name: /Обновить/ });
+ expect(b.getAttribute("aria-busy")).toBe("true");
+ expect((b as HTMLButtonElement).disabled).toBe(true);
+ expect(b.querySelector("svg")).toBeNull();
+ expect(b.querySelector(".ui-spinner")).not.toBeNull();
+ });
+ it("по умолчанию type=button, aria-pressed пробрасывается", () => {
+ render();
+ const b = screen.getByRole("button", { name: "Фильтр" });
+ expect(b.getAttribute("type")).toBe("button");
+ expect(b.getAttribute("aria-pressed")).toBe("true");
+ expect(b.className).toContain("ui-btn--secondary");
+ });
+});
+
+describe("IconButton", () => {
+ it("доступное имя из label", () => {
+ render(} />);
+ expect(screen.getByRole("button", { name: "Закрыть" }).getAttribute("title")).toBe("Закрыть");
+ });
+});
+
+describe("Segmented", () => {
+ const options = [{ value: "a", label: "А" }, { value: "b", label: "Б" }];
+ it("кнопками: radiogroup, onChange, aria-checked", () => {
+ const onChange = vi.fn();
+ render();
+ expect(screen.getByRole("radiogroup", { name: "Режим" })).toBeTruthy();
+ expect(screen.getByRole("radio", { name: "А" }).getAttribute("aria-checked")).toBe("true");
+ expect(screen.getByRole("radio", { name: "Б" }).getAttribute("aria-checked")).toBe("false");
+ fireEvent.click(screen.getByRole("radio", { name: "Б" }));
+ expect(onChange).toHaveBeenCalledWith("b");
+ });
+ it("ссылками: nav, aria-current у активной", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByRole("navigation", { name: "Разделы" })).toBeTruthy();
+ expect(screen.getByRole("link", { name: "Б" }).getAttribute("aria-current")).toBe("page");
+ expect(screen.getByRole("link", { name: "А" }).hasAttribute("aria-current")).toBe(false);
+ });
+});
+
+describe("ThemeSwitch", () => {
+ afterEach(() => document.documentElement.removeAttribute("data-theme"));
+ it("системная тёмная -> checked; щелчки переключают light и dark", () => {
+ stubMatchMedia(true);
+ render();
+ const sw = screen.getByRole("switch", { name: "Тёмная тема" });
+ expect(sw.getAttribute("aria-checked")).toBe("true");
+ fireEvent.click(sw);
+ expect(localStorage.getItem("graph-theme")).toBe("light");
+ expect(document.documentElement.getAttribute("data-theme")).toBe("light");
+ expect(sw.getAttribute("aria-checked")).toBe("false");
+ fireEvent.click(sw);
+ expect(localStorage.getItem("graph-theme")).toBe("dark");
+ expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
+ });
+});
+
+describe("Chip и Notice", () => {
+ it("Chip: кнопка снятия вызывает onRemove", () => {
+ const onRemove = vi.fn();
+ render(Спринт);
+ fireEvent.click(screen.getByRole("button", { name: "Снять фильтр Спринт" }));
+ expect(onRemove).toHaveBeenCalledOnce();
+ });
+ it("Notice: error — alert, info — status", () => {
+ render(<>текстинфо>);
+ expect(screen.getByRole("alert").textContent).toContain("Сбой");
+ expect(screen.getByRole("status").textContent).toContain("инфо");
+ });
+});