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. - - -
- - - - 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 ( +
+
+ + {s.label} + +
+ ) + })} +
+ )} +
+ ) + } +) +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 ( +
+ + + {/* 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",