fix(admin-ui): guest nav filter + suppress drafts 401

This commit is contained in:
Александр Зимин
2026-05-10 21:19:07 +00:00
parent 35c4974a2e
commit c57003cd73
23 changed files with 2625 additions and 2712 deletions
@@ -0,0 +1,499 @@
# Handoff: Ordinis MDM — UI redesign
## Overview
Full UI redesign for the **Ordinis MDM admin** (`ordinis-admin-ui` — React 19 + TanStack Router + Vite + Tailwind v4 + currently `@nstart/ui@0.1.3`). Covers:
1. **Design system** — earthy / woody light theme + Claude-style warm dark theme. Inter + JetBrains Mono + Tektur. Custom token palette.
2. **Dictionary catalog** — bundle-grouped grid with FK chips, scope/bundle filters, search.
3. **Dictionary editor** — tabs (Records / Relations / Fields / JSON / Events / History), keyboard-driven table, record drawer with sectioned form, time-travel viewer, status banner for draft/review/published flows.
4. **Modals** — Create dictionary, Confirm delete, Toast notifications.
5. **Theme switcher** — Light (earthy) / Dark (Claude-warm), persisted to `localStorage`.
## About the design files
`design/compact.html` and `design/ui-kit.html` are **single-file React prototypes** using inline Babel + CDN React 18. They are **design references**, not production code.
**Your task:** recreate the screens in the existing `ordinis-admin-ui` codebase using its real patterns:
- TanStack Router routes
- TanStack Query for data
- Tailwind v4 utility classes (project already uses `@tailwindcss/vite`)
- Component primitives from `@nstart/ui` (or progressively replace with **shadcn/ui** behind a barrel — see "Stack migration" below)
- React Hook Form + AJV for forms
- Phosphor Icons
**Do not** ship the prototype HTML to production. **Do not** import the prototype as a module — it uses `window.*` globals and inline Babel that won't survive the Vite build.
## Fidelity
**High-fidelity.** Colors, typography, spacing, and most interactions are decided. Pixel-perfect recreation is the goal, with these adaptations:
- Inline styles → Tailwind classes + `@theme` tokens in `globals.css`.
- Mock data (`SATS`, `DICTS`, `HISTORY`) → real TanStack Query.
- Hand-built `<button>`/`<div>` chrome → `@nstart/ui` (short-term) or `shadcn/ui` primitives (long-term).
## Files in this bundle
| Path | What it is |
|---|---|
| `design/compact.html` | The main prototype — catalog, editor, drawer, modals, time-travel, theme switcher |
| `design/ui-kit.html` | Design-system reference — tokens, type scale, components in isolation |
| `screenshots/01-catalog-light.png` | Catalog (Earth theme) |
| `screenshots/02-catalog-dark.png` | Catalog (Claude-warm dark) |
| `screenshots/03-uikit-light.png` | Design-system reference (Earth) |
| `screenshots/04-uikit-dark.png` | Design-system reference (Dark) |
| `review.md` | Original code review of `ordinis-admin-ui` — bugs and improvements that drove this redesign |
Open both HTML files in any modern browser. Toggle Earth / Dark in the top-right corner. Editor screen and record drawer are accessed by clicking any dictionary row in the catalog.
---
## Design tokens
Both themes share token names, just different values. Apply in `globals.css` via Tailwind v4's `@theme` directive.
### Light theme (Earth — default, `:root`)
```css
@theme {
/* text */
--color-ink: #1a1714; /* body, headings */
--color-ink-2: #52483d; /* secondary text, labels */
--color-mute: #8c8276; /* placeholders, captions, mono labels */
/* surfaces */
--color-bg: #fbf8ee; /* page background */
--color-surface: #ffffff; /* cards, modals, drawer */
--color-surface-2: #f4eedc; /* hovers, table-row stripe */
/* lines */
--color-line: #e3dccb; /* hairlines, card borders */
--color-line-2: #efe9d8; /* subtle dividers */
/* brand + actions */
--color-navy: #3a2818; /* primary button bg (deep coffee) */
--color-accent: #b05a2e; /* terracotta — links, focus, FK chips */
--color-accent-bg: rgb(176 90 46 / 11%);
--color-on-accent: #ffffff;
/* semantic */
--color-green: #4f7038; /* "ok" / live */
--color-green-bg: #e4ead0;
--color-warn: #a8762a; /* warning / draft */
--color-warn-bg: #f6e8bc;
--color-pink: #a64636; /* error / destructive */
--color-pink-bg: #f4d9cd;
}
```
### Dark theme (Claude-warm, `[data-theme="dark"]`)
```css
[data-theme="dark"] {
--color-ink: #f0ece4;
--color-ink-2: #c8c1b3;
--color-mute: #8a8474;
--color-bg: #1c1a17;
--color-surface: #262421;
--color-surface-2: #2e2b27;
--color-line: #3a3733;
--color-line-2: #2d2a27;
--color-navy: #d97757; /* primary becomes Claude-orange in dark */
--color-accent: #d97757;
--color-accent-bg: rgb(217 119 87 / 16%);
--color-on-accent: #1c1a17;
--color-green: #6fa97d;
--color-green-bg: rgb(111 169 125 / 18%);
--color-warn: #d4a653;
--color-warn-bg: rgb(212 166 83 / 18%);
--color-pink: #d18272;
--color-pink-bg: rgb(209 130 114 / 20%);
}
```
### Scope colors (dictionary visibility)
| Scope | Light fg | Light bg | Dark behavior |
|---|---|---|---|
| `PUBLIC` | `--color-accent` (`#b05a2e`) | `--color-accent-bg` | inherits accent |
| `INTERNAL` | `--color-warn` (`#a8762a`) | `--color-warn-bg` | inherits warn |
| `TENANT` | `--color-pink` (`#a64636`) | `--color-pink-bg` | inherits pink |
Always pair with a 6-8px square swatch prefix (`<span class="scope-dot">`).
### Typography
| Font | Source | Used for |
|---|---|---|
| **Inter** 400/500/600/700 | Google Fonts | All UI text, headings, body |
| **JetBrains Mono** 400/500/600 | Google Fonts | IDs, FK refs, dates, numerics |
| **Tektur** 500/600/700 | Google Fonts | UPPERCASE captions (`.cap`), logo, theme switch labels |
`font-feature-settings: "ss01","cv11","cv02","cv03","cv04"` on body for Inter's stylistic alternates.
**Scale:**
- 22px / 600 — section header (rare, e.g. modal title)
- 17px / 600 — page title in editor
- 15px / 600 — dictionary card title
- 13px / 400-600 — body text, buttons, tabs (this is the workhorse)
- 12.5px / 500 — table cell text
- 11px Mono / 500 — IDs, dates
- 10.5px Tektur uppercase, `letter-spacing: .10em` — captions / section labels
### Spacing
Tight, density-first. The whole UI optimizes for showing many records at once.
- Card padding: `12px 14px`
- Drawer body padding: `4px 18px 24px`
- Drawer section grid gap: `14px`
- Modal padding: `20px 22px`
- Toolbar height: `40px`, with `0 14px` horizontal padding
- Page gutter: `18px` horizontal
### Radius / shadow
| Element | Value |
|---|---|
| Buttons, inputs | `border-radius: 6px` |
| Cards | `border-radius: 8px` |
| Modal, drawer | `border-radius: 8px` (drawer only on inner edge) |
| Toast | `border-radius: 8px` |
| Chip (badge, FK) | `border-radius: 4-5px` |
Shadows are minimal in light, almost absent in dark. Drawer: `-20px 0 60px -20px rgba(15,17,21,.35)`. Modal: `0 24px 48px -16px rgba(15,17,21,.30)`. Toast: `0 12px 30px -10px rgba(40,25,10,.38)`.
---
## Screens
The prototype is **one page** that toggles between catalog and editor via a state machine (`view: "catalog" | "editor"`). In production these become two TanStack Router routes.
### Screen 1 — Catalog (`/dictionaries`)
**Layout (1280px+):**
```
┌─ sidebar 220px ──┬─ main ─────────────────────────────────────┐
│ logo (ORDINIS) │ header (h=56): breadcrumb · ⌕ search · usr │
│ nav rail ├────────────────────────────────────────────┤
│ ─ Главная │ toolbar (h=44): filter chips · "+ Создать" │
│ ▣ Справочники 40 ├────────────────────────────────────────────┤
│ Мои черновики │ bundle group: cuod (10) │
│ На ревью 7 │ ┌── card ──┐ ┌── card ──┐ │
│ Outbox 2 │ │ title │ │ title │ … │
│ Webhooks │ │ desc │ │ desc │ │
│ Аудит │ │ FK chips │ │ FK chips │ │
│ Поиск │ └──────────┘ └──────────┘ │
│ │ bundle group: geo (5) │
│ user · profile │ … │
└──────────────────┴────────────────────────────────────────────┘
```
**Toolbar:**
- Search input — `width: 280px`, placeholder "Поиск по названию, коду…", searches `id + title + desc`.
- Scope chip group — three toggleable buttons with colored dot prefix.
- Bundle filter — single-select chips after a small "BUNDLE" Tektur label.
- Right side: `"+ Создать справочник"` primary button → opens modal.
**Card:**
- 3px left strip in scope color.
- Title (15px / 600 / `--color-navy`) + id below in Mono 11px gray.
- Description (12px) clamped to 2 lines.
- Relations row — `→ ссылается` label + chips for `fk[]`; `← N использ.` + chips for `refBy[]` (dimmed dashed border style).
- Right column: ScopeBadge + version mono.
- Hover: `translateY(-1px)` + soft shadow; entire card is the click target → opens editor.
**FK chip:** clickable button, `padding: 2px 7px`, `border-radius: 4px`, mono 11px. Active variant: `bg: --color-accent-bg`, `color: --color-accent`. Dimmed: transparent bg + dashed border. `onClick` stops propagation and navigates to that dictionary.
### Screen 2 — Editor (`/dictionaries/$name`)
**Layout:**
```
┌─ sidebar (collapsed to 56px in editor) ─┬─ main ─────────────────┐
│ ⌂ ▣ ✎ ◐ ↗ ⇆ ☷ ⌕ │ breadcrumb + actions │
│ │ title row │
│ │ status banner │
│ ├────────────────────────┤
│ │ tabs (h=40) │
│ ├────────────────────────┤
│ │ tab body │
│ │ - records (table) │
│ │ - relations (FK list)│
│ │ - fields (schema) │
│ │ - json (raw schema) │
│ │ - events (audit) │
│ │ - history (timeline) │
│ ├────────────────────────┤
│ │ footer toolbar │
└──────────────────────────────────────────┴────────────────────────┘
```
**Breadcrumb row:** "Справочники / **Космические аппараты**" + right-aligned: `[Export]` `[Compare]` `[⋯ menu]`.
**Title row:** large title (17px / 600) + scope badge + version mono + edit pencil.
**Status banner (sits above tabs):** 1-line card showing current workflow state.
- Draft: `--color-warn-bg` background, left edge `--color-warn`, copy "Черновик · v1.8.0+draft · 3 правки · открыт {user}".
- Review: `--color-accent-bg`, copy "На ревью · ждёт {reviewer} · открыто {time}".
- Live: `--color-green-bg`, "Опубликовано · v1.8.0 · {time}".
- Buttons on the right adapt to state: `Запросить ревью` (draft), `Approve / Request changes` (review), `Создать черновик` (live).
**Tab bar:** horizontal scrollable on narrow viewports; active tab has `--color-accent` bottom border, weight 600. Right side of bar has utility buttons (`Time-travel ◷` opens a popover with date picker).
**Records tab** — the main view:
- Compact table, row height 32px, monospace IDs in first column.
- Sticky header with column sort arrows and a search icon per column (filter).
- Row hover: `--color-surface-2` background.
- Click anywhere in a row → opens **RecordDrawer** on the right.
- Bulk-select via leftmost checkbox column.
- Footer toolbar: "N из M записей" · paging · "+ Запись" (right-aligned, primary).
**Relations tab** — two sections:
- "Исходящие FK" — table: field · target dict · cardinality · critical / soft.
- "Входящие ссылки" — table: source dict · field · count of records using it.
**Fields tab** — full JSON-schema view as an editable grid:
- Columns: name · type · required · unique · i18n · index · description.
- Inline edit; "+ Поле" at bottom.
**JSON tab** — Monaco editor with the raw `schema.json`; read-only on Live records.
**Events tab** — audit log filtered to this dict:
- Filter pills: webhook · publish · review · import · retried.
- One row per event with mono timestamp, actor avatar, target record, status badge.
**History tab** — vertical timeline:
- One node per commit, current `HEAD` marked.
- Each node shows: short SHA, message, author, diff counters (`+N -M`).
- Click → opens diff in a slide-over.
### Screen 3 — RecordDrawer (right slide-over)
Opens on row click. **Two widths**: 520px (default) and 880px (toggle via `/` button).
**Header (sticky):** breadcrumb "редактирование записи · satellites" caption, then `id` mono + `· name`, then `[/]` `[История]` `[×]`.
**Toolbar (sticky):** search field for filtering schema fields by name + "только заполненные" toggle + counter "N / M".
**Section anchor chips:** horizontally scrolling row of section names with field-count badge; click to scroll-to-section. Active chip is filled navy/accent.
**Body:** sections in order with sticky section headers (`.cap` style). Each section is a 2-column grid (or 4 in wide mode). Section list — see **Record schema** below.
**Wide mode (`880px`):** adds a left ToC rail (180px) with the same sections as a vertical list, mirroring the chip strip.
**Footer (sticky):** `[Удалить]` (pink) · "не сохранено · N полей" caption · `[Отмена]` · `[Сохранить]` (primary).
### Screen 4 — Modals
**Create dictionary** — centered dialog, 560px:
- "НОВЫЙ СПРАВОЧНИК" cap header.
- Fields: Название (text), id (mono, slug-validated, auto-derived from title), Bundle (select), Scope (radio with colored dots), Поддерживаемые локали (multi-chip — `ru / en / zh / es / fr / de / ar`).
- Footer: `[Отмена]` · `[Создать]`.
**Confirm delete** — centered dialog, 440px:
- Pink left-accent stripe (4px).
- "ПОДТВЕРЖДЕНИЕ" cap header.
- Copy explains consequences (e.g. "12 справочников ссылаются на эту запись через FK").
- Footer: `[Отмена]` · `[Удалить]` (pink danger button).
**Toast** — bottom-right slide-up:
- Navy (or Claude-orange in dark) `--color-navy` background.
- "OK" cap header in toast-cap color (orange-cream), then short message, then `×`.
- Auto-dismiss after 4s.
### Screen 5 — Time-travel
A popover from the time-travel button in the editor tab bar. Two parts:
- **Date picker** — calendar with 30 day range; selecting a date snapshots the dict and editor to that point.
- **Quick presets** — "сейчас" · "1ч назад" · "вчера 18:00" · "перед последним публикованием".
- "Применить" / "Сбросить" footer.
When active, all editor surfaces gain a thin amber top border and a banner: "Просмотр на {timestamp} · только чтение · [Сбросить]".
---
## Record schema (sections in drawer)
The drawer's section list is the canonical "what fields a record can have" definition. The prototype encodes this in `RECORD_SCHEMA` (see `design/compact.html` ~line 220).
| Section | Fields | Notes |
|---|---|---|
| **Основное** | id, active toggle, name_ru/en/zh/es, description | name_ru is the only required loc; others optional |
| **Связи (FK)** | operator, status (enum), orbit_type, mission, launch_site, launch_vehicle, manufacturer, data_center | Each is a FK to another dict |
| **Геометрия орбиты** | altitude_km, inclination, period_min, eccentricity, raan, arg_perigee | numerics with units |
| **Физические параметры** | mass_kg, power_w, design_life, dimensions | |
| **Инструменты и продукты** | instruments[], products[], ground_stations[] | tag inputs |
| **Внешние идентификаторы** | cospar_id, norad_id, sat_cat, itu | mono text |
| **Даты и события** | launch_date, decom_date, first_light, last_contact | |
| **Флаги** | public, deprecated, sensitive, draft_only | toggles |
| **Произвольные данные** | raw_json | JSON editor (Monaco) |
| **Системные** (readonly) | created_at, updated_at, created_by, version | |
In production these sections must come from the dictionary's JSON-schema metadata (`x-section` per property), not be hard-coded.
---
## Components inventory
Map each prototype piece to a target component file. Where possible reuse `@nstart/ui`; where it's broken, replace.
### Atomic
| Prototype | Target | Notes |
|---|---|---|
| `Cap` (uppercase Tektur label) | `<Cap>` in `components/ui/Cap.tsx` | Plain `<span class="cap">`, Tektur 10.5px |
| `ScopeBadge` | `components/catalog/ScopeBadge.tsx` | Pill with colored dot + uppercase label |
| `ScopeDot` | `components/catalog/ScopeDot.tsx` | 8×8 colored square |
| `FkChip` | `components/catalog/FkChip.tsx` | Active + dimmed variants |
| `StatusBadge` (DRAFT / LIVE / DEPRECATED) | `components/ui/StatusBadge.tsx` | Background = `--color-warn-bg` / `--color-green-bg` / `--color-pink-bg` |
| `Btn` / `BtnPrimary` / `BtnDanger` / `BtnGhost` | `@nstart/ui` `Button` (variant) | Patch theme tokens; danger uses `--color-pink` |
| `IconBtn` (28x28 with icon glyph) | `components/ui/IconButton.tsx` | |
| `Toggle` (switch) | `@nstart/ui` `Toggle` or shadcn `Switch` | Native indeterminate not needed |
| `TagInput` | replace `@nstart/ui` MultiSelect → **shadcn Combobox + cmdk** | See "Stack migration" |
### Composite
| Prototype piece | Target component | File |
|---|---|---|
| Sidebar (nav rail + collapse) | `<Sidebar>` | `components/layout/Sidebar.tsx` |
| Header (breadcrumb + search + user) | `<TopBar>` | `components/layout/TopBar.tsx` |
| Catalog toolbar | `<CatalogToolbar>` | `components/catalog/CatalogToolbar.tsx` |
| Catalog card | `<DictCard>` | `components/catalog/DictCard.tsx` |
| Catalog bundle group | `<BundleGroup>` | `components/catalog/BundleGroup.tsx` |
| Editor tab bar | `<EditorTabs>` | `components/editor/EditorTabs.tsx` |
| Editor status banner | `<WorkflowBanner>` | `components/editor/WorkflowBanner.tsx` (variant = draft/review/live) |
| Records table | `<RecordsTable>` | `components/editor/RecordsTable.tsx` (TanStack Table) |
| Record drawer | `<RecordDrawer>` | `components/editor/RecordDrawer.tsx` |
| Drawer field renderers | `<DrawerField>` | `components/editor/DrawerField.tsx` (kind-switch: text / mono / num / date / fk / enum / toggle / tags / textarea / json) |
| Create-dict modal | `<CreateDictModal>` | `components/modals/CreateDictModal.tsx` |
| Confirm modal | `<ConfirmModal>` | `components/modals/ConfirmModal.tsx` |
| Toast | `<Toast>` | use `sonner` (recommended) or `@nstart/ui` Toast |
| Time-travel popover | `<TimeTravelPopover>` | `components/editor/TimeTravelPopover.tsx` |
| Theme switcher | `<ThemeSwitch>` | `components/layout/ThemeSwitch.tsx`, writes `data-theme` attr + `localStorage` |
---
## Stack migration plan (`@nstart/ui` → `shadcn/ui`)
Three-phase migration. Don't do it all at once. Phase 1 unblocks the redesign; phases 2-3 happen over the following weeks.
### Phase 1 — Patch & ship (1 day)
1. `pnpm patch @nstart/ui` to fix three things in `dist/index.js`:
- `Select` / `MultiSelect` / `DatePicker` popover → `createPortal(node, document.body)` with `position: fixed`, computed top/left from `triggerRef.getBoundingClientRect()`, `z-index: 9999`, listens to scroll/resize.
- Outside-click detection: check both `triggerRef.contains(target)` and `popoverRef.contains(target)`.
- `Checkbox`: forward ref to inner `<input>`, sync `indeterminate` prop via `useEffect`.
2. Add `@theme` block to `globals.css` with both palettes (above).
3. Add `<ThemeSwitch>` component → toggles `data-theme` on `<html>`.
4. Wire `@nstart/ui` theme tokens to our `--color-*` variables (one CSS file, override their CSS vars).
### Phase 2 — Barrel + Radix replacements for broken components (3-4 days)
Create a barrel at `src/ui/index.ts`:
```ts
// re-export anything from @nstart/ui that works
export { Button, Alert, Badge, Tabs, TextInput, Drawer } from '@nstart/ui';
// override the broken ones with our wrappers
export { Select, MultiSelect } from './Select'; // Radix Select + cmdk
export { Modal } from './Modal'; // Radix Dialog
export { Checkbox } from './Checkbox'; // Radix Checkbox
export { Combobox } from './Combobox'; // cmdk-based
```
Then a codemod / find-and-replace across the codebase: `from '@nstart/ui'``from '@/ui'`. Adopt **shadcn/ui** primitives inside the wrappers:
- `npx shadcn@latest init` (Tailwind v4 mode, slate base, CSS variables = yes).
- `npx shadcn@latest add select combobox dialog checkbox dropdown-menu popover tooltip command`.
- Edit the generated files to use our `--color-*` tokens directly (replace shadcn's `primary` / `destructive` etc. with our names).
### Phase 3 — Full shadcn for new screens, gradual replace for old (2-3 weeks, parallel to feature work)
- All new components use shadcn directly.
- Old `@nstart/ui` Button / Alert / Badge / Tabs / TextInput / Drawer get migrated one at a time as touched.
- End state: `@nstart/ui` removed from `package.json`, patch deleted.
---
## Interactions & behavior
### Tables (all 5 — catalog list, records, fields, events, history-preview)
- Row hover background uses `--color-surface-2`; default is `--color-surface`. **Never hardcode `#fff` / `#fafbfc`** in `onMouseEnter`/`Leave` handlers — those break dark mode (rows go white-on-white). Either use CSS `tr:hover { background: var(--color-surface-2) }` or pass theme tokens to the inline handler.
- Row height: 32px in production; 28px in compact density mode.
- IDs in the first column are `mono` + `--color-accent`; clickable but `stopPropagation` is not needed (row click and id click both navigate to the same place).
### Catalog
- Search debounced 150ms.
- Filter chips are URL-synced via TanStack Router `validateSearch` (`?q&scopes&bundle&onlyWithRefs`).
- Card click → `navigate({ to: '/dictionaries/$name', params: { name: id } })`.
### Editor
- Tab change → `?tab=records|relations|fields|json|events|history`.
- Status banner → mutations (request-review, approve, publish, create-draft) hit existing API and invalidate the dict query.
- Records table sort/filter are URL-synced.
- Pressing `j`/`k` moves selected row; `Enter` opens drawer; `Esc` closes; `n` opens new-record drawer; `/` focuses search.
### Drawer
- Width toggle persists per-user to localStorage (`ord-drawer-wide`).
- "Только заполненные" filter only hides fields with no value (excluding active toggles set to false — those still show because the field is "configured").
- "История" button opens the History tab of the current dict filtered to this record.
- Save → optimistic update + toast; conflict → modal with diff.
### Time-travel
- Opens via tab-bar button.
- Sets `?asOf=2026-04-30T09:00:00Z` in search.
- All API queries scope to that timestamp; mutations disabled (UI goes read-only with amber border).
- "Сбросить" clears `asOf`.
### Theme
- Click switch → `document.documentElement.setAttribute('data-theme', val)` + `localStorage.setItem('ord-theme', val)`.
- Initial load reads localStorage; falls back to `prefers-color-scheme`.
---
## Responsive
Desktop-first (1280px+ baseline). Three breakpoints:
| Breakpoint | Changes |
|---|---|
| `≤ 1024px` | Sidebar becomes overlay drawer; hamburger in TopBar; main grid `0 1fr` |
| `≤ 880px` | Drawer and modals go fullscreen (`100vw`, `100dvh`, `border-radius: 0`); editor body is single column |
| `≤ 560px` | Table columns 7+ hidden via `nth-child`; toolbar wraps; sidebar always overlay |
---
## Accessibility checklist
- All buttons have visible focus rings — use `--color-accent` 2px outline-offset 2px.
- Drawer + modal are focus-trapped (shadcn Dialog gives this free).
- Drawer + modal close on `Esc`.
- Theme switch has `aria-label`; current value is `aria-pressed`.
- Tables: `<th scope="col">`; sortable headers are `<button>` with `aria-sort`.
- Status badges include text, never color-only.
- All FK chips have a `title` with the full target id.
---
## Open questions for the team
1. **JSON-schema FK convention** — is it `x-ref`, `$ref`, or `x-dict`? `extractFks` must match.
2. **Backend graph endpoint** — willing to add `GET /api/v1/dictionaries/graph` so the catalog doesn't recompute on every load?
3. **Workflow state machine** — confirm exact transitions (draft → review → live → archived?). Current banner assumes draft / review / live.
4. **Time-travel granularity** — minute? second? day? Affects calendar UX.
5. **Sections in record drawer** — confirm `x-section` will be added to schema metadata, or do we need a separate ordering file?
6. **Are sensitive fields really record-level** or are they field-level (mask in UI based on user permission)?
---
## Out of scope
- Mobile-first redesign — admin remains desktop-primary.
- Bulk import / export UX — separate work.
- Permission management UI — separate work.
- Webhook subscription manager — separate work.
- The form-validation bugs in `review.md` (#4 AJV errors, #12 token storage) — covered in Phase 1 patch + ongoing.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,680 @@
<!doctype html>
<html lang="ru"><head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=1100"/>
<title>Ordinis · UI Kit</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Tektur:wght@500;600;700&display=swap" rel="stylesheet"/>
<style>
:root{
/* Earthy light — v2. Cleaner contrast, less muddy. */
--ink:#1a1714; --ink-2:#52483d; --mute:#8c8276;
--line:#e3dccb; --line-2:#efe9d8; --bg:#fbf8ee;
--surface:#ffffff; --surface-2:#f4eedc;
--navy:#3a2818; --accent:#b05a2e; --accent-bg:rgba(176,90,46,.11);
--green:#4f7038; --amber:#a8762a; --pink:#a64636; --purple:#7a4a2e;
--on-accent:#ffffff;
--bg-public:rgba(176,90,46,.13); --bg-internal:#f6e8bc; --bg-tenant:#f4d9cd;
--bg-draft:var(--line-2); --bg-live:#e4ead0; --bg-deprec:#f4d9cd;
--al-info-bg:rgba(176,90,46,.09); --al-info-bd:rgba(176,90,46,.28);
--al-warn-bg:#f6e8bc; --al-warn-bd:#e2c982;
--al-err-bg:#f4d9cd; --al-err-bd:#dfb29e;
--al-ok-bg:#e4ead0; --al-ok-bd:#bcc995;
--shadow-l:0 12px 30px -16px rgba(50,30,15,.18);
--shadow-m:0 12px 28px -12px rgba(50,30,15,.22);
--shadow-toast:0 12px 30px -10px rgba(40,25,10,.38);
--shadow-drawer:0 1px 2px rgba(50,30,12,.18);
--toast-cap:#d4a07a;
--avt-pink-bg:#f4d9cd; --avt-green-bg:#e4ead0;
--primary:var(--navy);
}
:root[data-theme="claude-light"]{
--ink:#2c2b27; --ink-2:#5a564f; --mute:#8a8474;
--line:#e6e1d4; --line-2:#efeae0; --bg:#faf9f5;
--surface:#ffffff; --surface-2:#f5f3ec;
--navy:#d97757; --accent:#d97757; --accent-bg:rgba(217,119,87,.12);
--green:#4a7d4f; --amber:#b97c2f; --pink:#b34d6a; --purple:#a85d3e;
--on-accent:#ffffff;
--bg-public:rgba(217,119,87,.14); --bg-internal:#fcecc7; --bg-tenant:#f8d8de;
--bg-draft:#efeae0; --bg-live:#dde9da; --bg-deprec:#f8d8de;
--al-info-bg:rgba(217,119,87,.10); --al-info-bd:rgba(217,119,87,.30);
--al-warn-bg:#fcecc7; --al-warn-bd:#ecd393;
--al-err-bg:#f8d8de; --al-err-bd:#ecb6c2;
--al-ok-bg:#dde9da; --al-ok-bd:#bdd3b9;
--shadow-l:0 12px 30px -16px rgba(89,67,40,.22);
--shadow-m:0 12px 28px -12px rgba(89,67,40,.20);
--shadow-toast:0 14px 36px -10px rgba(89,67,40,.30);
--shadow-drawer:0 1px 2px rgba(89,67,40,.18);
--toast-cap:#f5c8b3;
--avt-pink-bg:#f8d8de; --avt-green-bg:#dde9da;
--primary:var(--accent);
}
:root[data-theme="claude"]{
--ink:#f0ece4; --ink-2:#c8c1b3; --mute:#8a8474;
--line:#3a3733; --line-2:#2d2a27; --bg:#1c1a17;
--surface:#262421; --surface-2:#2e2b27;
--navy:#d97757; --accent:#d97757; --accent-bg:rgba(217,119,87,.16);
--green:#6fa97d; --amber:#d4a653; --pink:#d18272; --purple:#a991d4;
--on-accent:#1c1a17;
--bg-public:rgba(217,119,87,.18); --bg-internal:rgba(212,166,83,.18); --bg-tenant:rgba(209,130,114,.20);
--bg-draft:#2d2a27; --bg-live:rgba(111,169,125,.20); --bg-deprec:rgba(209,130,114,.20);
--al-info-bg:rgba(217,119,87,.10); --al-info-bd:rgba(217,119,87,.35);
--al-warn-bg:rgba(212,166,83,.10); --al-warn-bd:rgba(212,166,83,.35);
--al-err-bg:rgba(209,130,114,.10); --al-err-bd:rgba(209,130,114,.35);
--al-ok-bg:rgba(111,169,125,.10); --al-ok-bd:rgba(111,169,125,.35);
--shadow-l:0 12px 30px -16px rgba(0,0,0,.6);
--shadow-m:0 12px 28px -12px rgba(0,0,0,.55);
--shadow-toast:0 14px 36px -10px rgba(0,0,0,.7);
--shadow-drawer:0 1px 2px rgba(0,0,0,.5);
--toast-cap:#f0c3ab;
--avt-pink-bg:rgba(209,130,114,.22); --avt-green-bg:rgba(111,169,125,.22);
--primary:var(--accent);
}
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--ink);font-family:Inter,system-ui,sans-serif;font-feature-settings:"cv11","ss01";font-size:13px;line-height:1.5}
.mono{font-family:"JetBrains Mono",monospace;font-feature-settings:"calt","liga"}
.cap{font-family:Tektur,Inter,sans-serif;font-size:10.5px;font-weight:600;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}
.brand{font-family:Tektur;font-weight:600;letter-spacing:.22em;display:inline-flex;align-items:center;gap:9px;color:var(--ink)}
.brand svg{display:block;color:var(--accent);flex-shrink:0}
.brand .wm{display:inline-flex;align-items:baseline;gap:6px}
.brand .wm em{font-style:normal;font-weight:400;letter-spacing:.32em;color:var(--mute);font-size:.78em}
.shell{max-width:1180px;margin:0 auto;padding:32px 28px 80px}
.head{display:flex;align-items:baseline;gap:14px;border-bottom:1px solid var(--line);padding-bottom:18px;margin-bottom:28px}
.head h1{font-family:Tektur;font-size:22px;letter-spacing:.04em;margin:0;font-weight:600}
.head .sub{color:var(--mute);font-size:12.5px}
.toc{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:28px}
.toc a{height:24px;padding:0 10px;border-radius:12px;border:1px solid var(--line);background:var(--surface);color:var(--ink-2);font-size:11.5px;text-decoration:none;display:inline-flex;align-items:center;gap:6px}
.toc a:hover{border-color:var(--navy);color:var(--navy)}
.toc a .n{font-family:"JetBrains Mono",monospace;font-size:10px;color:var(--mute)}
section{margin-bottom:36px}
section h2{display:flex;align-items:baseline;gap:10px;margin:0 0 14px;font-size:11px;font-weight:600;letter-spacing:.18em;text-transform:uppercase;color:var(--mute);font-family:Tektur}
section h2 .num{font-family:"JetBrains Mono",monospace;color:var(--accent);letter-spacing:0}
section h2::after{content:"";flex:1;height:1px;background:var(--line-2)}
.card{background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:18px}
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
.col{display:flex;flex-direction:column;gap:10px}
.grid{display:grid;gap:14px}
.label{font-family:Tektur;font-size:9.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);font-weight:600;margin-bottom:6px;display:block}
/* primitives */
.btn{height:32px;padding:0 14px;border:1px solid var(--line);border-radius:6px;background:var(--surface);color:var(--ink);cursor:pointer;font:inherit;font-size:12.5px;display:inline-flex;align-items:center;gap:6px;transition:background .12s, border-color .12s}
.btn:hover{border-color:var(--ink-2);background:var(--surface-2);color:var(--ink)}
.btn-pri{background:var(--primary);border-color:var(--primary);color:var(--on-accent);font-weight:600}
.btn-pri:hover{background:color-mix(in srgb, var(--primary) 88%, #000);border-color:color-mix(in srgb, var(--primary) 88%, #000);color:var(--on-accent)}
.btn-danger{color:var(--pink);border-color:var(--line);background:var(--surface)}
.btn-danger:hover{border-color:var(--pink);background:var(--al-err-bg);color:var(--pink)}
.btn-ghost{border-color:transparent;background:transparent;color:var(--ink-2)}
.btn-ghost:hover{background:var(--line-2);color:var(--ink);border-color:transparent}
.btn-sm{height:26px;padding:0 10px;font-size:11.5px}
.btn-xs{height:22px;padding:0 8px;font-size:11px}
.icon{width:32px;height:32px;border:1px solid var(--line);border-radius:6px;background:var(--surface);color:var(--ink-2);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;font-size:14px;transition:background .12s, border-color .12s, color .12s}
.icon:hover{border-color:var(--ink-2);color:var(--ink);background:var(--surface-2)}
.inp{height:32px;padding:0 10px;border:1px solid var(--line);border-radius:6px;font:inherit;font-size:12.5px;background:var(--surface);color:var(--ink);outline:none}
.inp:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg)}
.inp-mono{font-family:"JetBrains Mono",monospace;font-size:12px}
.inp-err{border-color:var(--pink);box-shadow:0 0 0 3px var(--al-err-bg)}
.chk{appearance:none;width:16px;height:16px;border:1px solid var(--line);border-radius:3px;cursor:pointer;position:relative;background:var(--surface);vertical-align:middle}
.chk:checked{background:var(--primary);border-color:var(--primary)}
.chk:checked::after{content:"✓";position:absolute;top:-1px;left:2px;color:var(--on-accent);font-size:12px;line-height:1}
.rad{appearance:none;width:16px;height:16px;border:1px solid var(--line);border-radius:50%;cursor:pointer;position:relative;background:var(--surface)}
.rad:checked{border-color:var(--primary)}
.rad:checked::after{content:"";position:absolute;inset:3px;border-radius:50%;background:var(--primary)}
.swt{width:32px;height:18px;border-radius:9px;background:var(--line);position:relative;cursor:pointer;display:inline-block;transition:background .15s}
.swt::after{content:"";position:absolute;left:2px;top:2px;width:14px;height:14px;border-radius:50%;background:var(--surface);transition:left .15s;box-shadow:var(--shadow-drawer)}
.swt.on{background:var(--green)}
.swt.on::after{left:16px}
.badge{padding:1px 7px;border-radius:3px;font-size:10.5px;font-family:"JetBrains Mono",monospace;display:inline-block}
.bg-public{background:var(--bg-public);color:var(--navy)}
.bg-internal{background:var(--bg-internal);color:var(--amber)}
.bg-tenant{background:var(--bg-tenant);color:var(--pink)}
.bg-draft{background:var(--bg-draft);color:var(--mute)}
.bg-live{background:var(--bg-live);color:var(--green)}
.bg-deprec{background:var(--bg-deprec);color:var(--pink)}
.chip{height:24px;padding:0 9px;border-radius:12px;border:1px solid var(--line);background:var(--surface);color:var(--ink-2);font-size:11.5px;display:inline-flex;align-items:center;gap:6px;cursor:pointer}
.chip.on{background:var(--primary);border-color:var(--primary);color:var(--on-accent)}
.chip .x{opacity:.6;cursor:pointer}
.tag{padding:3px 8px;border-radius:4px;background:var(--accent-bg);color:var(--accent);font-family:"JetBrains Mono",monospace;font-size:11px;display:inline-flex;align-items:center;gap:6px}
.dot{width:6px;height:6px;border-radius:50%;display:inline-block}
.status{display:inline-flex;align-items:center;gap:5px;font-size:11.5px}
.s-op{color:var(--green)}.s-pl{color:var(--mute)}.s-dc{color:var(--pink)}.s-ls{color:var(--amber)}
.tab-bar{display:flex;gap:0;border-bottom:1px solid var(--line)}
.tab{border:none;background:transparent;padding:9px 12px;font:inherit;font-size:12.5px;cursor:pointer;color:var(--mute);border-bottom:2px solid transparent;margin-bottom:-1px}
.tab.on{color:var(--ink);font-weight:600;border-bottom-color:var(--navy)}
.seg{display:inline-flex;border:1px solid var(--line);border-radius:6px;overflow:hidden}
.seg button{height:28px;padding:0 12px;border:none;background:var(--surface);color:var(--ink-2);font:inherit;font-size:12px;cursor:pointer;border-right:1px solid var(--line)}
.seg button:last-child{border-right:none}
.seg button.on{background:var(--primary);color:var(--on-accent)}
.alert{padding:10px 14px;border-radius:7px;display:flex;gap:10px;align-items:flex-start;font-size:12.5px;border:1px solid}
.alert .ico{font-family:Tektur;font-weight:700;width:18px;text-align:center}
.al-info{background:var(--al-info-bg);border-color:var(--al-info-bd);color:var(--ink)}
.al-info .ico{color:var(--accent)}
.al-warn{background:var(--al-warn-bg);border-color:var(--al-warn-bd);color:var(--ink)}
.al-warn .ico{color:var(--amber)}
.al-err{background:var(--al-err-bg);border-color:var(--al-err-bd);color:var(--ink)}
.al-err .ico{color:var(--pink)}
.al-ok{background:var(--al-ok-bg);border-color:var(--al-ok-bd);color:var(--ink)}
.al-ok .ico{color:var(--green)}
.table{width:100%;border-collapse:collapse;font-size:12.5px}
.table th{text-align:left;padding:7px 12px;font-size:10px;color:var(--mute);font-weight:600;font-family:Tektur;letter-spacing:.16em;text-transform:uppercase;border-bottom:1px solid var(--line);background:var(--surface)}
.table td{padding:7px 12px;border-bottom:1px solid var(--line-2)}
.table tr:hover td{background:var(--surface-2)}
.kbd{font-family:"JetBrains Mono",monospace;font-size:10.5px;padding:1px 5px;border:1px solid var(--line);border-radius:3px;background:var(--surface-2);color:var(--ink-2);box-shadow:0 1px 0 var(--line)}
/* type scale */
.t-row{display:grid;grid-template-columns:120px 1fr 80px;gap:14px;align-items:baseline;padding:8px 0;border-bottom:1px solid var(--line-2)}
.t-row:last-child{border-bottom:none}
.t-row .name{color:var(--mute);font-size:11px;font-family:"JetBrains Mono",monospace}
.t-row .sz{color:var(--mute);font-size:11px;font-family:"JetBrains Mono",monospace;text-align:right}
/* color swatch */
.sw{display:flex;flex-direction:column;gap:4px}
.sw .chip-c{height:56px;border-radius:6px;border:1px solid rgba(0,0,0,.05)}
.sw .nm{font-size:11.5px}
.sw .vl{font-family:"JetBrains Mono",monospace;font-size:11px;color:var(--mute)}
.modal-preview{width:100%;background:var(--surface);border:1px solid var(--line);border-radius:8px;overflow:hidden;box-shadow:var(--shadow-l)}
.mp-hd{padding:12px 16px;border-bottom:1px solid var(--line-2);display:flex;align-items:center;gap:10px}
.mp-bd{padding:16px}
.mp-ft{padding:10px 16px;border-top:1px solid var(--line-2);background:var(--surface-2);display:flex;gap:8px;justify-content:flex-end}
.field{display:flex;flex-direction:column;gap:5px}
.hint{font-size:11px;color:var(--mute)}
.toast{background:var(--primary);color:var(--on-accent);padding:9px 13px;border-radius:8px;font-size:12.5px;display:inline-flex;align-items:center;gap:10px;box-shadow:var(--shadow-toast)}
.toast .cap{color:var(--toast-cap)!important}
.empty{padding:30px;text-align:center;border:1px dashed var(--line);border-radius:8px;color:var(--mute)}
.empty .ttl{color:var(--ink);font-size:14px;margin-bottom:4px;font-weight:600}
.loading{height:8px;border-radius:4px;background:linear-gradient(90deg,var(--line-2) 0,var(--line) 50%,var(--line-2) 100%);background-size:200% 100%;animation:shimmer 1.4s infinite}
@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
.skl{display:flex;flex-direction:column;gap:8px}
.timeline{position:relative;padding-left:18px}
.timeline::before{content:"";position:absolute;left:5px;top:6px;bottom:6px;width:1px;background:var(--line)}
.tl-item{position:relative;padding-bottom:14px}
.tl-item::before{content:"";position:absolute;left:-17px;top:5px;width:11px;height:11px;border-radius:50%;background:var(--surface);border:2px solid var(--accent)}
.tl-meta{display:flex;align-items:center;gap:8px;flex-wrap:nowrap;white-space:nowrap;overflow:hidden}
.tl-meta .ts{margin-left:auto;font-family:"JetBrains Mono",monospace;font-size:11px;color:var(--mute)}
.bar{height:6px;border-radius:3px;background:var(--line-2);overflow:hidden}
.bar > span{display:block;height:100%;background:var(--accent)}
.menu{background:var(--surface);border:1px solid var(--line);border-radius:7px;box-shadow:var(--shadow-m);padding:5px;min-width:200px}
.menu .it{padding:7px 10px;border-radius:5px;font-size:12.5px;color:var(--ink-2);display:flex;align-items:center;gap:10px;cursor:pointer}
.menu .it:hover{background:var(--line-2);color:var(--ink)}
.menu .it .k{margin-left:auto;font-family:"JetBrains Mono",monospace;font-size:10.5px;color:var(--mute)}
.menu hr{border:none;border-top:1px solid var(--line-2);margin:4px 0}
.menu .it.danger{color:var(--pink)}
.tt{position:relative;display:inline-block}
.tt::after{content:attr(data-tip);position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);background:var(--ink);color:var(--bg);padding:4px 8px;border-radius:5px;font-size:11px;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .12s}
.tt:hover::after{opacity:1}
.avt{width:24px;height:24px;border-radius:50%;background:var(--accent-bg);color:var(--accent);display:inline-flex;align-items:center;justify-content:center;font-size:10.5px;font-weight:600;font-family:Tektur;letter-spacing:.04em}
/* theme switcher */
.theme-switch{position:fixed;top:18px;right:18px;z-index:50;display:inline-flex;border:1px solid var(--line);border-radius:8px;background:var(--surface);box-shadow:var(--shadow-m);overflow:hidden;font-family:Tektur;font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;font-weight:600}
.theme-switch button{height:30px;padding:0 11px;border:none;background:transparent;color:var(--mute);cursor:pointer;font:inherit;font-size:9.5px;letter-spacing:.14em;border-right:1px solid var(--line);display:inline-flex;align-items:center;gap:6px}
.theme-switch button:last-child{border-right:none}
.theme-switch button.on{background:var(--primary);color:var(--on-accent)}
.theme-switch .sw-dot{width:8px;height:8px;border-radius:50%;display:inline-block;border:1px solid rgba(0,0,0,.1)}
html,body{transition:background .2s, color .2s}
</style>
</head>
<body>
<div class="theme-switch" id="theme-switch">
<button data-th="claude-light" class="on"><span class="sw-dot" style="background:#faf9f5;border-color:#e6e1d4"></span>Warm</button>
<button data-th="claude"><span class="sw-dot" style="background:#262421"></span>Dark</button>
</div>
<script>
(function(){
const sw = document.getElementById('theme-switch');
const saved = localStorage.getItem('ord-theme') || 'claude-light';
apply(saved);
sw.addEventListener('click', e => {
const b = e.target.closest('button[data-th]');
if(!b) return;
apply(b.dataset.th);
localStorage.setItem('ord-theme', b.dataset.th);
});
function apply(th){
document.documentElement.setAttribute('data-theme', th);
sw.querySelectorAll('button').forEach(b => b.classList.toggle('on', b.dataset.th === th));
}
})();
</script>
<div class="shell">
<header class="head">
<span class="brand" style="font-size:18px">
<svg width="24" height="24" viewBox="0 0 32 32" fill="none" aria-hidden="true">
<circle cx="16" cy="16" r="13" stroke="currentColor" stroke-width="1.6"/>
<circle cx="16" cy="16" r="6" stroke="currentColor" stroke-width="1" opacity=".45"/>
<circle cx="22.5" cy="16" r="2.2" fill="currentColor"/>
</svg>
<span class="wm">ORDINIS<em>MDM</em></span>
</span>
<h1>UI Kit</h1>
<div style="flex:1"></div>
<div class="sub">v1.0 · admin design system · 2026</div>
</header>
<nav class="toc">
<a href="#type"><span class="cap">01</span> Type</a>
<a href="#color"><span class="cap">02</span> Color</a>
<a href="#btn"><span class="cap">03</span> Buttons</a>
<a href="#form"><span class="cap">04</span> Form</a>
<a href="#sel"><span class="cap">05</span> Select &amp; Chips</a>
<a href="#status"><span class="cap">06</span> Badges &amp; Status</a>
<a href="#nav"><span class="cap">07</span> Tabs &amp; Nav</a>
<a href="#alert"><span class="cap">08</span> Alerts</a>
<a href="#table"><span class="cap">09</span> Tables</a>
<a href="#empty"><span class="cap">10</span> Empty &amp; Loading</a>
<a href="#overlay"><span class="cap">11</span> Overlays</a>
<a href="#timeline"><span class="cap">12</span> Timeline &amp; Meta</a>
</nav>
<!-- TYPE -->
<section id="type">
<h2><span class="num">01</span> Typography</h2>
<div class="card">
<div class="t-row"><span class="name">Tektur 22/600</span><span style="font-family:Tektur;font-weight:600;font-size:22px;letter-spacing:.04em">ORDINIS — каталоги мастер-данных</span><span class="sz">page title</span></div>
<div class="t-row"><span class="name">Inter 16/600</span><span style="font-size:16px;font-weight:600">Космические аппараты</span><span class="sz">card title</span></div>
<div class="t-row"><span class="name">Inter 13/400</span><span>Каталог космических аппаратов ДЗЗ-миссий, включая планируемые и действующие.</span><span class="sz">body</span></div>
<div class="t-row"><span class="name">Inter 12.5/500</span><span style="font-size:12.5px">Кнопки и плотный UI, чтобы помещалось много данных.</span><span class="sz">ui</span></div>
<div class="t-row"><span class="name">JetBrains 12/400</span><span class="mono" style="font-size:12px">RES-P-1 · satellites.v1.8.0 · 2026-04-30T09:11Z</span><span class="sz">code/id</span></div>
<div class="t-row"><span class="name">Tektur 10/600</span><span class="cap">всё что заглавными — это лейблы и микрокопия</span><span class="sz">cap</span></div>
</div>
</section>
<!-- COLOR -->
<section id="color">
<h2><span class="num">02</span> Color</h2>
<div class="card">
<div class="label">Ink &amp; surfaces</div>
<div class="grid" style="grid-template-columns:repeat(6,1fr)">
<div class="sw"><div class="chip-c" style="background:var(--ink)"></div><div class="nm">ink</div><div class="vl mono">--ink</div></div>
<div class="sw"><div class="chip-c" style="background:var(--ink-2)"></div><div class="nm">ink-2</div><div class="vl mono">--ink-2</div></div>
<div class="sw"><div class="chip-c" style="background:var(--mute)"></div><div class="nm">mute</div><div class="vl mono">--mute</div></div>
<div class="sw"><div class="chip-c" style="background:var(--line)"></div><div class="nm">line</div><div class="vl mono">--line</div></div>
<div class="sw"><div class="chip-c" style="background:var(--line-2)"></div><div class="nm">line-2</div><div class="vl mono">--line-2</div></div>
<div class="sw"><div class="chip-c" style="background:var(--bg)"></div><div class="nm">bg</div><div class="vl mono">--bg</div></div>
</div>
<div class="label" style="margin-top:18px">Brand &amp; semantic</div>
<div class="grid" style="grid-template-columns:repeat(6,1fr)">
<div class="sw"><div class="chip-c" style="background:var(--navy)"></div><div class="nm">navy</div><div class="vl mono">--navy</div></div>
<div class="sw"><div class="chip-c" style="background:var(--accent)"></div><div class="nm">accent</div><div class="vl mono">--accent</div></div>
<div class="sw"><div class="chip-c" style="background:var(--green)"></div><div class="nm">green</div><div class="vl mono">--green</div></div>
<div class="sw"><div class="chip-c" style="background:var(--amber)"></div><div class="nm">amber</div><div class="vl mono">--amber</div></div>
<div class="sw"><div class="chip-c" style="background:var(--pink)"></div><div class="nm">pink</div><div class="vl mono">--pink</div></div>
<div class="sw"><div class="chip-c" style="background:var(--purple)"></div><div class="nm">purple</div><div class="vl mono">--purple</div></div>
</div>
</div>
</section>
<!-- BUTTONS -->
<section id="btn">
<h2><span class="num">03</span> Buttons</h2>
<div class="card col" style="gap:18px">
<div>
<div class="label">Variants</div>
<div class="row">
<button class="btn btn-pri">Сохранить</button>
<button class="btn">Отмена</button>
<button class="btn btn-danger">Удалить</button>
<button class="btn btn-ghost">Сбросить</button>
<button class="btn" disabled style="opacity:.4;cursor:default">Disabled</button>
</div>
</div>
<div>
<div class="label">Sizes</div>
<div class="row">
<button class="btn btn-pri">Default 32</button>
<button class="btn btn-pri btn-sm">Small 26</button>
<button class="btn btn-pri btn-xs">Mini 22</button>
</div>
</div>
<div>
<div class="label">Icon buttons</div>
<div class="row">
<button class="icon"></button>
<button class="icon"></button>
<button class="icon"></button>
<button class="icon"></button>
<button class="icon"></button>
<button class="icon"></button>
</div>
</div>
<div>
<div class="label">With label + counter</div>
<div class="row">
<button class="btn">Записи <span class="badge bg-public mono">127</span></button>
<button class="btn">Поля <span class="badge bg-public mono">12</span></button>
<button class="btn btn-pri">+ Создать</button>
<button class="btn">⤓ Экспорт</button>
<button class="btn">⟲ Time-travel</button>
</div>
</div>
</div>
</section>
<!-- FORM -->
<section id="form">
<h2><span class="num">04</span> Form fields</h2>
<div class="card grid" style="grid-template-columns:1fr 1fr;gap:18px">
<div class="field"><span class="cap">текст</span><input class="inp" placeholder="Resurs-P 1"/><span class="hint">обычная строка</span></div>
<div class="field"><span class="cap">id (mono)</span><input class="inp inp-mono" value="RES-P-1" readonly/><span class="hint">readonly</span></div>
<div class="field"><span class="cap">число</span><input class="inp inp-mono" value="475"/><span class="hint">км</span></div>
<div class="field"><span class="cap">дата</span><input class="inp inp-mono" type="date" value="2013-06-25"/></div>
<div class="field"><span class="cap">ошибка</span><input class="inp inp-err" value="RES P 1"/><span class="hint" style="color:var(--pink)">пробелы недопустимы — только snake-kebab</span></div>
<div class="field"><span class="cap">select</span><select class="inp"><option>planned</option><option>operational</option><option>decommissioned</option><option>lost</option></select></div>
<div class="field" style="grid-column:span 2"><span class="cap">textarea</span><textarea class="inp" style="height:60px;padding:8px 10px;resize:vertical;font-family:inherit">Каталог космических аппаратов ДЗЗ-миссий.</textarea></div>
<div class="field" style="grid-column:span 2"><span class="cap">json</span><textarea class="inp inp-mono" style="height:90px;padding:8px 10px;background:var(--surface-2);resize:vertical;line-height:1.55">{
"tle": { "line1": "1 39186U …", "line2": "2 39186 …" },
"tags": ["eo","optical","high-res"]
}</textarea></div>
<div class="field"><span class="cap">checkbox</span>
<div class="row" style="gap:14px">
<label class="row" style="gap:6px"><input type="checkbox" class="chk" checked/> Активен</label>
<label class="row" style="gap:6px"><input type="checkbox" class="chk"/> Только в draft</label>
</div>
</div>
<div class="field"><span class="cap">radio</span>
<div class="row" style="gap:14px">
<label class="row" style="gap:6px"><input type="radio" name="r" class="rad" checked/> PUBLIC</label>
<label class="row" style="gap:6px"><input type="radio" name="r" class="rad"/> INTERNAL</label>
<label class="row" style="gap:6px"><input type="radio" name="r" class="rad"/> TENANT</label>
</div>
</div>
<div class="field"><span class="cap">switch</span>
<div class="row" style="gap:10px"><span class="swt on"></span><span style="font-size:12.5px;color:var(--ink-2)">видна потребителям</span></div>
</div>
<div class="field"><span class="cap">range</span>
<div class="row" style="gap:10px;width:100%"><input type="range" min="0" max="100" value="62" style="flex:1;accent-color:var(--accent)"/><span class="mono" style="font-size:11.5px;color:var(--mute);width:32px;text-align:right">62%</span></div>
</div>
</div>
</section>
<!-- SELECT & CHIPS -->
<section id="sel">
<h2><span class="num">05</span> Selects, chips, tags</h2>
<div class="card col" style="gap:18px">
<div>
<div class="label">Multi-select (тэги)</div>
<div style="display:flex;flex-wrap:wrap;gap:6px;padding:6px;border:1px solid var(--line);border-radius:6px;background:var(--surface)">
<span class="tag">MSU-GS <span style="opacity:.6;cursor:pointer">×</span></span>
<span class="tag">Geoton-L1 <span style="opacity:.6;cursor:pointer">×</span></span>
<span class="tag">KShMSA-VR <span style="opacity:.6;cursor:pointer">×</span></span>
<input class="inp" style="border:none;height:24px;padding:0;font-size:12px;background:transparent;flex:1;min-width:80px" placeholder="добавить…"/>
</div>
</div>
<div>
<div class="label">Filter chips</div>
<div class="row">
<button class="chip on">все <span class="mono" style="opacity:.7">12</span></button>
<button class="chip">webhooks <span class="mono" style="opacity:.7">2</span></button>
<button class="chip">publish <span class="mono" style="opacity:.7">1</span></button>
<button class="chip">review <span class="mono" style="opacity:.7">1</span></button>
<button class="chip">edit <span class="mono" style="opacity:.7">1</span></button>
</div>
</div>
<div>
<div class="label">Segmented control</div>
<div class="row" style="gap:16px">
<div class="seg"><button class="on">версия</button><button>день</button></div>
<div class="seg"><button class="on">все</button><button>draft</button><button>review</button><button>live</button></div>
</div>
</div>
</div>
</section>
<!-- BADGES -->
<section id="status">
<h2><span class="num">06</span> Badges &amp; status</h2>
<div class="card col" style="gap:14px">
<div>
<div class="label">Scope</div>
<div class="row">
<span class="badge bg-public">PUBLIC</span>
<span class="badge bg-internal">INTERNAL</span>
<span class="badge bg-tenant">TENANT</span>
</div>
</div>
<div>
<div class="label">Lifecycle</div>
<div class="row">
<span class="badge bg-draft">DRAFT</span>
<span class="badge bg-internal">REVIEW</span>
<span class="badge bg-live">LIVE</span>
<span class="badge bg-deprec">DEPRECATED</span>
</div>
</div>
<div>
<div class="label">Inline status</div>
<div class="row" style="gap:18px">
<span class="status s-op"><span class="dot" style="background:var(--green)"></span>operational</span>
<span class="status s-pl"><span class="dot" style="background:var(--mute)"></span>planned</span>
<span class="status s-dc"><span class="dot" style="background:var(--pink)"></span>decommissioned</span>
<span class="status s-ls"><span class="dot" style="background:var(--amber)"></span>lost</span>
</div>
</div>
<div>
<div class="label">Avatars &amp; people</div>
<div class="row">
<span class="avt">EM</span>
<span class="avt" style="background:var(--avt-pink-bg);color:var(--pink)">AK</span>
<span class="avt" style="background:var(--avt-green-bg);color:var(--green)">VP</span>
<span style="font-size:12.5px;color:var(--ink-2)">e.morozov, a.korovin, v.petrov</span>
</div>
</div>
</div>
</section>
<!-- TABS -->
<section id="nav">
<h2><span class="num">07</span> Tabs &amp; sidebar nav</h2>
<div class="grid" style="grid-template-columns:1fr 280px;gap:14px">
<div class="card">
<div class="tab-bar">
<button class="tab on">Записи <span class="badge bg-draft mono">127</span></button>
<button class="tab">Связи <span class="badge bg-draft mono">6</span></button>
<button class="tab">Поля <span class="badge bg-draft mono">12</span></button>
<button class="tab">JSON</button>
<button class="tab">События</button>
<button class="tab">История</button>
</div>
<div class="row" style="padding:14px 4px 0;gap:6px">
<button class="btn btn-sm">operational ▾</button>
<button class="btn btn-sm">оператор ▾</button>
<input class="inp" placeholder="Поиск по записям…" style="height:28px;flex:1;max-width:260px"/>
</div>
</div>
<div class="card" style="padding:0">
<div style="padding:10px 12px;border-bottom:1px solid var(--line-2)"><span class="cap">справочники</span></div>
<div style="padding:6px">
<div style="padding:7px 10px;border-radius:5px;background:var(--accent-bg);color:var(--navy);font-weight:600;font-size:12.5px;display:flex;justify-content:space-between"><span>Главная</span></div>
<div style="padding:7px 10px;border-radius:5px;color:var(--ink-2);font-size:12.5px;display:flex;justify-content:space-between"><span>Справочники</span><span class="mono" style="color:var(--mute)">48</span></div>
<div style="padding:7px 10px;border-radius:5px;color:var(--ink-2);font-size:12.5px;display:flex;justify-content:space-between"><span>Мои черновики</span><span class="mono" style="color:var(--mute)">3</span></div>
<div style="padding:7px 10px;border-radius:5px;color:var(--ink-2);font-size:12.5px;display:flex;justify-content:space-between"><span>На ревью</span><span class="mono" style="color:var(--amber)">7</span></div>
<div style="padding:7px 10px;border-radius:5px;color:var(--ink-2);font-size:12.5px;display:flex;justify-content:space-between"><span>Outbox</span><span class="mono" style="color:var(--pink)">2</span></div>
</div>
</div>
</div>
</section>
<!-- ALERTS -->
<section id="alert">
<h2><span class="num">08</span> Alerts</h2>
<div class="card col">
<div class="alert al-info"><span class="ico">i</span><div><strong>Новая версия в работе.</strong> v1.9.0-draft создаётся в фоне на основе текущего состояния.</div></div>
<div class="alert al-ok"><span class="ico"></span><div><strong>Опубликовано.</strong> v1.8.0 ушла в production. 3 потребителя получили webhook.</div></div>
<div class="alert al-warn"><span class="ico">!</span><div><strong>Поле «design_life» изменило тип.</strong> Старые значения автоматически приведены к number — проверьте записи.</div></div>
<div class="alert al-err"><span class="ico">×</span><div><strong>Webhook legacy-erp вернул 504.</strong> Поставлен на ретрай (попытка 3/5). <a href="#" style="color:var(--pink)">Смотреть лог →</a></div></div>
</div>
</section>
<!-- TABLES -->
<section id="table">
<h2><span class="num">09</span> Tables</h2>
<div class="card" style="padding:0;overflow:hidden">
<table class="table">
<thead><tr><th style="width:30px"></th><th>ID</th><th>Название</th><th>Оператор</th><th>Статус</th><th>Орбита</th><th>Запуск</th><th style="width:30px"></th></tr></thead>
<tbody>
<tr><td><input type="checkbox" class="chk"/></td><td class="mono" style="color:var(--accent)">RES-P-1</td><td>Ресурс-П №1</td><td>Роскосмос</td><td><span class="status s-op"><span class="dot" style="background:var(--green)"></span>operational</span></td><td class="mono">SSO</td><td class="mono" style="color:var(--mute)">2013-06-25</td><td style="color:var(--mute)"></td></tr>
<tr><td><input type="checkbox" class="chk" checked/></td><td class="mono" style="color:var(--accent)">SNT-1B</td><td>Sentinel-1B</td><td>ESA</td><td><span class="status s-dc"><span class="dot" style="background:var(--pink)"></span>decommissioned</span></td><td class="mono">SSO</td><td class="mono" style="color:var(--mute)">2016-04-25</td><td style="color:var(--mute)"></td></tr>
<tr><td><input type="checkbox" class="chk"/></td><td class="mono" style="color:var(--accent)">LST-9</td><td>Landsat 9</td><td>NASA</td><td><span class="status s-op"><span class="dot" style="background:var(--green)"></span>operational</span></td><td class="mono">SSO</td><td class="mono" style="color:var(--mute)">2021-09-27</td><td style="color:var(--mute)"></td></tr>
</tbody>
</table>
</div>
</section>
<!-- EMPTY / LOADING -->
<section id="empty">
<h2><span class="num">10</span> Empty &amp; loading</h2>
<div class="grid" style="grid-template-columns:1fr 1fr;gap:14px">
<div class="empty">
<div class="ttl">Здесь пока ничего нет</div>
<div style="font-size:12.5px;color:var(--mute);margin-bottom:14px">Создайте первый справочник, чтобы начать работу.</div>
<button class="btn btn-pri btn-sm">+ Создать справочник</button>
</div>
<div class="card skl">
<div class="loading" style="width:30%"></div>
<div class="loading"></div>
<div class="loading" style="width:80%"></div>
<div class="loading" style="width:60%"></div>
<div class="loading" style="width:90%"></div>
</div>
</div>
<div class="card" style="margin-top:14px">
<div class="label">Progress</div>
<div class="col">
<div class="row" style="gap:10px"><div class="bar" style="flex:1"><span style="width:24%"></span></div><span class="mono" style="font-size:11px;color:var(--mute);width:40px;text-align:right">24%</span></div>
<div class="row" style="gap:10px"><div class="bar" style="flex:1"><span style="width:68%;background:var(--green)"></span></div><span class="mono" style="font-size:11px;color:var(--mute);width:40px;text-align:right">68%</span></div>
<div class="row" style="gap:10px"><div class="bar" style="flex:1"><span style="width:92%;background:var(--amber)"></span></div><span class="mono" style="font-size:11px;color:var(--mute);width:40px;text-align:right">92%</span></div>
</div>
</div>
</section>
<!-- OVERLAYS -->
<section id="overlay">
<h2><span class="num">11</span> Modals, menus, tooltips, toasts</h2>
<div class="grid" style="grid-template-columns:1fr 1fr;gap:14px;align-items:start">
<div>
<div class="label">Modal</div>
<div class="modal-preview" style="max-width:480px">
<div class="mp-hd"><span class="cap" style="color:var(--accent)">новый справочник</span><div style="flex:1"></div><span style="color:var(--mute);cursor:pointer">×</span></div>
<div class="mp-bd" style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div class="field" style="grid-column:span 2"><span class="cap">название</span><input class="inp" value="Космические аппараты"/></div>
<div class="field"><span class="cap">id</span><input class="inp inp-mono" value="satellites"/></div>
<div class="field"><span class="cap">bundle</span><select class="inp"><option>cuod</option></select></div>
</div>
<div class="mp-ft"><button class="btn btn-sm">Отмена</button><button class="btn btn-pri btn-sm">Создать</button></div>
</div>
</div>
<div>
<div class="label">Dropdown menu</div>
<div class="menu" style="max-width:240px">
<div class="it">Открыть в новой вкладке <span class="k">⌘↵</span></div>
<div class="it">Дублировать <span class="k">⌘D</span></div>
<div class="it">Экспорт CSV…</div>
<hr/>
<div class="it">Поставить на ревью</div>
<div class="it">Time-travel…</div>
<hr/>
<div class="it danger">Удалить запись</div>
</div>
<div class="label" style="margin-top:18px">Toast &amp; tooltip</div>
<div class="row" style="gap:24px">
<div class="toast"><span class="cap">ok</span><span>Справочник создан как draft v0.1.0</span></div>
<div class="tt" data-tip="последнее изменение 2 часа назад"><button class="btn btn-sm">наведи</button></div>
<div class="row" style="gap:4px"><span class="kbd"></span><span class="kbd">K</span><span style="font-size:11.5px;color:var(--mute);margin-left:6px">палитра действий</span></div>
</div>
</div>
</div>
</section>
<!-- TIMELINE / META -->
<section id="timeline">
<h2><span class="num">12</span> Timeline, meta, callouts</h2>
<div class="grid" style="grid-template-columns:1fr 1fr;gap:14px;align-items:start">
<div class="card">
<div class="label">Timeline (история записи)</div>
<div class="timeline">
<div class="tl-item">
<div class="tl-meta"><span class="cap" style="color:var(--accent)">изменено</span><span class="mono" style="font-size:11px;color:var(--accent)">v1.8.0</span><span class="ts">30.04.26</span></div>
<div style="margin-top:3px">+ raw_json (TLE данные)</div>
<div style="font-size:11.5px;color:var(--mute);margin-top:2px">автор · <span class="mono">e.morozov</span></div>
</div>
<div class="tl-item">
<div class="tl-meta"><span class="cap" style="color:var(--amber)">переименование</span><span class="mono" style="font-size:11px;color:var(--accent)">v1.2.0</span><span class="ts">22.05.25</span></div>
<div style="margin-top:3px">оператор: РКА → Роскосмос</div>
<div style="font-size:11.5px;color:var(--mute);margin-top:2px">автор · <span class="mono">e.morozov</span></div>
</div>
<div class="tl-item">
<div class="tl-meta"><span class="cap" style="color:var(--green)">создано</span><span class="mono" style="font-size:11px;color:var(--accent)">v1.0.0</span><span class="ts">12.01.25</span></div>
<div style="margin-top:3px">запись создана</div>
<div style="font-size:11.5px;color:var(--mute);margin-top:2px">автор · <span class="mono">e.morozov</span></div>
</div>
</div>
</div>
<div class="card">
<div class="label">Meta-grid (сайдбар записи)</div>
<div class="grid" style="grid-template-columns:1fr 1fr;gap:10px">
<div><span class="cap">bundle</span><div class="mono" style="margin-top:2px">cuod</div></div>
<div><span class="cap">версия</span><div class="mono" style="margin-top:2px">1.8.0</div></div>
<div><span class="cap">записей</span><div class="mono" style="margin-top:2px">127</div></div>
<div><span class="cap">локали</span><div class="mono" style="margin-top:2px">ru, en</div></div>
</div>
<div style="border-top:1px solid var(--line-2);margin-top:14px;padding-top:12px">
<span class="cap">связи (6)</span>
<div style="display:flex;flex-direction:column;gap:4px;margin-top:6px;font-size:11.5px">
<div style="color:var(--mute)">→ ссылается</div>
<a class="mono" style="color:var(--accent);text-decoration:none">operators</a>
<a class="mono" style="color:var(--accent);text-decoration:none">satellite_statuses</a>
<div style="color:var(--mute);margin-top:6px">← используют</div>
<a class="mono" style="color:var(--accent);text-decoration:none">acquisitions</a>
</div>
</div>
</div>
</div>
</section>
<div style="margin-top:48px;padding-top:18px;border-top:1px solid var(--line);display:flex;align-items:baseline;gap:14px;color:var(--mute);font-size:11.5px">
<span class="brand" style="font-size:12px">
<svg width="16" height="16" viewBox="0 0 32 32" fill="none" aria-hidden="true">
<circle cx="16" cy="16" r="13" stroke="currentColor" stroke-width="1.8"/>
<circle cx="22.5" cy="16" r="2.4" fill="currentColor"/>
</svg>
ORDINIS
</span> <span>UI Kit v1.0</span><span style="flex:1"></span>
<span class="mono">Inter · JetBrains Mono · Tektur</span>
</div>
</div>
</body></html>
@@ -0,0 +1,123 @@
# Ordinis Admin UI — баги и улучшения
Обзор по итогам чтения `package.json`, `main.tsx`, `api/{client,queries,mutations}.ts`, `auth/*`, `routes/__root.tsx`, `routes/dictionaries.$name.tsx`, `components/form/SchemaDrivenForm.tsx`, `components/version/useAppVersion.ts`, `lib/dates.ts`, `i18n.ts`.
---
## 🐞 Реальные баги
### 1. `time-travel`: повреждённое значение в `<input type="datetime-local">`
`routes/dictionaries.$name.tsx`:
```ts
value={timeTravelAt ? new Date(timeTravelAt).toISOString().slice(0,16) : ''}
```
`toISOString()` всегда отдаёт UTC. Если у пользователя часовой пояс `+03:00`, то значение в picker'е показывается на 3 часа раньше, чем выбрал пользователь, а `onChange` пишет обратно `new Date(value).toISOString()` (теперь уже трактует local) — день/время «уплывают» при каждом редактировании.
**Фикс:** использовать local-форматтер (есть `formatIsoDate`/`extractTime` в `lib/dates`), или
```ts
const d = new Date(timeTravelAt);
const pad = (n:number)=>String(n).padStart(2,'0');
const v = `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
```
### 2. Дублирующиеся 401-интерсепторы → бесконечный цикл редиректов
В `api/client.ts` уже стоит response-interceptor, который при 401 удаляет токен. В `auth/TokenSync.tsx` навешивается **второй** interceptor, который при 401 дёргает `signinSilent/Redirect`. Оба регистрируются — оба отрабатывают на одном и том же ошибочном ответе. Если `signinSilent` упадёт, может уйти в `signinRedirect` параллельно с уже идущим redirect (race). Рекомендую:
- удалить response-handler из `api/client.ts`, оставить только в `TokenSync`;
- guard'ить флагом `redirecting` (модульная переменная), чтобы один 401 не вызвал N редиректов из разных одновременных запросов.
### 3. Race в `TokenSync` + `apiClient.defaults`
`apiClient.defaults.headers.common['Authorization']` ставится в `useEffect`, но request-interceptor в `api/client.ts` каждый раз сам читает токен из `localStorage`. Получается двойная синхронизация и при выходе из аккаунта в одной вкладке остаётся «зомби-хедер» в `defaults` до следующего ререндера. Оставьте **либо** interceptor (он самодостаточен), **либо** `defaults.headers` — не оба.
### 4. AJV: ошибки навешиваются на `data` (root), а не на конкретное поле
`SchemaDrivenForm.tsx``applyAjvErrors`:
```ts
const fieldPath = path ? (`data.${path}` as `data.${string}`) : 'data'
setError(fieldPath as 'data', { type: 'ajv', message: err.message })
```
Каст `as 'data'` ломает RHF: `setError` ожидает реальный path, иначе все ошибки складываются в один ключ `data` и `formState.errors.data?.[key]` **не находит их**, а tab-badge со счётчиком всегда показывает 0/1 случайно. Уберите каст и работайте через `setError(fieldPath as Path<FormValues>, ...)`. Также для `required` (AJV выдаёт `params.missingProperty`) instancePath пустой — нужно собирать путь из `missingProperty`.
### 5. `nowIsoLocal()` для `validFrom` в edit-modal
В `dictionaries.$name.tsx` при открытии edit-form `validFrom` дефолтится через `nowIsoLocal()`, но `defaultValues` в `useForm` берётся **только при mount**. Если пользователь подержит модалку открытой 5 минут и сохранит — `validFrom` всё ещё «момент открытия», что не совпадает с тем, что бы пользователь ожидал (бекенд проверяет `validFrom > existingFrom`). Лучше дефолтить пустым, а на submit подставлять `now()` если поле пустое.
### 6. `extraColumns` берёт первые 2 enum/FK без сортировки и без локализации
`Object.entries(props)` зависит от key insertion order, который у JSON-схемы непредсказуем (или придёт из бекенда в разном порядке между запросами). Лучше: либо явный `x-list-column` флажок в схеме, либо `schema.required` приоритет, либо стабильный alphabetical sort.
### 7. `useEffect([visibleKeys])` в дикт-роуте: новая ссылка каждую перерисовку
```ts
const visibleKeys = useMemo(() => visibleRecords.map(r => r.businessKey), [visibleRecords])
```
`visibleRecords` пересоздаётся при каждом рендере родителя, потому что `filteredRecords` зависит от `recordsResult.data` (стабильно), но `slice` всё равно даёт новый массив → `visibleKeys` тоже новая ссылка → effect для очистки selection срабатывает на любой ререндер. Безвредно, но создаёт лишние `setState`. Сравнивайте по содержимому (`useMemo` с deps на `JSON.stringify(visibleKeys)` или сравнение длин и первого/последнего ключа).
### 8. `useDictPendingDrafts(undefined)` всё равно вызывает `enabled: Boolean(...)`-query, но `queryKey` у него `['drafts','by-dict','']`
В кэше может застрять пустой ключ; при последующем переходе с разных дикторов один и тот же ключ `''` будет показываться. Передавайте `null`/исключайте полностью через `useQuery({...spread, enabled, queryKey: enabled ? key : ['noop']})`.
### 9. `schema.events.disclaimer` (i18n) ссылается на путь Java-файла
В строках интернационализации (ru/en) лежит абсолютный путь к Java-классу — попадёт пользователю в UI. Это похоже на ошибку при копировании комментария в i18n-resource. Уберите.
### 10. CSV-экспорт filename парсится небезопасным regex
`mutations.ts``useBulkExportRecords`:
```ts
const match = cd?.match(/filename="?([^";]+)"?/)
```
Не понимает `filename*=UTF-8''...` (RFC 5987) — всё, что отдаёт сервер с не-ASCII именем, окажется неправильно декодированным. Парсите оба варианта.
### 11. `apiClient` `timeout: 10_000` слишком жёсткий для CSV-экспорта и cascade-close
`bulk-close`/`cascade-close` на больших объёмах могут идти 20–30 c (по комментарию в `mutations.ts` бекенд сам падает по 30s timeout). Поставьте per-request `timeout` для тяжёлых эндпойнтов (axios `{ timeout: 60_000 }` в config).
### 12. `oidcConfig.userStore` использует `localStorage`
Это реальная XSS-уязвимость для access_token (и refresh-token, если он там окажется). Стандартная рекомендация oidc-client-ts — держать токен в **памяти** (по умолчанию) и хранить в `sessionStorage` максимум, а лучше использовать BFF (refresh httponly-cookie). Мин. шаг — переключиться на `sessionStorage`.
### 13. `apiClient` Authorization-header не добавляется на cross-origin/CORS, если `localStorage` пуст после reload
Token сохраняется в `localStorage` через `TokenSync` **только** после init React. Первые запросы, которые идут до того, как `useEffect` стрельнул, пойдут без `Authorization` → 401 → infinite redirect (см. п.2). Решение — читать токен напрямую из `userManager` синхронно в interceptor, или делать `<RequireAuth>` блокирующим до `auth.user.access_token`.
### 14. `RequireAuth` крутит `signinRedirect` в loop при `auth.error`
Условие на строчке `if (auth.isLoading || auth.activeNavigator) return` не учитывает `auth.error` — сперва рендерится «Не удалось подключиться» (правильно), но `useEffect` всё равно запустит `signinRedirect()` ниже. Добавьте `if (auth.error) return` в guard.
### 15. `apiClient` baseURL смешивает env-`VITE_API_BASE` и `/api/v1`
Если кто-то задаст `VITE_API_BASE=https://api.example.com` без `/v1`, то все запросы превратятся в `/dictionaries` относительно корня. Лучше `(import.meta.env.VITE_API_BASE ?? '') + '/api/v1'` или жёсткое требование trailing-slash.
### 16. Pagination для записей: client-side, но `recordsQuery` тащит **все** записи на каждый смену фильтра
Если справочник вырастет до 10k+, запрос будет тяжёлым, плюс `JSON.stringify(r.data)` для search-фильтра O(n*m). Перевести на server-side как минимум для search/scope-фильтра. Сейчас стоит TODO в комментарии «типичный словарь до 100 записей» — но никаких guard'ов нет.
### 17. `Checkbox` ref + `indeterminate` через colback ref, но `Checkbox` компонент из `@nstart/ui` может не пробрасывать ref на нативный input
Если `Checkbox` обёрнут в `forwardRef` и привязывает ref к **обёртке**`el.indeterminate = …` упадёт молча. Проверить, что ref идёт на `<input>`. Чаще делают через prop `indeterminate`.
### 18. `setEdit({ kind: 'closed' })` в `onError` cascade-flow теряет `closeMut.error`
Сразу после ошибки 409 `setEdit` закрывает modal, но `closeMut.error` остаётся в state до следующего mutate, и в открытом cascade-dialog'е может промелькнуть alert с этим текстом из stale state. Делайте `closeMut.reset()` перед `setCascadeKey(...)`.
### 19. `searchQuery` отправляет min-length-проверку в hook, но и `useSearch` отправляет `q ?? ''`
Если кто-то в коде вызовет `searchQuery(...)` напрямую (для prefetch — обычный паттерн TanStack Router loader'а), он получит запрос `q=ab` → 400 от бекенда. Лучше вернуть `enabled` из самого `queryOptions` или `throw` в `queryFn`.
### 20. i18n plural-keys `_one/_few/_many/_other` для `en-US`
В русском есть `_few/_many`, в английском — только `_one/_other`. В английских ресурсах при `count=2` i18next выдаст fallback на `_other`. Норм. **Но**: ключи `myDrafts.total_few/_many` в `en-US` отсутствуют, а в `ru-RU` есть. На деплоях с `fallbackLng: 'ru-RU'` английский пользователь увидит русский plural при `count=2..4`. Завести явные `_one/_other` для всех ключей.
---
## ✨ Улучшения (не баги, но стоит сделать)
- **`api/client.ts`** — нет `axios-retry` или повтора `GET` идемпотентных запросов на 5xx; query-level `retry: 1` без backoff. Добавить `retryDelay: exponentialBackoff`.
- **`useCreateRecord` / `useUpdateRecord`** генерируют новый `Idempotency-Key` на каждом mutate-вызове. Если RHF сабмит дёрнет дважды (двойной клик / Enter), сервер увидит **два разных** ключа и создаст два состояния. Генерировать ключ один раз на форму (в `useState` инициализаторе) и сбрасывать после `onSuccess`.
- **`onSuccess` мутаций — слишком широкий invalidate.** В `useApproveDraft` инвалидируется `['records']` целиком — все справочники перезапросят свои listing'и. Достаточно `['records', dictName]`, который можно достать из draft-response.
- **`refetchInterval: 30_000` для outbox/reviews/version**: четыре одновременных интервала на одном экране дают 4 запроса в полминуты + tab-в-фоне. Добавьте `refetchIntervalInBackground: false` или объедините в один `useQuery` с `select`.
- **`UpdateBanner` через `__APP_COMMIT__`**: если build-time-define в `vite.config.ts` отсутствует, TS падает (`vite-env.d.ts` объявляет глобал, но рантайм будет `undefined`). Проверьте, что в `vite.config.ts` `define` установлен и для `dev`-режима тоже (часто забывают).
- **Доступность.** `<input type="datetime-local">` без `aria-describedby` для hint, `<button aria-pressed>` без видимой смены состояния помимо цвета (контраст 3:1 достаточен?). Скрин-ридеру ничего не озвучивается.
- **Плейн `<select>` в FK-полях** — потерял всю стилизацию модалки на мобильных Safari (родной picker), может «прорезать» modal. Сейчас в коде это аргументируется в комментарии, но при 5 опциях SingleSelect лучше — выбирать гибрид по `options.length`.
- **`humanize`** дублируется в `dictionaries.$name.tsx` и в `SchemaDrivenForm.tsx`. Вынесите в `lib/strings.ts`.
- **`scopeFilter` через URL** — если пользователь руками вобьёт `?scopes=public,internal` (lowercase), `validateSearch` отфильтрует. Но `toUpperCase()` применён только к scope-CSV, не к `q`. Нормализуйте всё на парсинге.
- **Типы.** `JsonSchema.properties: Record<string, JsonSchema>` — нет `nullable`, `oneOf`, `anyOf`. Поле сложного союза свалится в branch `schema.type === 'object'` и пойдёт в JSON-textarea. Добавить explicit unsupported branch + UI hint.
- **Файл `i18n.ts` — 899 строк/55 КБ ресурсов в одном модуле.** Это попадает в основной bundle. Разделите по namespace'ам и подгружайте `lazy` (i18next backend).
- **`AuthBadge``auth.removeUser()` после фейла logout** оставляет SSO-сессию в Keycloak активной. Если пользователь сразу логинится снова, он получит ту же сессию автоматически (что обычно не то, чего ждёт оператор после «Выйти»). Логируйте 5xx и показывайте toast.
- **`apiClient.interceptors.request` Accept-Language**: всегда отправляется `navigator.language,ru-RU;q=0.9,en-US;q=0.8`. Если пользователь переключил язык в `LanguageSwitch`, заголовок остаётся прежним до перезагрузки. Слушайте `i18n.on('languageChanged')` и обновляйте `apiClient.defaults.headers['Accept-Language']`.
- **`AoiPickerDialog`** парсится через regex `/^-?\d+(\.\d+)?(,...){3}$/` — не пропускает экспоненциальную форму, отрицательные `-0.5` без целой части (`-.5`) и т. п. `parseFloat`/`Number` стабильнее.
- **`bulkExportMut` не имеет timeout override** — при 10k записей десятисекундный axios-default обрежет.
- **`route.__root.tsx`**: navigation-меню — простой `<nav>` без `aria-label`, активная ссылка различима только цветом. Добавить `aria-current="page"` через `activeProps`.
---
## 📌 Топ-5 что чинить в первую очередь
1. Двойные 401-interceptor'ы → возможный redirect-loop (#2, #13, #14).
2. AJV-ошибки не привязаны к конкретным полям (#4) — у пользователя «непонятно почему форма красная».
3. Time-travel TZ-bug (#1) — пользователь видит не ту дату, что ввёл.
4. Idempotency-Key per-mutate, а не per-form (улучшение, но критично для retry-сценариев).
5. `localStorage` для access_token (#12) — security.
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB