feat(web): NavUser chrome и унификация ReUI PRO kit
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m38s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

Тема в NavUser вместо ModeToggle в header; KpiStatGrid/QuickActionGrid по эталону EvoBGP; обновлены rules и contract.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-21 00:23:04 +07:00
co-authored by Cursor
parent af4c0303cb
commit c40eff8a01
16 changed files with 797 additions and 256 deletions
+67 -32
View File
@@ -1,44 +1,71 @@
--- ---
description: Frontend — shadcn/ui + ReUI PRO workflow для EvoFirewall description: Frontend — shadcn primitives + ReUI PRO blocks; CLI-first; kit patterns
globs: globs: apps/web/**/*,packages/ui/**/*
- apps/web/**
- packages/ui/**
alwaysApply: false alwaysApply: false
--- ---
# Frontend — shadcn/ui + ReUI PRO # Frontend — shadcn/ui + ReUI PRO
**Источники:** MCP **`user-reui`** + **`plugin-shadcn-shadcn`** + docs. Не выдумывать UI. **Источник истины:** MCP **`user-reui`** (pages/blocks/KPI/settings) + MCP `plugin-shadcn-shadcn` (primitives) + docs. Не выдумывать UI.
Monorepo — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). ReUI — [`reui-mcp.mdc`](reui-mcp.mdc). Patterns — [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc).
| Документ | URL | | Документ | URL |
|----------|-----| |----------|-----|
| shadcn Components | https://ui.shadcn.com/docs/components | | **shadcn Components** | https://ui.shadcn.com/docs/components |
| ReUI Blocks | https://reui.io/blocks | | **ReUI Blocks** | https://reui.io/blocks |
| ReUI llms.txt | https://reui.io/llms.txt | | **ReUI llms.txt** | https://reui.io/llms.txt |
| License | https://reui.io/docs/license-setup | | **ReUI Settings** | https://reui.io/blocks/application/settings |
| Design contract | [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md) | | **Installation** | https://ui.shadcn.com/docs/installation |
| **Monorepo** | https://ui.shadcn.com/docs/monorepo |
| **Forms (RHF)** | https://ui.shadcn.com/docs/forms/react-hook-form |
## Шаг 0 ## Шаг 0 — перед любым UI-кодом
0. Поиск `reui-kit/*` / shared 0. Найти существующие `reui-kit/*` / shared
1. Pages / KPI / settings / enterprise `user-reui` (`search` → `get_block` / `compose_page`) + cite `previewUrl` 1. **Pages / KPI / settings / enterprise** → MCP `user-reui` (`search` → `get_block` / `compose_page`) + cite `previewUrl`
2. Primitives `plugin-shadcn-shadcn` + `pnpm dlx shadcn@latest docs <component>` 2. **Primitives** → MCP `plugin-shadcn-shadcn` + `pnpm dlx shadcn@latest docs <component>`
3. CLI add из `apps/web` → adapt 3. CLI add из `apps/web` → adapt
## Shared / kit (цель) **Новая страница** → сначала ReUI PRO `compose_page` / blocks ([reui.io/blocks](https://reui.io/blocks)), не ui.shadcn.com/blocks как primary.
| Component | Путь | ## Shared / kit (обязательно)
| Component | Файл |
|-----------|------| |-----------|------|
| `PageShell` | `page-shell.tsx` |
| `ResourcePage` | `reui-kit/resource-page.tsx` | | `ResourcePage` | `reui-kit/resource-page.tsx` |
| `KpiStatGrid` | `reui-kit/kpi-stat-grid.tsx` | | `OpsDashboard` / `KpiStatGrid` / `QuickActionGrid` | `reui-kit/ops-dashboard.tsx`, `kpi-stat-grid.tsx`, `quick-action-grid.tsx` |
| `OpsDashboard` | `reui-kit/ops-dashboard.tsx` | | `KanbanBoard` | `reui-kit/kanban-board.tsx` |
| `SettingsShell` | `reui-kit/settings-shell.tsx` |
| `DetailPanel` | `reui-kit/detail-panel.tsx` | | `DetailPanel` | `reui-kit/detail-panel.tsx` |
| `SettingsShell` | `reui-kit/settings-shell.tsx` |
| `EmptyState` | `empty-state.tsx` |
| `QueryState` | `query-state.tsx` |
| `ConfirmDialog` | `confirm-dialog.tsx` |
| `StatusBadge` | `status-badge.tsx` |
| `FormSheet` | `form-sheet.tsx` |
| `FormField` | `form-field.tsx` |
| `LoadingButton` | `loading-button.tsx` |
| `SettingRow` | `setting-row.tsx` |
**Не использовать как эталон:** устаревшие `PageHeader` / `SectionCards` / `DataGridCard` / `TableCard` (если удалены или мёртвы).
**Overlay:** Sheet — forms; AlertDialog — destructive confirm.
## Шаг 1 — CLI
```bash
cd apps/web
pnpm dlx shadcn@latest add button field input ...
pnpm dlx shadcn@latest add @reui/frame @reui/data-grid @reui/filters
pnpm dlx shadcn@latest add @reui/stats-12 @reui/card-35 @reui/settings-16 @reui/auth-13
pnpm dlx shadcn@latest apply b2fA --only theme -y
```
## Приоритет композиции ## Приоритет композиции
1. `@evofw/ui/components/*` 1. `@evofw/ui/components/*` (primitives)
2. ReUI PRO block → adapt 2. ReUI PRO block → adapt (`blocks/` reference + kit)
3. `reui-kit/*` + shared 3. `reui-kit/*` + shared
4. Domain-обёртка 4. Domain-обёртка
@@ -46,22 +73,30 @@ alwaysApply: false
| ❌ | ✅ | | ❌ | ✅ |
|----|-----| |----|-----|
| raw `<table>` | DataGrid / `ResourcePage` | | `<table>`, raw select | DataGrid / `Select` |
| `bg-emerald-*` | semantic / ReUI `variant` | | `bg-emerald-*` | semantic / ReUI `variant` |
| Card как ops-shell | ReUI `Frame` | | Card как ops-shell | ReUI `Frame` |
| Hand-roll KPI | `KpiStatGrid` / [stats-12](https://reui.io/preview/base/stats-12) | | Hand-roll KPI | `KpiStatGrid` / [stats-12](https://reui.io/preview/base/stats-12) |
| `space-y-*` | `flex` + `gap-*` | | `space-y-*` | `flex` + `gap-*` |
| `@/components/ui/*` | `@evofw/ui/components/*` | | `@/components/ui/*` | `@evofw/ui/components/*` |
## Эталоны ## Эталоны проекта
| Зона | Preview | | Зона | Файл | Preview |
|------|---------| |------|------|---------|
| Shell | https://reui.io/preview/base/app-shell-12 | | Shell | `layout/app-shell.tsx` | [app-shell-12](https://reui.io/preview/base/app-shell-12) |
| Login | https://reui.io/preview/base/auth-13 | | Login | `routes/login.tsx` | [auth-13](https://reui.io/preview/base/auth-13) |
| Dashboard / KPI | https://reui.io/preview/base/stats-12 | | Dashboard | `routes/_auth/index.tsx` | [stats-12](https://reui.io/preview/base/stats-12) / dashboard-1 |
| Lists | https://reui.io/preview/base/data-grid-filtering-2 | | Lists | `ResourcePage` | [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) |
| Settings | https://reui.io/preview/base/settings-16 | | Settings | `settings/integrations.tsx` | [settings-16](https://reui.io/preview/base/settings-16) |
| Empty | https://reui.io/preview/base/empty-state-12 |
См. [`reui-mcp.mdc`](reui-mcp.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc). ## Чеклист
- [ ] `user-reui` + preview/docs для зоны
- [ ] Frame surface
- [ ] CLI add при новых items
- [ ] `pnpm --filter @evofw/web build`
## Язык
Ответы — русский. Commits — [`commit-messages-ru.mdc`](commit-messages-ru.mdc).
+170 -28
View File
@@ -1,52 +1,194 @@
--- ---
description: Frontend UI patterns — Frame surface, kit, spacing, states description: Единые UI-паттерны web — shared components, docs workflow, матрица стандартизации
globs: globs: apps/web/**/*
- apps/web/**
- packages/ui/**
alwaysApply: false alwaysApply: false
--- ---
# Frontend UI Patterns # Frontend UI Patterns
См. [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`reui-mcp.mdc`](reui-mcp.mdc), [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md), [llms.txt](https://reui.io/llms.txt). См. также: [`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 / поиск существующих shared/domain / `reui-kit`
1. Skill ReUI + shadcn — component selection
2. **Primary:** MCP `user-reui` — search → `get_block` / `compose_page` (`surface: "frame"`) — **всегда cite `previewUrl` + `docsUrl`**
3. Primitives: MCP `plugin-shadcn-shadcn` + `pnpm dlx shadcn@latest docs <component>`
4. CLI: `cd apps/web && pnpm dlx shadcn@latest add @reui/...` → сверить API
5. Код по examples + docs (только после MCP ↔ docs)
6. Context7 — **только** TanStack Router/Query, Recharts
7. MCP `validate_usage` / `get_audit_checklist` — перед merge
## Surface ## Surface
**`surface: frame`**. Ops / list / dashboard / detail / settings — ReUI Frame, не Card shell. Проект использует **ReUI Frame** (`surface: frame`), не shadcn Card как оболочку list/ops-экранов. Эталон списка: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2).
## Docs workflow ## Обязательные референсы по зонам
0. Поиск `reui-kit/*` | Зона | Preview |
1. MCP **`user-reui`** — search → `get_block` / `compose_page` (`surface: "frame"`) — cite `previewUrl` + `docsUrl` |------|---------|
2. Primitives: `plugin-shadcn-shadcn` + CLI docs | KPI | [stats-12](https://reui.io/preview/base/stats-12) — EvoBGP hybrid SoT |
3. CLI: `cd apps/web && pnpm dlx shadcn@latest add @reui/...` | Quick Actions | [stats-12](https://reui.io/preview/base/stats-12) · [card-12](https://reui.io/preview/base/card-12) → `QuickActionGrid` |
4. Adapt в kit — не hand-roll | List | [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) |
5. Context7 — только TanStack / Recharts | Settings | [settings-16](https://reui.io/preview/base/settings-16), [Application Settings](https://reui.io/blocks/application/settings) |
6. `validate_usage` / `get_audit_checklist` | Settings rows / Health-check | [settings-2](https://reui.io/preview/base/settings-2), [settings-3](https://reui.io/preview/base/settings-3) |
| Auth | [auth-13](https://reui.io/preview/base/auth-13) |
| Empty | [empty-state-12](https://reui.io/preview/base/empty-state-12) |
| Forms | [form-7](https://reui.io/preview/base/form-7) |
| Shell | [app-shell-12](https://reui.io/preview/base/app-shell-12) |
## Матрица **SettingRow:** `FieldSeparator` opt-in (`separated`); не между toggle-row и nested fields (Health-check). Settings-секции — отдельные Frame + `gap`, без hairline под PageHeader.
| Элемент | Shared | Preview | ## Иерархия компонентов
|---------|--------|---------|
```
@evofw/ui/components/* ← shadcn CLI (packages/ui)
@/components/reui/* ← ReUI CLI @reui/* (apps/web)
apps/web/src/components/ ← shared + domain + layout
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 ← KPI stats-12 + charts
catalog-board-toggle.tsx
empty-state.tsx
query-state.tsx
confirm-dialog.tsx
status-badge.tsx
form-sheet.tsx
form-field.tsx
loading-button.tsx
layout/ ← app-shell, site-header, app-sidebar
domain-* ← бизнес-компоненты
```
## Матрица стандартизации
| Элемент | Shared | Primitive |
|---------|--------|-----------|
| Page wrapper | `PageShell` | — | | Page wrapper | `PageShell` | — |
| KPI | `reui-kit/KpiStatGrid` | [stats-12](https://reui.io/preview/base/stats-12) | | List page | `ResourcePage` | ReUI `Frame` + `data-grid` + `filters` + shadcn `Tabs` `variant="line"` |
| Data list | `reui-kit/ResourcePage` | [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) | | Catalog / Board | `CatalogBoardToggle` + `ResourcePage` / `KanbanBoard` | `?view=board` на `/groups`, `/services` |
| Dashboard | `reui-kit/OpsDashboard` | [dashboard-1](https://reui.io/preview/base/dashboard-1) | | Kanban | `KanbanBoard` / `KanbanBoardSkeleton` | ReUI `kanban` + `Frame` |
| Settings | `reui-kit/SettingsShell` | [settings-16](https://reui.io/preview/base/settings-16) | | Detail | `DetailPanel` | ReUI `Frame` |
| Empty | `EmptyState` | [empty-state-12](https://reui.io/preview/base/empty-state-12) | | Settings | `SettingsShell` | — |
| Status | `StatusBadge` | `@reui/badge` | | Dashboard KPI | `OpsDashboard` / `KpiStatGrid` | ReUI Frame [stats-12](https://reui.io/preview/base/stats-12) hybrid |
| Nav | `AppShell` | [app-shell-12](https://reui.io/preview/base/app-shell-12) | | Quick Actions | `QuickActionGrid` | Frame tiles + Badge «Перейти» |
| Empty | `EmptyState` | `Empty` |
| 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` |
| Delete confirm | `ConfirmDialog` | `AlertDialog` |
| 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`.
Toggle «Доска» / «К каталогу» — всегда `outline` в `primaryAction` / `KanbanBoard.toolbarActions` (не отдельный Frame-shell).
## Line tabs (project standard)
Эталон: [c-tabs-2](https://reui.io/preview/base/components/c-tabs-2) + counted [filtering-2](https://reui.io/preview/base/data-grid-filtering-2).
```tsx
<TabsList variant="line" className="gap-5">
<TabsTrigger value="…" className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3">
<span>Label</span>
<span className="bg-muted text-muted-foreground … tabular-nums rounded-md">{count}</span>
</TabsTrigger>
</TabsList>
```
- Примитив: [`packages/ui/.../tabs.tsx`](../../packages/ui/src/components/tabs.tsx) — line = `h-auto`, без `flex-1`, без `dark:data-active:bg-input/30`, underline `after:bottom-0`
- Active = яркий текст + **foreground underline**, без фона
- Count pill всегда `bg-muted`
- Active state — Base UI `data-active`, не Radix `data-[state=active]`
- Не трогать internals `reui/date-selector`
## Catalog / Board (`/groups`, `/services`)
| Режим | UI | Search |
|-------|-----|--------|
| Catalog (default) | `ResourcePage` + primary create | omit / `view=catalog` |
| Board | `KanbanBoard` DnD | `?view=board` |
- Groups tabs: Все / С доменами / Пустые
- Services tabs: Все / Включены / Выключены / Без группы
- DnD только на board; kanban hooks/cards не удалять
## Dashboard KPI
Эталон: [stats-12](https://reui.io/preview/base/stats-12) — icon tile + value + label + optional badge/footer; клик через `to` (`Link`) / `onSelect`. Compact strip: [card-35](https://reui.io/preview/base/card-35).
## Sidebar
- Каждый `SidebarMenuButton` получает `isActive` от pathname (`useRouterState`)
- `--sidebar-accent` в `AppShell` — заметный mix primary (~14%), не 5%
## Overlay selection
| Сценарий | Компонент |
|----------|-----------|
| Create/edit форма | `Sheet` |
| Destructive confirm | `AlertDialog` via `ConfirmDialog` |
| Modal preview | `Dialog` |
## Block registry
| Зона | 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) |
| Line tabs | [c-tabs-2](https://reui.io/preview/base/components/c-tabs-2) |
| KPI | [stats-12](https://reui.io/preview/base/stats-12) — на dashboard, certificates, domains, groups, services |
| Login | [auth-13](https://reui.io/preview/base/auth-13) |
| Settings | [settings-16](https://reui.io/preview/base/settings-16) / [settings-6](https://reui.io/preview/base/settings-6) |
| Empty | [empty-state-12](https://reui.io/preview/base/empty-state-12) |
## Spacing ## Spacing
``` ```
AppShell main: gap-4 md:gap-6, px-4 md:px-6, py-4 md:py-5 AppShell main: gap-4 md:gap-6, px-4 md:px-6, py-4 md:py-5
(--sidebar-width: 240px; header h-12; AppSwitcher + AppsMenu — Shared App Shell chrome)
PageShell: gap-4 md:gap-6 PageShell: gap-4 md:gap-6
Toolbar: gap-2 Title/desc: gap-px
Card/Frame grid: gap-4 (dashboard denser: gap-2 md:gap-3)
FieldGroup: gap-4
Item list: gap-2
Toolbar / header actions: gap-2
Tabs list (line): gap-5
``` ```
**Запрещено:** `space-y-*`, raw colors, Card как ops-shell, page-level Spinner без Skeleton. Shared chrome (vps-tracker / CFDM / EvoBGP): см. [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md) — секция **Shared App Shell chrome**. Preview: [app-shell-12](https://reui.io/preview/base/app-shell-12).
## Button hierarchy **Запрещено:** `space-y-*`, raw colors (`bg-emerald-*`), custom empty divs, page-level Spinner / plain «Загрузка…» без Skeleton.
Max 1 primary (`default`) на экран; остальные `outline` / `ghost`. ## UX/UI (состояния данных)
Каждый блок: **default, hover, focus, disabled, empty, loading, error**.
- **Loading** — `Skeleton` / `ResourcePage` skeleton / `KanbanBoardSkeleton` / `OpsDashboard` skeleton, не Spinner на странице
- **Empty** — `EmptyState` с CTA
- **Zero-results** — message внутри DataGrid (+ «Сбросить»)
- **Error** — `QueryState` / `Alert` + `onRetry`
- **Overflow** — `truncate`, `max-w-*`, `Tooltip`; `tabular-nums` для чисел
- **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)
1. `default` — главный CTA
2. `outline` — вторичные действия
3. `ghost` / `link` — навигация, cancel
4. `destructive` — только с `ConfirmDialog`
+19 -11
View File
@@ -7,9 +7,7 @@ alwaysApply: true
Проект: **Base UI** (`style: base-nova`), surface lock **`frame`**. Проект: **Base UI** (`style: base-nova`), surface lock **`frame`**.
Связанные: [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`frontend-monorepo.mdc`](frontend-monorepo.mdc), [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc), [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md). Связанные: [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`frontend-monorepo.mdc`](frontend-monorepo.mdc), [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc).
Skill: [`.agents/skills/reui/SKILL.md`](../../.agents/skills/reui/SKILL.md) (версия из `get_agent_skill`; обновление: `curl -fsSL https://mcp.reui.io/install | node -`).
| Документ | URL | | Документ | URL |
|----------|-----| |----------|-----|
@@ -20,7 +18,6 @@ Skill: [`.agents/skills/reui/SKILL.md`](../../.agents/skills/reui/SKILL.md) (в
| **Blocks** | https://reui.io/blocks | | **Blocks** | https://reui.io/blocks |
| **Settings blocks** | https://reui.io/blocks/application/settings | | **Settings blocks** | https://reui.io/blocks/application/settings |
| **License** | https://reui.io/docs/license-setup | | **License** | https://reui.io/docs/license-setup |
| **Agent skills** | https://reui.io/docs/agent-skills |
| **Base UI components** | https://reui.io/docs/components/base/<name> | | **Base UI components** | https://reui.io/docs/components/base/<name> |
## Primary MCP ## Primary MCP
@@ -39,15 +36,17 @@ Skill: [`.agents/skills/reui/SKILL.md`](../../.agents/skills/reui/SKILL.md) (в
| Data Grid | `@reui` | `@/components/reui/data-grid/*` → `ResourcePage` | | Data Grid | `@reui` | `@/components/reui/data-grid/*` → `ResourcePage` |
| Filters | `@reui` | `@/components/reui/filters` | | Filters | `@reui` | `@/components/reui/filters` |
| Frame surface | `@reui` | `@/components/reui/frame` | | Frame surface | `@reui` | `@/components/reui/frame` |
| KPI | block [stats-12](https://reui.io/preview/base/stats-12) | `reui-kit/KpiStatGrid` | | KPI | block [stats-12](https://reui.io/preview/base/stats-12) | `reui-kit/KpiStatGrid` (EvoBGP hybrid SoT) |
| Quick Actions | Frame tiles sibling KPI | `reui-kit/QuickActionGrid` |
| Semantic badge / alert | `@reui` | `@/components/reui/badge`, `@/components/reui/alert` | | Semantic badge / alert | `@reui` | `@/components/reui/badge`, `@/components/reui/alert` |
| Number / date / autocomplete / color / kanban | `@reui` | `@/components/reui/*` | | Number / date / autocomplete / color / kanban | `@reui` | `@/components/reui/*` |
**Сложные списки** — `ResourcePage` (Frame + data-grid + filters), не raw `<table>`. **Сложные списки** — `ResourcePage` (Frame + data-grid + filters), не raw `<table>`, не DataGridCard.
**Quick Actions** — только `QuickActionGrid` (не Card / Button grid).
## MCP workflow ## MCP workflow
0. Поиск существующих `reui-kit/*`, `@/components/reui/*` 0. Codegraph / поиск существующих `reui-kit/*`, `@/components/reui/*`
1. `user-reui` `search` (`surface: "frame"`, `category` при известном) 1. `user-reui` `search` (`surface: "frame"`, `category` при известном)
2. Страница целиком → `compose_page`; секция → `get_block` 2. Страница целиком → `compose_page`; секция → `get_block`
3. `get_component` для API primitives из `componentsUsed` 3. `get_component` для API primitives из `componentsUsed`
@@ -65,10 +64,18 @@ Skill: [`.agents/skills/reui/SKILL.md`](../../.agents/skills/reui/SKILL.md) (в
| PRO blocks (reference) | `apps/web/src/components/blocks/` | adapt into kit, не копипаст в routes | | PRO blocks (reference) | `apps/web/src/components/blocks/` | adapt into kit, не копипаст в routes |
| Kit | `apps/web/src/components/reui-kit/` | `@/components/reui-kit/*` | | Kit | `apps/web/src/components/reui-kit/` | `@/components/reui-kit/*` |
## Установленные ReUI (apps/web)
**Components:** `frame`, `data-grid/*`, `filters`, `kanban`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `KanbanBoard`, `DetailPanel`, `SettingsShell`
**Blocks (reference):** `stats-12`, `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, …
## License ## License
```env ```env
# apps/web/.env.local (gitignored) # .env.local (gitignored)
REUI_LICENSE_KEY= REUI_LICENSE_KEY=
``` ```
@@ -78,7 +85,7 @@ REUI_LICENSE_KEY=
| Зона | Preview | | Зона | Preview |
|------|---------| |------|---------|
| KPI | https://reui.io/preview/base/stats-12 | | KPI / Quick Actions | https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-12 |
| List | https://reui.io/preview/base/data-grid-filtering-2 | | List | https://reui.io/preview/base/data-grid-filtering-2 |
| Settings | https://reui.io/preview/base/settings-16 | | Settings | https://reui.io/preview/base/settings-16 |
| Auth | https://reui.io/preview/base/auth-13 | | Auth | https://reui.io/preview/base/auth-13 |
@@ -91,8 +98,9 @@ REUI_LICENSE_KEY=
- ReUI в `packages/ui` / импорт как `@evofw/ui` - ReUI в `packages/ui` / импорт как `@evofw/ui`
- Radix-варианты docs — только Base UI - Radix-варианты docs — только Base UI
- Raw `bg-emerald-*` вместо ReUI `variant` - Raw `bg-emerald-*` вместо ReUI `variant`
- Hand-roll data-grid/filters/KPI/settings rows при наличии `@reui` / kit - Hand-roll data-grid/filters/KPI/Quick Actions/settings rows при наличии `@reui` / kit
- Смешивать Card и Frame на ops-экране - Смешивать Card и Frame на ops-экране
- Ставить shadcn/ui blocks выше ReUI PRO
## Чеклист ## Чеклист
@@ -100,4 +108,4 @@ REUI_LICENSE_KEY=
- [ ] `surface: frame` - [ ] `surface: frame`
- [ ] CLI add из `apps/web` при новом item - [ ] CLI add из `apps/web` при новом item
- [ ] Kit / `@/components/reui` / `@evofw/ui` — правильный слой - [ ] Kit / `@/components/reui` / `@evofw/ui` — правильный слой
- [ ] `pnpm --filter web build` (после init Vite) - [ ] `pnpm --filter @evofw/web build`
+2 -1
View File
@@ -3,6 +3,7 @@ description: Use the ReUI registry (blocks, primitives, icons) correctly
globs: ["**/*.tsx","**/*.ts"] globs: ["**/*.tsx","**/*.ts"]
alwaysApply: false alwaysApply: false
--- ---
--- ---
name: reui name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs. description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
@@ -42,7 +43,7 @@ Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain
| Command | Invoke | Does | | Command | Invoke | Does |
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ | | ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan в†’ install в†’ read API в†’ adapt в†’ craft в†’ audit. | | **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan install read API adapt craft audit. |
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. | | **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. | | **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). | | **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
+23 -29
View File
@@ -1,50 +1,44 @@
--- ---
description: ВСЕГДА использовать MCP-плагин shadcn UI перед любым UI-кодом description: shadcn MCP — primitives secondary; pages/blocks → user-reui first
alwaysApply: true alwaysApply: true
--- ---
# shadcn MCP — обязательно # shadcn MCP — primitives (secondary)
Перед **любой** задачей с UI (новый экран, компонент, стили, рефакторинг внешнего вида) — **сначала MCP** `plugin-shadcn-shadcn`, не память и не веб-поиск. Перед UI-задачей: **сначала** MCP `user-reui` для pages / KPI / lists / settings / Frame ([`reui-mcp.mdc`](reui-mcp.mdc)).
MCP `plugin-shadcn-shadcn` — для **primitives** `@shadcn` и registry sync, не вместо PRO blocks.
CLI и docs — **после** MCP, по команде из `get_add_command_for_items`. CLI и docs — **после** MCP, по команде из `get_add_command_for_items`.
Enterprise / PRO (Data Grid, Filters, Frame, blocks) — дополнительно [`reui-mcp.mdc`](reui-mcp.mdc) и MCP **`user-reui`**. ## Порядок (primitives)
## Порядок (строго)
0. Поиск существующих shared / `reui-kit` 0. Поиск существующих shared / `reui-kit`
1. **`get_project_registries`** — какие registry доступны (`@shadcn`, `@reui`) 1. **`get_project_registries`**
2. **`search_items_in_registries`** — компонент, block, example 2. **`search_items_in_registries`** — primitive / example
3. **`get_item_examples_from_registries`** — полный код примера перед написанием JSX 3. **`get_item_examples_from_registries`**
4. **`get_add_command_for_items`** — точная CLI-команда `pnpm dlx shadcn@latest add ...` 4. **`get_add_command_for_items`** → CLI из `apps/web`
5. Выполнить add из `apps/web` (см. [`frontend-monorepo.mdc`](frontend-monorepo.mdc)) 5. `pnpm dlx shadcn@latest docs <component>` — сверить API
6. **CLI docs:** `cd apps/web && pnpm dlx shadcn@latest docs <component>` — сверить API с [ui.shadcn.com/docs/components](https://ui.shadcn.com/docs/components) 6. Адаптировать под TanStack → `apps/web/src/`
7. Сверить examples из MCP с API из docs CLI — реализовать только после совпадения 7. Context7 — только TanStack / Recharts / не-shadcn
8. Адаптировать пример под TanStack Router / Query → `apps/web/src/` 8. **`get_audit_checklist`** — перед merge
9. **Context7** — только TanStack / Recharts / не-shadcn
10. **`get_audit_checklist`** — перед merge PR
## Когда вызывать MCP ## Когда
| Задача | MCP | | Задача | MCP |
|--------|-----| |--------|-----|
| Новая страница / layout | `user-reui` compose/block **или** shadcn `search` → block | | Новая страница / KPI / list / settings | **`user-reui`** ([`reui-mcp.mdc`](reui-mcp.mdc)) |
| Нет примитива в `@evofw/ui` | `plugin-shadcn-shadcn` → add | | Нет примитива в `@evofw/ui` | `plugin-shadcn-shadcn` → add |
| Data Grid, Filters, Frame, KPI | `user-reui` ([`reui-mcp.mdc`](reui-mcp.mdc)) | | Сомнение в props примитива | examples + docs CLI |
| Сомнение в API/props | examples + `get_component` |
| Ревью UI перед сдачей | `get_audit_checklist` / ReUI `validate_usage` |
## Запрещено ## Запрещено
- Писать UI по памяти, не проверив MCP - Писать UI по памяти
- Копипаст с ui.shadcn.com / reui.io без CLI - Ставить ui.shadcn.com/blocks выше ReUI PRO
- Самописные примитивы, если есть item в registry - Самописные примитивы при наличии registry item
- Пропускать MCP «потому что компонент простой»
## Сервер и инструменты ## Сервер
- **MCP:** `plugin-shadcn-shadcn` + `user-reui` - **MCP:** `plugin-shadcn-shadcn` (+ `user-reui` primary)
- Перед вызовом — schema инструмента - Schema: `mcps/plugin-shadcn-shadcn/tools/`
Связанные: [`shadcn-ui-production.mdc`](shadcn-ui-production.mdc), [`reui-mcp.mdc`](reui-mcp.mdc), skill `.agents/skills/shadcn`. Связанные: [`shadcn-ui-production.mdc`](shadcn-ui-production.mdc), [`reui-mcp.mdc`](reui-mcp.mdc), [`frontend-shadcn.mdc`](frontend-shadcn.mdc).
+14 -8
View File
@@ -1,21 +1,23 @@
--- ---
description: shadcn/ui + ReUI PRO — глобальные UI-принципы EvoFirewall description: shadcn/ui + ReUI PRO — глобальные UI-принципы; ReUI PRO выше shadcn
alwaysApply: true alwaysApply: true
--- ---
# shadcn/ui — правила проекта # shadcn/ui + ReUI PRO — правила проекта
UI строится по [shadcn/ui](https://ui.shadcn.com/docs/installation) + **ReUI** (`@reui`): [Components](https://ui.shadcn.com/docs/components), [Blocks](https://ui.shadcn.com/blocks), [ReUI Get Started](https://reui.io/docs/get-started), [llms.txt](https://reui.io/llms.txt). UI строится по [shadcn/ui](https://ui.shadcn.com/docs/installation) + **ReUI PRO** (`@reui`): [Components](https://ui.shadcn.com/docs/components), [Blocks](https://ui.shadcn.com/blocks), [ReUI Get Started](https://reui.io/docs/get-started), [llms.txt](https://reui.io/llms.txt).
**Иерархия:** ReUI PRO (`user-reui`) **выше** базового shadcn. Pages / KPI / lists / settings / shell → ReUI; shadcn — primitives.
**Первый шаг UI-задачи:** **Первый шаг UI-задачи:**
1. MCP **`user-reui`** для pages / KPI / data-grid / settings / Frame (`surface: frame`) 1. MCP **`user-reui`** для pages / KPI / data-grid / settings / Frame / Quick Actions (`surface: frame`)
2. MCP **`plugin-shadcn-shadcn`** для primitives `@shadcn` 2. MCP **`plugin-shadcn-shadcn`** для primitives `@shadcn`
См. [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`reui-mcp.mdc`](reui-mcp.mdc). См. [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`reui-mcp.mdc`](reui-mcp.mdc). Contract: [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md).
## Frontend ## Frontend
Детали: [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc), [`frontend-monorepo.mdc`](frontend-monorepo.mdc), [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md). Детали: [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc), [`frontend-monorepo.mdc`](frontend-monorepo.mdc).
Кратко: MCP → CLI из `apps/web` → Block → kit → `pnpm --filter @evofw/web build`. Кастомный CSS и самописные примитивы **запрещены**. Кратко: MCP → CLI из `apps/web` → Block → kit → `pnpm --filter @evofw/web build`. Кастомный CSS и самописные примитивы **запрещены**.
@@ -23,10 +25,14 @@ UI строится по [shadcn/ui](https://ui.shadcn.com/docs/installation) +
- Monorepo: `apps/web` + `packages/ui` (`@evofw/ui`), pnpm workspaces - Monorepo: `apps/web` + `packages/ui` (`@evofw/ui`), pnpm workspaces
- Vite + TanStack Router/Query + shadcn **base-nova** + ReUI **@reui** - Vite + TanStack Router/Query + shadcn **base-nova** + ReUI **@reui**
- Конфиг: [`apps/web/components.json`](../../apps/web/components.json), [`packages/ui/components.json`](../../packages/ui/components.json) - Конфиг: [`apps/web/components.json`](apps/web/components.json), [`packages/ui/components.json`](packages/ui/components.json)
- Тема: `pnpm dlx shadcn@latest apply b2fA --only theme -y`; ReUI tokens — [Styling](https://reui.io/docs/styling) - Тема: `pnpm dlx shadcn@latest apply b2fA --only theme -y`; ReUI tokens — [Styling](https://reui.io/docs/styling)
- License: `REUI_LICENSE_KEY` в `apps/web/.env.local` - License: `REUI_LICENSE_KEY` в `apps/web/.env.local`
## Backend → UI
При правках API с экранами: [`docs/ui-design-contract.md`](docs/ui-design-contract.md). Backend: `apps/api` (Fastify + Drizzle).
## Язык ## Язык
Русский. Русский. Commits: [`commit-messages-ru.mdc`](commit-messages-ru.mdc).
+3 -1
View File
@@ -24,7 +24,9 @@ pnpm --filter @evofw/web build
## ReUI PRO ## ReUI PRO
Surface: **frame**. Contract: `docs/ui-design-contract.md`. MCP `user-reui` + CLI из `apps/web`. Surface: **frame**. Hierarchy: **ReUI PRO > shadcn**. Contract: `docs/ui-design-contract.md`.
MCP `user-reui` primary + `plugin-shadcn-shadcn` primitives. CLI из `apps/web`.
KPI/QuickActions: EvoBGP hybrid SoT (`KpiStatGrid`, `QuickActionGrid`).
## Docs ## Docs
+4 -2
View File
@@ -8,6 +8,7 @@ import {
SettingsIcon, SettingsIcon,
} from 'lucide-react' } from 'lucide-react'
import { AppSwitcher } from '@/components/app-switcher' import { AppSwitcher } from '@/components/app-switcher'
import { NavUser } from '@/components/layout/nav-user'
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -93,7 +94,8 @@ export function AppSidebar() {
<NavSection label="Операции" items={opsNav} pathname={pathname} /> <NavSection label="Операции" items={opsNav} pathname={pathname} />
<NavSection label="Система" items={systemNav} pathname={pathname} /> <NavSection label="Система" items={systemNav} pathname={pathname} />
</SidebarContent> </SidebarContent>
<SidebarFooter /> <SidebarFooter>
</Sidebar> <NavUser />
</SidebarFooter> </Sidebar>
) )
} }
+212
View File
@@ -0,0 +1,212 @@
import { Link } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
import { useTheme } from 'next-themes'
import {
ChevronsUpDownIcon,
LogOutIcon,
MonitorIcon,
MoonIcon,
PaletteIcon,
SettingsIcon,
SunIcon,
} from 'lucide-react'
import { cn } from '@evofw/ui/lib/utils'
import { Avatar, AvatarFallback } from '@evofw/ui/components/avatar'
import { Button } from '@evofw/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@evofw/ui/components/dropdown-menu'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@evofw/ui/components/sidebar'
import {
authPortalUrl,
getClaims,
isAuthEnabled,
logout,
} from '@/lib/auth'
/** Sidebar footer NavUser — theme in footer, not header. @see https://reui.io/preview/base/app-shell-1 */
const THEMES = [
{
value: 'light',
label: 'Светлая',
icon: <SunIcon className="size-3.5" aria-hidden />,
},
{
value: 'dark',
label: 'Тёмная',
icon: <MoonIcon className="size-3.5" aria-hidden />,
},
{
value: 'system',
label: 'Системная',
icon: <MonitorIcon className="size-3.5" aria-hidden />,
},
] as const
function ThemeSegmentedToggle() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
const currentTheme = mounted ? (theme ?? 'system') : 'system'
return (
<div
role="radiogroup"
aria-label="Тема"
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{THEMES.map(({ value, label, icon }) => {
const isActive = currentTheme === value
return (
<Button
key={value}
type="button"
role="radio"
aria-checked={isActive}
aria-label={label}
variant="ghost"
size="icon-xs"
onClick={() => setTheme(value)}
className={cn(
'rounded-full',
isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{icon}
</Button>
)
})}
</div>
)
}
function initials(source: string): string {
const base = source.trim()
if (!base) return '?'
const parts = base.split(/\s+/).filter(Boolean)
if (parts.length >= 2) {
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
}
return base.slice(0, 2).toUpperCase()
}
export function NavUser() {
const { isMobile } = useSidebar()
const authOn = isAuthEnabled()
const claims = getClaims()
const name = claims?.name?.trim() || (authOn ? 'Пользователь' : 'Гость')
const email = claims?.email?.trim() || (authOn ? '' : 'auth выключен')
const fallback = initials(name || email)
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton
size="lg"
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
/>
}
>
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">{fallback}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs text-muted-foreground">
{email || '—'}
</span>
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--anchor-width) min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center gap-2 py-2 font-normal text-foreground">
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">
{fallback}
</AvatarFallback>
</Avatar>
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs text-muted-foreground">
{email || '—'}
</span>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings" />}
>
<SettingsIcon aria-hidden />
Настройки
</DropdownMenuItem>
{authOn ? (
<DropdownMenuItem
nativeButton={false}
render={<a href={authPortalUrl()} />}
>
Auth Portal
</DropdownMenuItem>
) : null}
<DropdownMenuItem className="cursor-default focus:bg-transparent">
<PaletteIcon aria-hidden />
Тема
<div className="ml-auto">
<ThemeSegmentedToggle />
</div>
</DropdownMenuItem>
</DropdownMenuGroup>
{authOn ? (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => logout()}>
<LogOutIcon aria-hidden />
Выйти
</DropdownMenuItem>
</DropdownMenuGroup>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
@@ -11,7 +11,6 @@ import {
import { Separator } from '@evofw/ui/components/separator' import { Separator } from '@evofw/ui/components/separator'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover' import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { AppsMenu } from '@/components/layout/apps-menu' import { AppsMenu } from '@/components/layout/apps-menu'
import { ModeToggle } from '@/components/mode-toggle'
import { SidebarTrigger } from '@evofw/ui/components/sidebar' import { SidebarTrigger } from '@evofw/ui/components/sidebar'
export interface RouteBreadcrumbLoaderData { export interface RouteBreadcrumbLoaderData {
@@ -71,7 +70,7 @@ function useDynamicBreadcrumbLabels() {
}, [matches]) }, [matches])
} }
/** Header chrome — AppsMenu + SystemMonitor + ModeToggle (EvoBGP etalon). */ /** Header chrome — AppsMenu + SystemMonitor; theme in NavUser. */
export function SiteHeader() { export function SiteHeader() {
const pathname = useRouterState({ select: (s) => s.location.pathname }) const pathname = useRouterState({ select: (s) => s.location.pathname })
const dynamicLabels = useDynamicBreadcrumbLabels() const dynamicLabels = useDynamicBreadcrumbLabels()
@@ -114,7 +113,6 @@ export function SiteHeader() {
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<AppsMenu /> <AppsMenu />
<SystemMonitorPopover /> <SystemMonitorPopover />
<ModeToggle />
</div> </div>
</header> </header>
) )
-29
View File
@@ -1,29 +0,0 @@
import { Moon, Sun } from 'lucide-react'
import { useTheme } from 'next-themes'
import { Button } from '@evofw/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@evofw/ui/components/dropdown-menu'
export function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" />}>
<Sun className="size-5 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute size-5 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Сменить тему</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>Светлая</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>Тёмная</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>Системная</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
+6 -1
View File
@@ -10,7 +10,12 @@ export {
} from './resource-page' } from './resource-page'
export { export {
KpiStatGrid, KpiStatGrid,
type KpiStatCard, KpiStatCard,
KpiStatCardTile,
kpiStatItemKey,
type KpiStatItem,
type KpiStatCardData,
type KpiStatCard as KpiStatCardType,
type KpiStatVariant, type KpiStatVariant,
type OpsKpiCard, type OpsKpiCard,
} from './kpi-stat-grid' } from './kpi-stat-grid'
@@ -0,0 +1,10 @@
/** Shared grid column classes for hybrid KPI / Quick Actions tiles. */
export function kpiCols(count: number): string {
if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
}
@@ -1,40 +1,44 @@
import type { ReactNode } from 'react' import type { KeyboardEvent, ReactNode } from 'react'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { Frame, FramePanel } from '@/components/reui/frame' import { Frame, FramePanel } from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { Item, ItemMedia } from '@evofw/ui/components/item'
import { cn } from '@evofw/ui/lib/utils' import { cn } from '@evofw/ui/lib/utils'
import { kpiCols } from './kpi-cols'
import { Item, ItemMedia } from '@evofw/ui/components/item'
import { Skeleton } from '@evofw/ui/components/skeleton' import { Skeleton } from '@evofw/ui/components/skeleton'
export type KpiStatVariant = 'default' | 'warning' | 'destructive' export type KpiStatVariant = 'default' | 'warning' | 'destructive'
export interface KpiStatCard { /**
id: string * KPI tile data — horizontal compact hybrid (icon left + label/Badge + value).
label: string * @see https://reui.io/preview/base/stats-12
value: string | number */
hint?: string export type KpiStatItem = {
id?: string
label: ReactNode
value: ReactNode
hint?: ReactNode
to?: string to?: string
search?: Record<string, unknown> search?: Record<string, unknown>
onSelect?: () => void onSelect?: () => void
onClick?: () => void
selected?: boolean selected?: boolean
active?: boolean
icon?: ReactNode icon?: ReactNode
iconClassName?: string iconClassName?: string
/** Semantic color for the primary value */
variant?: KpiStatVariant variant?: KpiStatVariant
footer?: ReactNode footer?: ReactNode
} }
/** @deprecated Use KpiStatCard */ /** CFDM-compatible card shape (id required). */
export type OpsKpiCard = KpiStatCard export type KpiStatCardData = KpiStatItem & { id: string }
interface KpiStatGridProps { /** @deprecated Use KpiStatCardData */
cards: KpiStatCard[] export type OpsKpiCard = KpiStatCardData
isLoading?: boolean
emptyMessage?: ReactNode /** @deprecated Use KpiStatCardData — type alias for CFDM kit parity */
emptyIcon?: ReactNode export type KpiStatCard = KpiStatCardData
className?: string
skeletonCount?: number
}
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current' const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
@@ -44,107 +48,146 @@ const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
destructive: 'text-destructive', destructive: 'text-destructive',
} }
function kpiCols(count: number): string { function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
if (count <= 1) return 'grid-cols-1' if (event.key === 'Enter' || event.key === ' ') {
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2' event.preventDefault()
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3' onActivate()
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4' }
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
} }
function resolveFooter(card: KpiStatCard): ReactNode { function resolveActivate(item: KpiStatItem): (() => void) | undefined {
if (card.footer) return card.footer return item.onClick ?? item.onSelect
if (card.hint) { }
function isSelected(item: KpiStatItem): boolean {
return Boolean(item.selected ?? item.active)
}
function resolveFooter(item: KpiStatItem): ReactNode {
if (item.footer) return item.footer
if (typeof item.hint === 'string') {
return ( return (
<Badge variant="outline" size="sm"> <Badge variant="outline" size="sm">
{card.hint} {item.hint}
</Badge> </Badge>
) )
} }
if (item.hint) return item.hint
return null return null
} }
function KpiStatCardBody({ card }: { card: KpiStatCard }) { function KpiStatCardBody({ item }: { item: KpiStatItem }) {
const footer = resolveFooter(card) const footer = resolveFooter(item)
const valueVariant = card.variant ?? 'default' const valueVariant = item.variant ?? 'default'
return ( return (
<div className="relative z-10 flex h-full items-start gap-3"> <div className="relative z-10 flex h-full items-start gap-3">
{card.icon ? ( {item.icon ? (
<Item <Item
className={cn( className={cn(
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4', 'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
card.iconClassName ?? DEFAULT_ICON_CLASS, item.iconClassName ?? DEFAULT_ICON_CLASS,
)} )}
> >
<ItemMedia variant="icon" className="size-auto"> <ItemMedia variant="icon" className="size-auto">
{card.icon} {item.icon}
</ItemMedia> </ItemMedia>
</Item> </Item>
) : null} ) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5"> <div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<span className="text-muted-foreground text-sm font-medium">{card.label}</span> <div className="text-muted-foreground text-sm font-medium">{item.label}</div>
{footer ? <div className="shrink-0">{footer}</div> : null} {footer ? <div className="shrink-0">{footer}</div> : null}
</div> </div>
<span <div
className={cn( className={cn(
'text-2xl leading-none font-bold tabular-nums', 'text-2xl leading-none font-bold tabular-nums',
VALUE_VARIANT_CLASS[valueVariant], VALUE_VARIANT_CLASS[valueVariant],
)} )}
> >
{card.value} {item.value}
</span> </div>
</div> </div>
</div> </div>
) )
} }
function KpiStatCardItem({ card }: { card: KpiStatCard }) { function panelClassName(item: KpiStatItem, className?: string) {
const interactive = Boolean(card.to || card.onSelect) const onActivate = resolveActivate(item)
const panelClass = cn( const clickable = Boolean(item.to || onActivate)
'relative isolate flex h-full flex-col', const selected = isSelected(item)
card.selected && 'ring-primary/30 bg-muted/30 ring-1',
interactive &&
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
)
if (card.to) { return cn(
return ( 'relative isolate flex h-full flex-col',
clickable &&
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
selected && 'ring-primary/30 bg-muted/30 ring-1',
className,
)
}
/** Single KPI tile — used for embedded / standalone contexts. */
export function KpiStatCardTile({
item,
embedded = false,
className,
}: {
item: KpiStatItem
embedded?: boolean
className?: string
}) {
const onActivate = resolveActivate(item)
const panelClass = panelClassName(item, className)
let panel: ReactNode
if (item.to) {
panel = (
<FramePanel className={panelClass}> <FramePanel className={panelClass}>
<Link <Link to={item.to} search={item.search} className="focus-visible:outline-none">
to={card.to} <KpiStatCardBody item={item} />
{...(card.search ? { search: card.search } : {})}
className="focus-visible:outline-none"
>
<KpiStatCardBody card={card} />
</Link> </Link>
</FramePanel> </FramePanel>
) )
} } else if (onActivate) {
panel = (
if (card.onSelect) { <FramePanel
return ( className={panelClass}
onClick={onActivate}
role="button"
tabIndex={0}
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
>
<KpiStatCardBody item={item} />
</FramePanel>
)
} else {
panel = (
<FramePanel className={panelClass}> <FramePanel className={panelClass}>
<button <KpiStatCardBody item={item} />
type="button"
onClick={card.onSelect}
className="w-full text-start focus-visible:outline-none"
>
<KpiStatCardBody card={card} />
</button>
</FramePanel> </FramePanel>
) )
} }
return ( if (embedded) {
<FramePanel className={panelClass}> return <Frame className="h-full ring-1 ring-foreground/10">{panel}</Frame>
<KpiStatCardBody card={card} /> }
</FramePanel>
) return <Frame className="h-full">{panel}</Frame>
}
/** @deprecated Prefer KpiStatCardTile — kept for existing imports */
export function KpiStatCard({
item,
embedded = false,
className,
}: {
item: KpiStatItem
embedded?: boolean
className?: string
}) {
return <KpiStatCardTile item={item} embedded={embedded} className={className} />
} }
function KpiStatGridSkeleton({ count }: { count: number }) { function KpiStatGridSkeleton({ count }: { count: number }) {
@@ -168,23 +211,78 @@ function KpiStatGridSkeleton({ count }: { count: number }) {
) )
} }
interface KpiStatGridProps {
/** EvoBGP primary API */
items?: KpiStatItem[]
/** CFDM-compatible API */
cards?: KpiStatCardData[]
isLoading?: boolean
emptyMessage?: ReactNode
emptyIcon?: ReactNode
className?: string
skeletonCount?: number
/** Wrap each tile in its own Frame (analytics panels). */
embedded?: boolean
'aria-label'?: string
}
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
const onActivate = resolveActivate(item)
const panelClass = panelClassName(item)
if (item.to) {
return (
<FramePanel className={panelClass}>
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
<KpiStatCardBody item={item} />
</Link>
</FramePanel>
)
}
if (onActivate) {
return (
<FramePanel
className={panelClass}
onClick={onActivate}
role="button"
tabIndex={0}
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
return (
<FramePanel className={panelClass}>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
/** /**
* Hybrid KPI — EvoBGP visual (colored icon + Badge) + horizontal compact layout. * Hybrid KPI — EvoBGP visual + horizontal compact layout (icon left).
* Preview: https://reui.io/preview/base/stats-12 * Preview: https://reui.io/preview/base/stats-12
*/ */
export function KpiStatGrid({ export function KpiStatGrid({
items,
cards, cards,
isLoading = false, isLoading = false,
emptyMessage, emptyMessage,
emptyIcon, emptyIcon,
className, className,
skeletonCount = 4, skeletonCount = 4,
embedded = false,
'aria-label': ariaLabel,
}: KpiStatGridProps) { }: KpiStatGridProps) {
if (isLoading) { if (isLoading) {
return <KpiStatGridSkeleton count={skeletonCount} /> return <KpiStatGridSkeleton count={skeletonCount} />
} }
if (cards.length === 0) { const list = items ?? cards ?? []
if (list.length === 0 && (emptyMessage || emptyIcon)) {
return ( return (
<Frame dense spacing="sm" className={cn('w-full', className)}> <Frame dense spacing="sm" className={cn('w-full', className)}>
<FramePanel className="flex items-center gap-3 p-4"> <FramePanel className="flex items-center gap-3 p-4">
@@ -197,13 +295,31 @@ export function KpiStatGrid({
) )
} }
if (embedded) {
return (
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
{list.map((item, index) => (
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
))}
</div>
</section>
)
}
return ( return (
<Frame className={cn('@container w-full', className)}> <Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
<div className={cn('grid gap-2', kpiCols(cards.length))}> <div className={cn('grid gap-2', kpiCols(list.length || 1))}>
{cards.map((card) => ( {list.map((item, index) => (
<KpiStatCardItem key={card.id} card={card} /> <KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
))} ))}
</div> </div>
</Frame> </Frame>
) )
} }
export function kpiStatItemKey(item: KpiStatItem, index: number): string {
if (item.id) return item.id
if (typeof item.label === 'string') return item.label
return `kpi-${index}`
}
@@ -10,6 +10,7 @@ import {
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { Item, ItemMedia } from '@evofw/ui/components/item' import { Item, ItemMedia } from '@evofw/ui/components/item'
import { cn } from '@evofw/ui/lib/utils' import { cn } from '@evofw/ui/lib/utils'
import { kpiCols } from './kpi-cols'
export interface QuickActionItem { export interface QuickActionItem {
id: string id: string
@@ -30,16 +31,6 @@ interface QuickActionGridProps {
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current' const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
function kpiCols(count: number): string {
if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
}
function QuickActionBody({ action }: { action: QuickActionItem }) { function QuickActionBody({ action }: { action: QuickActionItem }) {
return ( return (
<div className="relative z-10 flex h-full items-start gap-3"> <div className="relative z-10 flex h-full items-start gap-3">
@@ -99,7 +90,7 @@ export function QuickActionGrid({
> >
<Link <Link
to={action.to} to={action.to}
{...(action.search ? { search: action.search } : {})} search={action.search}
className="focus-visible:outline-none" className="focus-visible:outline-none"
aria-label={`${action.title}: ${action.description}`} aria-label={`${action.title}: ${action.description}`}
> >
+70 -22
View File
@@ -1,71 +1,119 @@
# UI Design Contract (EvoFirewall) # UI Design Contract (ops apps)
Единый контракт ops-приложений семейства (vps-tracker / CFDM / EvoBGP / **EvoFirewall**). Surface: **ReUI Frame**. Kit: `apps/web/src/components/reui-kit/`. Единый контракт для **CFDM · vps-tracker · EvoBGP · EvoFirewall · auth-portal**.
Surface: **ReUI Frame**. Kit: `apps/web/src/components/reui-kit/`.
Иерархия: **ReUI PRO > shadcn primitives**.
Карта: [llms.txt](https://reui.io/llms.txt) · [Styling](https://reui.io/docs/styling) · [License](https://reui.io/docs/license-setup) · [Blocks](https://reui.io/blocks) · [MCP](https://reui.io/docs/mcp) · [Agent skills](https://reui.io/docs/agent-skills) Карта: [llms.txt](https://reui.io/llms.txt) · [Styling](https://reui.io/docs/styling) · [License](https://reui.io/docs/license-setup) · [Blocks](https://reui.io/blocks) · [MCP](https://reui.io/docs/mcp)
## Surface ## Surface
Project lock: **`surface: frame`**. Ops / list / dashboard / detail / settings — только **Frame**, не shadcn Card как shell. Не смешивать Card и Frame на одном ops-экране. Project lock: **`surface: frame`**. Ops / list / dashboard / detail / settings — только **Frame**, не shadcn Card как shell. Не смешивать Card и Frame на одном ops-экране.
```ts
// apps/web/src/lib/ui-surface.ts (где есть)
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).
## Canonical PRO references ## Canonical PRO references
| Зона | Block | Preview | | Зона | Block | Preview |
|------|-------|---------| |------|-------|---------|
| Shell | `app-shell-12` | https://reui.io/preview/base/app-shell-12 | | Shell | `app-shell-12` (+ cmdk/monitor где нужно) | https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/app-shell-7 |
| KPI | `stats-12` | https://reui.io/preview/base/stats-12 | | KPI | horizontal compact hybrid (EvoBGP SoT: icon left + label/Badge + value ± variant) | https://reui.io/preview/base/stats-12 |
| Quick Actions | Frame tiles (sibling KPI) + Badge «Перейти» | https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-12 |
| Dashboard | `dashboard-1` | https://reui.io/preview/base/dashboard-1 | | Dashboard | `dashboard-1` | https://reui.io/preview/base/dashboard-1 |
| Lists | `data-grid-filtering-2` | https://reui.io/preview/base/data-grid-filtering-2 | | Lists | `data-grid-filtering-2` | https://reui.io/preview/base/data-grid-filtering-2 |
| Settings | `settings-16` | https://reui.io/preview/base/settings-16 | | Settings | `settings-16` + SettingRow | https://reui.io/preview/base/settings-16 |
| Auth | `auth-13` | https://reui.io/preview/base/auth-13 | | Auth | `auth-13` | https://reui.io/preview/base/auth-13 |
| Empty | `empty-state-12` | https://reui.io/preview/base/empty-state-12 | | Empty | `empty-state-12` | https://reui.io/preview/base/empty-state-12 |
| Forms | `form-7` → Sheet | https://reui.io/preview/base/form-7 | | Forms | `form-7` → Sheet/Drawer | https://reui.io/preview/base/form-7 |
## Kit API (`reui-kit/`) — целевой ## Kit API (`reui-kit/`)
| Component | Role | | Component | Role |
|-----------|------| |-----------|------|
| `ResourcePage` | Frame + line tabs + Filters + DataGrid | | `ResourcePage` | Frame + line tabs + Filters + DataGrid |
| `KpiStatGrid` | KPI tiles | | `KpiStatGrid` | EvoBGP hybrid KPI tiles (`items`/`cards`, `variant`, Badge) |
| `OpsDashboard` | KPI + charts + attention | | `QuickActionGrid` | KPI-like quick action tiles (gated by `showQuickActions`) |
| `OpsDashboard` | KPI + optional `afterKpi` + charts + attention queue |
| `SettingsShell` | settings nav + Outlet | | `SettingsShell` | settings nav + Outlet |
| `DetailPanel` | detail Frame sections | | `DetailPanel` | detail Frame sections |
| `filter-utils` | apply/clear ReUI Filters |
`KpiStatGrid` / `QuickActionGrid` markup — SoT **EvoBGP**; в остальных apps diff только `@scope/ui` imports.
## Dashboard layout
| App | Section order |
|-----|---------------|
| EvoBGP / CFDM / EvoFirewall | KPI → **QuickActionGrid** → charts / rest |
| vps-tracker | banner → KPI → charts → attention → **QuickActionGrid** → CSV |
| auth-portal | portal-specific; Quick Actions при наличии dashboard |
Gating: DB `show_quick_actions` / `showQuickActions` / `ui_show_quick_actions` (default `true`).
## Shared App Shell chrome ## Shared App Shell chrome
Эталон: EvoBGP / CFDM + [app-shell-12](https://reui.io/preview/base/app-shell-12). Эталон разметки: production apps + ReUI [app-shell-12](https://reui.io/preview/base/app-shell-12).
При переключении между apps меняются **только** sidebar nav labels/hrefs и `main` content.
| Токен / зона | Значение | | Токен / зона | Значение |
|--------------|----------| |--------------|----------|
| `--sidebar-width` | `240px` | | `SIDEBAR_WIDTH` / `--sidebar-width` | `240px` |
| Header | `h-12`, sticky, `border-b` | | 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` |
| `main` | `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` | | `main` | `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` |
| Search | hotkey ⌘K / Ctrl+K only (не кнопка в header) |
App Switcher: auth-portal `GET /api/v1/app-switcher`. Планируемый app id: **`fw`**. Запрещено в chrome: `SidebarRail`, sync-row footer, Search pill в header, issues Badge в header, `ModeToggle` в header (тема только в NavUser), Provider `color-mix` для `--sidebar*`.
NavUser (footer): avatar + name/email; dropdown — Настройки / Тема (segmented) / Выйти. Preview: [app-shell-1](https://reui.io/preview/base/app-shell-1).
App Switcher: auth-portal `GET /api/v1/app-switcher`. Ids: `cfdm` · `vps` · `bgp` · `fw`. Admin: portal `/admin/apps`.
QuickActionGrid / KPI icons: только semantic **text** (`text-info` / `text-primary` / …) на kit `bg-muted` — без solid fills.
## System monitor
`SystemMonitorPopover` in header after AppsMenu. Preview: https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/app-shell-7
## MCP workflow ## MCP workflow
1. MCP `user-reui``search` / `get_block` / `get_component` with `surface: "frame"` 1. MCP `user-reui``search` / `get_block` / `compose_page` / `get_component` with `surface: "frame"`
2. Cite `previewUrl` + `docsUrl` 2. Cite `previewUrl` + `docsUrl`
3. CLI from `apps/web`: `pnpm dlx shadcn@latest add @reui/...` 3. CLI from `apps/web`: `pnpm dlx shadcn@latest add @reui/...`
4. Adapt into kit — do not hand-roll KPI/grid/settings 4. Adapt into kit — do not hand-roll KPI / Quick Actions / grid / settings rows
5. `validate_usage` / `get_audit_checklist` 5. `validate_usage` / `get_audit_checklist`
Primitives: MCP `plugin-shadcn-shadcn` + `@evofw/ui`. Primitives: MCP `plugin-shadcn-shadcn` + project `@scope/ui` (`@cfdm/ui` / `@evobgp/ui` / `@evofw/ui` / `@authportal/ui`).
## License ## License
```env ```env
# apps/web/.env.local # apps/web/.env.local (gitignored)
REUI_LICENSE_KEY= REUI_LICENSE_KEY=
``` ```
`apps/web/components.json``Authorization: Bearer ${REUI_LICENSE_KEY}`. `apps/web/components.json``@reui` с `Authorization: Bearer ${REUI_LICENSE_KEY}`.
## Spacing
- AppShell main / PageShell: `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5`
- No `space-y-*` / `space-x-*` — use `flex` + `gap-*`
- Max 1 primary CTA per screen
- Semantic tokens only — no raw `bg-emerald-*`
## Forbidden ## Forbidden
- Card as ops list/dashboard shell - Card as ops list/dashboard shell
- Hand-rolled tables when ReUI DataGrid exists - Hand-rolled data tables when ReUI DataGrid / `ResourcePage` exists
- Hand-rolled KPI when `KpiStatGrid` exists - Hand-rolled KPI when `KpiStatGrid` exists
- Mixing Card and Frame on one ops screen - Hand-rolled Quick Actions when `QuickActionGrid` exists
- Raw `bg-emerald-*` / `space-y-*` - SectionCards / DataGridCard as design эталон
- Mixing Card and Frame surfaces on one ops screen