fix(design-preview): размеры, отступы и пороги ширины как на досках Auth Portal

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-18 14:53:25 +07:00
1 parent 3e1ad15ba3
commit b82c8031c2
12 files changed
+89 -60

No files matched your search

+10 -11
View File
@@ -60,10 +60,10 @@ export function PageHead({
actions?: ReactNode
}) {
return (
<header className="@container flex flex-wrap items-end justify-between gap-x-4 gap-y-3">
<div className="flex min-w-0 flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight text-balance">{title}</h1>
<p className="text-muted-foreground max-w-prose text-sm text-pretty">{sub}</p>
<header className="flex flex-wrap items-end justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<h1 className="text-xl font-semibold tracking-[-0.01em]">{title}</h1>
<p className="text-muted-foreground text-sm">{sub}</p>
</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</header>
@@ -298,7 +298,7 @@ export function KvFrame({
items: Array<{ label: string; value: ReactNode }>
}) {
return (
<Frame spacing="sm" className="w-full">
<Frame className="[--frame-panel-header-py-adjust:4px] w-full">
<FrameHeader>
<FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription>
@@ -331,7 +331,7 @@ export function CopyList({
items: Array<{ label: string; value: string }>
}) {
return (
<Frame spacing="sm" className="w-full">
<Frame className="[--frame-panel-header-py-adjust:4px] w-full">
<FrameHeader>
<FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription>
@@ -347,7 +347,6 @@ export function CopyList({
<span className="text-muted-foreground min-w-0 flex-1 basis-48 text-[13px]">{item.label}</span>
<Button
type="button"
size="sm"
variant="outline"
aria-label={`Скопировать: ${item.label}`}
onClick={() => {
@@ -425,7 +424,7 @@ export function PendingChip({ mark }: { mark: 'new' | 'dirty' | 'deleted' }) {
/** Вкладки страницы как на досках: линия-подчёркивание, число — мелким индексом. */
export function PageTabsList({ children }: { children: ReactNode }) {
return (
<TabsList variant="line" className="h-10! w-full justify-start gap-6 overflow-x-auto border-b">
<TabsList variant="line" className="h-10! w-full justify-start gap-0.5 overflow-x-auto border-b">
{children}
</TabsList>
)
@@ -433,9 +432,9 @@ export function PageTabsList({ children }: { children: ReactNode }) {
export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) {
return (
<TabsTrigger value={value} className="px-1 text-sm after:-bottom-px!">
<TabsTrigger value={value} className="gap-1.5 px-4 text-sm after:-bottom-px!">
{label}
{count !== undefined ? <sup className="text-muted-foreground ml-1 text-[10px] font-medium tabular-nums">{count}</sup> : null}
{count !== undefined ? <em className="font-mono text-[10px] font-normal not-italic opacity-60">{count}</em> : null}
</TabsTrigger>
)
}
@@ -468,7 +467,7 @@ export function FormFrame({
children: ReactNode
}) {
return (
<Frame spacing="sm" className="w-full min-w-0">
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle>
@@ -33,7 +33,7 @@ export function AppCards({
action?: ReactNode
}) {
return (
<Frame spacing="sm" className="w-full min-w-0">
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle>
@@ -109,7 +109,7 @@ export function ActivityFeed({
action?: ReactNode
}) {
return (
<Frame spacing="sm" className="w-full min-w-0">
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle>
+35 -8
View File
@@ -13,19 +13,25 @@ html[data-preview-density='compact'] [data-slot='sidebar-inset'] > div:last-chil
/* Палитра MikrotikManager v2 (доски Auth Portal): только пока смонтирован макет. */
html[data-preview-density] {
--background: oklch(0.973 0.004 247.858);
--foreground: oklch(0.205 0.01 264);
--foreground: oklch(0.141 0.006 264);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.006 264);
--popover: oklch(1 0 0);
--secondary: oklch(0.955 0.005 264);
--muted: oklch(0.95 0.006 264);
--muted-foreground: oklch(0.44 0.02 264);
--accent: oklch(0.93 0.01 264);
--border: oklch(0.88 0.008 264);
--input: oklch(0.86 0.008 264);
--popover-foreground: oklch(0.141 0.006 264);
--secondary: oklch(0.958 0.005 247.858);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.958 0.005 247.858);
--muted-foreground: oklch(0.52 0.01 264);
--accent: oklch(0.942 0.016 264.376);
--accent-foreground: oklch(0.205 0 0);
--border: oklch(0.904 0.006 264);
--input: oklch(0.904 0.006 264);
--ring: oklch(0.488 0.243 264.376);
--sidebar: oklch(0.985 0.003 264);
--sidebar-foreground: oklch(0.141 0.006 264);
--sidebar-accent: oklch(0.93 0.01 264);
--sidebar-border: oklch(0.88 0.008 264);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.904 0.006 264);
--sidebar-primary: oklch(0.488 0.243 264.376);
--destructive-foreground: oklch(0.45 0.2 27);
--success: oklch(0.6 0.15 163);
@@ -40,16 +46,22 @@ html[data-preview-density].dark {
--background: oklch(0.133 0.011 264.376);
--foreground: oklch(0.985 0 0);
--card: oklch(0.191 0.011 264.376);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.191 0.011 264.376);
--popover-foreground: oklch(0.985 0 0);
--secondary: oklch(0.252 0.01 264.376);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.252 0.01 264.376);
--muted-foreground: oklch(0.648 0.008 264.376);
--accent: oklch(0.285 0.022 264.376);
--accent-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.488 0.243 264.376);
--sidebar: oklch(0.191 0.011 264.376);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.262 0.014 264.376);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.577 0.245 27.325);
@@ -70,6 +82,15 @@ html[data-preview-density][data-preview-contrast='high'] {
html[data-preview-density].dark[data-preview-contrast='high'] {
--background: oklch(0 0 0);
--foreground: oklch(1 0 0);
--success: oklch(0.82 0.19 150);
--success-foreground: oklch(0.9 0.15 150);
--warning: oklch(0.9 0.17 95);
--warning-foreground: oklch(0.95 0.12 95);
--destructive: oklch(0.78 0.2 25);
--destructive-foreground: oklch(0.88 0.12 25);
--info: oklch(0.8 0.15 300);
--info-foreground: oklch(0.9 0.1 300);
--card: oklch(0.12 0 0);
--popover: oklch(0.1 0 0);
--muted: oklch(0.22 0 0);
@@ -104,3 +125,9 @@ html[data-preview-density] #main-content :focus-visible {
transition: none !important;
}
}
/* Шапка таблицы как на досках: 12px, лёгкая подложка. */
html[data-preview-density] [data-slot='data-grid-table'] thead th {
font-size: 0.75rem;
background: color-mix(in oklch, var(--muted) 30%, transparent);
}
@@ -34,7 +34,7 @@ export function ExplainBanner({
<div className="min-w-0 flex-1 basis-80 text-pretty">
<b className="font-semibold">{title}</b> {children}
</div>
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
<Button type="button" variant="outline" onClick={() => setOpen(true)}>
Объяснить
</Button>
</div>
+4 -4
View File
@@ -11,12 +11,12 @@ const VALUE: Record<KpiTone, string> = {
er: 'text-destructive',
}
/** Колонки по числу ячеек: 4 → 4/2/1, 3 → 3/1, 2 → 2/1 (пороги контейнера как на досках). */
/** Колонки по числу ячеек и ширине окна (на досках — ширина приложения): 4 → 4/2/1, 3 → 3/1, 2 → 2/1. */
const COLS: Record<number, string> = {
1: 'grid-cols-1',
2: 'grid-cols-1 @[640px]:grid-cols-2',
3: 'grid-cols-1 @[1240px]:grid-cols-3',
4: 'grid-cols-1 @[640px]:grid-cols-2 @[1240px]:grid-cols-4',
2: 'grid-cols-2 max-[640px]:grid-cols-1',
3: 'grid-cols-1 min-[1241px]:grid-cols-3',
4: 'grid-cols-1 min-[641px]:grid-cols-2 min-[1241px]:grid-cols-4',
}
const GLYPH: Record<KpiTone, string> = { neutral: '', ok: '●', in: '', wn: '▲', er: '✕' }
@@ -202,7 +202,7 @@ export function AppLinksPage() {
{ label: 'Скрытые', match: (r) => !r.enabled },
]}
tools={
<Button type="button" size="sm" variant="outline" onClick={resetOrder}>
<Button type="button" variant="outline" onClick={resetOrder}>
Сбросить порядок
</Button>
}
@@ -66,7 +66,7 @@ export function AuditPage() {
description="Последние события · иконка отражает тип, цвет — важность"
items={AUDIT_FEED}
action={
<Button variant="outline" size="sm" onClick={() => setTab('table')}>
<Button variant="outline" onClick={() => setTab('table')}>
Весь журнал
</Button>
}
@@ -127,7 +127,6 @@ export function OidcPage() {
tools={
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setRotating(clients.find((c) => c.stale) ?? clients[0] ?? null)}
>
+14 -14
View File
@@ -266,7 +266,7 @@ export function PreviewSidebar() {
const pinned = NAV_ITEMS.filter((i) => pins.includes(i.id))
return (
<Sidebar collapsible="icon">
<SidebarHeader>
<SidebarHeader className="pb-1.5">
<PreviewAppSwitcher />
</SidebarHeader>
<SidebarContent>
@@ -285,7 +285,7 @@ export function PreviewSidebar() {
{NAV_GROUPS.map((group, gi) => (
<div key={group} className="flex flex-col">
{gi > 0 ? <SidebarSeparator className="mx-2 my-1 hidden group-data-[collapsible=icon]:block" /> : null}
<SidebarGroup>
<SidebarGroup className="py-0">
<SidebarGroupLabel
render={
<button
@@ -294,13 +294,13 @@ export function PreviewSidebar() {
onClick={() => setClosed((c) => ({ ...c, [group]: !c[group] }))}
/>
}
className="cursor-pointer justify-between text-[11px] font-semibold tracking-wide uppercase"
className="h-7 cursor-pointer justify-between text-[11px] font-semibold tracking-wide uppercase"
>
{group}
<ChevronDownIcon className={cn('size-3.5 transition-transform', closed[group] && '-rotate-90')} aria-hidden />
</SidebarGroupLabel>
<SidebarGroupContent className={cn(closed[group] && 'group-data-[collapsible=icon]:block hidden')}>
<SidebarMenu>
<SidebarMenu className="gap-px">
{NAV_ITEMS.filter((i) => i.group === group).map((item) => (
<NavEntry
key={item.id}
@@ -330,7 +330,7 @@ const MONITOR_METRICS = [
{ l: 'Сессии', v: '5', p: 40, tone: 'success' },
] as const
/** Единый центр состояния: внимание + сводка + список проблем. Адаптивен по ширине шапки. */
/** Единый центр состояния: внимание + сводка + список проблем. Адаптивен по ширине окна, как на досках. */
function MonitorPopover() {
const bad = MONITOR_ITEMS.filter((m) => m.tone !== 'ok')
const errors = bad.filter((m) => m.tone === 'er').length
@@ -344,22 +344,22 @@ function MonitorPopover() {
variant="outline"
type="button"
aria-label={`Состояние системы: внимание, ${errors} сбой, ${warnings} предупреждения`}
className="border-warning/40 bg-warning/10 hover:bg-warning/20 aria-expanded:bg-warning/20 relative h-8 gap-2 px-2 @max-[760px]/header:w-8 @max-[760px]/header:border-transparent @max-[760px]/header:bg-transparent @max-[760px]/header:px-0"
className="border-warning/40 bg-warning/10 hover:bg-warning/20 aria-expanded:bg-warning/20 relative h-8 gap-2 px-2 max-[760px]:w-8 max-[760px]:border-transparent max-[760px]:bg-transparent max-[760px]:px-0"
/>
}
>
<ActivityIcon className="text-warning size-4" aria-hidden />
<span className="text-warning hidden text-sm font-medium @[1000px]/header:inline">Внимание</span>
<span className="hidden items-center gap-1.5 text-sm tabular-nums @[1240px]/header:inline-flex">
<span className="text-warning hidden text-sm font-medium min-[1001px]:inline">Внимание</span>
<span className="hidden items-center gap-1.5 text-sm tabular-nums min-[1241px]:inline-flex">
<span className="bg-destructive size-1.5 rounded-full" aria-hidden />
{errors}
<span className="bg-warning size-1.5 rounded-full" aria-hidden />
{warnings}
</span>
<span className="bg-destructive hidden size-4.5 min-w-4.5 items-center justify-center rounded-full px-1 text-[11px] font-semibold text-white @[760px]/header:inline-flex @[1240px]/header:hidden">
<span className="bg-destructive hidden size-4.5 min-w-4.5 items-center justify-center rounded-full px-1 text-[11px] font-semibold text-white min-[761px]:inline-flex min-[1241px]:hidden">
{bad.length}
</span>
<span className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 @[760px]/header:hidden" aria-hidden />
<span className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 min-[761px]:hidden" aria-hidden />
</PopoverTrigger>
<PopoverContent align="end" className="flex w-[22rem] max-w-[calc(100vw-1.5rem)] flex-col gap-2 p-2">
<div className="flex items-start justify-between gap-2 px-1.5 pt-1.5">
@@ -425,7 +425,7 @@ function FreshnessPopover() {
<Popover>
<PopoverTrigger
render={
<Button type="button" variant="ghost" size="icon-sm" aria-label="Источники и свежесть данных" title="Источники и свежесть данных" />
<Button type="button" variant="ghost" size="icon" aria-label="Источники и свежесть данных" title="Источники и свежесть данных" />
}
>
<InfoIcon aria-hidden />
@@ -444,7 +444,7 @@ function FreshPill() {
return (
<span
role="status"
className="text-muted-foreground hidden h-[30px] items-center gap-2 rounded-lg border px-2.5 text-xs @[1320px]/header:inline-flex"
className="text-muted-foreground hidden h-[30px] items-center gap-2 rounded-lg border px-2.5 text-xs min-[1321px]:inline-flex"
>
<span className="bg-success size-1.5 rounded-full" aria-hidden />
Обновлено 14:02 · Live
@@ -460,7 +460,7 @@ export function PreviewHeader() {
const current = NAV_ITEMS.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
return (
<header className="bg-background/80 @container/header sticky top-0 z-20 flex min-h-12 shrink-0 items-center gap-2 border-b px-4 py-1.5 backdrop-blur md:px-6">
<header className="bg-background/80 sticky top-0 z-20 flex min-h-12 shrink-0 items-center gap-2 border-b px-4 py-1.5 backdrop-blur md:px-6">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb className="min-w-0">
@@ -476,7 +476,7 @@ export function PreviewHeader() {
<Button
type="button"
variant="outline"
size="icon-sm"
size="icon"
aria-label="Командная палитра (⌘K)"
className="md:hidden"
onClick={() => setPaletteOpen(true)}
+16 -12
View File
@@ -222,7 +222,7 @@ export function PreviewSectionGrid<T extends object>({
<Switch size="sm" checked={on} onCheckedChange={(v) => rowSwitch.onChange(r, v)} aria-label="Включено" />
) : null}
<DropdownMenu>
<DropdownMenuTrigger render={<Button type="button" variant="ghost" size="icon-sm" aria-label="Действия" />}>
<DropdownMenuTrigger render={<Button type="button" variant="ghost" size="icon" aria-label="Действия" />}>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-48">
@@ -265,9 +265,12 @@ export function PreviewSectionGrid<T extends object>({
onReorder(String(active.id), String(over.id))
}
const minWidth = visibleColumns.reduce(
(sum, c) => sum + c.size,
(selectable ? SELECT_COLUMN_SIZE : 0) + (onReorder ? DRAG_COLUMN_SIZE : 0) + ACTIONS_COLUMN_SIZE,
// Колонки сжимаются под ширину рамки (текст переносится, как на досках); прокрутка — только на узких экранах.
const minWidth = Math.round(
visibleColumns.reduce(
(sum, c) => sum + c.size,
(selectable ? SELECT_COLUMN_SIZE : 0) + (onReorder ? DRAG_COLUMN_SIZE : 0) + ACTIONS_COLUMN_SIZE,
) * 0.7,
)
const selectedIds = table.getSelectedRowModel().rows.map((r) => r.id)
const clear = () => setRowSelection({})
@@ -284,7 +287,7 @@ export function PreviewSectionGrid<T extends object>({
emptyMessage={emptyMessage}
tableLayout={{ headerSticky: false, dense: false, width: 'auto', rowsDraggable: Boolean(onReorder) }}
>
<Frame spacing="sm" className={cn('w-full min-w-0', className)}>
<Frame className={cn('[--frame-panel-header-py-adjust:4px] w-full min-w-0', className)}>
<FrameHeader className="flex-row flex-wrap items-center gap-x-3 gap-y-2">
<div className="flex min-w-0 flex-1 basis-52 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle>
@@ -293,9 +296,13 @@ export function PreviewSectionGrid<T extends object>({
{typeof tools === 'function' ? tools(selectedIds) : tools}
{card ? (
<Tabs value={cards ? 'cards' : 'table'} onValueChange={(v) => setCards(v === 'cards')} aria-label="Вид списка">
<TabsList>
<TabsTrigger value="table">Таблица</TabsTrigger>
<TabsTrigger value="cards">Карточки</TabsTrigger>
<TabsList className="h-9 gap-0.5 p-[3px]">
<TabsTrigger value="table" className="h-7 px-2.5 text-[12.5px]">
Таблица
</TabsTrigger>
<TabsTrigger value="cards" className="h-7 px-2.5 text-[12.5px]">
Карточки
</TabsTrigger>
</TabsList>
</Tabs>
) : null}
@@ -363,7 +370,6 @@ export function PreviewSectionGrid<T extends object>({
<b className="mr-1.5 font-semibold">Выбрано: {selectedIds.length}</b>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
if (bulk?.onRun) bulk.onRun(selectedIds)
@@ -375,7 +381,6 @@ export function PreviewSectionGrid<T extends object>({
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
if (bulk?.offRun) bulk.offRun(selectedIds)
@@ -387,7 +392,6 @@ export function PreviewSectionGrid<T extends object>({
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="text-destructive"
onClick={() => {
@@ -398,7 +402,7 @@ export function PreviewSectionGrid<T extends object>({
Удалить…
</Button>
<span className="flex-1" />
<Button type="button" size="sm" variant="outline" onClick={clear}>
<Button type="button" variant="outline" onClick={clear}>
Снять выбор
</Button>
</div>
+3 -3
View File
@@ -37,13 +37,13 @@ export function UnsavedBar() {
<b className="font-semibold">{pending.length}</b> · изменения ещё не записаны в портал
</span>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" size="sm" variant="outline" onClick={() => setDiffOpen(true)}>
<Button type="button" variant="outline" onClick={() => setDiffOpen(true)}>
Просмотреть diff
</Button>
<Button type="button" size="sm" variant="outline" onClick={discardAll}>
<Button type="button" variant="outline" onClick={discardAll}>
Отменить
</Button>
<Button type="button" size="sm" onClick={applyAll}>
<Button type="button" onClick={applyAll}>
Сохранить
</Button>
</div>
+2 -2
View File
@@ -59,7 +59,7 @@ export function UsersGrid({ users, handlers, marks }: { users: AdminUser[]; hand
{ id: 'role', title: 'Роль', size: 130, sortValue: (u) => (u.is_admin ? 'a' : 'u'), cell: (u) => <span className="font-mono text-[12.5px]">{u.is_admin ? 'Админ' : 'Пользователь'}</span> },
{ id: 'apps', title: 'Приложения', size: 220, cell: (u) => <span className="block truncate font-mono text-[12.5px]">{appsSummary(u)}</span> },
{ id: 'passkey', title: 'Passkey', size: 100, sortValue: (u) => u.passkey_count, cell: (u) => <span className="font-mono text-[12.5px]">{passkeyLabel(u.passkey_count)}</span> },
{ id: 'activity', title: 'Активность · IP', size: 240, cell: (u) => <span className="font-mono text-[12.5px] whitespace-nowrap">{USER_META[u.id]?.activity ?? '—'}</span> },
{ id: 'activity', title: 'Активность · IP', size: 240, cell: (u) => <span className="font-mono text-[12.5px]">{USER_META[u.id]?.activity ?? '—'}</span> },
],
[marks],
)
@@ -108,7 +108,7 @@ export function UsersGrid({ users, handlers, marks }: { users: AdminUser[]; hand
onDelete: handlers.onBulkDelete,
}}
tools={(ids) => (
<Button type="button" variant="outline" size="sm" onClick={() => handlers.onBulkResetPasskeys(ids.length > 0 ? ids : ['u-3'])}>
<Button type="button" variant="outline" onClick={() => handlers.onBulkResetPasskeys(ids.length > 0 ? ids : ['u-3'])}>
Сбросить passkeys…
</Button>
)}