diff --git a/.cursor/rules/frontend-ui-patterns.mdc b/.cursor/rules/frontend-ui-patterns.mdc index bd07566..eaf9cc5 100644 --- a/.cursor/rules/frontend-ui-patterns.mdc +++ b/.cursor/rules/frontend-ui-patterns.mdc @@ -6,38 +6,44 @@ alwaysApply: false # Frontend UI Patterns -См. также: [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [Components](https://ui.shadcn.com/docs/components), [Blocks](https://ui.shadcn.com/blocks). +См. также: [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`reui-mcp.mdc`](reui-mcp.mdc), [Components](https://ui.shadcn.com/docs/components), [ReUI llms.txt](https://reui.io/llms.txt). ## Docs workflow (обязательно) 0. Codegraph `codegraph_explore` — найти существующие shared/domain-компоненты -1. Skill [`.agents/skills/shadcn/SKILL.md`](../.agents/skills/shadcn/SKILL.md) — component selection -2. MCP `plugin-shadcn-shadcn` — search → examples → add command -3. CLI: `cd apps/web && pnpm dlx shadcn@latest docs ` → сверить API с [Components](https://ui.shadcn.com/docs/components) +1. Skill [`.agents/skills/shadcn/SKILL.md`](../.agents/skills/shadcn/SKILL.md) + ReUI skill — component selection +2. MCP `plugin-shadcn-shadcn` / `user-reui` — search → examples → add command (`surface: "frame"`) +3. CLI: `cd apps/web && pnpm dlx shadcn@latest docs ` / `add @reui/...` → сверить API 4. Код по examples + docs API (только после совпадения MCP ↔ docs) -5. Context7 — **только** TanStack Router/Query, Recharts (не shadcn primitives) +5. Context7 — **только** TanStack Router/Query, Recharts (не shadcn/ReUI primitives) 6. MCP `get_audit_checklist` — перед merge 7. Codegraph `codegraph_status` — Pending sync пустой +## Surface + +Проект использует **ReUI Frame** (`surface: frame`), не shadcn Card как оболочку list/ops-экранов. Эталон списка: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2). + ## Иерархия компонентов ``` -@cfdm/ui/components/* ← только CLI (packages/ui) +@cfdm/ui/components/* ← shadcn CLI (packages/ui) +@/components/reui/* ← ReUI CLI @reui/* (apps/web) apps/web/src/components/ ← shared + domain + layout - page-shell.tsx ← обёртка страницы - page-header.tsx + page-shell.tsx + reui-kit/ + resource-page.tsx ← list: Frame + line Tabs + Filters + DataGrid + kanban-board.tsx ← kanban + KanbanBoardSkeleton + detail-panel.tsx ← detail: Frame header/metrics + settings-shell.tsx + ops-dashboard.tsx empty-state.tsx query-state.tsx confirm-dialog.tsx - data-grid-card.tsx - section-cards.tsx status-badge.tsx - form-sheet.tsx ← Sheet + RHF FormProvider - form-field.tsx ← Field + Controller + aria-invalid - table-card.tsx ← Card + Table wrapper - loading-button.tsx ← Button + Spinner + label swap - skeletons.tsx ← SectionCardsSkeleton, TableSkeleton - layout/ ← app-shell, site-header + form-sheet.tsx + form-field.tsx + loading-button.tsx + layout/ ← app-shell, site-header, app-sidebar domain-* ← бизнес-компоненты ``` @@ -46,22 +52,55 @@ apps/web/src/components/ ← shared + domain + layout | Элемент | Shared | Primitive | |---------|--------|-----------| | Page wrapper | `PageShell` | — | -| Page title | `PageHeader` | — | -| Stat metrics | `SectionCards` | `Card` | -| Data list | `DataGridCard` | ReUI `data-grid` | +| List page | `ResourcePage` | ReUI `Frame` + `data-grid` + `filters` + shadcn `Tabs` | +| Kanban | `KanbanBoard` / `KanbanBoardSkeleton` | ReUI `kanban` + `Frame` | +| Detail | `DetailPanel` | ReUI `Frame` | +| Settings | `SettingsShell` | — | +| Dashboard | `OpsDashboard` / `KpiStatGrid` | ReUI `Frame` | | Empty | `EmptyState` | `Empty` | -| Loading / Error | `QueryState` | `Skeleton`, `Alert` | -| Status | `StatusBadge` | `Badge` | +| Loading / Error | `QueryState` / kit skeletons | `Skeleton`, `Alert` | +| Status | `StatusBadge` | ReUI `Badge` (`success`/`info`/`warning`) | | Create/Edit | `FormSheet` + `*-edit-sheet.tsx` | `Sheet`, `Field` | | Form field | `FormField` | `Field`, `Input`, `Select` | | Submit button | `LoadingButton` | `Button`, `Spinner` | -| Table wrapper | `TableCard` | `Table`, `Card` | | Delete confirm | `ConfirmDialog` | `AlertDialog` | -| List row | — | `Item variant="outline" size="sm"` | -| Nav | `AppSidebar` | `Sidebar` | +| Nav | `AppSidebar` (`isActive` обязателен) | `Sidebar` | | Breadcrumbs | `SiteHeader` | `Breadcrumb` | | Dates | `lib/format.ts` | — | +## Header actions + +Независимые CTA в header → `flex shrink-0 flex-wrap items-center justify-end gap-2`. + +**Не** оборачивать в `ButtonGroup` (он склеивает кнопки). `ButtonGroup` — только для связанных контролок (filter chips и т.п.). + +Max 1 primary (`default`) на экран; остальные `outline` / `ghost`. + +## Line tabs (ResourcePage) + +Эталон: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2). + +```tsx + + + Label + {count} + + +``` + +- `h-auto!` снимает клип underline у `TabsList` (`h-8`) +- Active count — контрастный chip (`bg-foreground` / `text-background`) +- Active state — Base UI `data-active`, не Radix `data-[state=active]` + +## Sidebar + +- Каждый `SidebarMenuButton` получает `isActive` от pathname (`useRouterState`) +- `--sidebar-accent` в `AppShell` — заметный mix primary (~14%), не 5% + ## Overlay selection | Сценарий | Компонент | @@ -72,35 +111,38 @@ apps/web/src/components/ ← shared + domain + layout ## Block registry -| Зона | Block | -|------|-------| -| Shell | sidebar-07 | -| Dashboard | dashboard-01 | -| Login | login-03 | +| Зона | Block / эталон | +|------|----------------| +| Shell | [app-shell-12](https://reui.io/preview/base/app-shell-12) | +| List + tabs + filters | [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) | +| Login | auth-13 / login-03 | ## Spacing ``` -PageShell: gap-4 md:gap-6, px-4 lg:px-6 py-4 md:py-6 -Card grid: gap-4 +AppShell main: gap-4 md:gap-6, px-4 md:px-6, py-4 md:py-5 +PageShell: gap-4 md:gap-6 +Title/desc: gap-px +Card/Frame grid: gap-4 FieldGroup: gap-4 Item list: gap-2 -Toolbar: gap-2 +Toolbar / header actions: gap-2 +Tabs list (line): gap-5 ``` -**Запрещено:** `space-y-*`, raw colors (`bg-emerald-*`), custom empty divs, page-level Spinner. +**Запрещено:** `space-y-*`, raw colors (`bg-emerald-*`), custom empty divs, page-level Spinner / plain «Загрузка…» без Skeleton. -## UX/UI 2026 (состояния данных) +## UX/UI (состояния данных) Каждый блок: **default, hover, focus, disabled, empty, loading, error**. -- **Loading** — `Skeleton` с размерами финального контента (`QueryState skeleton={…}`), не Spinner на странице -- **Empty** — `EmptyState` с CTA (кнопка создания) -- **Zero-results** — отдельный empty с «Сбросить фильтр» (не «Создать») -- **Error** — `QueryState` + `onRetry` + иконка + текст +- **Loading** — `Skeleton` / `ResourcePage` skeleton / `KanbanBoardSkeleton`, не Spinner на странице +- **Empty** — `EmptyState` с CTA +- **Zero-results** — message внутри DataGrid (+ «Сбросить») +- **Error** — `QueryState` / `Alert` + `onRetry` - **Overflow** — `truncate`, `max-w-*`, `Tooltip`; `tabular-nums` для чисел -- **Density** — таблицы `h-10 text-sm`; max 1 primary CTA на экран -- **A11y** — `aria-invalid` на полях, `aria-label`/`sr-only` на icon-only кнопках, цвет не единственный сигнал статуса +- **Density** — operational (`dense` Frame/DataGrid); max 1 primary CTA +- **A11y** — `aria-invalid`, `aria-label`/`sr-only` на icon-only, `aria-current="page"` на active nav/settings ## Button hierarchy (max 1 primary per screen) diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index e806cb2..bee101d 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -1,4 +1,4 @@ -import { Link } from '@tanstack/react-router' +import { Link, useRouterState } from '@tanstack/react-router' import { LayoutDashboardIcon, GlobeIcon, @@ -31,12 +31,26 @@ const infrastructureNav = [ const operationsNav = [ { to: '/services', label: 'Сервисы', icon: ServerIcon, exact: false }, { to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon, exact: false }, - { to: '/settings/integrations', label: 'Настройки', icon: SettingsIcon, exact: false }, + { to: '/settings/integrations', label: 'Настройки', icon: SettingsIcon, exact: false, matchPrefix: '/settings' }, ] as const +function isNavActive( + pathname: string, + to: string, + exact: boolean, + matchPrefix?: string, +) { + if (matchPrefix) { + return pathname === matchPrefix || pathname.startsWith(`${matchPrefix}/`) + } + if (exact) return pathname === to + return pathname === to || pathname.startsWith(`${to}/`) +} + function NavSection({ label, items, + pathname, }: { label: string items: readonly { @@ -44,7 +58,9 @@ function NavSection({ label: string icon: typeof GlobeIcon exact: boolean + matchPrefix?: string }[] + pathname: string }) { return ( @@ -54,6 +70,12 @@ function NavSection({ s.location.pathname }) + return ( - - + + diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index 84e9be3..ea1a421 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -10,7 +10,7 @@ export function AppShell({ children }: { children: ReactNode }) { className={cn( '[--sidebar:color-mix(in_oklab,var(--color-sidebar)_60%,transparent)]', '[--sidebar-border:transparent]', - '[--sidebar-accent:color-mix(in_oklab,var(--color-primary)_5%,transparent)]', + '[--sidebar-accent:color-mix(in_oklab,var(--color-primary)_14%,transparent)]', '[--sidebar-accent-foreground:var(--color-primary)]', )} style={ diff --git a/apps/web/src/components/reui-kit/detail-panel.tsx b/apps/web/src/components/reui-kit/detail-panel.tsx index be9c913..28b8bd4 100644 --- a/apps/web/src/components/reui-kit/detail-panel.tsx +++ b/apps/web/src/components/reui-kit/detail-panel.tsx @@ -41,13 +41,17 @@ function DetailPanelHeader({ return ( -
+
{title} {description ? ( {description} ) : null}
- {actions ?
{actions}
: null} + {actions ? ( +
+ {actions} +
+ ) : null} {children ? {children} : null} diff --git a/apps/web/src/components/reui-kit/index.ts b/apps/web/src/components/reui-kit/index.ts index dbfa3de..665ec9a 100644 --- a/apps/web/src/components/reui-kit/index.ts +++ b/apps/web/src/components/reui-kit/index.ts @@ -3,6 +3,6 @@ export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics' export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page' export { KpiStatGrid, type KpiStatCard, type OpsKpiCard } from './kpi-stat-grid' export { OpsDashboard, OpsDashboardHintLink } from './ops-dashboard' -export { KanbanBoard, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board' +export { KanbanBoard, KanbanBoardSkeleton, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board' export { DetailPanel, type DetailMetricCard } from './detail-panel' export { SettingsShell, type SettingsTabConfig } from './settings-shell' diff --git a/apps/web/src/components/reui-kit/kanban-board.tsx b/apps/web/src/components/reui-kit/kanban-board.tsx index 32d6e15..d56bd97 100644 --- a/apps/web/src/components/reui-kit/kanban-board.tsx +++ b/apps/web/src/components/reui-kit/kanban-board.tsx @@ -19,6 +19,7 @@ import { GripVerticalIcon, PlusIcon } from 'lucide-react' import { cn } from '@cfdm/ui/lib/utils' import { Button } from '@cfdm/ui/components/button' +import { Skeleton } from '@cfdm/ui/components/skeleton' export interface KanbanColumnConfig { id: string @@ -207,20 +208,22 @@ export function KanbanBoard({ ) return ( -
-
-
-
-

{title}

+
+
+
+
+

+ {title} +

{description ? ( -

+

{description}

) : null}
{toolbarActions ? (
{toolbarActions} @@ -275,3 +278,35 @@ export function KanbanBoard({
) } + +export function KanbanBoardSkeleton({ columns = 3 }: { columns?: number }) { + return ( +
+
+
+
+ + +
+ +
+
+
+ {Array.from({ length: columns }).map((_, i) => ( + + + + + + +
+ + + +
+ + ))} +
+
+ ) +} diff --git a/apps/web/src/components/reui-kit/ops-dashboard.tsx b/apps/web/src/components/reui-kit/ops-dashboard.tsx index 516cd04..e262dfe 100644 --- a/apps/web/src/components/reui-kit/ops-dashboard.tsx +++ b/apps/web/src/components/reui-kit/ops-dashboard.tsx @@ -50,10 +50,12 @@ export function OpsDashboard({ className="text-foreground @container mx-auto flex w-full max-w-7xl flex-col gap-4 md:gap-6" >
-

{title}

-

- {description} -

+
+

{title}

+

+ {description} +

+
diff --git a/apps/web/src/components/reui-kit/resource-page.tsx b/apps/web/src/components/reui-kit/resource-page.tsx index d50fa54..92434ce 100644 --- a/apps/web/src/components/reui-kit/resource-page.tsx +++ b/apps/web/src/components/reui-kit/resource-page.tsx @@ -279,7 +279,9 @@ export function ResourcePage({ ) : null}
{primaryAction ? ( -
{primaryAction}
+
+ {primaryAction} +
) : null} ) : null} @@ -287,14 +289,17 @@ export function ResourcePage({ {tabs && tabs.length > 0 ? ( <> -
+
- + {tabs.map((tab) => ( {tab.label} diff --git a/apps/web/src/components/reui-kit/settings-shell.tsx b/apps/web/src/components/reui-kit/settings-shell.tsx index 0257d6a..506f8ad 100644 --- a/apps/web/src/components/reui-kit/settings-shell.tsx +++ b/apps/web/src/components/reui-kit/settings-shell.tsx @@ -58,10 +58,12 @@ export function SettingsShell({
-

{title}

-

- {description} -

+
+

{title}

+

+ {description} +

+
diff --git a/apps/web/src/routes/_auth/certificates.tsx b/apps/web/src/routes/_auth/certificates.tsx index 4452f90..a57c84a 100644 --- a/apps/web/src/routes/_auth/certificates.tsx +++ b/apps/web/src/routes/_auth/certificates.tsx @@ -70,7 +70,7 @@ function CertificatesPage() { ) return ( - + setSheetOpen(true)}> - Новая запись - - } emptyState={{ title: 'DNS-записи не найдены', description: 'Синхронизируйте зону или создайте запись вручную', diff --git a/apps/web/src/routes/_auth/domains/$domainId/index.tsx b/apps/web/src/routes/_auth/domains/$domainId/index.tsx index 94278a0..a78ba06 100644 --- a/apps/web/src/routes/_auth/domains/$domainId/index.tsx +++ b/apps/web/src/routes/_auth/domains/$domainId/index.tsx @@ -347,7 +347,7 @@ function DomainOverviewPage() { columns={columns} data={subdomainRows} getRowId={(row) => String(row.subdomain.id)} - primaryAction={ + toolbarExtra={ diff --git a/apps/web/src/routes/_auth/groups.tsx b/apps/web/src/routes/_auth/groups.tsx index 51bc7e2..30ea188 100644 --- a/apps/web/src/routes/_auth/groups.tsx +++ b/apps/web/src/routes/_auth/groups.tsx @@ -17,7 +17,7 @@ import { PageShell } from '@/components/page-shell' import { EmptyState } from '@/components/empty-state' import { ConfirmDialog } from '@/components/confirm-dialog' import { DomainGroupEditSheet } from '@/components/domain-group-edit-sheet' -import { KanbanBoard } from '@/components/reui-kit' +import { KanbanBoard, KanbanBoardSkeleton } from '@/components/reui-kit' import { DomainKanbanCard } from '@/components/kanban/domain-kanban-card' import { useDomainGroupsKanban } from '@/hooks/use-domain-groups-kanban' import { groupColumnId } from '@/components/board/column-ids' @@ -150,7 +150,7 @@ function GroupsPage() { if (isLoading) { return ( -
Загрузка доски…
+
) } diff --git a/apps/web/src/routes/_auth/services.tsx b/apps/web/src/routes/_auth/services.tsx index 6432ab3..894292c 100644 --- a/apps/web/src/routes/_auth/services.tsx +++ b/apps/web/src/routes/_auth/services.tsx @@ -28,7 +28,7 @@ import { ServiceGroupEditSheet } from '@/components/service-group-edit-sheet' import { ServicesBulkToolbar } from '@/components/services-board/services-bulk-toolbar' import { useServicesKanban } from '@/hooks/use-services-kanban' import { useServicesSelection } from '@/hooks/use-services-selection' -import { KanbanBoard } from '@/components/reui-kit' +import { KanbanBoard, KanbanBoardSkeleton } from '@/components/reui-kit' import { ServiceKanbanCard } from '@/components/kanban/service-kanban-card' import { Button } from '@cfdm/ui/components/button' @@ -374,7 +374,7 @@ function ServicesPage() { /> {isLoading ? ( -
Загрузка доски…
+ ) : isError ? ( {} : handleKanbanValueChange} toolbarActions={ -
+ <> -
+ } renderCard={(service, ctx) => (