diff --git a/CHANGELOG.md b/CHANGELOG.md
index 16f327e..0a20daf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
- Initial scaffold (stack proof): base stack copied/adapted from Flight-Ops (`../flight`) — Next.js 16 App Router + React 19 + TypeScript strict, bun package manager, Node 22 in CI/Docker.
- Single-player only by design: NO auth/accounts/multiplayer anywhere (Flight-Ops's users/sessions/admin/leaderboard patterns deliberately not copied).
- Frontend component layer: scificn-ui v0.1.0 integrated copy-paste (Radix + Tailwind v4, SCI-FI theme) — Button, Panel, Badge, Card, Alert in `components/ui/`; theme tokens in `app/globals.css` with a brightened `--text-muted` override (`#4A4A40` → `#6B6B5C`).
+- Interface base: the scificn-ui **Mission Control** showcase (https://www.scificn.dev/showcase/mission-control) rebuilt as the app's main UI — `app/page.tsx` renders `components/mission-control.tsx` (client dashboard: sticky header, stat-card row, 3-column grid of Panel/Tabs/StatusGrid/ProgressRing/BarChart/LineChart/Terminal panels, sticky footer), placeholder ship content only, no game logic; additional upstream components copied into `components/ui/` (Tabs, StatCard, Terminal, StatusGrid, ProgressRing, BarChart, LineChart) + `@radix-ui/react-tabs` dependency.
- DB plumbing: SQLite via Drizzle ORM + sql.js (WASM) at `db/space-wanderer.db`, volume-mounted; empty schema placeholder — game tables land with the first game systems.
- API pattern established: `/api/version` + OpenAPI 3.x spec generated in code (`lib/openapi.ts`) served at `/api/openapi`.
- CI/e2e/docker pipeline: single "build + unit + e2e" job on the dind runner, isolated stack `space-wanderer-ci` with static IP 172.39.0.10 (fresh DB volume per run), Playwright e2e (`smoke.spec.ts`, `state.spec.ts`) CI-only against that stack; prod compose behind Traefik at `https://space-wanderer.dev.kkhost.pl` (not deployed yet).
diff --git a/ORCHESTRATOR.md b/ORCHESTRATOR.md
index 933014b..46041de 100644
--- a/ORCHESTRATOR.md
+++ b/ORCHESTRATOR.md
@@ -46,7 +46,7 @@ Credentials are deliberately NOT stored in this file — see §1 for where they
## 7. App architecture quick map
- **Scaffolded (v0.1.0)** — stack proof only, no game systems yet; the rest of this section is what's built + what lands next.
- Next.js 16 App Router + React 19 + TypeScript strict, bun package manager (bun.lock), Node 22 in CI/Docker.
-- **Frontend component layer: scificn-ui v0.1.0** (https://scificn.dev) — copy-paste model on Radix + Tailwind v4; components live in `components/ui/` (Button, Panel, Badge, Card, Alert), theme tokens in `app/globals.css`. Deliberate deviation from upstream: `--text-muted` brightened `#4A4A40` → `#6B6B5C` (+12pt lightness) for readability on the near-black background. Upstream repo has **no LICENSE file** — user-approved risk, keep components minimal and re-verify licensing before any redistribution of this codebase.
+- **Frontend component layer: scificn-ui v0.1.0** (https://scificn.dev) — copy-paste model on Radix + Tailwind v4; components live in `components/ui/` (Button, Panel, Badge, Card, Alert, Tabs, StatCard, Terminal, StatusGrid, ProgressRing, BarChart, LineChart), theme tokens in `app/globals.css`. The app's interface base is the scificn-ui **Mission Control** showcase layout (https://www.scificn.dev/showcase/mission-control) rebuilt as `components/mission-control.tsx` and rendered from `app/page.tsx`, with placeholder ship content — no game logic yet. Deliberate deviation from upstream: `--text-muted` brightened `#4A4A40` → `#6B6B5C` (+12pt lightness) for readability on the near-black background. Upstream repo has **no LICENSE file** — user-approved risk, keep components minimal and re-verify licensing before any redistribution of this codebase.
- SQLite via Drizzle ORM + sql.js (WASM, kept external in next.config.ts) at db/space-wanderer.db volume-mounted; schema designed to switch to Postgres later. Schema is EMPTY at scaffold stage — first game tables land with the turn engine.
- Vitest unit tests colocated (`**/*.test.ts`); Playwright e2e in `e2e/`, CI-only.
- Server-authoritative TURN engine in lib/ — unlike Flight-Ops there is NO tick loop or sim clock: state advances only when a player action resolves a turn, and every action endpoint returns the full post-action snapshot which the client applies immediately (polling optional/later for multiplayer).
diff --git a/app/page.tsx b/app/page.tsx
index 5cfd162..658166b 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,81 +1,9 @@
-import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
-import { Badge } from '@/components/ui/badge';
-import { Button } from '@/components/ui/button';
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
-import { Panel, PanelContent, PanelFooter, PanelHeader } from '@/components/ui/panel';
-import pkg from '@/package.json';
+import { MissionControl } from '@/components/mission-control';
+
+export const metadata = {
+ title: 'SpaceWanderer',
+};
export default function Home() {
- return (
-
-
-
- SYS:ONLINE
-
- v{pkg.version} — STACK PROOF
-
-
-
-
- SpaceWanderer
-
-
- Turn-based space adventure — build & expand your ship from found parts, survive the void.
-
-
-
-
- Ship status
-
-
-
-
- HULL
- NOMINAL
-
-
- CREW
- —
-
-
- NEXT ENCOUNTER
- SCANNING...
-
-
-
-
-
-
- Scaffold online
-
- Base stack + scificn-ui frontend integrated. No game systems yet — single player only, by design.
-
-
-
-
- INITIATE
-
- SCIFICN/UI v0.1.0 · SCI-FI THEME
-
-
-
-
- );
+ return ;
}
diff --git a/bun.lock b/bun.lock
index d8465d6..2c4a8ee 100644
--- a/bun.lock
+++ b/bun.lock
@@ -6,6 +6,7 @@
"name": "space-wanderer",
"dependencies": {
"@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.2",
@@ -119,10 +120,38 @@
"@playwright/test": ["@playwright/test@1.63.0", "", { "dependencies": { "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ=="],
+ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="],
+
+ "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.15", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA=="],
+
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="],
+ "@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="],
+
+ "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg=="],
+
+ "@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="],
+
+ "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.10", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw=="],
+
+ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="],
+
+ "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ=="],
+
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="],
+ "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.21", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog=="],
+
+ "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="],
+
+ "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ=="],
+
+ "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.5", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg=="],
+
+ "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw=="],
+
+ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="],
+
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.11", "", { "os": "android", "cpu": "arm" }, "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.11", "", { "os": "android", "cpu": "arm64" }, "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA=="],
diff --git a/components/mission-control.tsx b/components/mission-control.tsx
new file mode 100644
index 0000000..bceb7f4
--- /dev/null
+++ b/components/mission-control.tsx
@@ -0,0 +1,653 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { Badge } from '@/components/ui/badge'
+import { BarChart } from '@/components/ui/bar-chart'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { LineChart } from '@/components/ui/line-chart'
+import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel'
+import { ProgressRing } from '@/components/ui/progress-ring'
+import { StatCard } from '@/components/ui/stat-card'
+import { StatusGrid, type SystemEntry } from '@/components/ui/status-grid'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { Terminal, type TerminalLine } from '@/components/ui/terminal'
+
+// Interface base: layout + visual treatment rebuilt from the scificn-ui Mission Control
+// showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui), with
+// placeholder SpaceWanderer content — no game logic yet. Single player, no auth, by design.
+
+function useNarrow(threshold = 900): boolean {
+ const [narrow, setNarrow] = useState(
+ () => typeof window !== 'undefined' && window.innerWidth < threshold
+ )
+ useEffect(() => {
+ const handle = () => setNarrow(window.innerWidth < threshold)
+ window.addEventListener('resize', handle, { passive: true })
+ return () => window.removeEventListener('resize', handle)
+ }, [threshold])
+ return narrow
+}
+
+function now(): string {
+ return new Date().toLocaleTimeString('en-GB', { hour12: false })
+}
+
+// ── Placeholder content (no game logic — real values land with the turn engine) ──
+
+const shipSystems: SystemEntry[] = [
+ { name: 'REACTOR', status: 'ACTIVE', detail: '94.2%' },
+ { name: 'LIFE SUPPORT', status: 'ACTIVE' },
+ { name: 'NAV COMPUTER', status: 'SCANNING' },
+ { name: 'WEAPONS', status: 'OFFLINE' },
+ { name: 'COMMS', status: 'ACTIVE' },
+]
+
+const bayOutput = [
+ { label: 'PWR', value: 14 },
+ { label: 'NAV', value: 9 },
+ { label: 'COM', value: 6 },
+ { label: 'WPN', value: 3 },
+ { label: 'LSS', value: 12 },
+ { label: 'ENG', value: 11 },
+ { label: 'BAY', value: 7 },
+ { label: 'MED', value: 5 },
+]
+
+const hullTrack = [100, 98, 97, 96, 94, 95, 92, 90, 89, 87]
+const hullLabels = hullTrack.map((_, i) => `T-${hullTrack.length - 1 - i}`)
+
+const encounters = [
+ { name: 'DERELICT FREIGHTER', when: 'T-07:14', id: '#E-102' },
+ { name: 'PIRATE SKIRMISH', when: 'T-22:40', id: '#E-103' },
+ { name: 'ANOMALY FIELD', when: 'T-48:12', id: '#E-104' },
+]
+
+const shipData = [
+ { label: 'TOTAL MODULES', value: '34' },
+ { label: 'HULL INTEGRITY', value: '87%' },
+ { label: 'REACTOR OUTPUT', value: '94.2%' },
+ { label: 'CREW ACTIVE', value: '3 / 6' },
+ { label: 'SECTOR', value: '7G // DRIFT LANE' },
+ { label: 'DRIFT VELOCITY', value: '0.4 KM/S' },
+ { label: 'TURN', value: '12' },
+]
+
+const logLines: TerminalLine[] = [
+ { type: 'system', text: 'SPACEWANDERER // INITIALIZING SHIP SYSTEMS...', timestamp: now() },
+ { type: 'output', text: 'HULL SCAN COMPLETE — INTEGRITY 87%', timestamp: now() },
+ { type: 'output', text: 'REACTOR OUTPUT STABLE AT 94.2%', timestamp: now() },
+ { type: 'output', text: 'CREW MANIFEST LOADED — 3 ACTIVE PERSONNEL', timestamp: now() },
+ { type: 'warn', text: 'WEAPONS OFFLINE — MODULE NOT INSTALLED', timestamp: now() },
+ { type: 'system', text: 'ALL SYSTEMS NOMINAL. AWAITING FIRST TURN.', timestamp: now() },
+]
+
+export function MissionControl() {
+ const narrow = useNarrow()
+ const [tick, setTick] = useState(0)
+
+ useEffect(() => {
+ const id = setInterval(() => setTick((t) => t + 1), 1000)
+ return () => clearInterval(id)
+ }, [])
+
+ const timeStr = new Date().toLocaleTimeString('en-GB', { hour12: false })
+
+ return (
+
+ {/* ── HEADER ── */}
+
+
+ ◄ SHIP
+
+
+ │
+
+
+ SPACEWANDERER
+
+
+ {!narrow && (
+
+ // SHIP STATUS // CREW MANIFEST
+
+ )}
+
+
+
+ {!narrow && (
+ SHIP NOMINAL
+ )}
+
+ SECTOR SCAN
+
+
+ {timeStr}
+
+
+
+ {/* ── BODY ── */}
+
+ {/* STAT CARDS — placeholder ship readouts */}
+
+
+
+
+
+
+
+ {/* 3-COLUMN GRID */}
+
+ {/* ── LEFT: SHIP SYSTEMS + VESSEL METRICS ── */}
+
+
+
+ SHIP SYSTEMS
+
+ 4 ONLINE
+
+
+
+
+
+
+
+
+ VESSEL METRICS
+
+
+
+
+
+ {[
+ { label: 'HULL INTEGRITY', note: 'last scan T-02' },
+ { label: 'REACTOR OUTPUT', note: 'idle cycle' },
+ { label: 'CREW ROSTER', note: 'of 6 berths' },
+ ].map((item) => (
+
+
+ {item.label}
+
+
+ {item.note}
+
+
+ ))}
+
+
+
+
+
+ {/* ── CENTER: OPERATIONS TABS ── */}
+
+
+
+ OPERATIONS // SHIP COMMAND
+
+ SYS NOMINAL
+
+
+
+
+
+
+ SYSTEMS
+ NEXT MISSION
+ LOG
+ HULL TRACK
+
+
+ {/* SYSTEMS TAB */}
+
+
+
+ {[
+ { label: 'TOTAL MODULES', value: 34 },
+ { label: 'ONLINE', value: 29 },
+ { label: 'EFFICIENCY', value: '85%' },
+ ].map((s) => (
+
+
+ {s.label}
+
+
+ {s.value}
+
+
+ ))}
+
+
+
+ {/* NEXT MISSION TAB */}
+
+
+
+ ENCOUNTER #E-102 — DERELICT FREIGHTER
+
+ Placeholder briefing. Real encounter resolution lands with the turn engine.
+
+
+
+
+ {[
+ { label: 'DESTINATION', value: 'SECTOR 7G // DRIFT LANE' },
+ { label: 'ETA', value: 'T-07:14 (PLACEHOLDER)' },
+ { label: 'THREAT LEVEL', value: 'LOW — UNMANNED' },
+ { label: 'CARGO MANIFEST', value: 'UNKNOWN' },
+ ].map((item) => (
+
+
+ {item.label}
+
+
+ {item.value}
+
+
+ ))}
+
+
+
+
+ {/* LOG TAB */}
+
+
+
+
+ {/* HULL TRACK TAB */}
+
+
+
+
+
+
+
+
+ {/* ── RIGHT: UPCOMING ENCOUNTERS + SHIP DATA ── */}
+
+
+
+ UPCOMING ENCOUNTERS
+
+ {encounters.length} QUEUED
+
+
+
+
+ {encounters.map((enc, i) => {
+ const isNext = i === 0
+ const isLast = i === encounters.length - 1
+ return (
+
+
+
+
+ {enc.name}
+
+
+ {enc.when} // {enc.id}
+
+
+
+ {isNext ? 'NEXT' : 'SCHED'}
+
+
+
+ )
+ })}
+
+
+
+
+
+
+ SHIP DATA
+
+
+
+ {shipData.map((item, i) => (
+
+
+ {item.label}
+
+
+ {item.value}
+
+
+ ))}
+
+
+
+
+
+
+
+ {/* ── FOOTER ── */}
+
+
+ INTERFACE BASE: SCIFICN MISSION CONTROL
+
+ │
+
+ CONTENT: PLACEHOLDER — NO GAME LOGIC YET
+
+ │
+
+ SINGLE PLAYER // NO AUTH
+
+
+
+
+ SYS ONLINE
+
+
+ )
+}
diff --git a/components/ui/bar-chart.tsx b/components/ui/bar-chart.tsx
new file mode 100644
index 0000000..5c578ff
--- /dev/null
+++ b/components/ui/bar-chart.tsx
@@ -0,0 +1,257 @@
+import * as React from 'react'
+import { cn } from '@/lib/utils'
+
+export type BarChartVariant = 'DEFAULT' | 'ACTIVE' | 'WARNING' | 'CRITICAL'
+
+export interface BarChartEntry {
+ label: string
+ value: number
+}
+
+export interface BarChartProps extends React.HTMLAttributes {
+ data: BarChartEntry[]
+ orientation?: 'horizontal' | 'vertical'
+ variant?: BarChartVariant
+ title?: string
+ showValues?: boolean
+ /** Override the max value for scaling. Defaults to max(data). */
+ max?: number
+}
+
+const VERTICAL_CHART_HEIGHT = 144 // px — 9rem
+
+const BarChart = React.forwardRef(
+ (
+ {
+ className,
+ data,
+ orientation = 'horizontal',
+ variant = 'ACTIVE',
+ title,
+ showValues = true,
+ max,
+ style,
+ ...props
+ },
+ ref
+ ) => {
+ const computedMax = max ?? Math.max(...data.map((d) => d.value), 1)
+
+ const accentColor =
+ variant === 'ACTIVE' ? 'var(--color-green)' :
+ variant === 'WARNING' ? 'var(--color-amber)' :
+ variant === 'CRITICAL' ? 'var(--color-red)' :
+ 'var(--text-secondary)'
+
+ const accentGlow =
+ variant === 'ACTIVE' ? 'var(--glow-green)' :
+ variant === 'WARNING' ? 'var(--glow-amber)' :
+ variant === 'CRITICAL' ? 'var(--glow-red)' :
+ 'none'
+
+ return (
+
+ {/* Title bar */}
+ {title && (
+
+ {title}
+
+ )}
+
+ {orientation === 'horizontal' ? (
+ /* ── Horizontal bars ── */
+
+ {data.map((entry, i) => {
+ const pct = Math.max((entry.value / computedMax) * 100, 0)
+ return (
+
+ {/* Label */}
+
+ {entry.label}
+
+
+ {/* Track */}
+
+
+ {/* Value */}
+ {showValues && (
+
+ {entry.value}
+
+ )}
+
+ )
+ })}
+
+ ) : (
+ /* ── Vertical bars ── */
+
+ {/* Bar area */}
+
+ {data.map((entry, i) => {
+ const pct = Math.max((entry.value / computedMax) * 100, 0)
+ const barPx = Math.round((pct / 100) * VERTICAL_CHART_HEIGHT)
+
+ return (
+
+ {/* Value above bar */}
+ {showValues && (
+
+ {entry.value}
+
+ )}
+
+ {/* Bar */}
+
0 ? barPx : 0,
+ minHeight: entry.value > 0 ? 2 : 0,
+ background: accentColor,
+ boxShadow: accentGlow,
+ opacity: 0.85,
+ flexShrink: 0,
+ transition: 'height 0.4s ease',
+ }}
+ />
+
+ )
+ })}
+
+
+ {/* Labels row */}
+
+ {data.map((entry, i) => (
+
+ {entry.label}
+
+ ))}
+
+
+ )}
+
+ )
+ }
+)
+BarChart.displayName = 'BarChart'
+
+export { BarChart }
diff --git a/components/ui/line-chart.tsx b/components/ui/line-chart.tsx
new file mode 100644
index 0000000..66f62e2
--- /dev/null
+++ b/components/ui/line-chart.tsx
@@ -0,0 +1,312 @@
+'use client'
+
+import * as React from 'react'
+import { cn } from '@/lib/utils'
+
+export type LineChartVariant = 'DEFAULT' | 'ACTIVE' | 'WARNING' | 'CRITICAL'
+
+export interface LineChartSeries {
+ id: string
+ label: string
+ data: number[]
+ variant?: LineChartVariant
+}
+
+export interface LineChartProps extends React.HTMLAttributes
{
+ series: LineChartSeries[]
+ labels: string[]
+ title?: string
+ height?: number
+ min?: number
+ max?: number
+ showDots?: boolean
+ showArea?: boolean
+ showGrid?: boolean
+ showLegend?: boolean
+ animated?: boolean
+}
+
+const LEFT = 40
+const RIGHT = 8
+const TOP = 8
+const BOTTOM = 24
+
+const SERIES_COLORS = [
+ 'var(--color-green)',
+ 'var(--color-amber)',
+ 'var(--color-red)',
+ 'var(--color-blue)',
+]
+
+const VARIANT_COLORS: Record = {
+ DEFAULT: 'var(--text-secondary)',
+ ACTIVE: 'var(--color-green)',
+ WARNING: 'var(--color-amber)',
+ CRITICAL: 'var(--color-red)',
+}
+
+function seriesColor(s: LineChartSeries, idx: number): string {
+ if (s.variant) return VARIANT_COLORS[s.variant]
+ return SERIES_COLORS[idx % SERIES_COLORS.length]
+}
+
+const LineChart = React.forwardRef(
+ (
+ {
+ className,
+ series,
+ labels,
+ title,
+ height = 160,
+ min,
+ max,
+ showDots = true,
+ showArea = false,
+ showGrid = true,
+ showLegend,
+ animated = true,
+ style,
+ ...props
+ },
+ ref
+ ) => {
+ const containerRef = React.useRef(null)
+ const [svgW, setSvgW] = React.useState(500)
+ const [drawn, setDrawn] = React.useState(false)
+
+ React.useEffect(() => {
+ if (!containerRef.current) return
+ const ro = new ResizeObserver(entries => {
+ setSvgW(entries[0].contentRect.width)
+ })
+ ro.observe(containerRef.current)
+ return () => ro.disconnect()
+ }, [])
+
+ React.useEffect(() => {
+ const id = requestAnimationFrame(() => setDrawn(true))
+ return () => cancelAnimationFrame(id)
+ }, [])
+
+ const allValues = series.flatMap(s => s.data)
+ const dataMin = allValues.length > 0 ? Math.min(...allValues) : 0
+ const dataMax = allValues.length > 0 ? Math.max(...allValues) : 100
+ const minV = min ?? dataMin
+ const maxV = max ?? dataMax
+ const range = Math.max(maxV - minV, 1)
+
+ const plotW = svgW - LEFT - RIGHT
+ const plotH = height - TOP - BOTTOM
+ const n = labels.length
+
+ function xPos(i: number) {
+ return LEFT + (n <= 1 ? plotW / 2 : (i / (n - 1)) * plotW)
+ }
+
+ function yPos(v: number) {
+ return TOP + (1 - (v - minV) / range) * plotH
+ }
+
+ function buildLinePath(data: number[]): string {
+ return data
+ .map((v, i) => `${i === 0 ? 'M' : 'L'} ${xPos(i).toFixed(1)} ${yPos(v).toFixed(1)}`)
+ .join(' ')
+ }
+
+ function buildAreaPath(data: number[]): string {
+ if (data.length === 0) return ''
+ const line = buildLinePath(data)
+ const firstX = xPos(0).toFixed(1)
+ const lastX = xPos(data.length - 1).toFixed(1)
+ const bottomY = (TOP + plotH).toFixed(1)
+ return `${line} L ${lastX} ${bottomY} L ${firstX} ${bottomY} Z`
+ }
+
+ const gridFracs = [0, 1 / 3, 2 / 3, 1]
+ const showLeg = showLegend ?? series.length > 1
+ const step = Math.max(1, Math.ceil(n / 8))
+
+ const shouldAnimate = animated
+
+ return (
+
+ {title && (
+
+ {title}
+
+ )}
+
+
+
+ {/* Grid */}
+ {showGrid && gridFracs.map((f, gi) => {
+ const y = TOP + f * plotH
+ const val = minV + (1 - f) * range
+ return (
+
+
+
+ {val.toFixed(0)}
+
+
+ )
+ })}
+
+ {/* X-axis labels */}
+ {labels.map((label, i) => {
+ if (i % step !== 0 && i !== n - 1) return null
+ return (
+
+ {label}
+
+ )
+ })}
+
+ {/* Series */}
+ {series.map((s, si) => {
+ if (s.data.length === 0) return null
+ const color = seriesColor(s, si)
+ const linePath = buildLinePath(s.data)
+ const areaPath = buildAreaPath(s.data)
+
+ return (
+
+ {/* Area fill */}
+ {showArea && (
+
+ )}
+
+ {/* Glow layer */}
+
+
+ {/* Main line */}
+
+
+ {/* Dots */}
+ {showDots && s.data.map((v, i) => (
+
+ ))}
+
+ )
+ })}
+
+
+
+ {/* Legend */}
+ {showLeg && (
+
+ {series.map((s, si) => {
+ const color = seriesColor(s, si)
+ return (
+
+ )
+ })}
+
+ )}
+
+ )
+ }
+)
+LineChart.displayName = 'LineChart'
+
+export { LineChart }
diff --git a/components/ui/progress-ring.tsx b/components/ui/progress-ring.tsx
new file mode 100644
index 0000000..1b00352
--- /dev/null
+++ b/components/ui/progress-ring.tsx
@@ -0,0 +1,158 @@
+import * as React from 'react'
+import { cn } from '@/lib/utils'
+
+export type ProgressRingVariant = 'DEFAULT' | 'ACTIVE' | 'WARNING' | 'CRITICAL'
+
+export interface ProgressRingProps extends React.HTMLAttributes {
+ value: number
+ size?: number
+ strokeWidth?: number
+ label?: string
+ showValue?: boolean
+ variant?: ProgressRingVariant
+}
+
+const ProgressRing = React.forwardRef(
+ (
+ {
+ className,
+ value = 0,
+ size = 120,
+ strokeWidth = 6,
+ label,
+ showValue = true,
+ variant = 'DEFAULT',
+ style,
+ ...props
+ },
+ ref
+ ) => {
+ const pct = Math.min(Math.max(value, 0), 100)
+ const cx = size / 2
+ const cy = size / 2
+ const r = (size - strokeWidth) / 2
+ const circ = 2 * Math.PI * r
+ const offset = circ * (1 - pct / 100)
+
+ const accentColor =
+ variant === 'ACTIVE' ? 'var(--color-green)' :
+ variant === 'WARNING' ? 'var(--color-amber)' :
+ variant === 'CRITICAL' ? 'var(--color-red)' :
+ 'var(--text-secondary)'
+
+ const textGlow =
+ variant === 'ACTIVE' ? 'var(--text-glow-green)' :
+ variant === 'WARNING' ? 'var(--text-glow-amber)' :
+ variant === 'CRITICAL' ? 'var(--text-glow-red)' :
+ 'none'
+
+ const isSmall = size < 80
+
+ return (
+
+
+ {/* Track ring */}
+
+
+ {/* Glow layer — wider, low opacity */}
+ {pct > 0 && (
+
+ )}
+
+ {/* Main arc */}
+
+
+
+ {/* Center label */}
+
+ {showValue && (
+
+ {Math.round(pct)}%
+
+ )}
+ {label && (
+
+ {label}
+
+ )}
+
+
+ )
+ }
+)
+ProgressRing.displayName = 'ProgressRing'
+
+export { ProgressRing }
diff --git a/components/ui/stat-card.tsx b/components/ui/stat-card.tsx
new file mode 100644
index 0000000..daddc40
--- /dev/null
+++ b/components/ui/stat-card.tsx
@@ -0,0 +1,146 @@
+import * as React from 'react'
+import { cva, type VariantProps } from 'class-variance-authority'
+import { cn } from '@/lib/utils'
+
+const statCardVariants = cva(
+ [
+ 'relative flex flex-col font-mono',
+ 'border border-solid rounded-none',
+ ],
+ {
+ variants: {
+ variant: {
+ DEFAULT: ['border-[var(--border)]'],
+ ACTIVE: ['border-[var(--color-green)]'],
+ WARNING: ['border-[var(--color-amber)]'],
+ CRITICAL: ['border-[var(--color-red)]'],
+ },
+ },
+ defaultVariants: { variant: 'DEFAULT' },
+ }
+)
+
+export interface StatCardProps
+ extends React.HTMLAttributes,
+ VariantProps {
+ label: string
+ value: string | number
+ /** Delta text, e.g. "+2.3%" */
+ delta?: string
+ /** true = positive (▲ green), false = negative (▼ red), undefined = neutral */
+ deltaPositive?: boolean
+ /** Small status line shown at the bottom of the card */
+ sublabel?: string
+}
+
+const StatCard = React.forwardRef(
+ (
+ {
+ className,
+ variant = 'DEFAULT',
+ label,
+ value,
+ delta,
+ deltaPositive,
+ sublabel,
+ style,
+ ...props
+ },
+ ref
+ ) => {
+ const accentColor =
+ variant === 'ACTIVE' ? 'var(--color-green)' :
+ variant === 'WARNING' ? 'var(--color-amber)' :
+ variant === 'CRITICAL' ? 'var(--color-red)' :
+ 'var(--text-secondary)'
+
+ const accentGlow =
+ variant === 'ACTIVE' ? 'var(--text-glow-green)' :
+ variant === 'WARNING' ? 'var(--text-glow-amber)' :
+ variant === 'CRITICAL' ? 'var(--text-glow-red)' :
+ 'none'
+
+ const deltaColor =
+ deltaPositive === true ? 'var(--color-green)' :
+ deltaPositive === false ? 'var(--color-red)' :
+ 'var(--text-muted)'
+
+ const deltaSymbol =
+ deltaPositive === true ? '▲ ' :
+ deltaPositive === false ? '▼ ' :
+ '— '
+
+ return (
+
+ {/* Label */}
+
+ {label}
+
+
+ {/* Value */}
+
+ {value}
+
+
+ {/* Delta */}
+ {delta !== undefined && (
+
+ {deltaSymbol}{delta}
+
+ )}
+
+ {/* Sublabel */}
+ {sublabel && (
+
+ {sublabel}
+
+ )}
+
+ )
+ }
+)
+StatCard.displayName = 'StatCard'
+
+export { StatCard, statCardVariants }
diff --git a/components/ui/status-grid.tsx b/components/ui/status-grid.tsx
new file mode 100644
index 0000000..035d3ba
--- /dev/null
+++ b/components/ui/status-grid.tsx
@@ -0,0 +1,158 @@
+import * as React from 'react'
+import { cn } from '@/lib/utils'
+
+export type SystemStatus = 'ACTIVE' | 'OFFLINE' | 'WARNING' | 'CRITICAL' | 'SCANNING'
+
+export interface SystemEntry {
+ name: string
+ status: SystemStatus
+ /** Optional secondary text shown beside the name */
+ detail?: string
+}
+
+export interface StatusGridProps extends React.HTMLAttributes {
+ systems: SystemEntry[]
+ title?: string
+ columns?: 1 | 2 | 3
+}
+
+function statusColor(s: SystemStatus): string {
+ switch (s) {
+ case 'ACTIVE': return 'var(--color-green)'
+ case 'OFFLINE': return 'var(--text-muted)'
+ case 'WARNING': return 'var(--color-amber)'
+ case 'CRITICAL': return 'var(--color-red)'
+ case 'SCANNING': return 'var(--color-green)'
+ }
+}
+
+function statusGlow(s: SystemStatus): string {
+ switch (s) {
+ case 'ACTIVE': return 'var(--glow-green)'
+ case 'SCANNING': return 'var(--glow-green)'
+ case 'WARNING': return 'var(--glow-amber)'
+ case 'CRITICAL': return 'var(--glow-red)'
+ default: return 'none'
+ }
+}
+
+const StatusGrid = React.forwardRef(
+ ({ className, systems, title, columns = 1, style, ...props }, ref) => {
+ return (
+
+ {/* Header */}
+ {title && (
+
+ {title}
+
+ )}
+
+ {/* Grid of rows */}
+
+ {systems.map((system, i) => {
+ const color = statusColor(system.status)
+ const glow = statusGlow(system.status)
+ const isScanning = system.status === 'SCANNING'
+
+ // border logic: no bottom border on last row(s), no right on last column
+ const totalRows = Math.ceil(systems.length / columns)
+ const rowIndex = Math.floor(i / columns)
+ const colIndex = i % columns
+ const isLastRow = rowIndex === totalRows - 1
+ const isLastCol = colIndex === columns - 1
+
+ return (
+
+ {/* Name + optional detail */}
+
+ {system.name}
+ {system.detail && (
+
+ {system.detail}
+
+ )}
+
+
+ {/* Status dot + label */}
+
+
+
+ {system.status}
+
+
+
+ )
+ })}
+
+
+ )
+ }
+)
+StatusGrid.displayName = 'StatusGrid'
+
+export { StatusGrid }
diff --git a/components/ui/tabs.tsx b/components/ui/tabs.tsx
new file mode 100644
index 0000000..6a6a0b9
--- /dev/null
+++ b/components/ui/tabs.tsx
@@ -0,0 +1,72 @@
+import * as React from 'react'
+import * as TabsPrimitive from '@radix-ui/react-tabs'
+import { cn } from '@/lib/utils'
+
+const Tabs = TabsPrimitive.Root
+const TabsGroup = TabsPrimitive.List // alias
+
+/* ── List ── */
+const TabsList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+TabsList.displayName = TabsPrimitive.List.displayName
+
+/* ── Trigger ── */
+const TabsTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
+
+/* ── Content ── */
+const TabsContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+TabsContent.displayName = TabsPrimitive.Content.displayName
+
+export { Tabs, TabsGroup, TabsList, TabsTrigger, TabsContent }
diff --git a/components/ui/terminal.tsx b/components/ui/terminal.tsx
new file mode 100644
index 0000000..b5a8f7f
--- /dev/null
+++ b/components/ui/terminal.tsx
@@ -0,0 +1,187 @@
+'use client'
+
+import * as React from 'react'
+import { cn } from '@/lib/utils'
+
+export type TerminalLineType = 'input' | 'output' | 'warn' | 'error' | 'system'
+
+export interface TerminalLine {
+ type?: TerminalLineType
+ text: string
+ timestamp?: string
+}
+
+export interface TerminalProps extends React.HTMLAttributes {
+ lines?: TerminalLine[]
+ prompt?: string
+ title?: string
+ height?: string | number
+ blinkCursor?: boolean
+}
+
+function lineColor(type: TerminalLineType = 'output'): string {
+ switch (type) {
+ case 'input': return 'var(--color-green)'
+ case 'output': return 'var(--text-secondary)'
+ case 'warn': return 'var(--color-amber)'
+ case 'error': return 'var(--color-red)'
+ case 'system': return 'var(--text-muted)'
+ }
+}
+
+function linePrefix(type: TerminalLineType = 'output'): string {
+ switch (type) {
+ case 'input': return '>'
+ case 'output': return ' '
+ case 'warn': return '⚠'
+ case 'error': return '✕'
+ case 'system': return '#'
+ }
+}
+
+const Terminal = React.forwardRef(
+ (
+ {
+ className,
+ lines = [],
+ prompt = '>',
+ title = 'TERMINAL',
+ height = '16rem',
+ blinkCursor = true,
+ style,
+ ...props
+ },
+ ref
+ ) => {
+ const bodyRef = React.useRef(null)
+
+ React.useEffect(() => {
+ if (bodyRef.current) {
+ bodyRef.current.scrollTop = bodyRef.current.scrollHeight
+ }
+ }, [lines])
+
+ return (
+
+ {/* Title bar */}
+
+
+ {title}
+
+ {/* Status dot */}
+
+
+
+ {/* Output body */}
+
+ {lines.map((line, i) => (
+
+ {line.timestamp && (
+
+ {line.timestamp}
+
+ )}
+
+ {linePrefix(line.type)}
+
+ {line.text}
+
+ ))}
+
+ {/* Blinking cursor */}
+ {blinkCursor && (
+
+ {prompt}
+
+
+ )}
+
+
+ )
+ }
+)
+Terminal.displayName = 'Terminal'
+
+export { Terminal }
diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts
index 4489427..283d3f5 100644
--- a/e2e/smoke.spec.ts
+++ b/e2e/smoke.spec.ts
@@ -5,11 +5,18 @@ test('landing page loads with the SpaceWanderer title', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
});
-test('scificn-ui themed components render on the landing page', async ({ page }) => {
+test('mission control layout renders scificn-ui components', async ({ page }) => {
await page.goto('/');
- // Panel header badge + footer button prove the copy-paste component layer is wired in.
- await expect(page.getByText('SYS:ONLINE')).toBeVisible();
- await expect(page.getByRole('button', { name: 'INITIATE' })).toBeVisible();
+ // Stat card, tab trigger and footer badge prove the copied component layer is wired in.
+ await expect(page.getByText('NEXT ENCOUNTER')).toBeVisible();
+ await expect(page.getByRole('button', { name: 'SYSTEMS' })).toBeVisible();
+ await expect(page.getByText('SYS ONLINE')).toBeVisible();
+});
+
+test('operations tabs switch panels client-side', async ({ page }) => {
+ await page.goto('/');
+ await page.getByRole('button', { name: 'LOG' }).click();
+ await expect(page.getByText('SHIP LOG // PLACEHOLDER FEED')).toBeVisible();
});
test('GET /api/version reports the app version as JSON', async ({ page }) => {
diff --git a/package.json b/package.json
index ac4de78..45b1db7 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
},
"dependencies": {
"@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.2",