diff --git a/.cursor/rules/kpi-hybrid.mdc b/.cursor/rules/kpi-hybrid.mdc index cfe7f38..80fed71 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), [`web-shadcn.mdc`](web-shadcn.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 — 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 — semantic `text-*` на IconTile elevated | +| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA, IconTile elevated) | -Импорты UI: `@evobgp/ui/components/*`. +Импорты UI: `@evobgp/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 7a85287..81f73e7 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`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`, `icon-tile`, `stepper` -**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `KanbanBoard`, `DetailPanel`, `SettingsShell` +**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `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`, … @@ -110,6 +110,7 @@ REUI_LICENSE_KEY= - Hand-roll data-grid/filters/KPI/Quick Actions/settings rows при наличии `@reui` / kit - Смешивать Card и Frame на ops-экране - Ставить shadcn/ui blocks выше ReUI PRO +- **settings-7** как target (`surface: card`) — только [settings-3](https://reui.io/preview/base/settings-3) / [settings-16](https://reui.io/preview/base/settings-16) ## Чеклист diff --git a/.cursor/rules/web-shadcn.mdc b/.cursor/rules/web-shadcn.mdc index 800ab69..d9f7ba0 100644 --- a/.cursor/rules/web-shadcn.mdc +++ b/.cursor/rules/web-shadcn.mdc @@ -101,6 +101,9 @@ pnpm --filter @evobgp/web run build **WEB-22** | NEVER | Legacy Svelte UI удалён. Не восстанавливать `web-legacy-svelte/` и не копировать Svelte-паттерны в React-код. *Проверка:* отсутствие каталога `web-legacy-svelte/`; `pnpm --filter @evobgp/web run typecheck`. +**WEB-23** | NEVER | **settings-7** как target ([preview](https://reui.io/preview/base/settings-7) — `surface: card`). Новые settings-экраны — [settings-3](https://reui.io/preview/base/settings-3) / [settings-16](https://reui.io/preview/base/settings-16) (`surface: frame`). Существующий `apps/web/src/components/settings/*` на DNA settings-7 **не** копировать как эталон; миграция — отдельный CHANGE. +*Проверка:* review MCP `get_block` / cite; нет новых экранов с settings-7. + --- ## Documentation Sync (Web) diff --git a/apps/web/src/components/blocks/settings-7/components/setting-row.tsx b/apps/web/src/components/blocks/settings-7/components/setting-row.tsx index 274402a..371dbe1 100644 --- a/apps/web/src/components/blocks/settings-7/components/setting-row.tsx +++ b/apps/web/src/components/blocks/settings-7/components/setting-row.tsx @@ -1,62 +1,2 @@ -import { type ReactNode } from "react" - -import { cn } from "@evobgp/ui/lib/utils" -import { - Field, - FieldContent, - FieldDescription, - FieldLabel, - FieldSeparator, - FieldTitle, -} from "@evobgp/ui/components/field" - -interface SettingRowProps { - title: string - description?: ReactNode - children: ReactNode - last?: boolean - labelFor?: string - contentClassName?: string - titleAddon?: ReactNode -} - -export function SettingRow({ - title, - description, - children, - last, - labelFor, - contentClassName, - titleAddon, -}: SettingRowProps) { - return ( - <> - -
-
- {labelFor ? ( - {title} - ) : ( - {title} - )} - {titleAddon} -
- - {description ? ( - {description} - ) : null} -
- - -
- {children} -
-
-
- - {!last ? : null} - - ) -} \ No newline at end of file +/** @deprecated Import from `@/components/settings/setting-row` — settings-7 is not a target. */ +export { SettingRow } from '@/components/settings/setting-row' diff --git a/apps/web/src/components/blocks/settings-7/components/settings-card.tsx b/apps/web/src/components/blocks/settings-7/components/settings-card.tsx index 7cf1c2d..8aaa87a 100644 --- a/apps/web/src/components/blocks/settings-7/components/settings-card.tsx +++ b/apps/web/src/components/blocks/settings-7/components/settings-card.tsx @@ -1,53 +1,2 @@ -import { type ReactNode } from 'react' - -import { cn } from '@evobgp/ui/lib/utils' -import { - Frame, - FrameDescription, - FrameFooter, - FrameHeader, - FramePanel, - FrameTitle, -} from '@/components/reui/frame' - -interface SettingsCardProps { - title: string - description?: string - children: ReactNode - footer?: ReactNode - className?: string - contentClassName?: string - footerClassName?: string -} - -/** Settings section — Frame surface. Preview: https://reui.io/preview/base/settings-16 */ -export function SettingsCard({ - title, - description, - children, - footer, - className, - contentClassName, - footerClassName, -}: SettingsCardProps) { - return ( - - - - {title} - {description ? {description} : null} - - -
{children}
- - {footer ? ( - - {footer} - - ) : null} -
- - ) -} +/** @deprecated Import from `@/components/settings/settings-card` — settings-7 is not a target. */ +export { SettingsCard } from '@/components/settings/settings-card' diff --git a/apps/web/src/components/blocks/settings-7/components/settings-field-group.tsx b/apps/web/src/components/blocks/settings-7/components/settings-field-group.tsx index 0ee4249..923e57d 100644 --- a/apps/web/src/components/blocks/settings-7/components/settings-field-group.tsx +++ b/apps/web/src/components/blocks/settings-7/components/settings-field-group.tsx @@ -1,37 +1,2 @@ -import { type ReactNode } from "react" - -import { cn } from "@evobgp/ui/lib/utils" -import { - FieldDescription, - FieldGroup, - FieldLegend, - FieldSet, -} from "@evobgp/ui/components/field" - -interface SettingsFieldGroupProps { - legend: string - description: string - children: ReactNode - className?: string - fieldGroupClassName?: string -} - -export function SettingsFieldGroup({ - legend, - description, - children, - className, - fieldGroupClassName, -}: SettingsFieldGroupProps) { - return ( -
- {legend} - {/* Description */} - {description} - {/* List */} - - {children} - -
- ) -} \ No newline at end of file +/** @deprecated Import from `@/components/settings/settings-field-group` — settings-7 is not a target. */ +export { SettingsFieldGroup } from '@/components/settings/settings-field-group' diff --git a/apps/web/src/components/confirm-dialog.tsx b/apps/web/src/components/confirm-dialog.tsx index 2e33cc8..05b6b82 100644 --- a/apps/web/src/components/confirm-dialog.tsx +++ b/apps/web/src/components/confirm-dialog.tsx @@ -1,19 +1,16 @@ -import { Button } from '@evobgp/ui/components/button' -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerHeader, - DrawerTitle, - DrawerTrigger, -} from '@evobgp/ui/components/drawer' -import type { ReactElement, ReactNode } from 'react' +import { useState, type ReactElement, type ReactNode } from 'react' import { - confirmDrawerContentClassName, - DrawerActionsFooter, -} from '@/components/drawer-layout' + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@evobgp/ui/components/alert-dialog' type ConfirmDialogBaseProps = { title: string @@ -41,90 +38,52 @@ type ConfirmDialogControlled = ConfirmDialogBaseProps & { type ConfirmDialogProps = ConfirmDialogWithTrigger | ConfirmDialogControlled -function ConfirmDrawerBody({ - title, - description, - confirmLabel = 'Подтвердить', - cancelLabel = 'Отмена', - destructive, - onConfirm, - confirmDisabled, - confirmLoading, - confirmLoadingLabel, - controlled, -}: ConfirmDialogBaseProps & { controlled?: boolean }) { - const confirmText = - confirmLoading && confirmLoadingLabel - ? confirmLoadingLabel - : confirmLoading - ? `${confirmLabel}…` - : confirmLabel - - return ( - <> - - {title} - {description ? {description} : null} - - - }> - {cancelLabel} - - {controlled ? ( - - ) : ( - - } - onClick={onConfirm} - > - {confirmText} - - )} - - - ) -} - +/** Confirm overlay — Alert Dialog. Preview: https://reui.io/preview/base/components/c-alert-dialog-11 */ export function ConfirmDialog(props: ConfirmDialogProps) { - const bodyProps: ConfirmDialogBaseProps = { - title: props.title, - description: props.description, - confirmLabel: props.confirmLabel, - cancelLabel: props.cancelLabel, - destructive: props.destructive, - onConfirm: props.onConfirm, - confirmDisabled: props.confirmDisabled, - confirmLoading: props.confirmLoading, - confirmLoadingLabel: props.confirmLoadingLabel, + const [uncontrolledOpen, setUncontrolledOpen] = useState(false) + const isControlled = props.open !== undefined + const open = isControlled ? props.open : uncontrolledOpen + + function setOpen(next: boolean) { + if (!isControlled) setUncontrolledOpen(next) + if (isControlled) props.onOpenChange?.(next) } - if (props.trigger) { - return ( - - - - - - - ) + const confirmText = + props.confirmLoading && props.confirmLoadingLabel + ? props.confirmLoadingLabel + : props.confirmLoading + ? `${props.confirmLabel ?? 'Подтвердить'}…` + : (props.confirmLabel ?? 'Подтвердить') + + function handleConfirm() { + props.onConfirm() + if (!props.confirmLoading && !isControlled) setOpen(false) } return ( - - - - - + + {props.trigger ? : null} + + + {props.title} + {props.description ? ( + {props.description} + ) : null} + + + + {props.cancelLabel ?? 'Отмена'} + + + {confirmText} + + + + ) } diff --git a/apps/web/src/components/drawer-layout.tsx b/apps/web/src/components/drawer-layout.tsx deleted file mode 100644 index 5f67399..0000000 --- a/apps/web/src/components/drawer-layout.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { ReactNode } from 'react' - -import { cn } from '@evobgp/ui/lib/utils' -import { DrawerFooter } from '@evobgp/ui/components/drawer' - -/** Shared footer layout for right-side form and confirm drawers. */ -export function DrawerActionsFooter({ - children, - className, -}: { - children: ReactNode - className?: string -}) { - return ( - -
- {children} -
-
- ) -} - -export const formDrawerContentClassName = - 'flex h-full max-h-dvh flex-col sm:max-w-lg' - -export const confirmDrawerContentClassName = 'flex h-auto max-h-dvh flex-col sm:max-w-sm' diff --git a/apps/web/src/components/empty-state.tsx b/apps/web/src/components/empty-state.tsx index 43bcd20..45a1c2a 100644 --- a/apps/web/src/components/empty-state.tsx +++ b/apps/web/src/components/empty-state.tsx @@ -1,28 +1,103 @@ +/** + * 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 + * Docs: https://reui.io/docs/components/base/icon-stack + */ +import { InboxIcon, type LucideIcon } from 'lucide-react' import type { ReactNode } from 'react' + +import { IconStack } from '@/components/reui/icon-stack' +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@evobgp/ui/components/empty' import { cn } from '@evobgp/ui/lib/utils' interface EmptyStateProps { + icon?: LucideIcon | ReactNode title: string description?: string - icon?: ReactNode action?: ReactNode className?: string + stackedIcon?: boolean + centered?: boolean } -export function EmptyState({ title, description, icon, action, className }: EmptyStateProps) { +function isLucideIcon(icon: LucideIcon | ReactNode): icon is LucideIcon { + if (typeof icon === 'function') return true + if (icon != null && typeof icon === 'object' && '$$typeof' in icon) { + if ('props' in icon) return false + return true + } + return false +} + +export function EmptyState({ + icon, + title, + description, + action, + className, + stackedIcon = true, + centered = true, +}: EmptyStateProps) { + const Icon = isLucideIcon(icon) ? icon : InboxIcon + const customIcon = icon && !isLucideIcon(icon) ? icon : null + + const body = ( + + + + {customIcon ? ( + stackedIcon ? ( + + ) : ( +
{customIcon}
+ ) + ) : stackedIcon ? ( + + ) : ( + + + )} +
+
+ {title} + {description ? ( + {description} + ) : null} +
+
+ {action ? ( + {action} + ) : null} +
+ ) + + if (!centered) return body + return (
- {icon ?
{icon}
: null} -
-

{title}

- {description ?

{description}

: null} -
- {action ?
{action}
: null} + {body}
) } diff --git a/apps/web/src/components/form-drawer.tsx b/apps/web/src/components/form-drawer.tsx index cdfeaa1..731bf38 100644 --- a/apps/web/src/components/form-drawer.tsx +++ b/apps/web/src/components/form-drawer.tsx @@ -2,18 +2,13 @@ import type { ReactNode } from 'react' import { cn } from '@evobgp/ui/lib/utils' import { - Drawer, - DrawerContent, - DrawerDescription, - DrawerHeader, - DrawerTitle, -} from '@evobgp/ui/components/drawer' -import { ScrollArea } from '@evobgp/ui/components/scroll-area' - -import { - DrawerActionsFooter, - formDrawerContentClassName, -} from '@/components/drawer-layout' + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@evobgp/ui/components/sheet' interface FormDrawerProps { open: boolean @@ -25,6 +20,7 @@ interface FormDrawerProps { className?: string } +/** Create/edit overlay — Sheet DNA (sheet-8 / sheet-1). Preview: https://reui.io/preview/base/sheet-8 */ export function FormDrawer({ open, onOpenChange, @@ -35,17 +31,22 @@ export function FormDrawer({ className, }: FormDrawerProps) { return ( - - - - {title} - {description ? {description} : null} - - -
{children}
-
- {footer} -
-
+ + + + {title} + {description ? {description} : null} + +
{children}
+ +
+ {footer} +
+
+
+
) } diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index 7d46168..479737a 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -47,6 +47,7 @@ import { CommandPalette, type CommandPaletteItem } from '@/components/layout/com import { NavUser } from '@/components/layout/nav-user' import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover' import { can, isAuthEnabled, permissionForPath } from '@/lib/auth' +import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface' interface NavItem { to: string @@ -161,6 +162,9 @@ export function AppShell({ children }: { children: ReactNode }) { } as CSSProperties } > + + К содержимому + @@ -196,7 +200,7 @@ export function AppShell({ children }: { children: ReactNode }) { - +
@@ -220,9 +224,9 @@ export function AppShell({ children }: { children: ReactNode }) {
-
+
{children} -
+
diff --git a/apps/web/src/components/patterns/illustrated-empty-state.tsx b/apps/web/src/components/patterns/illustrated-empty-state.tsx index c39798f..4338e97 100644 --- a/apps/web/src/components/patterns/illustrated-empty-state.tsx +++ b/apps/web/src/components/patterns/illustrated-empty-state.tsx @@ -2,7 +2,10 @@ import type { ReactNode } from 'react' import type { LucideIcon } from 'lucide-react' import { IconStack } from '@/components/reui/icon-stack' -import { Card, CardContent } from '@evobgp/ui/components/card' +import { + Frame, + FramePanel, +} from '@/components/reui/frame' import { Empty, EmptyDescription, @@ -11,7 +14,7 @@ import { EmptyTitle, } from '@evobgp/ui/components/empty' -/** empty-state-11 pattern adapted to Card surface. */ +/** empty-state-14: Frame + IconStack. Preview: https://reui.io/preview/base/empty-state-14 */ export function IllustratedEmptyState({ title, description, @@ -24,8 +27,8 @@ export function IllustratedEmptyState({ action?: ReactNode }) { return ( - - + + {action ?
{action}
: null} @@ -40,7 +43,7 @@ export function IllustratedEmptyState({ -
-
+ + ) } diff --git a/apps/web/src/components/patterns/kpi-sparkline-card.tsx b/apps/web/src/components/patterns/kpi-sparkline-card.tsx index 3ecd64a..d2e4caf 100644 --- a/apps/web/src/components/patterns/kpi-sparkline-card.tsx +++ b/apps/web/src/components/patterns/kpi-sparkline-card.tsx @@ -4,7 +4,7 @@ import { Badge } from '@/components/reui/badge' import { CardDotField } from '@/components/dashboard/card-dot-field' import { PanelCorners } from '@/components/patterns/panel-corners' import { toneStyles, type MetricTone } from '@/components/patterns/metric-tone-styles' -import { Card, CardContent } from '@evobgp/ui/components/card' +import { Frame, FramePanel } from '@/components/reui/frame' export type KpiSparklineMetric = { id: string @@ -44,21 +44,21 @@ function Sparkline({ values, color }: { values: readonly number[]; color: string ) } -/** KPI card with sparkline (dashboard-5 / chart-15 pattern, Card surface). */ +/** KPI sparkline tile — chart-15 Frame DNA. Preview: https://reui.io/preview/base/chart-15 */ export function KpiSparklineCard({ metric }: { metric: KpiSparklineMetric }) { const tone = toneStyles[metric.tone] return ( - - - - -
+ + + + +
{metric.title} {metric.label}
-
-
+
+
{metric.value}
@@ -71,7 +71,7 @@ export function KpiSparklineCard({ metric }: { metric: KpiSparklineMetric }) {
- - + + ) } diff --git a/apps/web/src/components/query-state.tsx b/apps/web/src/components/query-state.tsx index fac6d8b..9a86e15 100644 --- a/apps/web/src/components/query-state.tsx +++ b/apps/web/src/components/query-state.tsx @@ -34,23 +34,29 @@ export function QueryState({ children, }: QueryStateProps) { if (isLoading) { - return <>{skeleton ?? } + return ( +
+ {skeleton ?? } +
+ ) } if (isError) { return ( - } - title="Ошибка загрузки" - description={error instanceof Error ? error.message : 'Не удалось загрузить данные'} - action={ - onRetry ? ( - - ) : null - } - /> +
+ + + Повторить + + ) : null + } + /> +
) } if (empty || data == null) { 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 28b3fac..e3153ab 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 '@evobgp/ui/lib/utils' import { kpiCols } from './kpi-cols' -import { Item, ItemMedia } from '@evobgp/ui/components/item' +import { IconTile } from '@/components/reui/icon-tile' import { Skeleton } from '@evobgp/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 b0c283f..b00d9f2 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 '@evobgp/ui/components/item' +import { IconTile } from '@/components/reui/icon-tile' import { cn } from '@evobgp/ui/lib/utils' import { kpiCols } from './kpi-cols' @@ -54,16 +54,13 @@ function QuickActionBody({ action }: { action: QuickActionItem }) { return (
{action.icon ? ( - + {action.icon} + ) : null}
diff --git a/apps/web/src/components/reui-kit/settings-shell.tsx b/apps/web/src/components/reui-kit/settings-shell.tsx index a7b59c5..e0a40bf 100644 --- a/apps/web/src/components/reui-kit/settings-shell.tsx +++ b/apps/web/src/components/reui-kit/settings-shell.tsx @@ -9,7 +9,7 @@ import { PageHeader } from '@/components/page-header' export interface SettingsTabConfig { id: string - to: string + to: '/settings' | '/tenant-settings' label: string icon?: ReactNode } @@ -72,19 +72,35 @@ export function SettingsShell({ > {tabs.map((tab) => { const isActive = pathname.startsWith(tab.to) + const className = cn( + 'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors', + isMobile && 'shrink-0', + !isMobile && 'w-full', + isActive + ? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60' + : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground', + ) + if (tab.to === '/tenant-settings') { + return ( + + {tab.icon} + {tab.label} + + ) + } return ( {tab.icon} {tab.label} 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..cc2d209 --- /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 "@evobgp/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/components/settings/appearance-settings-tab.tsx b/apps/web/src/components/settings/appearance-settings-tab.tsx index 9bb186c..7e6502b 100644 --- a/apps/web/src/components/settings/appearance-settings-tab.tsx +++ b/apps/web/src/components/settings/appearance-settings-tab.tsx @@ -3,9 +3,9 @@ import { useTheme } from 'next-themes' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import { SettingRow } from '@/components/blocks/settings-7/components/setting-row' -import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card' -import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group' +import { SettingRow } from '@/components/settings/setting-row' +import { SettingsCard } from '@/components/settings/settings-card' +import { SettingsFieldGroup } from '@/components/settings/settings-field-group' import { Badge } from '@/components/reui/badge' import { ToggleGroup, diff --git a/apps/web/src/components/settings/connection-settings-tab.tsx b/apps/web/src/components/settings/connection-settings-tab.tsx index 0508fe9..9ad302c 100644 --- a/apps/web/src/components/settings/connection-settings-tab.tsx +++ b/apps/web/src/components/settings/connection-settings-tab.tsx @@ -4,9 +4,9 @@ import { Save } from 'lucide-react' import { useEffect, useState } from 'react' import { toast } from 'sonner' -import { SettingRow } from '@/components/blocks/settings-7/components/setting-row' -import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card' -import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group' +import { SettingRow } from '@/components/settings/setting-row' +import { SettingsCard } from '@/components/settings/settings-card' +import { SettingsFieldGroup } from '@/components/settings/settings-field-group' import { Badge } from '@/components/reui/badge' import { LoadingButton } from '@/components/loading-button' import { diff --git a/apps/web/src/components/settings/sections-settings-tab.tsx b/apps/web/src/components/settings/sections-settings-tab.tsx index e1cb265..abf8024 100644 --- a/apps/web/src/components/settings/sections-settings-tab.tsx +++ b/apps/web/src/components/settings/sections-settings-tab.tsx @@ -6,7 +6,7 @@ import { } from 'lucide-react' import { Fragment } from 'react' -import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card' +import { SettingsCard } from '@/components/settings/settings-card' import { Badge } from '@/components/reui/badge' import { Button } from '@evobgp/ui/components/button' import { diff --git a/apps/web/src/components/settings/session-settings-tab.tsx b/apps/web/src/components/settings/session-settings-tab.tsx index a6c8dd7..5b10e60 100644 --- a/apps/web/src/components/settings/session-settings-tab.tsx +++ b/apps/web/src/components/settings/session-settings-tab.tsx @@ -1,9 +1,9 @@ import { useQuery } from '@tanstack/react-query' import { MonitorIcon } from 'lucide-react' -import { SettingRow } from '@/components/blocks/settings-7/components/setting-row' -import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card' -import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group' +import { SettingRow } from '@/components/settings/setting-row' +import { SettingsCard } from '@/components/settings/settings-card' +import { SettingsFieldGroup } from '@/components/settings/settings-field-group' import { Badge } from '@/components/reui/badge' import { QueryState } from '@/components/query-state' import { TOKEN_STORAGE_KEY } from '@/lib/api-client' diff --git a/apps/web/src/components/settings/setting-row.tsx b/apps/web/src/components/settings/setting-row.tsx new file mode 100644 index 0000000..fa79498 --- /dev/null +++ b/apps/web/src/components/settings/setting-row.tsx @@ -0,0 +1,63 @@ +import { type ReactNode } from 'react' + +import { cn } from '@evobgp/ui/lib/utils' +import { + Field, + FieldContent, + FieldDescription, + FieldLabel, + FieldSeparator, + FieldTitle, +} from '@evobgp/ui/components/field' + +interface SettingRowProps { + title: string + description?: ReactNode + children: ReactNode + last?: boolean + labelFor?: string + contentClassName?: string + titleAddon?: ReactNode +} + +/** Settings row — settings-3 DNA. Preview: https://reui.io/preview/base/settings-3 */ +export function SettingRow({ + title, + description, + children, + last, + labelFor, + contentClassName, + titleAddon, +}: SettingRowProps) { + return ( + <> + +
+
+ {labelFor ? ( + {title} + ) : ( + {title} + )} + {titleAddon} +
+ + {description ? ( + {description} + ) : null} +
+ + +
+ {children} +
+
+
+ + {!last ? : null} + + ) +} diff --git a/apps/web/src/components/settings/settings-card.tsx b/apps/web/src/components/settings/settings-card.tsx new file mode 100644 index 0000000..6b6139b --- /dev/null +++ b/apps/web/src/components/settings/settings-card.tsx @@ -0,0 +1,53 @@ +import { type ReactNode } from 'react' + +import { cn } from '@evobgp/ui/lib/utils' +import { + Frame, + FrameDescription, + FrameFooter, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' + +interface SettingsCardProps { + title: string + description?: string + children: ReactNode + footer?: ReactNode + className?: string + contentClassName?: string + footerClassName?: string +} + +/** Settings section — Frame surface. Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3 */ +export function SettingsCard({ + title, + description, + children, + footer, + className, + contentClassName, + footerClassName, +}: SettingsCardProps) { + return ( + + + + {title} + {description ? {description} : null} + + +
{children}
+ + {footer ? ( + + {footer} + + ) : null} +
+ + ) +} diff --git a/apps/web/src/components/settings/settings-field-group.tsx b/apps/web/src/components/settings/settings-field-group.tsx new file mode 100644 index 0000000..11315b5 --- /dev/null +++ b/apps/web/src/components/settings/settings-field-group.tsx @@ -0,0 +1,36 @@ +import { type ReactNode } from 'react' + +import { cn } from '@evobgp/ui/lib/utils' +import { + FieldDescription, + FieldGroup, + FieldLegend, + FieldSet, +} from '@evobgp/ui/components/field' + +interface SettingsFieldGroupProps { + legend: string + description: string + children: ReactNode + className?: string + fieldGroupClassName?: string +} + +/** Settings field group — settings-3 DNA. Preview: https://reui.io/preview/base/settings-3 */ +export function SettingsFieldGroup({ + legend, + description, + children, + className, + fieldGroupClassName, +}: SettingsFieldGroupProps) { + return ( +
+ {legend} + {description} + + {children} + +
+ ) +} diff --git a/apps/web/src/lib/ui-surface.ts b/apps/web/src/lib/ui-surface.ts index 1ef0101..fae28ee 100644 --- a/apps/web/src/lib/ui-surface.ts +++ b/apps/web/src/lib/ui-surface.ts @@ -12,6 +12,10 @@ */ 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' + /** Grid for ReUI stats-12 KPI rows (3–6 tiles). */ export const kpiStatGridClassName = '@container w-full' diff --git a/apps/web/src/routes/_auth/_settings.tsx b/apps/web/src/routes/_auth/_settings.tsx new file mode 100644 index 0000000..3ca4cf5 --- /dev/null +++ b/apps/web/src/routes/_auth/_settings.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from '@tanstack/react-router' + +import { SettingsShell } from '@/components/reui-kit/settings-shell' + +export const Route = createFileRoute('/_auth/_settings')({ + component: SettingsShell, +}) diff --git a/apps/web/src/routes/_auth/settings.tsx b/apps/web/src/routes/_auth/_settings/settings.tsx similarity index 55% rename from apps/web/src/routes/_auth/settings.tsx rename to apps/web/src/routes/_auth/_settings/settings.tsx index 99bbac4..f7c2cb9 100644 --- a/apps/web/src/routes/_auth/settings.tsx +++ b/apps/web/src/routes/_auth/_settings/settings.tsx @@ -1,11 +1,8 @@ import { createFileRoute } from '@tanstack/react-router' import { z } from 'zod' -import { PageHeader } from '@/components/page-header' import { SettingsPageShell } from '@/components/settings/settings-page-shell' -import { - type SettingsTab, -} from '@/components/settings/settings-tabs-data' +import { type SettingsTab } from '@/components/settings/settings-tabs-data' const settingsSearchSchema = z.object({ tab: z @@ -14,7 +11,7 @@ const settingsSearchSchema = z.object({ reason: z.enum(['token-required']).optional(), }) -export const Route = createFileRoute('/_auth/settings')({ +export const Route = createFileRoute('/_auth/_settings/settings')({ component: SettingsComponent, validateSearch: (search) => settingsSearchSchema.parse(search), }) @@ -35,17 +32,10 @@ function SettingsComponent() { } return ( -
- - - -
+ ) } diff --git a/apps/web/src/routes/_auth/tenant-settings.tsx b/apps/web/src/routes/_auth/_settings/tenant-settings.tsx similarity index 97% rename from apps/web/src/routes/_auth/tenant-settings.tsx rename to apps/web/src/routes/_auth/_settings/tenant-settings.tsx index 593611f..03c73f3 100644 --- a/apps/web/src/routes/_auth/tenant-settings.tsx +++ b/apps/web/src/routes/_auth/_settings/tenant-settings.tsx @@ -12,7 +12,6 @@ import { FrameDataGrid } from '@/components/reui-kit' import { SelectField } from '@/components/select-field' import { SettingsKvGrid } from '@/components/settings/settings-kv-grid' import { SettingsSettingField } from '@/components/settings/settings-setting-field' -import { PageHeader } from '@/components/page-header' import { QueryState } from '@/components/query-state' import { LoadingButton } from '@/components/loading-button' @@ -28,7 +27,7 @@ import { } from '@/queries/settings' import { apiMutate } from '@/lib/api-client' -export const Route = createFileRoute('/_auth/tenant-settings')({ +export const Route = createFileRoute('/_auth/_settings/tenant-settings')({ component: TenantSettingsComponent, validateSearch: (search: Record) => ({ tab: (search.tab === 'revision' || search.tab === 'runtime-logs' || search.tab === 'additional' @@ -74,7 +73,7 @@ const BIRD_HINTS: Partial> = { } function TenantSettingsComponent() { - const search = useSearch({ from: '/_auth/tenant-settings' }) + const search = useSearch({ from: '/_auth/_settings/tenant-settings' }) const navigate = Route.useNavigate() const settingsQ = useQuery(settingsQueryOptions()) const qc = useQueryClient() @@ -116,11 +115,6 @@ function TenantSettingsComponent() { return (
- - diff --git a/apps/web/src/routes/_auth/modules/new.tsx b/apps/web/src/routes/_auth/modules/new.tsx index 1c9e1fd..40a17bb 100644 --- a/apps/web/src/routes/_auth/modules/new.tsx +++ b/apps/web/src/routes/_auth/modules/new.tsx @@ -1,7 +1,13 @@ import { createFileRoute, Link } from '@tanstack/react-router' -import { Card, CardContent } from '@evobgp/ui/components/card' import { Button } from '@evobgp/ui/components/button' import { PageHeader } from '@/components/page-header' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' export const Route = createFileRoute('/_auth/modules/new')({ component: NewModuleComponent, @@ -14,11 +20,14 @@ function NewModuleComponent() { title="Новый модуль" description="Создание модуля — через API или будущая форма" /> - - -

- Форма создания модуля будет добавлена позже. Сейчас модули можно создать через API: -

+ + + Создание через API + + Форма в UI появится позже. Сейчас модуль можно создать запросом ниже. + + +
 {`POST /v1/modules
 { "type": "DOMAINS", "name": "Мой список" }`}
@@ -26,8 +35,8 @@ function NewModuleComponent() {
           
-        
-      
+        
+      
     
) } diff --git a/docs/ui-design-contract.md b/docs/ui-design-contract.md index a0d5aad..97018b0 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/`) @@ -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 6eb7dd2..9bc286b 100644 --- a/packages/ui/src/components/sheet.tsx +++ b/packages/ui/src/components/sheet.tsx @@ -70,9 +70,8 @@ function SheetContent({ /> } > - - Close +