21 KiB
DESIGN.md — Ordinis Admin UI Design System
Status: v0 baseline (2026-05-14). Captured from observed patterns в
codebase after approval workflow saga (MR !179–!190). To be refined через
/design-consultation skill in a dedicated sprint.
Goal: Lock the implicit design system как source of truth для будущих features (Phase 4 webhook approvals, notifications surfaces, MDM expansion). Document invariants (tokens, primitives, semantic rules) — NOT specific components.
1. Voice & Tone
- Russian-first. UI primary language ru-RU; en-US is parallel mirror.
Every user-facing string registered in
src/i18n.ts. - Operational, not aspirational. Copy describes what user sees / does ("Очередь чистая", "Reject требует comment") — no marketing-speak.
- Domain-precise. Use exact role names (maker / reviewer / publisher) and operation codes (CREATE / UPDATE / CLOSE) — no generic "user".
- Apologize on failure. Empty states + error messages name what went wrong и what to do next ("Refresh page + retry", "Укажите причину").
2. Color Tokens
CSS variables defined в src/styles.css. Three themes: default (light
Anthropic warm), nstart (medium contrast brand), dark (Anthropic dark).
Semantic roles
| Token | Light | Dark | Use |
|---|---|---|---|
--color-ink |
#1a1714 |
#f0ece4 |
Primary text |
--color-ink-2 |
#52483d |
#c8c1b3 |
Secondary text |
--color-mute |
#8c8276 |
#8a8474 |
Tertiary text / placeholders |
--color-bg |
#faf9f5 |
#1c1a17 |
App background |
--color-surface |
#ffffff |
#262421 |
Card / panel surface |
--color-surface-2 |
#f5f3ec |
#2e2b27 |
Hover / nested surface |
--color-line |
#e6e1d4 |
#3a3733 |
Dividers / borders |
--color-line-2 |
#efeae0 |
#2d2a27 |
Subtle dividers |
--color-accent |
#b05a2e |
#d97757 |
Primary CTA / links |
--color-accent-bg |
accent @ 11% | accent @ 16% | Accent tint |
--color-on-accent |
#ffffff |
#1c1a17 |
Text on accent |
--color-green |
#4f7038 |
#4a7d4f |
Success / APPROVED |
--color-green-bg |
#e4ead0 |
#dde9da |
Success tint |
--color-warn |
#a8762a |
#b97c2f |
Warning / CHANGES_REQUESTED |
--color-warn-bg |
#f6e8bc |
#fcecc7 |
Warning tint |
--color-pink |
#a64636 |
#b34d6a |
Danger / REJECTED |
--color-pink-bg |
#f4d9cd |
#f8d8de |
Danger tint |
Rules
- Never hard-code hex. Use
bg-line,text-mute,border-accentчерез Tailwind utilities mapped to these tokens. - Status colors are stable across themes.
successalways green-family;dangeralways pink-family. - Tint backgrounds (
*-bg) use 11-16% alpha — for badge backgrounds and subtle highlights. Never tint at higher opacity (loses contrast).
3. Typography
Font families
| Token | Stack | Use |
|---|---|---|
--font-sans |
Inter, -apple-system, BlinkMacSystemFont, sans-serif |
Body, UI |
--font-mono |
JetBrains Mono, SF Mono, monospace |
Code, UUIDs, business keys, versions |
--font-display |
Tektur, Inter, sans-serif |
Caps headers, brand mark |
Type scale (Tailwind utilities)
| Utility | Use |
|---|---|
text-title-md |
Section headings (Panel title) |
text-body |
Body copy |
text-cell |
Table cell, dense info |
text-cap |
Caps tracking-wide labels (column headers) — uses --font-display |
text-mono |
Code / monospaced |
Rules
- Mono для identity: UUIDs (truncated
slice(0,8)…), business keys, version strings (v1.1.0). Reads as "this is an exact identifier". - Display caps для column headers:
text-cap text-mute font-display uppercase tracking-wider— visual hierarchy через caps, not size. tabular-numsдля timestamp + count columns — prevents jitter on refresh.- Never use system-ui / -apple-system as primary display font. Inter is fallback only.
4. Spacing & Layout
Base scale
Tailwind default 4px scale (gap-1 = 4px, gap-2 = 8px, gap-3 = 12px,
gap-4 = 16px). Avoid arbitrary spacing values (mt-[13px]) — use scale
or extend tokens.
Layout primitives
- Page chrome:
<PageHeader title description>for top of every route - Content area:
space-y-4(16px) between top-level sections - Panel:
<Panel>wraps tables / forms withbg-surface border border-line - Card:
<Card>для individual content blocks (bg-surface-2) - Drawer: Right-side slide-in for detail views (review draft, schema draft, history)
- Modal: Centered overlay для destructive confirmations + create flows
Drawer-vs-Modal rules
| Pattern | Use Drawer | Use Modal |
|---|---|---|
| Review existing entity | ✓ | |
| Read-only history | ✓ | |
| Diff side-by-side | ✓ (max-w-3xl) | |
| Create new entity | ✓ | |
| Destructive confirmation | ✓ (size=sm) | |
| Maker action requiring focus | ✓ |
Drawer = "look at this thing, maybe act on it". Modal = "do this thing now, then dismiss".
Width caps
- Drawer:
max-w-md(notifications),max-w-xl(schema draft),max-w-3xl(record review with diff panes) - Modal:
size="sm"(confirm),"md"(form),"xl"(JSON editor) - Body copy: implicit via Panel padding; for description text apply
max-w-proseесли viewport wide
5. Component Primitives
Locations: src/ui/components/*.tsx. Re-exported via @/ui.
Stable primitives (use freely)
<Button variant size>—primary | secondary | ghost | danger,sm | md | lg<Badge variant>—info | success | warning | danger | default | primary | outline<Panel>— surface wrapper для table/form blocks<Card>— content block (lighter surface than Panel)<Drawer isOpen onClose title side widthClassName>— slide-in detail view<Modal isOpen onClose title size>— centered overlay<Table TableHead TableRow TableHeaderCell TableBody TableCell>— tabular data<Tabs items value onValueChange>— Radix-based tabs with badge slot<EmptyState icon title description action>— no-data fallback<Alert variant title>— banner withinfo | success | warning | error<LoadingBlock size label>— skeleton / spinner<QueryErrorState error onRetry>— generic 4xx/5xx state с retry<UserCell uuid>— resolves sub → display name (three-tier cache)<TextInput TextArea Checkbox Select MultiSelect>— form inputs
Domain-specific (in src/components/)
<ReviewDrawer>— record draft review (records tab + Мои tab)<SchemaDraftDrawer>— schema draft review (dict page + Мои tab redirect)<DictionaryEditorDialog>— schema-aware editor с approval flow<MyDraftsPanel>— Мои tab on /reviews<NotificationsDrawer>+<NotificationsBell>— TODO 7 notification surfaces<ChangelogDiffModal>— historical schema diff viewer<TimeTravelModal>— record at point-in-time
6. Badge Variant Mapping
Visual category по semantic role, applied везде через <Badge variant=…>:
Operations (DraftOperation)
| Op | Variant | Color | Reason |
|---|---|---|---|
| CREATE | success | green | Net-new record — positive change |
| UPDATE | info | accent | Most common; neutral default |
| CLOSE | warning | warn | Soft-delete; destructive but reversible |
Record draft status (DraftStatus)
| Status | Variant | Reason |
|---|---|---|
| PENDING | info | Awaiting reviewer action |
| APPROVED | success | Terminal positive |
| REJECTED | danger | Terminal negative |
| WITHDRAWN | default | Maker-initiated cancel, neutral |
Schema draft status (SchemaDraftStatus)
| Status | Variant | Reason |
|---|---|---|
| draft | warning | Pre-submit, maker action pending |
| review_pending | info | Reviewer queue |
| approved | success | Awaiting publish |
| changes_requested | warning | Bounced to maker |
| rejected | danger | Terminal negative |
| published | primary | Terminal live (strongest signal) |
| withdrawn | default | Maker cancel |
Notification event type
| Pattern in eventType | Variant |
|---|---|
*Approved, *Published |
success |
*Rejected, *Withdrawn |
danger |
*Submitted, *ChangesRequested |
info |
| other | default |
7. Code / Diff Visual Language
Live vs proposed panes
- Live (current state):
bg-line/30background — muted, "established" - Proposed (draft):
bg-accent/4background — accent-tinted, "your suggestion" - CLOSE op note:
text-aurora italic— call-out пустой proposed pane
Diff summary chips (above panes)
+N added: text-aurora (green-family) with+prefix−M removed: text-danger with−prefix~K changed: text-warn with~prefix- Bucket details в
text-mute lowercaseparens
Rendered via shallowDiffSummary() from src/lib/diff.ts.
8. Interaction States
Required for all interactive elements
hover:bg-line/30или similar hover affordancefocus-visible:ring-2 focus-visible:ring-ringfocus ring (NEVERoutline: noneбез replacement)disabled:opacity-50 disabled:cursor-not-allowedдля disabled- Touch target ≥ 44×44 — use
min-h-[44px]for icon buttons (current gap; audit findings P1 in TODOS.md TODO 6)
Success / error affordances
- Mutation success: прокачка через
toast.success()(sonner) — auto-dismiss 4s - Mutation error:
toast.error()с structured message viaextractReviewError()/humanizeBulkReason()mappings - In-form validation: inline error under input с
role="alert"+aria-invalid+aria-describedby - Approve/reject decision flash: 600ms bg-green-bg/30 или bg-pink-bg/30
band на drawer footer перед
onClose()— ack ack для reviewer
Empty states (warmth required)
Every <EmptyState> MUST have:
icon— phosphor duotone, size 28-32title— outcome-framed ("Очередь пуста", not "No results")description— actionable hint (what to do next, where data appears)action(optional) — Link / Button to relevant route
9. Term Glossary (canonical)
| Code | EN | RU | Definition |
|---|---|---|---|
| maker | maker | автор | User who creates / submits a draft |
| reviewer | reviewer | ревьюер | User who approves / rejects drafts |
| publisher | publisher | публикатор | User who publishes approved schema drafts |
| draft | draft | черновик | Pending change awaiting approval |
| pending | pending | на ревью | Status: awaiting reviewer decision |
| approved | approved | одобрено | Status: reviewer approved (record published OR schema awaits publish) |
| rejected | rejected | отклонён | Status: reviewer rejected (terminal) |
| withdrawn | withdrawn | отозван | Status: maker cancelled (terminal) |
| WIP | in progress | в работе | Schema draft pre-submit OR changes_requested |
| decided | decided | закрыты | Any terminal status — APPROVED + REJECTED + WITHDRAWN |
| changes requested | changes requested | запрошены правки | Reviewer bounced schema draft back to maker |
| published | published | опубликован | Schema draft → live (terminal positive) |
Source: backend domain types (DraftStatus, SchemaDraftStatus, DraftOperation). NEVER use synonyms ("submitter" for maker, "validator" for reviewer) — breaks consistency with audit log + RBAC roles.
10. RBAC & Role Visibility
Backend gates (single source of truth). Frontend hide UI elements when roles missing — backend still enforces if frontend leaks.
| Composite role | Members | UI surface |
|---|---|---|
ordinis:reviewer |
schema:reviewer + record:reviewer |
/reviews tabs (Records, Schemas) — approve/reject buttons |
| (maker is implicit) | any authenticated user | /reviews Мои tab, draft create modals, dict editor |
Identity check pattern (in components):
const auth = useAuth()
const currentSub = auth.user?.profile?.sub
const isMine = draft.makerId === currentSub
For role gating use usePermissions() (src/auth/usePermissions.ts).
11. AI Slop Anti-Patterns (banned)
Per /design-review gstack skill plus our own observations:
- ❌ Purple/violet/indigo gradient backgrounds
- ❌ 3-column feature grid with icon-in-circle headers
- ❌ Decorative blobs / wavy SVG dividers / floating circles
- ❌ Emoji as design elements (rocket in heading)
- ❌ Colored left-border-only cards
- ❌ Generic hero copy ("Welcome to Ordinis", "Unlock the power…")
- ❌ Bubbly uniform
rounded-fullon every element - ❌ Centered everything (
text-align: centeron all body content) - ❌ system-ui / -apple-system as PRIMARY display font
Pass test: every visual element should be there because it does a job, not because empty space looks empty.
12. Responsive Breakpoints
Tailwind defaults — sm=640 / md=768 / lg=1024 / xl=1280 / 2xl=1536. Ordinis
is an admin tool — desktop primary. Policy:
- Target primary:
lg(1024) and up — full layout fidelity - Tolerate:
sm–md(640-1023) — degraded but functional (sidebar collapses to hamburger; tables horizontally scroll; tabs may wrap) - Minimum viable: 375px (iPhone SE narrow). No layout breaks; some density compromises accepted.
- Avoid breakpoint ping-pong: don't redefine layout at every step.
Standardize page padding:
px-4 sm:px-6 lg:px-8. Big shifts only atlg(sidebar collapse) andxl(max-width caps).
Explicit non-goal: native mobile-app feel. Users on phones are doing inspection / approval triage, not maker-flow data entry. See Section 19 disclosure.
13. Touch Targets
WCAG AAA recommends 44×44 minimum. Ordinis policy:
- All interactive elements (
button,awithhref,[role=button], inputs) —min-h-[44px]enforced. - Icon-only buttons (Bell, theme switch, language switch, hamburger) —
size-11(44×44) wrapper instead of currentsize-7. Visual icon stays small; hit area is the padded wrapper. - Tabs —
h-11instead ofh-10. Lose 4px header height, gain WCAG compliance. - Sidebar nav rows — full row click area (already correct);
py-2.5ensures ≥ 44px tall. - Table action links ("Open →") — wrap in
<Button variant="ghost" size="sm">which already has 44px hit area, despite small visual text. - Exception: icons inside larger clickable containers (e.g. unread dot inside a notification row that's clickable as a whole) — hit area belongs to parent, not the icon itself.
Enforcement: a follow-up MR sweeps current 28–32px buttons → 44px wrappers. Tracked in TODOS.md TODO 6 punch list.
14. Loading State Taxonomy
| Surface | Pattern | Rationale |
|---|---|---|
| Inline cell / quick value | <LoadingBlock size="sm"> |
Local replacement, no layout shift |
| Panel / table / drawer body | <LoadingBlock size="md"> |
Replaces whole section, matches Panel padding |
| Whole route mount | Skeleton matching real shape (TanStack Router pending state) | First paint should hint at content layout |
| Suspense fallback | <LoadingBlock size="md"> |
Same as panel — Suspense boundaries wrap panels |
| Inflight mutation | Button loading prop (replaces label with spinner) |
Local feedback at the trigger |
| Optimistic updates | No loading affordance — use useOptimistic |
Most modern pattern |
Never use a bare <Spinner> outside <Button loading>. Spinners detached
from context are anxiety-inducing — replace with a skeleton or LoadingBlock
sized to the missing content.
15. Motion Durations
Four-step scale. Implemented via Tailwind utility duration-{step} mapped
to CSS custom properties so the same scale applies to bespoke transitions.
| Step | Range | Use |
|---|---|---|
micro |
50–100ms | Hover, focus rings, toggle thumb, button press |
short |
150–250ms | Dropdown / popover open, success/error flash on form input, badge appearance |
medium |
300–400ms | Drawer slide-in, modal fade, ReviewDrawer decision flash, tab content fade |
long |
500–700ms | Route entrance choreography, hero reveals, complex sequence |
Easing
- Entrance (hidden → visible, off-screen → on-screen):
ease-out— start fast, settle in - Exit (visible → hidden):
ease-in— start gentle, accelerate away - Move (element relocates / resizes in-place):
ease-in-out - Linear: only for indeterminate loops (spinner rotation, progress bar bounce)
Rules
- Animate
transformandopacityonly — never width/height/top/left (triggers layout reflow on every frame) transition: allis banned — list properties explicitly- Respect
prefers-reduced-motion: replace entrance animation with instant state swap; spinner becomes pulsing dot - No animation > 700ms without user-controlled trigger (clicking expand, etc.)
16. Long-Content Overflow Rules
| Surface | Pattern |
|---|---|
| Table cells (string columns) | truncate + native title={fullValue} tooltip |
| Drawer / Modal title | truncate (single line, no wrap) |
| Body description text | Wrap naturally; max-w-prose on wide screens |
| Code blocks, JSON diff panes | overflow-x-auto whitespace-pre font-mono |
| Comments (makerComment / reviewComment) | line-clamp-2 on mobile, full on md+ |
| Notification preview text | line-clamp-2 always (drawer list is dense) |
| Search results / lists | Wrap; break-words for long URLs/identifiers |
| Tab labels | Never truncate — if label is long, label is wrong |
Tooltip + truncation rule: whenever applying truncate, ALWAYS pair with
native title attribute (or <Tooltip> primitive for rich content). Don't
let truncation hide information without a recovery path.
17. Dark Mode Parity
Audited 2026-05-14 — found *-bg alpha tokens differ between themes (11%
light vs 16% dark). Drift from independent token tweaks, not intentional.
Policy
- Surface elevation via opacity / shadow, NOT lightness inversion. Light → dark = different palette, not flipped lightness. Already implemented; keep.
- Accent saturation: reduce 10–20% in dark mode to avoid vibration on
dark surface. Already implemented (
--color-accent: #b05a2elight vs#d97757dark which is brighter but compensated for darker bg). *-bgalpha consistency: normalize to 12% in both themes via new--alpha-bg-tinttoken. One-time MR. Visual diff acceptable (slight saturation shift on info badges).- Text on accent (
--color-on-accent): verified ≥ 4.5:1 ratio both themes. Light = white (#fff) on#b05a2e≈ 4.8:1 OK. Dark =#1c1a17on#d97757≈ 7.2:1 OK. text-muteonbg-surface: light ≈ 4.6:1 (just passes), dark ≈ 4.8:1. Borderline — re-verify after Section 13 button rework.
18. Notifications Channel UX
Phase A (current, MR !190): every fired event dispatches to all enabled
channels at backend level. EmailChannel is the only Phase A channel.
Reviewer-pool address hardcoded in application.yml. Bell + drawer + toast
already shipped on frontend.
Phase B (deferred — TODO follow-up)
User-configurable preferences via new route /me/notifications/preferences.
Surface design when shipped:
- Table: rows = event types, columns = channels (Email / Express / Telegram / In-App). Toggle per cell.
- Save button bottom-right; optimistic update.
- "Mute all" + "Reset to defaults" quick actions in header.
- Defaults: all on; per-user state in
user_notification_prefstable (migration 0021 already has it).
Anti-pattern: don't bury preferences inside profile settings. Notifications preferences live one click from the bell — make the bell drawer footer have a settings cog.
Future channel ordering (Phase C, "Don't Disturb")
Per-channel priority for DND mode. Email always lowest (durable). Express/Slack mid. Telegram highest urgency. Bell + in-app toast always on.
19. Disclosure: Known Tradeoffs
This admin UI deliberately deprioritizes mobile-first design. Affordances:
- Native touch targets bigger than 44px not enforced on data-cell click
areas (would explode row heights). Cells with
<UserCell>or row-level click handlers rely on the parent row hit area. - Tables don't reformat into card-stacks on narrow screens — they scroll horizontally.
- Forms don't shift to one-column layouts under 768px — they compress.
- Server-side rendering not used; client-only SPA means full-app JS bundle required for any page.
Users on phones doing emergency approvals are supported. Users doing maker-flow data entry on phones are not. Don't ship to a context where the latter matters without prior architecture work.
Changelog
- 2026-05-14 v0 — Initial document. Captured tokens, components, badge
semantics, term glossary, drawer-vs-modal rules from approval workflow
saga codebase. Marked open items для full
/design-consultationsprint. Authored as part of TODO 5 closeout. - 2026-05-14 v1 — Section 12 (open items) resolved via
/design-consultation. Added Sections 12–19 with concrete policies: responsive breakpoints, touch targets, loading taxonomy, motion durations, overflow rules, dark mode parity, notifications channel UX, known-tradeoffs disclosure. Touch target follow-up MR pending; dark mode alpha normalization MR pending; Phase B notifications prefs TODO.