fix(ui): выровнять IconTile chrome и доступность
Перевести KPI и агентские тайлы на IconTile, skip-link и live regions. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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 (
|
||||
<Item
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
aria-label={isMt ? 'MikroTik' : 'Linux'}
|
||||
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',
|
||||
'size-10.5',
|
||||
isMt ? 'text-info' : 'text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
aria-label={isMt ? 'MikroTik' : 'Linux'}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
<Icon aria-hidden />
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<Icon aria-hidden />
|
||||
</IconTile>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
>
|
||||
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||
К содержимому
|
||||
</a>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<SidebarInset id="main-content">
|
||||
<SiteHeader />
|
||||
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
<SearchMenu hotkeyOnly />
|
||||
</SidebarProvider>
|
||||
|
||||
@@ -35,7 +35,11 @@ export function QueryState({
|
||||
children,
|
||||
}: QueryStateProps) {
|
||||
if (isLoading) {
|
||||
return skeleton ?? <DefaultSkeleton />
|
||||
return (
|
||||
<div role="status" aria-live="polite" aria-busy="true">
|
||||
{skeleton ?? <DefaultSkeleton />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
|
||||
@@ -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 (
|
||||
<div className="relative z-10 flex h-full items-start gap-3">
|
||||
{item.icon ? (
|
||||
<Item
|
||||
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',
|
||||
item.iconClassName ?? DEFAULT_ICON_CLASS,
|
||||
)}
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
aria-hidden="true"
|
||||
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{item.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
{item.icon}
|
||||
</IconTile>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
|
||||
@@ -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 (
|
||||
<div className="relative z-10 flex h-full items-start gap-3">
|
||||
{action.icon ? (
|
||||
<Item
|
||||
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',
|
||||
action.iconClassName ?? DEFAULT_ICON_CLASS,
|
||||
)}
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
aria-hidden="true"
|
||||
className={cn('size-10.5', action.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{action.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
{action.icon}
|
||||
</IconTile>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
|
||||
@@ -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<typeof iconTileVariants>["variant"]
|
||||
size?: VariantProps<typeof iconTileVariants>["size"]
|
||||
radius?: VariantProps<typeof iconTileVariants>["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 }
|
||||
@@ -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'
|
||||
|
||||
@@ -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')({
|
||||
|
||||
Reference in New Issue
Block a user