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
+
+ Закрыть
)}
diff --git a/packages/ui/src/components/sidebar.tsx b/packages/ui/src/components/sidebar.tsx
index 427f30e..7aa9ec4 100644
--- a/packages/ui/src/components/sidebar.tsx
+++ b/packages/ui/src/components/sidebar.tsx
@@ -194,8 +194,8 @@ function Sidebar({
side={side}
>
- Sidebar
- Displays the mobile sidebar.
+ Боковое меню
+ Навигация по приложению
{children}
@@ -254,7 +254,7 @@ function SidebarTrigger({
onClick,
...props
}: React.ComponentProps) {
- const { toggleSidebar } = useSidebar()
+ const { toggleSidebar, open, openMobile, isMobile } = useSidebar()
return (
)
}
@@ -282,10 +285,10 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {