Files
languard-servers-manager/FRONTEND.md
Tran G. (Revernomad) Khoa 624d7594e2 feat: multi-game adapter revamp, council protocol merge, and frontend design doc
- Revamp architecture for modular game server support (Arma 3 first, extensible)
- Merge ConfigSchema into ConfigGenerator per council decision (8→7 protocols)
- Add has_capability() method to GameAdapter protocol for explicit capability probing
- Add FRONTEND.md: production-grade dark neumorphism design with amber/orange palette
- Update all docs (ARCHITECTURE, MODULES, DATABASE, API, IMPLEMENTATION_PLAN, THREADING)
  to reflect protocol merge and multi-game adapter patterns
2026-04-16 17:05:04 +07:00

66 KiB
Raw Blame History

Languard Servers Manager — Frontend Design

Purpose

A real-time game server management dashboard that gives admins instant visibility and control over their dedicated servers. The interface must feel like a mission control center — dense with live data, fast to react, and unambiguous in its state signals.

Audience: Server administrators managing game servers. Technical, task-oriented, frequently under time pressure (server crashed, player is cheating, need to restart now).

Emotional tone: Confident, precise, operational. Not playful, not corporate, not minimal-for-the-sake-of-it.

Visual direction: Dark neumorphic command center — near-black surfaces with soft extruded/inset shadows creating tactile depth, amber and orange accents cutting through like instrument panel lights, monospaced data glowing against dark backgrounds. The aesthetic of a physical control panel — buttons you can feel, displays that look back-lit, surfaces that have real mass.

One thing the user should remember: "I can see exactly what's happening and act on it immediately."


Technology Stack

Layer Technology Rationale
Framework React 18 + TypeScript 5 Ecosystem, type safety, team familiarity
Build Vite 5 Fast HMR, native ESM, minimal config
Routing React Router v6 Standard, nested layouts
State (server) TanStack Query v5 Server state cache, background refetch, optimistic updates
State (client) Zustand Minimal boilerplate, no providers, good for WS state
Forms React Hook Form + Zod Adapter-driven dynamic form generation from JSON Schema
Styling Tailwind CSS v3 + CSS variables Utility-first with design tokens for theming
Charts Recharts Lightweight, responsive, sufficient for CPU/RAM/player time series
Icons Lucide React Consistent stroke style, tree-shakeable
HTTP Ky (fetch wrapper) Hooks, retry, typed responses
WS Native WebSocket + custom hook Minimal abstraction over browser API
Code quality ESLint + Prettier + tsc --noEmit Lint, format, type-check

No UI component library (shadcn, MUI, etc.). Every component is purpose-built to the design system below.


Design Tokens

Color

Black, white, dark yellow, and orange. The palette mirrors a military-grade instrument panel — near-black surfaces, white text for readability, amber/yellow for data highlights and warnings, orange for primary actions and live indicators.

:root {
  /* ── Surfaces ──────────────────────────────────────────────
     Dark neumorphism: surfaces are all near-black but subtly
     differentiated by lightness. Neumorphic shadows (below)
     create the illusion of physical depth — raised panels,
     sunken inputs, extruded buttons.
  */
  --color-base:     #0d0d0d;  /* deepest — page background          */
  --color-surface: #141414;  /* panels, cards                      */
  --color-elevated:#1a1a1a;  /* raised panels, modals              */
  --color-hover:   #1f1f1f;  /* hover state on interactive surfaces */

  /* ── Neumorphic shadow source ──────────────────────────────
     Neumorphism requires two opposing shadows on the same
     surface: a lighter shadow (simulating top-left light)
     and a darker shadow (simulating bottom-right depth).
     The source colors are derived from the surface itself.
  */
  --neu-light:     #1e1e1e;  /* light shadow — 4-5% above surface  */
  --neu-dark:      #0a0a0a;  /* dark shadow  — 4-5% below surface  */

  /* ── Text ───────────────────────────────────────────────── */
  --color-text:       #f5f5f5;  /* primary — near-white             */
  --color-text-dim:   #999999;  /* secondary — timestamps, meta     */
  --color-text-muted:  #555555;  /* disabled, placeholders           */

  /* ── Accent — dark yellow / orange ──────────────────────── */
  --color-accent:        #d4940a;  /* dark yellow — primary actions    */
  --color-accent-hover:  #e5a61c;  /* lighter on hover               */
  --color-accent-dim:    #8b6210;  /* muted accent for backgrounds    */
  --color-orange:        #d45e0a;  /* orange — secondary accent       */
  --color-orange-hover:  #e56e1c;  /* lighter on hover                */
  --color-orange-dim:    #8b3e0a;  /* muted orange for backgrounds    */

  /* ── Status ─────────────────────────────────────────────── */
  --color-running:  #d4940a;   /* amber glow — server is live       */
  --color-stopped:  #555555;   /* gray — idle                       */
  --color-starting: #d4940a;   /* amber — transitioning (pulsing)   */
  --color-crashed:  #cc3333;   /* red — needs attention             */
  --color-error:    #cc3333;   /* red — error states                */

  /* ── Danger ─────────────────────────────────────────────── */
  --color-danger:        #cc3333;  /* red — destructive actions       */
  --color-danger-hover:  #dd4444;

  /* ── Glow ────────────────────────────────────────────────
     Status indicators use a subtle glow (box-shadow) to
     simulate back-lit LEDs on a dark panel.
  */
  --glow-amber: 0 0 8px 2px oklch(72% 0.15 80 / 0.4);
  --glow-red:   0 0 8px 2px oklch(55% 0.20 25 / 0.4);
  --glow-orange: 0 0 8px 2px oklch(65% 0.17 50 / 0.35);

  /* ── Overlays ──────────────────────────────────────────── */
  --color-overlay:  oklch(8% 0 0 / 0.85); /* modal backdrop         */
}

Color rules:

  • Black is the only background. No white backgrounds anywhere. White is text only.
  • Amber (dark yellow) is the primary accent — used for: primary buttons, active tabs, selected items, the "running" status dot, data highlight values.
  • Orange is the secondary accent — used for: warning states, important metrics, secondary CTAs.
  • Red is reserved for danger — crashed, error, destructive actions. Never decorative.
  • Gray is the neutral — stopped status, disabled states, borders.
  • No purple. No blue. No decorative gradients.
  • Status dots glow via box-shadow — like back-lit LEDs on a physical panel.

Neumorphic Surface Treatment

Dark neumorphism creates the illusion of physical depth through opposing light/dark shadows on near-black surfaces. Every interactive element signals its affordance through shadow direction:

/* ── Raised (extruded) ────────────────────────────────────
   Buttons, cards, metric tiles — things that sit above the surface.
   Light shadow top-left, dark shadow bottom-right.
*/
.neu-raised {
  background: var(--color-surface);
  border-radius: var(--radius-md);
  box-shadow:
    4px 4px 8px var(--neu-dark),
   -4px -4px 8px var(--neu-light);
}

/* ── Inset (sunken) ────────────────────────────────────────
   Input fields, log viewer, search bars — things that go into the surface.
   Dark shadow top-left, light shadow bottom-right.
*/
.neu-inset {
  background: var(--color-base);
  border-radius: var(--radius-sm);
  box-shadow:
    inset 3px 3px 6px var(--neu-dark),
   inset -3px -3px 6px var(--neu-light);
}

/* ── Flat (flush) ──────────────────────────────────────────
   Modal surfaces, overlays — flat on the surface, no shadow play.
*/
.neu-flat {
  background: var(--color-elevated);
  border-radius: var(--radius-lg);
  box-shadow:
    0 8px 32px oklch(0% 0 0 / 0.5);
}

/* ── Pressed ───────────────────────────────────────────────
   Active/pressed button state — flips to inset.
*/
.neu-raised:active {
  box-shadow:
    inset 3px 3px 6px var(--neu-dark),
   inset -3px -3px 6px var(--neu-light);
}

/* ── Accent raised ─────────────────────────────────────────
   Primary action buttons with amber/orange fill.
   Neumorphic shadows on a colored surface.
*/
.neu-raised-accent {
  background: var(--color-accent);
  color: #0d0d0d;
  border-radius: var(--radius-md);
  box-shadow:
    4px 4px 8px var(--neu-dark),
   -4px -4px 8px var(--neu-light),
    0 0 12px oklch(72% 0.15 80 / 0.2);  /* subtle amber glow */
}

/* ── Status LED ────────────────────────────────────────────
   Status indicator dots with glow. Looks like a back-lit LED.
*/
.led-running {
  background: var(--color-running);
  border-radius: var(--radius-full);
  box-shadow: var(--glow-amber);
}
.led-crashed {
  background: var(--color-crashed);
  border-radius: var(--radius-full);
  box-shadow: var(--glow-red);
}

Neumorphism rules for this project:

  • Shadow intensity is proportional to interaction. Buttons get full shadows. Decorative cards get lighter shadows. Flat info panels get none.
  • Never use borders with neumorphism — shadows define edges, not lines. The only exception is focus rings for accessibility.
  • Inset for input, raised for output. Form fields, log viewers, and search bars are sunken. Metric tiles, buttons, and cards are raised.
  • Pressed = inset. When a raised button is clicked, it flips to inset shadows for tactile feedback.
  • Subtle, not extreme. Shadow offsets are 3-4px, blur 6-8px. This is not the exaggerated neumorphism of 2020 — it's understated depth that makes the interface feel like physical hardware.
  • Glow replaces color fill for status. Running servers don't just get a colored dot — they get a dot that glows, like a real LED indicator on a server rack.

Typography

:root {
  /* Display — for page titles */
  --font-display: "Space Grotesk", sans-serif;

  /* Body — for most UI text */
  --font-body: "Inter", sans-serif;

  /* Mono — for logs, code, ports, PIDs, timestamps */
  --font-mono: "JetBrains Mono", monospace;

  /* Scale */
  --text-xs:   0.75rem;   /* 12px — badges, tags             */
  --text-sm:   0.8125rem; /* 13px — table cells, meta        */
  --text-base: 0.875rem;  /* 14px — body, form labels        */
  --text-lg:   1rem;      /* 16px — section headings         */
  --text-xl:   1.25rem;   /* 20px — page titles              */
  --text-2xl:  1.5rem;    /* 24px — hero metrics             */
  --text-3xl:  2rem;      /* 32px — big status numbers       */

  /* Weight */
  --weight-regular: 400;
  --weight-medium:  500;
  --weight-semibold: 600;

  /* Line height */
  --leading-tight: 1.25;
  --leading-normal: 1.5;
  --leading-relaxed: 1.75; /* for log blocks */
}

Rules:

  • All data values (ports, PIDs, player counts, IPs) use --font-mono.
  • Log viewer is entirely monospaced.
  • Page titles use --font-display. Everything else uses --font-body.
  • Never use font weight alone to differentiate — combine with size or color.

Spacing

:root {
  --space-1:  0.25rem;  /* 4px  — tight internal gaps     */
  --space-2:  0.5rem;   /* 8px  — form field spacing      */
  --space-3:  0.75rem;  /* 12px — compact padding         */
  --space-4:  1rem;     /* 16px — standard padding         */
  --space-5:  1.5rem;   /* 24px — section gaps             */
  --space-6:  2rem;     /* 32px — page margins             */
  --space-8:  3rem;     /* 48px — major separations        */
}

Rhythm: 4px base unit. All spacing is a multiple of 4px. No arbitrary padding values.

Borders, Shadows, Radii

:root {
  /* Radii — consistent, not excessive */
  --radius-sm:  6px;   /* inputs, badges, small buttons   */
  --radius-md:  10px;  /* cards, panels                    */
  --radius-lg:  14px;  /* modals, overlays                 */
  --radius-full: 9999px; /* status LEDs, pills             */

  /* Neumorphism handles edge definition — borders are rare.
     Only use borders for: focus rings, table row separators,
     and explicit dividers between sections. */
  --border-subtle: 1px solid #222222;
  --border-focus: 2px solid var(--color-accent);
}

Note on neumorphic shadows + Tailwind: The neumorphic shadow classes above cannot be expressed as single Tailwind utilities. Use CSS custom classes (.neu-raised, .neu-inset, etc.) defined in globals.css and reference them via @apply or direct class names in components. Tailwind utilities handle everything else (spacing, layout, typography, color).

Motion

:root {
  --duration-fast:   100ms; /* hover states, toggles          */
  --duration-normal: 200ms; /* panel transitions, modals       */
  --duration-slow:   400ms; /* page transitions                */

  --ease-out:  cubic-bezier(0.16, 1, 0.3, 1);
  --ease-in-out: cubic-bezier(0.45, 0, 0.55, 1);
}

Rules:

  • Motion is for state transitions, not decoration.
  • Status changes (stopped→starting→running) use --duration-normal + --ease-out.
  • No scroll-triggered animations. No parallax. No loading spinners with decorative motion.
  • Log streaming has zero animation — lines appear instantly.
  • Modals slide in from bottom or fade in. Never bounce, never spring.

Layout Architecture

Shell

┌─────────────────────────────────────────────────────────┐
│  LOGO  Languard                     [user] [settings]  │  ← 48px header
├────────┬────────────────────────────────────────────────┤
│        │                                                │
│  NAV   │              CONTENT AREA                      │
│        │                                                │
│ ┌────┐ │  ┌──────────────────────────────────────────┐ │
│ │📊  │ │  │                                          │ │
│ ├────┤ │  │                                          │ │
│ │🖥️  │ │  │         Page Content                    │ │
│ ├────┤ │  │                                          │ │
│ │📋  │ │  │                                          │ │
│ ├────┤ │  │                                          │ │
│ │⚙️  │ │  │                                          │ │
│ ├────┤ │  └──────────────────────────────────────────┘ │
│ │🔧  │ │                                                │
│ └────┘ │                                                │
│  56px  │                                                │
├────────┴────────────────────────────────────────────────┤
│  Status bar: connected servers / WS status / version    │  ← 28px footer
└─────────────────────────────────────────────────────────┘
  • Sidebar: 56px collapsed (icons only), expands to 200px on hover/click. Icon + label for each section.
  • Header: App name left, user dropdown right. Dark, fixed.
  • Footer: Always visible — shows WebSocket connection status (connected/reconnecting/disconnected), number of running servers, app version. Critical for trust.
  • Content: Scrollable main area. Min width 1024px.

Navigation Structure

Route Icon Label Access
/ LayoutDashboard Dashboard All
/servers Server Servers All
/servers/:id (Server Detail) All
/servers/:id/config (Server Config) Admin
/missions (via server detail) Admin
/mods PuzzlePiece Mods All (view), Admin (edit)
/bans ShieldOff Bans All (view), Admin (edit)
/users Users Users Admin
/settings Settings Settings Admin

Responsive Breakpoints

Breakpoint Layout Sidebar
≥1440px Full layout Expanded by default
10241439px Full layout Collapsed by default
<1024px Not supported

This is a server management tool, not a consumer app. Mobile is not a target. The minimum viewport is 1024px wide. Below that, show a "Please use a desktop browser" message.


Page Designs

1. Login

Single centered card on dark background. No illustration, no hero, no marketing copy. The card itself is neu-raised — it appears to float above the base surface.

┌─────────────────────────────────┐
│                                 │
│     Languard                    │  ← display font, white
│     Server Manager              │  ← dim, monospaced?
│                                 │
│  ┌─────────────────────────┐    │
│  │ Username                │    │ ← neu-inset input
│  └─────────────────────────┘    │
│  ┌─────────────────────────┐    │
│  │ Password                │    │ ← neu-inset input
│  └─────────────────────────┘    │
│                                 │
│  ╔═══════════════════════╗     │ ← neu-raised-accent
│  ║       Sign In          ║     │   (amber fill)
│  ╚═══════════════════════╝     │
│                                 │
│  Error message area (red glow)  │
│                                 │
└─────────────────────────────────┘
  • No "remember me", no "forgot password" (single-host tool, admin manages users via CLI or initial setup).
  • Failed login shows inline error with rate-limit countdown after 5 attempts.
  • JWT stored in localStorage. On token expiry, redirect to login with a toast "Session expired".

2. Dashboard

A command-center overview. Not a marketing dashboard — dense, data-first. Neumorphic raised cards for metrics, inset panels for lists.

┌────────────────────────────────────────────────────────┐
│  Dashboard                              [Refresh]       │
├────────────────────────────────────────────────────────┤
│                                                        │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│  │(raised)  │ │(raised)  │ │(raised)  │ │(raised)  │ │
│  │    3     │ │    2     │ │    15    │ │   34%    │ │
│  │ Total    │ │ Running  │ │ Players  │ │ Avg CPU  │ │
│  └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│                                                        │
│  ┌──────────────────────────────────────────────────┐  │
│  │  SERVER STATUS                  (raised panel)    │  │
│  │                                                   │  │
│  │  🟡 Main Server          Arma 3   15/40  34%CPU  │  │ ← amber glow LED
│  │  🟡 Altis COOP           Arma 3   0/32   12%CPU  │  │
│  │  ⚫ Test Server           Arma 3   —      —      │  │ ← gray, no glow
│  │                                                   │  │
│  └──────────────────────────────────────────────────┘  │
│                                                        │
│  ┌──────────────────────────────────────────────────┐  │
│  │  RECENT EVENTS                  (inset panel)     │  │
│  │                                                   │  │
│  │  10:05  PlayerOne kicked (AFK)      Main Server   │  │
│  │  10:02  Server started              Altis COOP   │  │
│  │  09:58  Crashed (exit 1) 🔴        Test Server   │  │ ← red glow LED
│  │  09:45  Ban added: Cheater42        Main Server   │  │
│  │                                                   │  │
│  └──────────────────────────────────────────────────┘  │
│                                                        │
└────────────────────────────────────────────────────────┘
  • Summary cards: Neumorphic raised (neu-raised). Large amber number on top, white label below. Monospaced numbers. Running count glows amber. Crashed count glows red (if > 0).
  • Server status list: Clickable rows → navigate to server detail. Status dot is an LED with glow (led-running). Player count in mono. CPU is color-coded: <50% white, 50-80% amber, >80% orange-red.
  • Recent events: Inset panel (neu-inset). Last 10 events across all servers. Crash events show red LED.

3. Server List

Full CRUD list with filtering and bulk overview.

┌─────────────────────────────────────────────────────────────┐
│  Servers                     [+ New Server]  [Game: All ▾]  │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ Name          │ Game   │ Status  │ Players │ CPU │ ⚙ │   │
│  ├───────────────┼────────┼─────────┼─────────┼─────┼───┤   │
│  │ Main Server   │ Arma 3 │ 🟡 Run  │ 15/40   │ 34% │ ⋮ │   │ ← amber LED
│  │ Altis COOP    │ Arma 3 │ 🟡 Run  │ 0/32    │ 12% │ ⋮ │   │
│  │ Test Server   │ Arma 3 │ ⚫ Stop │ —       │ —   │ ⋮ │   │ ← gray, no glow
│  └───────────────┴────────┴─────────┴─────────┴─────┴───┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
  • Game filter dropdown: Populated from GET /games. Inset input style (neu-inset).
  • [+ New Server]: Raised accent button (neu-raised-accent). Amber fill, dark text.
  • Row actions (⋮ menu): Start/Stop/Restart/Kill, Edit, Delete. Actions are context-aware — "Start" is disabled when running, "Stop" is disabled when stopped.
  • Sort: Click column headers. Default sort: status (running first), then name.
  • Status column: LED dot + short label. Amber glow = running, gray = stopped, red glow = crashed.

4. New Server Dialog

Modal overlay. Multi-step for clarity, not wizardry.

┌──────────────────────────────────────┐
│  New Server                          │
│                                      │
│  ── Step 1: Game Type ──────────    │
│                                      │
│  ┌────────┐  ┌────────┐             │
│  │ Arma 3 │  │  + Add │  ← greyed   │
│  │  ★     │  │  more  │    if no    │
│  └────────┘  └────────┘  adapters   │
│                                      │
│  ── Step 2: Details ────────────    │
│                                      │
│  Server Name     [                  ]│
│  Description     [                  ]│
│  Executable      [/path/to/exe     ]│
│  Game Port       [2302             ]│
│  RCon Port       [2306             ]│
│                                      │
│  ── Step 3: Config ──────────────    │
│                                      │
│  (Pre-filled from adapter defaults)  │
│  Hostname        [My Arma 3 Server ]│
│  Max Players     [40               ]│
│  Admin Password  [••••••          ]│
│  ...                                 │
│                                      │
│          [Cancel]  [Create Server]   │
│                                      │
└──────────────────────────────────────┘
  • Game type selector: Visual cards, not a dropdown. Each shows game name + icon. Only registered game types appear (from GET /games).
  • Config sections: Dynamically rendered from GET /games/{type}/config-schema. Each section becomes a collapsible group. Fields generated from JSON Schema types (string → text input, integer → number input, boolean → toggle, enum → dropdown).
  • Sensitive fields: Password inputs with show/hide toggle. Pre-generated values shown once in a dismissible callout after creation.
  • Port auto-fill: Game port defaults from adapter's get_default_game_port(). RCon port from get_default_rcon_port(). User can override.

5. Server Detail

The primary operating surface. Tabbed layout with real-time data.

┌─────────────────────────────────────────────────────────────┐
│  ← Servers  │  Main Server                                 │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  🟡 RUNNING    Arma 3    PID: 12345    Uptime: 2h 15m      │ ← amber LED + glow
│                                                             │
│  [Stop]  [Restart]  [Kill ⚠]                               │  ← action bar
│                                                             │
│  ┌────────────┐ ┌────────────┐ ┌────────────┐             │
│  │(neu-raised)│ │(neu-raised)│ │(neu-raised)│             │
│  │    15      │ │   34.2%    │ │  1850 MB   │             │
│  │  Players   │ │    CPU     │ │     RAM     │             │
│  └────────────┘ └────────────┘ └────────────┘             │
│                                                             │
│  [Overview] [Logs] [Players] [Config] [Missions] [Mods]    │
│  ═════════                                                    │
│                                                             │
│  ── Tab Content Area ──                                     │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Action bar:

  • [Stop] and [Restart] are neu-raised (default surface style).
  • [Kill ⚠] is neu-raised with red text (not a red button — danger signals through color, not surface).
  • Buttons press to neu-inset on click (tactile feedback).
  • Action bar is fixed at top of content — always accessible.

Metric cards:

  • Neumorphic raised (neu-raised). Updated in real-time via WebSocket (type: "metrics").
  • Player count shows current/max in mono font, accent color when > 0.
  • CPU color-coded: <50% white, 50-80% amber, >80% orange.

Tabs: Rendered/hidden based on adapter capabilities:

Tab Condition Source
Overview Always Server detail + recent events
Logs Always WebSocket type: "log" + GET /servers/{id}/logs
Players has_capability("remote_admin") WebSocket type: "players" + GET /servers/{id}/players
Config Always (admin only) GET /servers/{id}/config
Missions has_capability("mission_manager") GET /servers/{id}/missions
Mods has_capability("mod_manager") GET /servers/{id}/mods + GET /mods

Tabs that the adapter doesn't support are simply not rendered. No disabled tabs, no "coming soon" badges.

5a. Server Detail — Logs Tab

┌─────────────────────────────────────────────────────────────┐
│  [Level: All ▾]  [Search...]              [Clear Logs ⚠]  │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  10:05:23  INFO   BattlEye Server: Initialized (v1.240)    │
│  10:05:24  INFO   Player PlayerOne connected               │
│  10:05:30  WARN   High ping detected: PlayerTwo (450ms)    │
│  10:06:01  ERROR  BattlEye: RCon connection timeout         │
│  10:06:15  INFO   Player PlayerOne disconnected            │
│                                                             │
│  ── streaming ──                                            │
│                                                             │
└─────────────────────────────────────────────────────────────┘
  • Entirely monospaced (--font-mono). This is a log terminal, not a chat.
  • Container uses neu-inset — the log area looks like a sunken display screen.
  • Level colors: INFO = dim white, WARN = amber, ERROR = red. Color on the level tag only, not the whole line.
  • Streaming: New lines prepend from top (newest-first) or append to bottom (oldest-first) — user toggle. Default: newest-first.
  • Virtualized list for performance. Logs can grow to tens of thousands of lines.
  • Search is client-side filter on loaded logs + server-side ?search= for historical.
  • No auto-scroll lock — if user scrolls up, stop auto-scroll. Resume when scrolled to bottom.

5b. Server Detail — Players Tab

┌─────────────────────────────────────────────────────────────┐
│  Players (15/40)                          [Say All]       │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌─────────┬──────────┬────────┬──────┬──────────────────┐│
│  │ Slot    │ Name     │ GUID   │ Ping │ Actions          ││
│  ├─────────┼──────────┼────────┼──────┼──────────────────┤│
│  │ 1       │ PlayerOne│ abc... │ 45ms │ [Kick] [Ban]     ││
│  │ 2       │ PlayerTwo│ def... │ 450ms│ [Kick] [Ban]     ││
│  │ ...     │          │        │      │                  ││
│  └─────────┴──────────┴────────┴──────┴──────────────────┘│
│                                                             │
└─────────────────────────────────────────────────────────────┘
  • Real-time: Table updates via WebSocket type: "players". No manual refresh.
  • GUID column shows truncated GUID (click to copy full).
  • Ping column color-coded: <100 green, 100-300 default, >300 amber, >500 red.
  • Actions: Kick opens a small popover for reason input. Ban opens a popover with reason + duration.
  • Say All button: Opens a message input. Sends POST /servers/{id}/remote-admin/say.
  • Viewer role: Sees the table, no action buttons.

5c. Server Detail — Config Tab

The most complex UI surface. Dynamic form generation from adapter's JSON Schema.

┌─────────────────────────────────────────────────────────────┐
│  Config        [Preview Config]  [Download ▾]               │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  ▸ Server   (modified*)                              │   │
│  │  ▾ Basic                  (neu-raised panel)        │   │
│  │  ┌───────────────────────────────────────────────┐  │   │
│  │  │ Hostname  ╔═══════════════════════════════╗   │  │   │ ← neu-inset input
│  │  │           ║ My Arma 3 Server              ║   │  │   │
│  │  │           ╚═══════════════════════════════╝   │  │   │
│  │  │ Max Players ╔════════════╗  Password ╔════╗  │  │   │
│  │  │            ║ 40         ║           ║••••║  │  │   │
│  │  │            ╚════════════╝  [👁]      ╚════╝  │  │   │
│  │  │ BattlEye  [● On ]    Verify Sig  [2 ▾]      │  │   │ ← toggle = raised
│  │  └───────────────────────────────────────────────┘  │   │
│  │  ▸ Profile                                           │   │
│  │  ▸ Launch                                            │   │
│  │  ▸ RCon                                              │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  [Reset Section]  ╔═══════════════╗                          │
│                   ║ Save Changes ║  ← neu-raised-accent      │
│                   ╚══════════════╝  (amber fill)             │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Dynamic form generation:

  • Config sections are collapsible panels, one per adapter section.
  • Each section's fields are rendered from GET /games/{type}/config-schema (JSON Schema).
  • JSON Schema → form field mapping:
    • type: "string" → text input (or textarea if format: "multiline")
    • type: "integer" → number input (with min/max from schema)
    • type: "number" → number input (step=0.1)
    • type: "boolean" → toggle switch
    • enum: [...] → dropdown select
    • type: "array" → repeatable field group (for motd_lines, etc.)
    • Sensitive fields (from get_sensitive_fields()) → password input with show/hide
  • Field descriptions from JSON Schema description → tooltip on hover.

Optimistic locking:

  • Each section stores its config_version from the last read.
  • On save (PUT /servers/{id}/config/{section}), sends the version.
  • On 409 Conflict: shows a diff dialog with "Your changes" vs "Current server values", with options to override or merge.

Dirty state:

  • Unsaved changes show (modified*) on the section header.
  • Navigation away from dirty form triggers an unsaved-changes dialog.
  • Reset Section reverts to the last saved state.

Preview:

  • Preview Config opens a modal with rendered config from GET /servers/{id}/config/preview.
  • Each entry in the label→content dict is shown as a labeled code block. Monospaced.
  • Download ▾ gives individual file downloads from GET /servers/{id}/config/download/{filename}.

5d. Server Detail — Missions Tab

┌─────────────────────────────────────────────────────────────┐
│  Missions                              [Upload .pbo]        │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ── Active Rotation ────────────────────────────────────── │
│  1. MyMission.Altis (Regular)            [↑] [↓] [✕]      │
│  2. ZeusOps.Altis (Veteran)             [↑] [↓] [✕]       │
│                                                             │
│  ── Available Missions ─────────────────────────────────── │
│  ┌──────────────────────┬──────────┬────────┬──────────┐  │
│  │ Filename            │ Terrain  │ Size   │ Actions  │  │
│  ├──────────────────────┼──────────┼────────┼──────────┤  │
│  │ MyMission.Altis.pbo │ Altis    │ 100 KB │ [+ Add]  │  │
│  │ ZeusOps.Altis.pbo   │ Altis   │ 50 KB  │ In rot.  │  │
│  │ Training.Stratis.pbo│ Stratis │ 25 KB  │ [+ Add]  │  │
│  └──────────────────────┴──────────┴────────┴──────────┘  │
│                                                             │
└─────────────────────────────────────────────────────────────┘
  • Only shown if has_capability("mission_manager").
  • Upload: Drag-and-drop zone + file picker. Extension validated from adapter's MissionManager.file_extension.
  • Rotation: Ordered list. Reorder via drag-and-drop or arrow buttons. Difficulty dropdown per entry.
  • Add to rotation: Button on each available mission. Moves it to rotation.
  • Remove from rotation: Removes from rotation, mission stays on disk.

5e. Server Detail — Mods Tab

┌─────────────────────────────────────────────────────────────┐
│  Mods                                  [Register Mod]       │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ── Active Mods ───────────────────────────────────────── │
│  ┌──────────┬─────────────────────┬────────────┬────────┐ │
│  │ Type     │ Mod                 │ Workshop ID │ Remove │ │
│  ├──────────┼─────────────────────┼────────────┼────────┤ │
│  │ Client   │ @CBA_A3            │ 450814997  │  [✕]   │ │
│  │ Server   │ @ACE_server        │ —          │  [✕]   │ │
│  └──────────┴─────────────────────┴────────────┴────────┘ │
│                                                             │
│  ── Available Mods ─────────────────────────────────────── │
│  ┌─────────────────────┬────────────┬──────────────────┐  │
│  │ @CBA_A3            │ 450814997  │ [+ Enable]       │  │
│  │ @ACE               │ 463289743  │ [+ Enable]       │  │
│  └─────────────────────┴────────────┴──────────────────┘  │
│                                                             │
└─────────────────────────────────────────────────────────────┘
  • Only shown if has_capability("mod_manager").
  • Client vs Server mod: Toggle on each mod assignment. Determines -mod= vs -serverMod= in Arma 3.
  • Sort order: Drag-and-drop reordering within each type. Affects load order.
  • Register Mod: Opens a form to add a new mod folder path + metadata.

6. Bans Page

┌─────────────────────────────────────────────────────────────┐
│  Bans                              [Server: All ▾]         │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌──────────┬──────────┬───────────────┬────────┬──────────┐│
│  │ Server   │ Name     │ GUID          │ Reason │ Expires  ││
│  ├──────────┼──────────┼───────────────┼────────┼──────────┤│
│  │ Main     │ Cheater42│ abc123...     │ Hacking│ Perm     ││
│  │ Altis    │ Troll99  │ def456...     │ Grief  │ 2h left  ││
│  └──────────┴──────────┴───────────────┴────────┴──────────┘│
│                                                             │
│  [+ Add Ban Manually]                                       │
│                                                             │
└─────────────────────────────────────────────────────────────┘
  • Cross-server view by default (all servers). Filterable by server.
  • Add Ban Manually: Opens a form with GUID/Name/Reason/Duration/Server selector.
  • Unban: Confirmation dialog → DELETE /servers/{id}/bans/{ban_id}.
  • Adapter's BanManager syncs to game ban file automatically (no extra UI needed).

7. Users Page (Admin Only)

Simple CRUD table: username, role, created date. Add/delete users. Change role. No inline password editing (use /auth/password).

8. Settings Page (Admin Only)

  • Change own password
  • System info (version, uptime, supported games)
  • API key management (future)

Component Architecture

Directory Structure

frontend/
├── index.html
├── vite.config.ts
├── tsconfig.json
├── package.json
├── tailwind.config.ts
├── postcss.config.js
│
├── public/
│   └── favicon.svg
│
└── src/
    ├── main.tsx                    # Mount point
    ├── App.tsx                     # Router + providers
    ├── vite-env.d.ts
    │
    ├── styles/
    │   ├── globals.css             # CSS variables, resets, base styles
    │   └── fonts.css               # @font-face declarations
    │
    ├── api/
    │   ├── client.ts               # Ky instance with base URL + auth interceptor
    │   ├── auth.ts                 # Login, me, users
    │   ├── servers.ts              # Server CRUD, start/stop/kill
    │   ├── config.ts               # Config CRUD, preview, download
    │   ├── players.ts              # Players, kick, ban
    │   ├── mods.ts                  # Mod registration, server mods
    │   ├── missions.ts             # Missions, upload, rotation
    │   ├── bans.ts                  # Bans CRUD
    │   ├── games.ts                # Game type discovery, schemas, defaults
    │   ├── logs.ts                  # Log queries
    │   ├── metrics.ts              # Metrics queries
    │   └── events.ts               # Event log queries
    │
    ├── hooks/
    │   ├── useWebSocket.ts         # Connection management, reconnection, channel sub
    │   ├── useAuth.ts              # JWT state, login/logout, role check
    │   ├── useServerStatus.ts      # WS-driven status for a server (or all)
    │   ├── useServerLogs.ts         # WS-driven log stream
    │   ├── useServerPlayers.ts      # WS-driven player list
    │   ├── useServerMetrics.ts      # WS-driven metrics
    │   ├── useConfigForm.ts         # Dynamic form from JSON Schema + optimistic locking
    │   ├── useCapability.ts         # Check adapter.has_capability()
    │   └── useConfirm.ts           # Confirmation dialog hook
    │
    ├── stores/
    │   ├── authStore.ts            # Zustand: token, user, role
    │   └── wsStore.ts              # Zustand: connection status, reconnect count
    │
    ├── components/
    │   ├── ui/                     # Primitives (no business logic)
    │   │   ├── Button.tsx          # neu-raised / neu-raised-accent / neu-raised-danger
    │   │   ├── Input.tsx           # neu-inset
    │   │   ├── Select.tsx          # neu-inset + custom dropdown (neu-raised)
    │   │   ├── Toggle.tsx          # neu-raised (on) / neu-inset (off) with amber LED
    │   │   ├── Badge.tsx
    │   │   ├── Modal.tsx           # neu-flat (drop shadow, no neumorphic play)
    │   │   ├── Toast.tsx
    │   │   ├── Tooltip.tsx
    │   │   ├── ConfirmDialog.tsx
    │   │   ├── EmptyState.tsx
    │   │   ├── LoadingBar.tsx       # amber bar, inset track
    │   │   ├── CodeBlock.tsx        # neu-inset
    │   │   └── StatusLed.tsx        # LED dot with glow (amber/red/gray)
    │   │
    │   ├── layout/
    │   │   ├── AppShell.tsx        # Header + sidebar + footer
    │   │   ├── Sidebar.tsx
    │   │   ├── Header.tsx
    │   │   └── StatusBar.tsx       # Footer: WS status, server count
    │   │
    │   ├── server/
    │   │   ├── ServerCard.tsx      # Dashboard summary card (neu-raised)
    │   │   ├── ServerList.tsx      # Table view (rows inside neu-raised container)
    │   │   ├── ServerStatusDot.tsx # Status LED with glow
    │   │   ├── ServerActionBar.tsx # Start/Stop/Restart/Kill buttons
    │   │   ├── ServerMetricCard.tsx # Player/CPU/RAM card (neu-raised, amber numbers)
    │   │   └── NewServerDialog.tsx  # Multi-step creation modal (neu-flat)
    │   │
    │   ├── config/
    │   │   ├── ConfigSection.tsx   # Collapsible config panel (neu-raised)
    │   │   ├── ConfigForm.tsx      # Dynamic form from JSON Schema (neu-inset inputs)
    │   │   ├── ConfigField.tsx     # Single field renderer
    │   │   ├── ConfigPreview.tsx   # Modal with rendered config (neu-inset code blocks)
    │   │   └── ConflictDialog.tsx  # Optimistic locking 409 handler
    │   │
    │   ├── players/
    │   │   ├── PlayerTable.tsx
    │   │   ├── KickPopover.tsx
    │   │   ├── BanPopover.tsx
    │   │   └── SayAllDialog.tsx
    │   │
    │   ├── logs/
    │   │   ├── LogViewer.tsx       # Virtualized log stream
    │   │   └── LogLine.tsx
    │   │
    │   ├── missions/
    │   │   ├── MissionTable.tsx
    │   │   ├── MissionUpload.tsx
    │   │   ├── RotationList.tsx
    │   │   └── RotationEntry.tsx
    │   │
    │   ├── mods/
    │   │   ├── ModTable.tsx
    │   │   ├── ServerModList.tsx
    │   │   └── ModRegistrationDialog.tsx
    │   │
    │   ├── bans/
    │   │   ├── BanTable.tsx
    │   │   └── BanFormDialog.tsx
    │   │
    │   └── charts/
    │       ├── MetricsChart.tsx    # CPU + RAM time series
    │       └── PlayerCountChart.tsx
    │
    ├── pages/
    │   ├── LoginPage.tsx
    │   ├── DashboardPage.tsx
    │   ├── ServerListPage.tsx
    │   ├── ServerDetailPage.tsx
    │   ├── ModsPage.tsx
    │   ├── BansPage.tsx
    │   ├── UsersPage.tsx
    │   └── SettingsPage.tsx
    │
    └── lib/
        ├── jsonSchemaToFields.ts   # JSON Schema → form field descriptors
        ├── formatUptime.ts         # Seconds → "2h 15m"
        ├── formatBytes.ts          # Bytes → human readable
        ├── timeAgo.ts              # Timestamp → "5 minutes ago"
        └── cn.ts                  # clsx + tailwind-merge utility

Key Component Contracts

ConfigForm — the hardest component. Must handle:

  • Dynamic rendering from JSON Schema (any game, any section)
  • Sensitive field masking
  • Dirty state tracking
  • Optimistic locking (send config_version on save)
  • 409 conflict resolution (show diff, allow override/merge)
  • Validation errors from adapter (field-level, from Pydantic)

LogViewer — performance-critical:

  • Virtualized rendering (react-window or similar)
  • Newest-first or oldest-first toggle
  • Level filter, text search
  • Auto-scroll with manual-override detection
  • Streams via WebSocket, paginated fallback via HTTP

ServerDetailPage — orchestrator:

  • Resolves adapter from server.game_type
  • Checks has_capability() to show/hide tabs
  • Manages WS subscriptions for the server
  • Handles status transitions in real-time (start → starting → running)

State Management Strategy

Server State (TanStack Query)

All API data uses TanStack Query with appropriate stale times:

Query staleTime refetchInterval
Server list 30s Background 30s (fallback for WS)
Server detail 15s
Config sections 5min — (manual save)
Players 0s WS-driven
Logs 0s WS-driven
Metrics 0s WS-driven
Missions 5min
Mods 5min
Bans 2min
Game types 30min — (rarely changes)
Config schema 30min — (tied to adapter version)

Optimistic updates on:

  • Server start/stop → immediately update status in cache, rollback on error
  • Player kick/ban → immediately remove from player list cache
  • Config save → immediately update config cache, rollback on 409 or error

Client State (Zustand)

Only two stores — keep it minimal:

authStore:

interface AuthState {
  token: string | null;
  user: { id: number; username: string; role: "admin" | "viewer" } | null;
  setAuth: (token: string, user: User) => void;
  logout: () => void;
  isAdmin: () => boolean;
}

wsStore:

interface WsState {
  status: "connected" | "reconnecting" | "disconnected";
  reconnectAttempts: number;
  lastEventAt: number | null;
}

URL State

Persisted in URL query params:

  • Server list filters (game_type, sort)
  • Log viewer filters (level, search)
  • Ban list filters (server, active_only)
  • Metrics chart time range (from, to, resolution)

WebSocket Integration

Connection Lifecycle

App Mount
  │
  ├── Connect to ws://localhost:8000/ws/all?token=<JWT>
  │     ├── On open: wsStore.status = "connected"
  │     ├── On close: wsStore.status = "disconnected"
  │     │     └── Auto-reconnect with exponential backoff (1s → 2s → 4s → ... → 30s)
  │     ├── On error: wsStore.status = "reconnecting"
  │     └── On message: dispatch to handlers
  │
  ├── Subscribe to: ["status", "event"]
  │
  └── On server detail navigation:
        └── Subscribe to: ["logs", "players", "metrics", "status", "event"]
            for that specific server_id

Message Dispatch

// hooks/useWebSocket.ts
function handleWsMessage(msg: WsMessage) {
  switch (msg.type) {
    case "status":
      queryClient.setQueryData(["servers", msg.server_id], (old) => ({
        ...old, status: msg.data.status, pid: msg.data.pid, started_at: msg.data.started_at,
      }));
      break;

    case "log":
      // Prepend to log cache (newest-first)
      queryClient.setQueryData(["servers", msg.server_id, "logs"], (old) => ({
        ...old, logs: [msg.data, ...old.logs],
      }));
      break;

    case "players":
      queryClient.setQueryData(["servers", msg.server_id, "players"], msg.data.players);
      break;

    case "metrics":
      queryClient.setQueryData(["servers", msg.server_id, "metrics"], (old) => ({
        ...old, current: msg.data,
      }));
      break;

    case "event":
      // Prepend to events cache + show toast for critical events
      if (msg.data.event_type === "crashed") {
        toast.error(`Server ${msg.server_id} crashed`);
      }
      break;
  }
}

Reconnection UX

  • StatusBar shows connection state at all times: green dot = connected, amber spinner = reconnecting, red X = disconnected.
  • During reconnection, all WS-driven data shows its last known value with a subtle "last updated X seconds ago" indicator.
  • On reconnect, TanStack Query invalidates all server data to get fresh state.
  • If JWT expires during WS connection, server closes the socket. Client detects 4xx on reconnect → redirect to login.

Adapter-Aware UI Patterns

The frontend never hardcodes game-specific logic. It queries adapter metadata and renders accordingly.

Capability Check Pattern

// hooks/useCapability.ts
function useCapability(serverId: number) {
  const { data: server } = useServerDetail(serverId);
  const { data: gameType } = useGameType(server?.game_type);

  return {
    hasMissions: gameType?.capabilities.includes("mission_manager") ?? false,
    hasMods: gameType?.capabilities.includes("mod_manager") ?? false,
    hasRemoteAdmin: gameType?.capabilities.includes("remote_admin") ?? false,
    hasBanManager: gameType?.capabilities.includes("ban_manager") ?? false,
  };
}

// Usage in ServerDetailPage:
const { hasMissions, hasMods, hasRemoteAdmin } = useCapability(serverId);
// Only render tabs that the adapter supports

Dynamic Config Form Pattern

// lib/jsonSchemaToFields.ts
interface FieldDescriptor {
  name: string;
  label: string;
  type: "text" | "number" | "boolean" | "select" | "textarea" | "password" | "array";
  default?: unknown;
  min?: number;
  max?: number;
  step?: number;
  enum?: string[];
  description?: string;
  isSensitive?: boolean;
}

function jsonSchemaToFields(
  schema: JsonSchema,
  sensitiveFields: string[]
): FieldDescriptor[] {
  // Walk schema.properties, map each to a FieldDescriptor
  // Mark fields in sensitiveFields as type: "password"
}

Game Type Card Pattern

// Used in NewServerDialog and Dashboard
<GameTypeCard
  gameType="arma3"
  displayName="Arma 3"
  capabilities={[...]}
  selected={selectedGame === "arma3"}
  onSelect={() => setSelectedGame("arma3")}
/>

Future adapters register themselves; the card list auto-populates from GET /games.


Error Handling UX

Scenario UI Response
API 401 Redirect to login with "Session expired" toast
API 403 "You don't have permission" inline message
API 404 Empty state component with "Not found"
API 409 (config conflict) ConflictDialog with diff + override/merge
API 422 (validation) Field-level red highlights + error messages
API 429 (rate limit) "Too many requests, try again in X seconds" toast
API 500 "Server error" toast with retry button
WS disconnect StatusBar indicator + stale data with timestamp
WS reconnect Automatic; no user action needed
Server crashed Toast notification + status dot turns red

No alert() calls. All feedback uses toast (transient) or inline (persistent) patterns.


Performance Budget

Metric Target
First Contentful Paint < 1.5s
Time to Interactive < 3s
Bundle size (gzipped) < 250KB JS
CSS size (gzipped) < 40KB
Log viewer render (1000 lines) < 16ms per frame
WebSocket message processing < 5ms per message

Techniques:

  • Vite code splitting per route (lazy React.lazy + Suspense)
  • Virtual list for log viewer (react-window)
  • TanStack Query deduplication (same query key = same request)
  • Tailwind purge for minimal CSS
  • Fonts: preload critical weights only (Inter 400/500/600, JetBrains Mono 400)
  • No icon font — Lucide is tree-shaken SVGs

Accessibility

  • Focus management: Modals trap focus. Close on Escape. Return focus to trigger.
  • Keyboard navigation: All interactive elements reachable via Tab. Action bar buttons have shortcuts (S=Start, X=Stop, R=Restart).
  • Color is not the only status indicator: Status LEDs are paired with text labels. Logs use prefix tags (INFO, WARN, ERROR), not just color.
  • Contrast: All text meets WCAG AA against its surface (4.5:1 minimum for body text).
  • Reduced motion: Respect prefers-reduced-motion — disable transitions when active.
  • ARIA: Live regions for WS status changes. aria-label on icon-only buttons.

Toast System

Transient notifications. Stack in bottom-right.

Type Use Duration
Success Config saved, server started, mod enabled 3s
Error API errors, WS disconnect, validation failures 7s (or dismiss)
Warning Rate limited, high CPU, approaching memory limit 5s
Info Ban synced to file, mission uploaded 3s

Max 3 visible at once. Oldest dismissed automatically.


Loading States

  • Route transitions: Suspense boundary with a minimal loading bar at the top of the content area (not a full-page spinner).
  • Data loading: Skeleton placeholders matching the layout shape of the loaded content. No spinners.
  • Actions: Button shows inline spinner for duration of request. Disabled during request.
  • Initial load: Dashboard shows skeleton cards → real data populates in place.

Empty States

Every list/table has a designed empty state:

Context Empty State
No servers "No servers yet" + [Create Server] button
No players "No players connected" (dimmed, no action needed)
No missions "No missions uploaded" + [Upload Mission] button
No mods registered "No mods registered" + [Register Mod] button
No bans "No active bans" (this is good news — no action needed)
No logs "No log entries yet — server may not have started"

Build & Dev Commands

# Development
npm run dev          # Vite dev server on :5173

# Production build
npm run build        # tsc + vite build
npm run preview      # Preview production build locally

# Quality
npm run lint         # ESLint
npm run format       # Prettier
npm run typecheck    # tsc --noEmit

globals.css (neumorphic classes)

The neumorphic shadow classes live in src/styles/globals.css and are referenced by Tailwind components via @apply or direct class names. They cannot be expressed as Tailwind utilities (multi-shadow syntax):

/* src/styles/globals.css — neumorphic primitives */

.neu-raised {
  background: var(--color-surface);
  border: none;
  border-radius: var(--radius-md);
  box-shadow:
    4px 4px 8px var(--neu-dark),
   -4px -4px 8px var(--neu-light);
  transition: box-shadow var(--duration-fast) var(--ease-out);
}

.neu-raised:active {
  box-shadow:
    inset 3px 3px 6px var(--neu-dark),
   inset -3px -3px 6px var(--neu-light);
}

.neu-inset {
  background: var(--color-base);
  border: none;
  border-radius: var(--radius-sm);
  box-shadow:
    inset 3px 3px 6px var(--neu-dark),
   inset -3px -3px 6px var(--neu-light);
}

.neu-flat {
  background: var(--color-elevated);
  border: none;
  border-radius: var(--radius-lg);
  box-shadow: 0 8px 32px oklch(0% 0 0 / 0.5);
}

.neu-raised-accent {
  background: var(--color-accent);
  color: #0d0d0d;
  border: none;
  border-radius: var(--radius-md);
  box-shadow:
    4px 4px 8px var(--neu-dark),
   -4px -4px 8px var(--neu-light),
    0 0 12px oklch(72% 0.15 80 / 0.2);
  transition: box-shadow var(--duration-fast) var(--ease-out);
}

.neu-raised-accent:hover {
  background: var(--color-accent-hover);
}

.neu-raised-accent:active {
  box-shadow:
    inset 3px 3px 6px var(--neu-dark),
   inset -3px -3px 6px var(--neu-light);
}

/* LED status indicators */
.led {
  width: 8px;
  height: 8px;
  border-radius: 9999px;
  display: inline-block;
}

.led-running {
  composes: led;
  background: var(--color-running);
  box-shadow: var(--glow-amber);
}

.led-crashed {
  composes: led;
  background: var(--color-crashed);
  box-shadow: var(--glow-red);
}

.led-stopped {
  composes: led;
  background: var(--color-stopped);
  box-shadow: none;
}

Vite Proxy (dev only)

// vite.config.ts
export default defineConfig({
  server: {
    proxy: {
      '/api': 'http://localhost:8000',
      '/ws': {
        target: 'ws://localhost:8000',
        ws: true,
      },
    },
  },
});

Frontend ↔ Backend Contract Summary

Frontend Action API Call WS Channel
View server list GET /servers
View server detail GET /servers/{id} Subscribe to server
Start server POST /servers/{id}/start status
Stop server POST /servers/{id}/stop status
View live logs GET /servers/{id}/logs (initial) log
View player list GET /servers/{id}/players (initial) players
View metrics GET /servers/{id}/metrics (initial) metrics
Edit config GET/PUT /servers/{id}/config/{section}
Preview config GET /servers/{id}/config/preview
Upload mission POST /servers/{id}/missions/upload
Manage rotation GET/PUT /servers/{id}/missions/rotation
Enable mods PUT /servers/{id}/mods
Kick player POST /servers/{id}/players/{slot}/kick players
Ban player POST /servers/{id}/players/{slot}/ban players
View events GET /servers/{id}/events event
Check capabilities GET /games/{type}
Get config schema GET /games/{type}/config-schema