+ ) : null}
+
{actions.map((action) => (
-
-
-
-
-
+
+
+ {action.icon}
+
+
+
+ {action.title}
+
+
+ Перейти
+
+
+ {action.description}
+
+
))}
diff --git a/apps/web/src/components/reui-kit/settings-shell.tsx b/apps/web/src/components/reui-kit/settings-shell.tsx
index 7a49258..fe2556f 100644
--- a/apps/web/src/components/reui-kit/settings-shell.tsx
+++ b/apps/web/src/components/reui-kit/settings-shell.tsx
@@ -1,104 +1,67 @@
-import type { ReactNode } from 'react'
-import { Link, Outlet, useRouterState } from '@tanstack/react-router'
-import { HeartPulseIcon, PaletteIcon, SettingsIcon } from 'lucide-react'
+import { Link, Outlet, useNavigate, useRouterState } from '@tanstack/react-router'
-import { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
-import { cn } from '@cfdm/ui/lib/utils'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
+import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
-export interface SettingsTabConfig {
- id: string
- to: string
- label: string
- icon?: ReactNode
+const SETTINGS_TABS = [
+ { to: '/settings/appearance', label: 'Внешний вид' },
+ { to: '/settings/health', label: 'Health-check' },
+ { to: '/settings/integrations', label: 'Интеграции' },
+] as const
+
+type SettingsTab = (typeof SETTINGS_TABS)[number]['to']
+
+function activeTab(pathname: string): SettingsTab {
+ const match = SETTINGS_TABS.find(
+ (tab) => pathname === tab.to || pathname.startsWith(`${tab.to}/`),
+ )
+ return match?.to ?? '/settings/appearance'
}
-const DEFAULT_TABS: SettingsTabConfig[] = [
- {
- id: 'appearance',
- to: '/settings/appearance',
- label: 'Внешний вид',
- icon:
,
- },
- {
- id: 'health',
- to: '/settings/health',
- label: 'Health-check',
- icon:
,
- },
- {
- id: 'integrations',
- to: '/settings/integrations',
- label: 'Интеграции',
- icon:
,
- },
-]
-
-interface SettingsShellProps {
- title?: string
- description?: string
- tabs?: SettingsTabConfig[]
-}
-
-export function SettingsShell({
- title = 'Настройки',
- description = 'Внешний вид, health-check и интеграции',
- tabs = DEFAULT_TABS,
-}: SettingsShellProps) {
- const isMobile = useIsMobile()
- const pathname = useRouterState({ select: (s) => s.location.pathname })
+/**
+ * Настройки CfSettings: вкладки внутри страницы, не боковой rail.
+ * Строки секций — settings-3 SettingField / SettingRow.
+ * Preview: https://reui.io/preview/base/settings-3
+ * Preview: https://reui.io/preview/base/settings-16
+ * Docs: https://reui.io/blocks/application/settings
+ */
+export function SettingsShell() {
+ const navigate = useNavigate()
+ const pathname = useRouterState({ select: (state) => state.location.pathname })
+ const value = activeTab(pathname)
return (
-
-
-
-
- {tabs.length > 1 ? (
-
+
{
+ const tab = SETTINGS_TABS.find((item) => item.to === next)
+ if (!tab) return
+ void navigate({ to: tab.to })
+ }}
+ >
+
+ {SETTINGS_TABS.map((tab) => (
+ }
+ className="text-muted-foreground hover:text-foreground h-auto px-0 pb-3"
>
- {tabs.map((tab) => {
- const isActive = pathname.startsWith(tab.to)
- return (
-
- {tab.icon}
- {tab.label}
-
- )
- })}
-
- ) : null}
-
-
-
-
-
-
+ {tab.label}
+
+ ))}
+
+
+
)
}
+
+export type { SettingsTab as SettingsTabConfig }
diff --git a/apps/web/src/components/reui/frame.tsx b/apps/web/src/components/reui/frame.tsx
index fcf6375..5ba5b68 100644
--- a/apps/web/src/components/reui/frame.tsx
+++ b/apps/web/src/components/reui/frame.tsx
@@ -3,28 +3,30 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@cfdm/ui/lib/utils"
/**
- * CSS variable architecture for FramePanel theming:
- *
- * The Frame parent sets --frame-panel-bg and --frame-panel-border-color.
- * FramePanel consumes them directly via bg-(--frame-panel-bg) and
- * border-(--frame-panel-border-color). This means:
- *
- * - variant="inverse" overrides those vars on Frame → all panels pick it up
- * -
adds a direct utility on the element
- * which wins over bg-(--frame-panel-bg) by Tailwind source order — no
- * :not() or !important needed
+ * Frame sets --frame-panel-bg and --frame-panel-border-color; FramePanel reads
+ * them back as bg-(--frame-panel-bg) and border-(--frame-panel-border-color).
+ * So variant="inverse" re-points every panel from one place, and a consumer's
+ * own bg-* on a panel still wins on source order, with no :not() and no `!`.
*/
const frameVariants = cva(
[
"relative flex flex-col bg-muted/50 gap-(--frame-gap) px-(--frame-px) py-(--frame-py) rounded-(--frame-radius)",
- "(--radius-xl)] [--frame-radius:var(--radius-xl)]",
- "(--radius-none)] (--radius-2xl)] (--radius-lg)] (--radius-none)]",
+ // Each rung is the radius that style's own shadcn Card resolves through, so
+ // a Frame and a Card side by side agree. lyra/sera are 0px, NOT
+ // var(--radius-none): no such token exists, and it only reached 0 by being
+ // invalid, which also left --frame-radius empty for anything reading it.
+ "[--frame-radius:var(--radius-xl)]",
"[--frame-gap:--spacing(0.75)] [--frame-px:--spacing(0.75)] [--frame-py:--spacing(0.75)] [--frame-panel-header-gap:0rem] [--frame-panel-footer-gap:--spacing(1)]",
"[--frame-panel-px-adjust:0px] [--frame-panel-py-adjust:0px] [--frame-panel-header-px-adjust:0px] [--frame-panel-header-py-adjust:0px] [--frame-panel-footer-px-adjust:0px] [--frame-panel-footer-py-adjust:0px]",
- "[--frame-panel-px:calc(var(--frame-panel-px-base)_+_var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)_+_var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)_+_var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)_+_var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)_+_var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)_+_var(--frame-panel-footer-py-adjust))]",
- "(1)] (1)] (1.25)] (1.5)] (1.5)] (0.5)] (1)] (1)]",
- // Default panel token values — overridden per-variant below
+ "[--frame-panel-px:calc(var(--frame-panel-px-base)+var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)+var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)+var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)+var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)+var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)+var(--frame-panel-footer-py-adjust))]",
+ // Luma alone re-times the frame: wider gap and padding, roomier bars.
+ "",
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
+ // Concentric: the panel nests inside the frame's corner rather than copying
+ // it. It is inset by the 1px border plus --frame-px, so subtracting exactly
+ // that keeps the two arcs parallel. `ghost` drops the border term (no
+ // border), `dense` pins it to the frame radius (panels sit flush).
+ "[--frame-panel-radius:calc(var(--frame-radius)-var(--frame-px)-1px)]",
],
{
variants: {
@@ -32,14 +34,18 @@ const frameVariants = cva(
default: "border border-[var(--frame-border-color)] bg-clip-padding",
inverse:
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
- ghost: "",
+ ghost: "[--frame-panel-radius:calc(var(--frame-radius)-var(--frame-px))]",
},
+ // Bars read as chrome, not a second content block: py runs 0.5/1.5/2/2.5
+ // against a body py of 2/3.5/4/5, while px stays level with the body so
+ // header, content and footer left-align. xs floors at 0.5 (2px), below
+ // which it stops reading as padding. No style-*.css overrides these.
spacing: {
- xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(1)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(1)] (3)] (1)] (3)] (3)]",
- sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(2.5)] (2)] (2)] (2)]",
+ xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(0.5)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(0.5)]",
+ sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(1.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(1.5)]",
default:
- "[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(3)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(3)] (2)] (2)] (2)]",
- lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(4)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(4)] (2)] (2)] (2)]",
+ "[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(2)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(2)]",
+ lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(2.5)]",
},
stacked: {
true: [
@@ -55,8 +61,9 @@ const frameVariants = cva(
],
},
dense: {
- // Positional rules must stay as parent selectors — cannot be expressed via CSS vars
- true: "p-0 gap-0 border-[var(--frame-border-color)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
+ // Parent selectors, not CSS vars: these are positional. Panels are
+ // pulled flush (-mx-px), so corners align with the frame's own radius.
+ true: "p-0 gap-0 border-[var(--frame-border-color)] [--frame-panel-radius:var(--frame-radius)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
false: "",
},
},
@@ -98,13 +105,11 @@ function FramePanel({
return (
) : (
-
- )}
-
- {history.length > 0 ? (
-
+
- Failover
- События журнала смены адресов
+ Задержка и ошибки
+ Проба health-check · последние 24 часа
-
+
- ) : (
-
)}
+
+
+
+ История failover
+ Переключения между нодами
+
+
+ {history.length > 0 ? (
+
+ ) : (
+
+ )}
+
+
)
diff --git a/apps/web/src/components/theme-provider.tsx b/apps/web/src/components/theme-provider.tsx
index 702c7e0..6d06994 100644
--- a/apps/web/src/components/theme-provider.tsx
+++ b/apps/web/src/components/theme-provider.tsx
@@ -1,7 +1,12 @@
import { ThemeProvider as NextThemesProvider } from 'next-themes'
-import type { ReactNode } from 'react'
+import { useEffect, type ReactNode } from 'react'
+import { applyAppearance, readContrast, readDensity } from '@/lib/appearance-mode'
export function ThemeProvider({ children }: { children: ReactNode }) {
+ useEffect(() => {
+ applyAppearance(readContrast(), readDensity())
+ }, [])
+
return (