-
-
-
- {tabs.length > 1 ? (
-
- ) : null}
-
-
-
-
-
+
+ {/* Design «VPS-Tracker»: settings sections as page line tabs. */}
+ {tabs.length > 1 ? (
+
+
+ {tabs.map((tab) => (
+ }
+ className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
+ >
+ {tab.icon}
+ {tab.label}
+
+ ))}
+
+
+ ) : null}
+
+
)
diff --git a/apps/web/src/lib/page-crumb.ts b/apps/web/src/lib/page-crumb.ts
new file mode 100644
index 0000000..a732ced
--- /dev/null
+++ b/apps/web/src/lib/page-crumb.ts
@@ -0,0 +1,13 @@
+import { createContext, useContext, useEffect } from 'react'
+
+/** Setter for the current page breadcrumb (detail routes: «VPS / 203.0.113.21»). */
+export const PageCrumbContext = createContext<(title: string | null) => void>(() => {})
+
+/** Detail pages call this; AppShell shows `section / title` while the page is mounted. */
+export function usePageCrumb(title: string | null | undefined) {
+ const setTitle = useContext(PageCrumbContext)
+ useEffect(() => {
+ setTitle(title || null)
+ return () => setTitle(null)
+ }, [setTitle, title])
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 1f93d91..461d7e4 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -5,6 +5,7 @@ import { RouterProvider } from '@tanstack/react-router'
import { Toaster } from '@cfdm/ui/components/sonner'
import '@cfdm/ui/globals.css'
+import '@/styles/theme.css'
import { queryClient } from '@/lib/queryClient'
import { createRouter } from '@/lib/router'
diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts
index 46dad30..6d6694f 100644
--- a/apps/web/src/routeTree.gen.ts
+++ b/apps/web/src/routeTree.gen.ts
@@ -31,11 +31,11 @@ import { Route as AuthAuditRouteImport } from './routes/_auth/audit'
import { Route as AuthAccountsRouteImport } from './routes/_auth/accounts'
import { Route as AuthSettingsRouteRouteImport } from './routes/_auth/settings/route'
import { Route as AuthSettingsIndexRouteImport } from './routes/_auth/settings/index'
-import { Route as AuthVpsVpsIdRouteImport } from './routes/_auth/vps.$vpsId'
+import { Route as AuthVpsVpsIdRouteImport } from './routes/_auth/vps_.$vpsId'
import { Route as AuthSettingsSyncRouteImport } from './routes/_auth/settings/sync'
import { Route as AuthSettingsNotificationsRouteImport } from './routes/_auth/settings/notifications'
import { Route as AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations'
-import { Route as AuthProjectsProjectIdRouteImport } from './routes/_auth/projects.$projectId'
+import { Route as AuthProjectsProjectIdRouteImport } from './routes/_auth/projects_.$projectId'
const AuthRoute = AuthRouteImport.update({
id: '/_auth',
@@ -147,9 +147,9 @@ const AuthSettingsIndexRoute = AuthSettingsIndexRouteImport.update({
getParentRoute: () => AuthSettingsRouteRoute,
} as any)
const AuthVpsVpsIdRoute = AuthVpsVpsIdRouteImport.update({
- id: '/$vpsId',
- path: '/$vpsId',
- getParentRoute: () => AuthVpsRoute,
+ id: '/vps_/$vpsId',
+ path: '/vps/$vpsId',
+ getParentRoute: () => AuthRoute,
} as any)
const AuthSettingsSyncRoute = AuthSettingsSyncRouteImport.update({
id: '/sync',
@@ -169,9 +169,9 @@ const AuthSettingsIntegrationsRoute =
getParentRoute: () => AuthSettingsRouteRoute,
} as any)
const AuthProjectsProjectIdRoute = AuthProjectsProjectIdRouteImport.update({
- id: '/$projectId',
- path: '/$projectId',
- getParentRoute: () => AuthProjectsRoute,
+ id: '/projects_/$projectId',
+ path: '/projects/$projectId',
+ getParentRoute: () => AuthRoute,
} as any)
export interface FileRoutesByFullPath {
@@ -184,7 +184,7 @@ export interface FileRoutesByFullPath {
'/dashboard': typeof AuthDashboardRoute
'/geo': typeof AuthGeoRoute
'/payments': typeof AuthPaymentsRoute
- '/projects': typeof AuthProjectsRouteWithChildren
+ '/projects': typeof AuthProjectsRoute
'/providers': typeof AuthProvidersRoute
'/renewals': typeof AuthRenewalsRoute
'/reports': typeof AuthReportsRoute
@@ -193,7 +193,7 @@ export interface FileRoutesByFullPath {
'/sync-journal': typeof AuthSyncJournalRoute
'/tariffs': typeof AuthTariffsRoute
'/topology': typeof AuthTopologyRoute
- '/vps': typeof AuthVpsRouteWithChildren
+ '/vps': typeof AuthVpsRoute
'/auth/callback': typeof AuthCallbackRoute
'/projects/$projectId': typeof AuthProjectsProjectIdRoute
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
@@ -211,7 +211,7 @@ export interface FileRoutesByTo {
'/dashboard': typeof AuthDashboardRoute
'/geo': typeof AuthGeoRoute
'/payments': typeof AuthPaymentsRoute
- '/projects': typeof AuthProjectsRouteWithChildren
+ '/projects': typeof AuthProjectsRoute
'/providers': typeof AuthProvidersRoute
'/renewals': typeof AuthRenewalsRoute
'/reports': typeof AuthReportsRoute
@@ -220,7 +220,7 @@ export interface FileRoutesByTo {
'/sync-journal': typeof AuthSyncJournalRoute
'/tariffs': typeof AuthTariffsRoute
'/topology': typeof AuthTopologyRoute
- '/vps': typeof AuthVpsRouteWithChildren
+ '/vps': typeof AuthVpsRoute
'/auth/callback': typeof AuthCallbackRoute
'/projects/$projectId': typeof AuthProjectsProjectIdRoute
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
@@ -241,7 +241,7 @@ export interface FileRoutesById {
'/_auth/dashboard': typeof AuthDashboardRoute
'/_auth/geo': typeof AuthGeoRoute
'/_auth/payments': typeof AuthPaymentsRoute
- '/_auth/projects': typeof AuthProjectsRouteWithChildren
+ '/_auth/projects': typeof AuthProjectsRoute
'/_auth/providers': typeof AuthProvidersRoute
'/_auth/renewals': typeof AuthRenewalsRoute
'/_auth/reports': typeof AuthReportsRoute
@@ -250,13 +250,13 @@ export interface FileRoutesById {
'/_auth/sync-journal': typeof AuthSyncJournalRoute
'/_auth/tariffs': typeof AuthTariffsRoute
'/_auth/topology': typeof AuthTopologyRoute
- '/_auth/vps': typeof AuthVpsRouteWithChildren
+ '/_auth/vps': typeof AuthVpsRoute
'/auth/callback': typeof AuthCallbackRoute
- '/_auth/projects/$projectId': typeof AuthProjectsProjectIdRoute
+ '/_auth/projects_/$projectId': typeof AuthProjectsProjectIdRoute
'/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/_auth/settings/notifications': typeof AuthSettingsNotificationsRoute
'/_auth/settings/sync': typeof AuthSettingsSyncRoute
- '/_auth/vps/$vpsId': typeof AuthVpsVpsIdRoute
+ '/_auth/vps_/$vpsId': typeof AuthVpsVpsIdRoute
'/_auth/settings/': typeof AuthSettingsIndexRoute
}
export interface FileRouteTypes {
@@ -338,11 +338,11 @@ export interface FileRouteTypes {
| '/_auth/topology'
| '/_auth/vps'
| '/auth/callback'
- | '/_auth/projects/$projectId'
+ | '/_auth/projects_/$projectId'
| '/_auth/settings/integrations'
| '/_auth/settings/notifications'
| '/_auth/settings/sync'
- | '/_auth/vps/$vpsId'
+ | '/_auth/vps_/$vpsId'
| '/_auth/settings/'
fileRoutesById: FileRoutesById
}
@@ -508,12 +508,12 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthSettingsIndexRouteImport
parentRoute: typeof AuthSettingsRouteRoute
}
- '/_auth/vps/$vpsId': {
- id: '/_auth/vps/$vpsId'
- path: '/$vpsId'
+ '/_auth/vps_/$vpsId': {
+ id: '/_auth/vps_/$vpsId'
+ path: '/vps/$vpsId'
fullPath: '/vps/$vpsId'
preLoaderRoute: typeof AuthVpsVpsIdRouteImport
- parentRoute: typeof AuthVpsRoute
+ parentRoute: typeof AuthRoute
}
'/_auth/settings/sync': {
id: '/_auth/settings/sync'
@@ -536,12 +536,12 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport
parentRoute: typeof AuthSettingsRouteRoute
}
- '/_auth/projects/$projectId': {
- id: '/_auth/projects/$projectId'
- path: '/$projectId'
+ '/_auth/projects_/$projectId': {
+ id: '/_auth/projects_/$projectId'
+ path: '/projects/$projectId'
fullPath: '/projects/$projectId'
preLoaderRoute: typeof AuthProjectsProjectIdRouteImport
- parentRoute: typeof AuthProjectsRoute
+ parentRoute: typeof AuthRoute
}
}
}
@@ -563,29 +563,6 @@ const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = {
const AuthSettingsRouteRouteWithChildren =
AuthSettingsRouteRoute._addFileChildren(AuthSettingsRouteRouteChildren)
-interface AuthProjectsRouteChildren {
- AuthProjectsProjectIdRoute: typeof AuthProjectsProjectIdRoute
-}
-
-const AuthProjectsRouteChildren: AuthProjectsRouteChildren = {
- AuthProjectsProjectIdRoute: AuthProjectsProjectIdRoute,
-}
-
-const AuthProjectsRouteWithChildren = AuthProjectsRoute._addFileChildren(
- AuthProjectsRouteChildren,
-)
-
-interface AuthVpsRouteChildren {
- AuthVpsVpsIdRoute: typeof AuthVpsVpsIdRoute
-}
-
-const AuthVpsRouteChildren: AuthVpsRouteChildren = {
- AuthVpsVpsIdRoute: AuthVpsVpsIdRoute,
-}
-
-const AuthVpsRouteWithChildren =
- AuthVpsRoute._addFileChildren(AuthVpsRouteChildren)
-
interface AuthRouteChildren {
AuthSettingsRouteRoute: typeof AuthSettingsRouteRouteWithChildren
AuthAccountsRoute: typeof AuthAccountsRoute
@@ -595,7 +572,7 @@ interface AuthRouteChildren {
AuthDashboardRoute: typeof AuthDashboardRoute
AuthGeoRoute: typeof AuthGeoRoute
AuthPaymentsRoute: typeof AuthPaymentsRoute
- AuthProjectsRoute: typeof AuthProjectsRouteWithChildren
+ AuthProjectsRoute: typeof AuthProjectsRoute
AuthProvidersRoute: typeof AuthProvidersRoute
AuthRenewalsRoute: typeof AuthRenewalsRoute
AuthReportsRoute: typeof AuthReportsRoute
@@ -604,7 +581,9 @@ interface AuthRouteChildren {
AuthSyncJournalRoute: typeof AuthSyncJournalRoute
AuthTariffsRoute: typeof AuthTariffsRoute
AuthTopologyRoute: typeof AuthTopologyRoute
- AuthVpsRoute: typeof AuthVpsRouteWithChildren
+ AuthVpsRoute: typeof AuthVpsRoute
+ AuthProjectsProjectIdRoute: typeof AuthProjectsProjectIdRoute
+ AuthVpsVpsIdRoute: typeof AuthVpsVpsIdRoute
}
const AuthRouteChildren: AuthRouteChildren = {
@@ -616,7 +595,7 @@ const AuthRouteChildren: AuthRouteChildren = {
AuthDashboardRoute: AuthDashboardRoute,
AuthGeoRoute: AuthGeoRoute,
AuthPaymentsRoute: AuthPaymentsRoute,
- AuthProjectsRoute: AuthProjectsRouteWithChildren,
+ AuthProjectsRoute: AuthProjectsRoute,
AuthProvidersRoute: AuthProvidersRoute,
AuthRenewalsRoute: AuthRenewalsRoute,
AuthReportsRoute: AuthReportsRoute,
@@ -625,7 +604,9 @@ const AuthRouteChildren: AuthRouteChildren = {
AuthSyncJournalRoute: AuthSyncJournalRoute,
AuthTariffsRoute: AuthTariffsRoute,
AuthTopologyRoute: AuthTopologyRoute,
- AuthVpsRoute: AuthVpsRouteWithChildren,
+ AuthVpsRoute: AuthVpsRoute,
+ AuthProjectsProjectIdRoute: AuthProjectsProjectIdRoute,
+ AuthVpsVpsIdRoute: AuthVpsVpsIdRoute,
}
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
diff --git a/apps/web/src/routes/_auth/audit.tsx b/apps/web/src/routes/_auth/audit.tsx
index 91802ff..e1156ed 100644
--- a/apps/web/src/routes/_auth/audit.tsx
+++ b/apps/web/src/routes/_auth/audit.tsx
@@ -24,7 +24,7 @@ import {
} from '@/components/domain/audit-labels'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@/components/reui/badge'
-import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
+import { CountedLineTabs } from '@/components/counted-line-tabs'
import { TableSkeleton } from '@/components/skeletons'
const auditSearchSchema = z.object({
@@ -197,22 +197,15 @@ function AuditPage() {
actions={
}
/>
-
-
- {ACTION_FILTERS.map((option) => (
-
- {option.label}
-
- {actionCounts[option.value]}
-
-
- ))}
-
-
+
({
+ id: option.value,
+ label: option.label,
+ count: actionCounts[option.value],
+ }))}
+ value={actionFilter}
+ onValueChange={setActionFilter}
+ />
-
-
+ }
title="Дашборд"
description="Сводка по VPS, балансам и здоровью инвентаря"
kpiCards={statsLoading ? [] : kpiCards}
isLoading={statsLoading}
- charts={
-
-
-
-
- }
- queue={
-
-
-
-
- Проблемы
- {issues.length > 0 ? (
-
- {issues.length}
-
- ) : null}
-
-
- Последние VPS
-
-
- Аккаунты
- {atRisk.length > 0 ? (
-
- {atRisk.length}
-
- ) : null}
-
-
-
- i.key}
- emptyTitle="Проблем не найдено"
- emptyDescription="Критичных проблем в инвентаре не обнаружено"
- pagination={false}
+ >
+
+
+
+ {snap.settings?.[0]?.showQuickActions !== false ? (
+
-
-
- }>
- Все VPS
-
- }
- columns={columnDefFromDataGrid(vpsColumns)}
- data={activeVps.slice(0, 8)}
- getRowId={(v) => v.id}
- pagination={false}
- onRowClick={(v) => navigate({ to: '/vps', search: { edit: v.id } })}
+ ) : null}
+
+
-
-
- r.id}
- emptyTitle="Рисков нет"
- emptyDescription="Балансы и синхронизация в норме"
- pagination={false}
+
-
-
-
- }
- />
-
- {snap.settings?.[0]?.showQuickActions !== false ? (
-
- ) : null}
+
+ i.key}
+ emptyTitle="Проблем не найдено"
+ emptyDescription="Критичных проблем в инвентаре не обнаружено"
+ pagination={false}
+ />
+
+
+ }>
+ Все VPS
+
+ }
+ columns={columnDefFromDataGrid(vpsColumns)}
+ data={activeVps.slice(0, 8)}
+ getRowId={(v) => v.id}
+ pagination={false}
+ onRowClick={(v) => navigate({ to: '/vps', search: { edit: v.id } })}
+ />
+
+
+ r.id}
+ emptyTitle="Рисков нет"
+ emptyDescription="Балансы и синхронизация в норме"
+ pagination={false}
+ />
+
+
+
+
- ) : undefined
+ {vps ? (
+
}>Редактировать
+ ) : null}
+ >
}
/>
-
}>
-
- К списку VPS
-
-
{(row) => (
-
-
- Обзор
- Финансы
- Заметки
- Синк
+
+
+ {[
+ ['overview', 'Обзор'],
+ ['finance', 'Финансы'],
+ ['notes', 'Заметки'],
+ ['sync', 'Синк'],
+ ].map(([value, label]) => (
+
+ {label}
+
+ ))}
diff --git a/apps/web/src/styles/theme.css b/apps/web/src/styles/theme.css
new file mode 100644
index 0000000..c140b63
--- /dev/null
+++ b/apps/web/src/styles/theme.css
@@ -0,0 +1,112 @@
+/*
+ * VPS-Tracker theme layer (design canvas «VPS-Tracker», see docs/ui-migration-plan.md).
+ * Overrides tokens only — @cfdm/ui/globals.css stays shadcn CLI output, ReUI components are not restyled.
+ */
+@import '@fontsource-variable/geist';
+@import '@fontsource-variable/geist-mono';
+
+:root {
+ --font-sans: 'Geist Variable', ui-sans-serif, system-ui, sans-serif;
+ --font-mono: 'Geist Mono Variable', ui-monospace, SFMono-Regular, monospace;
+
+ --background: oklch(0.973 0.004 247.858);
+ --foreground: oklch(0.141 0.006 264);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.141 0.006 264);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.141 0.006 264);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.958 0.005 247.858);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.958 0.005 247.858);
+ --muted-foreground: oklch(0.52 0.01 264);
+ --accent: oklch(0.942 0.016 264.376);
+ --accent-foreground: oklch(0.205 0 0);
+ --border: oklch(0.904 0.006 264);
+ --input: oklch(0.904 0.006 264);
+ --ring: oklch(0.488 0.243 264.376);
+ --destructive: oklch(0.577 0.245 27.325);
+ --destructive-foreground: oklch(0.444 0.177 26.899);
+ --success: oklch(0.696 0.17 162.48);
+ --success-foreground: oklch(0.378 0.077 168.94);
+ --warning: oklch(0.795 0.184 86.047);
+ --warning-foreground: oklch(0.421 0.095 57.708);
+ --info: oklch(0.606 0.25 292.717);
+ --info-foreground: oklch(0.283 0.141 291.089);
+ --invert: oklch(0.21 0.006 285.885);
+ --invert-foreground: oklch(0.985 0 0);
+ --chart-1: oklch(0.546 0.215 264.376);
+ --chart-2: oklch(0.527 0.154 150.069);
+ --chart-3: oklch(0.625 0.162 41.5);
+ --chart-4: oklch(0.591 0.224 296.4);
+
+ /* Sidebar stays dark in the light theme. */
+ --sidebar: oklch(0.152 0.01 264.376);
+ --sidebar-foreground: oklch(0.92 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.24 0.012 264.376);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 8%);
+ --sidebar-ring: oklch(0.488 0.243 264.376);
+}
+
+/* Dark-sidebar scope in light theme: inner text/border/muted tokens follow the sidebar. */
+:root:not(.dark) :is([data-slot='sidebar-inner'], [data-slot='sidebar'][data-mobile='true']) {
+ --foreground: oklch(0.92 0 0);
+ --muted-foreground: oklch(0.648 0.008 264.376);
+ --border: oklch(1 0 0 / 8%);
+ --muted: oklch(0.24 0.012 264.376);
+ --background: oklch(0.133 0.011 264.376);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --card: oklch(0.191 0.011 264.376);
+ --input: oklch(1 0 0 / 15%);
+ --accent: oklch(0.24 0.012 264.376);
+ --accent-foreground: oklch(0.985 0 0);
+ color-scheme: dark;
+}
+
+.dark {
+ --background: oklch(0.133 0.011 264.376);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.191 0.011 264.376);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.191 0.011 264.376);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.252 0.01 264.376);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.252 0.01 264.376);
+ --muted-foreground: oklch(0.648 0.008 264.376);
+ --accent: oklch(0.285 0.022 264.376);
+ --accent-foreground: oklch(0.985 0 0);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.488 0.243 264.376);
+ --destructive: oklch(0.704 0.191 22.216);
+ --destructive-foreground: oklch(0.577 0.245 27.325);
+ --success: oklch(0.696 0.17 162.48);
+ --success-foreground: oklch(0.596 0.145 163.225);
+ --warning: oklch(0.795 0.184 86.047);
+ --warning-foreground: oklch(0.681 0.162 75.834);
+ --info: oklch(0.606 0.25 292.717);
+ --info-foreground: oklch(0.541 0.281 293.009);
+ --invert: oklch(0.37 0.013 285.805);
+ --invert-foreground: oklch(0.985 0 0);
+ --chart-1: oklch(0.637 0.222 264.376);
+ --chart-2: oklch(0.696 0.17 151.328);
+ --chart-3: oklch(0.713 0.176 47.6);
+ --chart-4: oklch(0.714 0.173 303.9);
+
+ --sidebar: oklch(0.191 0.011 264.376);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.262 0.014 264.376);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.488 0.243 264.376);
+}
diff --git a/docs/ui-design-contract.md b/docs/ui-design-contract.md
index 87ecd18..c57743e 100644
--- a/docs/ui-design-contract.md
+++ b/docs/ui-design-contract.md
@@ -15,7 +15,7 @@ Project lock: **`surface: frame`**. Ops / list / dashboard / detail / settings
export const UI_SURFACE = 'frame' as const
```
-Settings: секции через Frame + `gap` (без hairline `Separator` под PageHeader); `SettingRow` без `FieldSeparator` по умолчанию (`separated` opt-in). Preview: [settings-3](https://reui.io/preview/base/settings-3) · [settings-16](https://reui.io/preview/base/settings-16).
+Settings: **line-tabs сверху** (Общие / Синхронизация / Уведомления / Интеграции) вместо боковой навигации; секции через Frame + `gap` (без hairline `Separator` под PageHeader); `SettingRow` без `FieldSeparator` по умолчанию (`separated` opt-in). Preview: [settings-3](https://reui.io/preview/base/settings-3) · [settings-16](https://reui.io/preview/base/settings-16).
## Canonical PRO references
@@ -31,6 +31,10 @@ Settings: секции через Frame + `gap` (без hairline `Separator` п
| Empty | `empty-state-14` (prefer) / `empty-state-12` | https://reui.io/preview/base/empty-state-14 · https://reui.io/preview/base/empty-state-12 |
| Forms | `form-7` → Sheet/Drawer | https://reui.io/preview/base/form-7 |
+## Theme
+
+Палитра — нейтрали hue 264 (макет «VPS-Tracker»), шрифты **Geist / Geist Mono**. Оверрайд токенов — `apps/web/src/styles/theme.css` поверх `@cfdm/ui/globals.css` (globals — только вывод CLI). В светлой теме сайдбар остаётся тёмным. План миграции: [`ui-migration-plan.md`](ui-migration-plan.md).
+
## Kit API (`reui-kit/`)
| Component | Role |
@@ -51,7 +55,7 @@ Settings: секции через Frame + `gap` (без hairline `Separator` п
| App | Section order |
|-----|---------------|
| EvoBGP / CFDM / EvoFirewall | KPI → **QuickActionGrid** → charts / rest |
-| vps-tracker | banner → KPI → charts → attention → **QuickActionGrid** → CSV |
+| vps-tracker | banner → KPI → line-tabs «Проблемы» (QuickActionGrid → charts → attention) · «Последние VPS» · «Аккаунты» → CSV |
| auth-portal | portal-specific; Quick Actions при наличии dashboard |
Gating: DB `show_quick_actions` / `showQuickActions` / `ui_show_quick_actions` (default `true`).
@@ -67,8 +71,9 @@ Gating: DB `show_quick_actions` / `showQuickActions` / `ui_show_quick_actions` (
| Sidebar / hover colors | theme `--sidebar` / `--sidebar-accent` — **без** AppShell `color-mix` override |
| Header | `h-12`, `sticky`, `border-b`, `px-4 md:px-6` |
| Header left | `SidebarTrigger` + `Separator` + Breadcrumb |
-| Header right | **AppsMenu** → **SystemMonitorPopover** (тема — в NavUser) |
-| Sidebar | AppSwitcher → groups → icons `size-4` → **NavUser** в `SidebarFooter` |
+| Header right | **SystemMonitorPopover** (пилюля «Внимание»/«Норма»; приложения — в AppSwitcher; тема — в NavUser) |
+| Sidebar | AppSwitcher (лого + подзаголовок + список приложений) → SpaceSwitcher → **сворачиваемые** группы (UPPERCASE label + шеврон, `collapsible`) → icons `size-4` → **NavUser** (muted-карточка) в `SidebarFooter` |
+| Проблемы в меню | точка `destructive`/`warning` у пункта (не числовой Badge) |
| `main` | `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` |
| Search | hotkey ⌘K / Ctrl+K only (не кнопка в header) |
diff --git a/docs/ui-migration-plan.md b/docs/ui-migration-plan.md
new file mode 100644
index 0000000..99fe722
--- /dev/null
+++ b/docs/ui-migration-plan.md
@@ -0,0 +1,93 @@
+# План миграции UI vps-tracker на дизайн «VPS-Tracker»
+
+Источник: артефакт-канвас [VPS-Tracker](https://claude.ai/artifact/7kPVbQNXa4AWgFkSekZJiN#page-dcb81aee8d83), доски `Vps*.dc.html` + шаблон `TplVps.dc.html` + токены `reui-auth.css`.
+Канвас — макет, имитирующий ReUI; его CSS **не переносим**. Используем как спецификацию регионов и токенов, а реализуем на ReUI PRO + shadcn primitives (контракт: [`ui-design-contract.md`](ui-design-contract.md)).
+
+Лицензия ReUI PRO: `REUI_LICENSE_KEY` есть, премиум-реестр отвечает 200 (проверено на `app-shell-12`).
+
+## Что не является страницами
+
+- Блоки `states` («Справочник состояний · не часть страницы») — только спецификация empty/loading/error, на страницах не рендерим.
+- Доски `VpsCollapsed`, `VpsLight`, `VpsMobile`, `VpsPalette` — состояния одного экрана; используем для проверки (свёрнутый сайдбар, светлая тема, ширина телефона, ⌘K).
+- Инструменты канваса: полосы `wedit`, «звёздочки»/закрепы в меню, атрибуты density/contrast — пропускаем.
+
+## Сохраняем то, чего нет в макете
+
+`SpaceSwitcher` (пространства — реальная функция), `GlobalSearch` (⌘K), фильтры ReUI `Filters` в списках, CSV-экспорт, row actions. Макет их не отменяет.
+
+## Сопоставление блоков макета → ReUI
+
+| Блок макета | Реализация | ReUI / shadcn |
+|---|---|---|
+| sidebar `asbtn` (лого + «Учёт серверов и платежей») + поповер приложений | `AppSwitcher` (+ пункты бывшего `AppsMenu`) | shadcn `sidebar`, `dropdown-menu`; эталон [app-shell-12](https://reui.io/preview/base/app-shell-12) `brand.tsx` |
+| `grph` — сворачиваемые группы (UPPERCASE + шеврон) | `NavGroup` в `app-shell.tsx` | shadcn `sidebar` + `collapsible` (паттерн Collapsible SidebarGroup) |
+| `pdot` — точка проблем у пункта меню | `SidebarMenuBadge` / точка `bg-destructive` | shadcn `sidebar` |
+| `user` — NavUser карточкой | `NavUser` | [app-shell-1](https://reui.io/preview/base/app-shell-1) user menu |
+| header: trigger + crumbs + `statb` | `AppShell` header, без `AppsMenu` | shadcn `breadcrumb`, `popover` |
+| `statb` «Внимание · ●1 ●2» → монитор | триггер `SystemMonitorPopover` | `@reui/badge` (`warning-light`/`destructive-light`) |
+| h1 + sub + actions | `PageHeader` | — |
+| `banner` / `note` | `Alert` | `@reui/alert` |
+| `kpi` (иконка слева, label, value, chip) | `KpiStatGrid` | `@reui/icon-tile`, `@reui/badge`, Frame ([stats-12](https://reui.io/preview/base/stats-12)) |
+| `ptabs` — вкладки страницы со счётчиками | `CountedLineTabs` | shadcn `tabs` variant `line` (c-tabs-2) |
+| `seg` — сегменты вида/снимка | `ToggleGroup` | shadcn `toggle-group` |
+| `grid` — Frame: заголовок, поиск, колонки, виды, bulk, футер | `ResourcePage` / `FrameDataGrid` | `@reui/data-grid`, `@reui/filters`, `@reui/frame` ([data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2)) |
+| `cards` (быстрые действия) | `QuickActionGrid` | Frame tiles |
+| `chart` | Recharts через shadcn `chart` | — |
+| `kv` | `DetailPanel` | `@reui/frame` |
+| `timeline` | `Timeline` | `@reui/timeline` |
+| `form` (`frow`) | `SettingRow` | [settings-16](https://reui.io/preview/base/settings-16) |
+
+## Тема и шрифты
+
+- Палитра макета: нейтрали с оттенком **hue 264** (`--background: oklch(0.133 0.011 264.376)` и т.д.), `info` — фиолетовый, `chart-1..4` из макета; в светлой теме **сайдбар остаётся тёмным**.
+- Где живёт: `packages/ui/src/styles/globals.css` — только вывод `shadcn` CLI, руками не трогаем. Оверрайд — слой приложения **`apps/web/src/styles/theme.css`** (импорт после `@cfdm/ui/globals.css` в `main.tsx`): переопределяет переменные `:root`/`.dark` и `--sidebar*` для светлой темы. Компоненты ReUI не рестайлим — меняются только токены.
+- Шрифты: **Geist** / **Geist Mono** через `@fontsource-variable/geist` и `@fontsource-variable/geist-mono`; `--font-sans` / `--font-mono` в `theme.css`.
+
+## Изменения по экранам (`apps/web/src/routes/_auth/*`)
+
+| Маршрут | Доска | Изменения | Статус |
+|---|---|---|---|
+| `dashboard` | VpsDashboard | баннер → KPI (6) → **вкладки** «Проблемы» (быстрые действия, графики Платежи/Расходы, «Здоровье инвентаря») · «Последние VPS» · «Аккаунты» (под риском) | сделано |
+| `vps` | VpsList | заголовок «VPS / Виртуальные серверы», Alert несвязанных доменов, Frame-грид «Инвентарь VPS» с подзаголовком | сделано (заголовок грида); Alert доменов и фильтры — уже были |
+| `vps.$vpsId` | VpsDetail | крошки VPS / IP, действия «К списку VPS» + «Редактировать», **вкладки** Обзор / Финансы / Заметки / Синк | сделано (line-вкладки, действия, крошки «VPS / IP»); маршрут вынесен в `vps_.$vpsId` |
+| `accounts` | VpsAccounts | KPI (4) + грид, действия «Синхронизировать все» + «Добавить» | уже соответствует («Синхронизировать все» — при наличии API-аккаунтов) |
+| `providers` | VpsProviders | грид «Хостеры» | уже соответствует |
+| `projects`, `projects.$projectId` | VpsProjects / VpsProjectDetail | KPI (4) + грид | уже соответствует; крошки «Проекты / имя»; маршрут вынесен в `projects_.$projectId` |
+| `tariffs` | VpsTariffs | Alert расхождений + грид | уже соответствует («Загрузить тарифы» — при наличии API) |
+| `payments` | VpsPayments | KPI (3) + грид | уже соответствует |
+| `balance` | VpsBalance | KPI (3) + грид, футер с итогами | уже соответствует |
+| `reports`, `resources` | VpsReports / VpsResources | сегмент периода, KPI, графики в Frame | уже соответствует |
+| `renewals` | VpsRenewals | KPI (3) + грид | уже соответствует |
+| `blocking`, `geo` | VpsBlocking / VpsGeo | KPI (4) + сегменты + вкладки Текущие / История | уже соответствует |
+| `topology` | VpsTopology | без изменений структуры (холст) | без изменений |
+| `spaces` | VpsSpaces | секции Frame | уже соответствует |
+| `sync-journal` | VpsSync | грид «Запуски синхронизации» | уже соответствует |
+| `audit` | VpsAudit | вкладки по действию + переключатель Лента / Таблица | сделано (общие CountedLineTabs) |
+| `settings/*` | VpsSettings | **вкладки сверху** Общие / Синхронизация / Уведомления / Интеграции вместо боковой навигации `SettingsShell` | сделано |
+
+## Общие компоненты
+
+- `layout/app-shell.tsx` — сворачиваемые группы, точки проблем, header без `AppsMenu`.
+- `app-switcher.tsx` — подзаголовок, приложения из `AppsMenu`; `layout/apps-menu.tsx` удаляется из header.
+- `layout/nav-user.tsx` — карточка (muted фон), меню Настройки / Тема / Выйти (без изменений логики).
+- `layout/system-monitor-popover.tsx` — триггер-пилюля «Внимание» со счётчиками ошибок/предупреждений, футер «Журнал синка».
+- `reui-kit/settings-shell.tsx` — line-tabs сверху.
+- `counted-line-tabs.tsx` — счётчик моноширинным текстом как в макете.
+
+## Порядок
+
+1. План + контракт (этот документ, [`ui-design-contract.md`](ui-design-contract.md)).
+2. Тема и шрифты (`theme.css`, fontsource).
+3. Shell: AppSwitcher, группы, NavUser, header, монитор.
+4. Kit: `CountedLineTabs`, `SettingsShell`.
+5. Экраны: dashboard → VPS detail → settings → audit → blocking/geo → прочие (тексты, колонки).
+6. Проверка: dev-сервер, 1440×1000 + свёрнутый сайдбар + светлая тема + телефон; `pnpm --filter web build`, `pnpm --filter web test`; `validate_usage`, `get_audit_checklist`.
+
+## Установка
+
+Новые ReUI-элементы не требуются: всё нужное уже установлено (`frame`, `badge`, `alert`, `data-grid`, `filters`, `timeline`, `icon-tile`) и shadcn (`sidebar`, `collapsible`, `tabs`, `toggle-group`). Добавляются только пакеты шрифтов. Если при реализации понадобится новый элемент — `cd apps/web && pnpm dlx shadcn@latest add @reui/`.
+
+## Итог проверки
+
+- Сверка: артефакт в браузере не рендерится без входа в claude.ai, поэтому соответствие проверено по структуре досок `.dc.html` (заголовки, подзаголовки, действия, KPI, вкладки, блоки) и скриншотам dev-сервера 1440×1000, свёрнутого меню, светлой темы и ширины телефона.
+- «Уже соответствует» — экран сверен по тексту (`h1`, подзаголовок, действия) с доской; визуальные отличия закрыты общими изменениями темы, shell, KPI и шапки страницы.
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c5f68d0..ec88a9a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -114,6 +114,12 @@ importers:
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.7)
+ '@fontsource-variable/geist':
+ specifier: ^5.3.0
+ version: 5.3.0
+ '@fontsource-variable/geist-mono':
+ specifier: ^5.3.0
+ version: 5.3.0
'@hookform/resolvers':
specifier: ^3.10.0
version: 3.10.0(react-hook-form@7.80.0(react@19.2.7))
@@ -978,6 +984,12 @@ packages:
'@floating-ui/utils@0.2.11':
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
+ '@fontsource-variable/geist-mono@5.3.0':
+ resolution: {integrity: sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw==}
+
+ '@fontsource-variable/geist@5.3.0':
+ resolution: {integrity: sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==}
+
'@hookform/resolvers@3.10.0':
resolution: {integrity: sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==}
peerDependencies:
@@ -4145,6 +4157,10 @@ snapshots:
'@floating-ui/utils@0.2.11': {}
+ '@fontsource-variable/geist-mono@5.3.0': {}
+
+ '@fontsource-variable/geist@5.3.0': {}
+
'@hookform/resolvers@3.10.0(react-hook-form@7.80.0(react@19.2.7))':
dependencies:
react-hook-form: 7.80.0(react@19.2.7)