{isBest && (
-
+
★ ЛУЧШИЙ
)}
@@ -130,10 +130,10 @@ function RouteOptimizerWanMatrixDataGrid({
className={cn(
"font-mono text-xs font-semibold",
leg.pingMs < 10
- ? "text-emerald-600 dark:text-emerald-400"
+ ? "text-success "
: leg.pingMs < 25
? "text-foreground"
- : "text-amber-600 dark:text-amber-400",
+ : "text-warning ",
)}
>
{leg.pingMs} мс
diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md
index 96c7fd6d..23a0e25a 100644
--- a/docs/ux-ui-plan.md
+++ b/docs/ux-ui-plan.md
@@ -171,3 +171,13 @@ HTTP-фикстуры; локальная приёмка: 43 основных +
| [Sheet 8](https://reui.io/preview/base/sheet-8?ref=mcp) | [Форма](ux-ui/screens/recursive-routes-form-desktop.png) | [390 px](ux-ui/screens/recursive-routes-form-mobile.png), [320 px](ux-ui/screens/recursive-routes-form-narrow.png) | Полный снимок → параметры → подтверждение API → возврат фокуса |
48 основных + 8 source E2E, backend recursive regression и production frontend прошли. HTTP-фикстуры; RouterOS/DB/JWT остаются внешней приёмкой. Далее оптимизатор по журналу.
+
+## Оптимизатор — визуальная проверка 2026-09-30
+
+| Экран / референс | ПК | Телефон | Схема |
+| --- | --- | --- | --- |
+| Сводка · [Stats 12](https://reui.io/preview/base/stats-12?ref=mcp) / Frame | [Снимок](ux-ui/screens/route-optimizer-overview-desktop.png) | [390 px](ux-ui/screens/route-optimizer-overview-mobile.png), [320 px](ux-ui/screens/route-optimizer-overview-narrow.png) | Источник → объяснение модели → сводка → OSPF |
+| Настройки · [Settings 16](https://reui.io/preview/base/settings-16?ref=mcp) | [Снимок](ux-ui/screens/route-optimizer-settings-desktop.png) | [390 px](ux-ui/screens/route-optimizer-settings-mobile.png), [320 px](ux-ui/screens/route-optimizer-settings-narrow.png) | Пороги → вес → локальный пересчёт |
+| Community · [Data Grid Filtering 2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | [Снимок](ux-ui/screens/route-optimizer-community-desktop.png) | [390 px](ux-ui/screens/route-optimizer-community-mobile.png), [320 px](ux-ui/screens/route-optimizer-community-narrow.png) | Устройство → базовый расчёт → модельная рекомендация → закрепление |
+
+35 E2E, TS/lint и production frontend прошли на HTTP-фикстурах. Это проверка layout; whole optimizer acceptance остаётся открытой по model provenance и URL/source-контексту. Точное продолжение — в журнале.
diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md
index d4633ba0..40e073ee 100644
--- a/docs/ux-ui-progress.md
+++ b/docs/ux-ui-progress.md
@@ -26,11 +26,18 @@
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
-| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.33; backend проверяет cost/план OSPF, далее layout/контекст |
+| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.34; адаптивность оптимизатора проверена, далее достоверность индексов/контекст |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
+## Блок 10.34 — адаптивность и доступность оптимизатора (2026-09-30)
+
+- KPI читаются на 320/390 px: одна колонка на телефоне, две/четыре при достаточной ширине контейнера. Визуальная проверка выявила скрытые KPI внутри native flex-fieldset; внутренний flex-контейнер восстановил layout без изменения общего KpiStatGrid.
+- Настройки/вкладки — настоящие кнопки с keyboard/ARIA; числовые поля, устройство и ECMP названы, отключённый ECMP блокирует range и алгоритмы. Панели/toolbar переносятся, таблицы прокручиваются внутри своей оболочки. Убрана неподтверждённая команда ECMP; сценарий явно черновой. Семантические токены и декоративные иконки исправлены в четырёх grid consumers. Vendor/theme не менялись.
+- Девять снимков 1440/390/320 px сохранены в плане и просмотрены. ReUI checklist выполнен в границах consumer-композиции. 35 E2E оптимизатора, TypeScript, профильный lint и production frontend прошли. В одном readonly E2E при teardown запрос catalog вышел из HTTP interception и получил ECONNREFUSED локального backend; тесты зелёные, реальный backend не участвовал.
+- Оптимизатор целиком ещё не принят: model fallback/clamp и URL/source-контекст требуют отдельного блока; затем карта/терминал. Реальная RouterOS/БД проверка остаётся внешней. Следующий шаг: отсутствующий индекс Community не подменять нулём, затем восстановление контекста из URL.
+
## Блок 10.33 — достоверность cost и плана OSPF (2026-09-30)
- Оптимизатор использует отдельный observed selector шаблонов: disabled/id/interface/currentCost должны быть достоверны. Cost не подставляется как 10, disabled не превращается в false. Missing/unsupported cost (включая 0 вне текущей write-policy 1–65535), дубликаты id и несколько активных шаблонов одного интерфейса отклоняются. Известные disabled и reference-интерфейсы сохраняют прежнее исключение из оптимизации.
diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md
index 14707d66..f9b05052 100644
--- a/docs/ux-ui-stage-10.md
+++ b/docs/ux-ui-stage-10.md
@@ -184,3 +184,7 @@ Strict receipt/server/weight, ref lock, readonly/disabled контекст и re
## 10.33. Backend cost и валидация плана
Observed selector вместо default cost/disabled; неоднозначные шаблоны отклоняются до write. Полный план валидируется, disabled сервер 409. DI HTTP/unit без RouterOS/DB, live-maps regression/backend build прошли; lint два прежних unused warning. Ранжирование прежнее, model fallback/clamp ещё требуют отдельного отображения. Далее layout/контекст.
+
+## 10.34. Адаптивность оптимизатора
+
+1440/390/320 px, keyboard/ARIA и disabled ECMP; native fieldset композиция исправлена после визуального обнаружения невидимых KPI. Семантические токены четырёх таблиц, девять снимков в плане; 35 E2E, TS/lint и production frontend прошли. Teardown catalog отказ backend записан в журнале. URL/source и model provenance ещё открыты.
diff --git a/docs/ux-ui/screens/route-optimizer-community-desktop.png b/docs/ux-ui/screens/route-optimizer-community-desktop.png
new file mode 100644
index 00000000..9ca7776a
Binary files /dev/null and b/docs/ux-ui/screens/route-optimizer-community-desktop.png differ
diff --git a/docs/ux-ui/screens/route-optimizer-community-mobile.png b/docs/ux-ui/screens/route-optimizer-community-mobile.png
new file mode 100644
index 00000000..d93e59e9
Binary files /dev/null and b/docs/ux-ui/screens/route-optimizer-community-mobile.png differ
diff --git a/docs/ux-ui/screens/route-optimizer-community-narrow.png b/docs/ux-ui/screens/route-optimizer-community-narrow.png
new file mode 100644
index 00000000..af15de37
Binary files /dev/null and b/docs/ux-ui/screens/route-optimizer-community-narrow.png differ
diff --git a/docs/ux-ui/screens/route-optimizer-overview-desktop.png b/docs/ux-ui/screens/route-optimizer-overview-desktop.png
new file mode 100644
index 00000000..4b882bb5
Binary files /dev/null and b/docs/ux-ui/screens/route-optimizer-overview-desktop.png differ
diff --git a/docs/ux-ui/screens/route-optimizer-overview-mobile.png b/docs/ux-ui/screens/route-optimizer-overview-mobile.png
new file mode 100644
index 00000000..a9bd8436
Binary files /dev/null and b/docs/ux-ui/screens/route-optimizer-overview-mobile.png differ
diff --git a/docs/ux-ui/screens/route-optimizer-overview-narrow.png b/docs/ux-ui/screens/route-optimizer-overview-narrow.png
new file mode 100644
index 00000000..73279596
Binary files /dev/null and b/docs/ux-ui/screens/route-optimizer-overview-narrow.png differ
diff --git a/docs/ux-ui/screens/route-optimizer-settings-desktop.png b/docs/ux-ui/screens/route-optimizer-settings-desktop.png
new file mode 100644
index 00000000..8c21c86e
Binary files /dev/null and b/docs/ux-ui/screens/route-optimizer-settings-desktop.png differ
diff --git a/docs/ux-ui/screens/route-optimizer-settings-mobile.png b/docs/ux-ui/screens/route-optimizer-settings-mobile.png
new file mode 100644
index 00000000..a69e9992
Binary files /dev/null and b/docs/ux-ui/screens/route-optimizer-settings-mobile.png differ
diff --git a/docs/ux-ui/screens/route-optimizer-settings-narrow.png b/docs/ux-ui/screens/route-optimizer-settings-narrow.png
new file mode 100644
index 00000000..52d087b3
Binary files /dev/null and b/docs/ux-ui/screens/route-optimizer-settings-narrow.png differ
diff --git a/tests/e2e/route-optimizer-layout.spec.ts b/tests/e2e/route-optimizer-layout.spec.ts
new file mode 100644
index 00000000..74935536
--- /dev/null
+++ b/tests/e2e/route-optimizer-layout.spec.ts
@@ -0,0 +1,28 @@
+import {test,expect} from "@playwright/test"
+import {mkdirSync} from "node:fs"
+for(const [device,width,height] of [["desktop",1440,1000],["mobile",390,844],["narrow",320,700]] as const)test("Оптимизатор: layout/keyboard "+device,async({page})=>{
+ await page.setViewportSize({width,height});await page.emulateMedia({reducedMotion:"reduce"})
+ const item={id:"i",interface:"ether1",currentCost:10,optimalCost:20,score:50,pingMs:0,dlMbps:0,ulMbps:0}
+ await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
+ await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==="/api/auth/config"?{required:false}:p==="/api/servers"?["home-router","jump-host","exit-node"].map((type,index)=>({id:index+1,name:"Роутер "+(index+1),host:"192.0.2."+(index+1),country:"RU",type,enabled:true,status:null,latency:null,...(index===0?{wanUplinks:[{id:"w",name:"WAN1",iface:"ether1",ip:"198.51.100.1",isp:"ISP",maxDl:100,maxUl:100}]}:{})})):p==="/api/filters/rules"?{rulesets:[{serverId:"1",rules:[{id:"r",community:"65001:1",communityName:"Сервис",gateway:"192.0.2.2",gatewayTunnelId:"",description:""}]}]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/ospf/catalog"?{interfaces:[],instances:[],neighbors:[],bfdSessions:[],routes:[],failures:[]}:p.endsWith("/preview")?{serverId:1,serverName:"Роутер 1",pingWeight:60,interfacesTotal:1,changedCount:1,unchangedCount:0,interfaces:[item],changes:[item]}:{}})})
+ await page.goto("/route-optimizer")
+ await expect(page.getByRole("table").filter({hasText:"Speed (dl/ul)"}).locator("tr[data-row-id]")).toHaveCount(1)
+ expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true)
+ await expect(page.getByLabel("Сводка оптимизатора").getByText("Home",{exact:true})).toBeVisible();
+ await page.addStyleTag({content:"nextjs-portal {display:none!important}"});mkdirSync("docs/ux-ui/screens",{recursive:true})
+ await page.screenshot({path:"docs/ux-ui/screens/route-optimizer-overview-"+device+".png",animations:"disabled"})
+ const settings=page.getByRole("button",{name:"Настройки оптимизатора",exact:true})
+ await settings.focus();await page.keyboard.press("Enter");await expect(settings).toHaveAttribute("aria-expanded","true")
+ await expect(page.getByRole("spinbutton",{name:"Вес задержки",exact:true})).toBeVisible()
+ expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true)
+ await page.getByRole("spinbutton",{name:"Вес задержки",exact:true}).scrollIntoViewIfNeeded();
+ await page.screenshot({path:"docs/ux-ui/screens/route-optimizer-settings-"+device+".png",animations:"disabled"})
+ await settings.focus();await page.keyboard.press(" ");await expect(settings).toHaveAttribute("aria-expanded","false")
+ const slider=page.getByRole("slider",{name:"Максимальное число путей ECMP",exact:true});await expect(slider).toBeDisabled()
+ const toggle=page.getByRole("switch",{name:"Черновик ECMP",exact:true});await toggle.focus();await page.keyboard.press(" ");await expect(toggle).toHaveAttribute("aria-checked","true");await expect(slider).toBeEnabled()
+ const community=page.getByRole("button",{name:"BGP community (1)",exact:true});await community.focus();await page.keyboard.press("Enter");await expect(community).toHaveAttribute("aria-pressed","true")
+ const grid=page.getByRole("table").filter({hasText:"Community"});await expect(grid.locator("tr[data-row-id]")).toHaveCount(1)
+ expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true)
+ await grid.scrollIntoViewIfNeeded();
+ await page.screenshot({path:"docs/ux-ui/screens/route-optimizer-community-"+device+".png",animations:"disabled"})
+})