fix(admin-ui): guest nav filter + suppress drafts 401
This commit is contained in:
@@ -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 & Chips</a>
|
||||
<a href="#status"><span class="cap">06</span> Badges & Status</a>
|
||||
<a href="#nav"><span class="cap">07</span> Tabs & 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 & Loading</a>
|
||||
<a href="#overlay"><span class="cap">11</span> Overlays</a>
|
||||
<a href="#timeline"><span class="cap">12</span> Timeline & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 |
Reference in New Issue
Block a user