diff --git a/.cursor/rules/frontend-ui-patterns.mdc b/.cursor/rules/frontend-ui-patterns.mdc index 95ce00e..0f636bb 100644 --- a/.cursor/rules/frontend-ui-patterns.mdc +++ b/.cursor/rules/frontend-ui-patterns.mdc @@ -72,7 +72,7 @@ apps/web/src/components/ ← shared + domain + layout | Settings | PageShell + Frame + `SettingRow` (или `SettingsShell` при 2+ секциях) | — | | Dashboard KPI | `OpsDashboard` / `KpiStatGrid` | ReUI Frame [stats-12](https://reui.io/preview/base/stats-12) hybrid | | Quick Actions | `QuickActionGrid` (gated `show_quick_actions`) | Frame tiles + Badge «Перейти» | -| Empty | `EmptyState` | `Empty` | +| Empty | `EmptyState` | `Empty` + `IconStack` ([empty-state-14](https://reui.io/preview/base/empty-state-14)) | | Loading / Error | `QueryState` / kit skeletons | `Skeleton`, `Alert` | | Status | `StatusBadge` | ReUI `Badge` (`success`/`info`/`warning`) | | Create/Edit | `FormSheet` + `*-edit-sheet.tsx` | `Sheet`, `Field` | diff --git a/.cursor/rules/kpi-hybrid.mdc b/.cursor/rules/kpi-hybrid.mdc index 9d746bf..0da58e0 100644 --- a/.cursor/rules/kpi-hybrid.mdc +++ b/.cursor/rules/kpi-hybrid.mdc @@ -1,11 +1,11 @@ --- -description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12). Запрет SectionCards и hand-roll. +description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12 + IconTile). Запрет SectionCards и hand-roll. alwaysApply: true --- -# KPI hybrid — только kit (stats-12 DNA) +# KPI hybrid — только kit (stats-12 + IconTile) -Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Markup в проекте: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`. +Preview: [stats-12](https://reui.io/preview/base/stats-12). Primitive: [icon-tile](https://reui.io/docs/components/base/icon-tile). SoT DNA = EvoBGP. Markup: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`. Связанные: [`reui-mcp.mdc`](reui-mcp.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc). @@ -14,16 +14,17 @@ Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Ma | Зона | Компонент / DNA | |------|-----------------| | KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) | -| Markup | horizontal compact hybrid: icon left `Item` `size-10.5` `bg-muted` + `border-background` + shadow + `ItemMedia` + label/Badge + value ± `variant` | -| Row icon tiles (data-grid) | та же DNA (`AgentPlatformIcon` и клоны) — semantic `text-*` на `bg-muted` | -| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA) | +| Markup | horizontal compact hybrid: icon left `IconTile` `variant="elevated"` `className="size-10.5"` + label/Badge + value ± `variant` | +| Row icon tiles (data-grid) | та же DNA (`AgentPlatformIcon` и клоны) — semantic `text-*` на IconTile elevated | +| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA, IconTile elevated) | -Импорты UI: `@evofw/ui/components/*`. +Импорты UI: `@evofw/ui/components/*`. IconTile: `@/components/reui/icon-tile`. ## NEVER - SectionCards / vertical-only KPI / hand-roll Frame/Card KPI -- Другой size / radius / solid brand fill вместо `bg-muted` +- `Item` `size-10.5` `bg-muted` hybrid вместо IconTile +- Другой size / radius / solid brand fill вместо `elevated` - `card-35` как замена stats-12 hybrid KPI - Копипаст ReUI block в route — adapt через `reui-kit/` -- Голый lucide `size-4` в name-cell без hybrid tile +- Голый lucide `size-4` в name-cell без IconTile diff --git a/.cursor/rules/reui-mcp.mdc b/.cursor/rules/reui-mcp.mdc index f9cd3a6..738ca50 100644 --- a/.cursor/rules/reui-mcp.mdc +++ b/.cursor/rules/reui-mcp.mdc @@ -75,9 +75,9 @@ alwaysApply: true ## Установленные ReUI (apps/web) -**Components:** `frame`, `data-grid/*`, `filters`, `kanban`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack` +**Components:** `frame`, `data-grid/*`, `filters`, `badge`, `alert`, `autocomplete`, `timeline`, `icon-stack`, `icon-tile`, `sortable`, `stepper` -**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `DetailPanel`, `SettingsShell` +**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `DetailPanel`, `SettingsShell` (только 2+ секции; `/settings` = PageShell + Frame) **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`, … diff --git a/apps/web/src/components/agents/agent-platform-icon.tsx b/apps/web/src/components/agents/agent-platform-icon.tsx index c82653a..265408e 100644 --- a/apps/web/src/components/agents/agent-platform-icon.tsx +++ b/apps/web/src/components/agents/agent-platform-icon.tsx @@ -1,6 +1,6 @@ import { Router, Terminal } from 'lucide-react' import { cn } from '@evofw/ui/lib/utils' -import { Item, ItemMedia } from '@evofw/ui/components/item' +import { IconTile } from '@/components/reui/icon-tile' type AgentPlatformIconProps = { platform: string @@ -9,7 +9,8 @@ type AgentPlatformIconProps = { /** * KPI-style platform tile for agent rows. - * Preview DNA: https://reui.io/preview/base/stats-12 · settings-14 + * Preview DNA: https://reui.io/preview/base/stats-12 + * Primitive: https://reui.io/docs/components/base/icon-tile */ export function AgentPlatformIcon({ platform, @@ -18,18 +19,17 @@ export function AgentPlatformIcon({ const isMt = platform === 'mikrotik' const Icon = isMt ? Router : Terminal return ( - - - - - + + ) } diff --git a/apps/web/src/components/empty-state.tsx b/apps/web/src/components/empty-state.tsx index da531cf..43ef8a7 100644 --- a/apps/web/src/components/empty-state.tsx +++ b/apps/web/src/components/empty-state.tsx @@ -27,8 +27,8 @@ interface EmptyStateProps { } /** - * ReUI Empty + IconStack — adapted from empty-state-12. - * @see https://reui.io/preview/base/empty-state-12 + * ReUI Empty + IconStack — Frame-friendly empty-state-14 / empty-state-12. + * Preview: https://reui.io/preview/base/empty-state-14 · https://reui.io/preview/base/empty-state-12 */ export function EmptyState({ icon: Icon = InboxIcon, diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index d0cd556..ede102d 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -4,6 +4,7 @@ import { SiteHeader } from '@/components/layout/site-header' import { SearchMenu } from '@/components/layout/search-menu' import { TooltipProvider } from '@evofw/ui/components/tooltip' import { SidebarInset, SidebarProvider } from '@evofw/ui/components/sidebar' +import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface' /** Shared ops chrome — etalon EvoBGP. @see docs/ui-design-contract.md */ export function AppShell({ children }: { children: ReactNode }) { @@ -16,12 +17,15 @@ export function AppShell({ children }: { children: ReactNode }) { } as CSSProperties } > + + К содержимому + - + -
+
{children} -
+
diff --git a/apps/web/src/components/query-state.tsx b/apps/web/src/components/query-state.tsx index 861d441..0df6ff8 100644 --- a/apps/web/src/components/query-state.tsx +++ b/apps/web/src/components/query-state.tsx @@ -35,7 +35,11 @@ export function QueryState({ children, }: QueryStateProps) { if (isLoading) { - return skeleton ?? + return ( +
+ {skeleton ?? } +
+ ) } if (isError) { diff --git a/apps/web/src/components/reui-kit/kpi-stat-grid.tsx b/apps/web/src/components/reui-kit/kpi-stat-grid.tsx index aaa699f..86054ce 100644 --- a/apps/web/src/components/reui-kit/kpi-stat-grid.tsx +++ b/apps/web/src/components/reui-kit/kpi-stat-grid.tsx @@ -5,7 +5,7 @@ import { Frame, FramePanel } from '@/components/reui/frame' import { Badge } from '@/components/reui/badge' import { cn } from '@evofw/ui/lib/utils' import { kpiCols } from './kpi-cols' -import { Item, ItemMedia } from '@evofw/ui/components/item' +import { IconTile } from '@/components/reui/icon-tile' import { Skeleton } from '@evofw/ui/components/skeleton' export type KpiStatVariant = 'default' | 'warning' | 'destructive' @@ -83,16 +83,13 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) { return (
{item.icon ? ( - + {item.icon} + ) : null}
diff --git a/apps/web/src/components/reui-kit/quick-action-grid.tsx b/apps/web/src/components/reui-kit/quick-action-grid.tsx index af56524..450e472 100644 --- a/apps/web/src/components/reui-kit/quick-action-grid.tsx +++ b/apps/web/src/components/reui-kit/quick-action-grid.tsx @@ -8,7 +8,7 @@ import { FrameTitle, } from '@/components/reui/frame' import { Badge } from '@/components/reui/badge' -import { Item, ItemMedia } from '@evofw/ui/components/item' +import { IconTile } from '@/components/reui/icon-tile' import { cn } from '@evofw/ui/lib/utils' import { kpiCols } from './kpi-cols' @@ -39,16 +39,13 @@ function QuickActionBody({ action }: { action: QuickActionItem }) { return (
{action.icon ? ( - + {action.icon} + ) : null}
diff --git a/apps/web/src/components/reui/icon-tile.tsx b/apps/web/src/components/reui/icon-tile.tsx new file mode 100644 index 0000000..fdc1959 --- /dev/null +++ b/apps/web/src/components/reui/icon-tile.tsx @@ -0,0 +1,125 @@ +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@evofw/ui/lib/utils" + +/** + * CSS variable architecture: + * + * The root owns four variables so every part of the tile stays in proportion + * and stays overridable from a single `className`: + * + * --icon-tile-size tile width/height + * --icon-tile-icon-size glyph size applied to child svgs + * --icon-tile-radius corner radius (also drives the nested inner card) + * --icon-tile-inset gap between the outer ring and the inner card + * + * The `frame` and `soft` variants paint their inner card with an `::after` + * pseudo element instead of a wrapper node. `isolate` makes the root a stacking + * context, so the negative z-index pseudo paints above the root background but + * below the in-flow icon - no extra DOM, and `render` composition keeps working. + * + * Tone: `soft` and `solid` derive every fill and border from `currentColor`, so + * a single text color class (e.g. `text-success`) retints the whole tile. They + * default to `text-primary`; override it to recolor without touching internals. + */ +const iconTileVariants = cva( + [ + "relative inline-flex shrink-0 items-center justify-center align-middle", + "size-(--icon-tile-size) rounded-(--icon-tile-radius)", + "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-(--icon-tile-icon-size)", + ], + { + variants: { + variant: { + /** Plain bordered surface. The quiet default for list rows and toolbars. */ + outline: "border border-border bg-background dark:bg-input/32", + /** Raised muted fill with a background-colored ring. Reads as a physical chip. */ + elevated: + "border-2 border-background bg-muted text-accent-foreground shadow-[0_1px_3px_0_rgb(0_0_0/0.14)] dark:border", + /** + * Tinted double container: an opacity-filled outer ring with no border + * around a bordered inner card, all derived from `currentColor`. The + * quiet, colorful sibling of `frame`. Retint with a text color class. + */ + soft: [ + "isolate p-(--icon-tile-inset) text-primary bg-current/10", + "after:absolute after:-z-10 after:inset-(--icon-tile-inset)", + "after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]", + "after:border after:border-current/20 after:bg-current/5", + ], + /** Filled tone with a contrasting glyph. Retint with `bg-*` + a text color. */ + solid: "bg-primary text-primary-foreground", + /** Double container - a muted ring around an inset card, matching Frame. */ + frame: [ + "isolate border border-border bg-muted/50 p-(--icon-tile-inset)", + "after:absolute after:-z-10 after:inset-(--icon-tile-inset)", + "after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]", + "after:border after:border-border after:bg-card after:shadow-xs", + ], + }, + size: { + xs: "[--icon-tile-size:--spacing(6)] [--icon-tile-icon-size:--spacing(3.5)] [--icon-tile-inset:--spacing(0.5)]", + sm: "[--icon-tile-size:--spacing(8)] [--icon-tile-icon-size:--spacing(4)] [--icon-tile-inset:--spacing(0.5)]", + default: + "[--icon-tile-size:--spacing(10)] [--icon-tile-icon-size:--spacing(4.5)] [--icon-tile-inset:--spacing(0.75)]", + lg: "[--icon-tile-size:--spacing(12)] [--icon-tile-icon-size:--spacing(5.5)] [--icon-tile-inset:--spacing(0.75)]", + xl: "[--icon-tile-size:--spacing(14)] [--icon-tile-icon-size:--spacing(7)] [--icon-tile-inset:--spacing(1)]", + }, + /** + * `default`: active style radius. `full`: circular. + * The plain value is the fallback outside a style scope; the `style-*` + * tokens win by specificity inside one, and survive the registry + * transform as the single resolved value per generated style. + * + * Each style token is clamped to a fraction of the tile. A flat radius + * is a circle once it reaches half the box, so the soft styles used to + * render `xs` (24px) and `sm` (32px) as plain circles and swallow the + * `full` variant's meaning. Clamping keeps one corner ratio at every + * size instead, so a tile still reads as the same shape when it scales. + * Luma and Rhea take the gentler quarter ratio; Maia stays at a third. + */ + radius: { + default: + "[--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))] [--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))]", + full: "[--icon-tile-radius:calc(infinity*1px)]", + }, + }, + defaultVariants: { + variant: "outline", + size: "default", + radius: "default", + }, + } +) + +interface IconTileProps extends useRender.ComponentProps<"span"> { + variant?: VariantProps["variant"] + size?: VariantProps["size"] + radius?: VariantProps["radius"] +} + +function IconTile({ + className, + variant = "outline", + size = "default", + radius = "default", + render, + ...props +}: IconTileProps) { + const defaultProps = { + "data-slot": "icon-tile", + "data-variant": variant, + "data-size": size, + className: cn(iconTileVariants({ variant, size, radius, className })), + } + + return useRender({ + defaultTagName: "span", + render, + props: mergeProps<"span">(defaultProps, props), + }) +} + +export { IconTile, iconTileVariants, type IconTileProps } \ No newline at end of file diff --git a/apps/web/src/lib/ui-surface.ts b/apps/web/src/lib/ui-surface.ts index 1dc35ba..a7e78a4 100644 --- a/apps/web/src/lib/ui-surface.ts +++ b/apps/web/src/lib/ui-surface.ts @@ -1,2 +1,6 @@ /** Project surface lock — ops screens use ReUI Frame only. */ export const UI_SURFACE = 'frame' as const + +/** Skip link — keyboard: first Tab. */ +export const SKIP_TO_CONTENT_CLASS = + 'bg-background text-foreground ring-ring sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-sm focus:ring-2' diff --git a/apps/web/src/routes/_auth/settings.tsx b/apps/web/src/routes/_auth/settings.tsx index 8fad486..d0de260 100644 --- a/apps/web/src/routes/_auth/settings.tsx +++ b/apps/web/src/routes/_auth/settings.tsx @@ -18,8 +18,9 @@ import { Input } from '@evofw/ui/components/input' import { Switch } from '@evofw/ui/components/switch' /** - * Control plane settings — Frame + SettingRow. - * Preview: https://reui.io/preview/base/settings-16 · settings-3 + * Control plane settings — single page: PageShell + Frame + SettingRow. + * SettingsShell (reui-kit) — только при 2+ секциях. + * Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3 */ export const Route = createFileRoute('/_auth/settings')({ diff --git a/docs/ui-design-contract.md b/docs/ui-design-contract.md index a0d5aad..35b9ef7 100644 --- a/docs/ui-design-contract.md +++ b/docs/ui-design-contract.md @@ -28,7 +28,7 @@ Settings: секции через Frame + `gap` (без hairline `Separator` п | Lists | `data-grid-filtering-2` | https://reui.io/preview/base/data-grid-filtering-2 | | Settings | `settings-16` + SettingRow | https://reui.io/preview/base/settings-16 | | 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-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 | ## Kit API (`reui-kit/`) @@ -39,7 +39,7 @@ Settings: секции через Frame + `gap` (без hairline `Separator` п | `KpiStatGrid` | EvoBGP hybrid KPI tiles (`items`/`cards`, `variant`, Badge) | | `QuickActionGrid` | KPI-like quick action tiles (gated by `showQuickActions`) | | `OpsDashboard` | KPI + optional `afterKpi` + charts + attention queue | -| `SettingsShell` | settings nav + Outlet | +| `SettingsShell` | settings nav + Outlet (**только 2+ секции**; текущий `/settings` — PageShell + Frame) | | `DetailPanel` | detail Frame sections | | `filter-utils` | apply/clear ReUI Filters | @@ -75,9 +75,9 @@ Gating: DB `show_quick_actions` / `showQuickActions` / `ui_show_quick_actions` ( 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`. +App Switcher: auth-portal `GET /api/v1/app-switcher`. Ids: `cfdm` · `vps` · `bgp` · `fw` · `dns`. Admin: portal `/admin/apps`. -QuickActionGrid / KPI icons: только semantic **text** (`text-info` / `text-primary` / …) на kit `bg-muted` — без solid fills. +QuickActionGrid / KPI icons: только semantic **text** (`text-info` / `text-primary` / …) на `IconTile` `elevated` — без solid fills. ## System monitor diff --git a/packages/ui/src/components/sheet.tsx b/packages/ui/src/components/sheet.tsx index 0dd8617..a9b2bc1 100644 --- a/packages/ui/src/components/sheet.tsx +++ b/packages/ui/src/components/sheet.tsx @@ -70,9 +70,8 @@ function SheetContent({ /> } > - - Close +