Compare commits
4
Commits
v0.1.0
...
ui-game-tabs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9faf0f3a33 | ||
|
|
4be04a779a | ||
|
|
5291e0fa6e | ||
|
|
e79015b15c |
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.2.0 — 2026-09-28 19:19
|
||||||
|
- Game interface template (visual only, zero game logic): the operations panel in `components/mission-control.tsx` is now seven tabs — WORLD MAP (NodeGraph of 10 placeholder solar systems linked by jump routes), LOCAL SYSTEM (NodeGraph of star + planets/moons plus an explorable-worlds list), CREW (crew cards with name/role and good/bad trait placeholders), SHIP SYSTEMS (system cards with inert EXPAND / UPGRADE / BUILD NEW affordances), SKILL TREE (tech cards unlocking new system builds/upgrades), MISSIONS (previous NEXT MISSION content), EVENT LOG (ship log terminal). All data is local placeholder constants.
|
||||||
|
- Left column: VESSEL METRICS progress rings replaced with scificn Progress bracket bars; SHIP SYSTEMS status panel, header stat cards and right column unchanged.
|
||||||
|
- New scificn-ui components copied into `components/ui/`: NodeGraph, Progress (+ `@radix-ui/react-progress` dependency).
|
||||||
|
|
||||||
## v0.1.0 — 2026-09-28 15:47
|
## v0.1.0 — 2026-09-28 15:47
|
||||||
- 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.
|
- 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).
|
- Single-player only by design: NO auth/accounts/multiplayer anywhere (Flight-Ops's users/sessions/admin/leaderboard patterns deliberately not copied).
|
||||||
|
|||||||
+1
-1
@@ -46,7 +46,7 @@ Credentials are deliberately NOT stored in this file — see §1 for where they
|
|||||||
## 7. App architecture quick map
|
## 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.
|
- **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.
|
- 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, 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.
|
- **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, NodeGraph, Progress), 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. As of v0.2.0 the operations panel is a 7-tab visual template (WORLD MAP, LOCAL SYSTEM, CREW, SHIP SYSTEMS, SKILL TREE, MISSIONS, EVENT LOG) built on NodeGraph + Card widgets; all data are local placeholder constants until real game systems land. 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.
|
- 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.
|
- 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).
|
- 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).
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "space-wanderer",
|
"name": "space-wanderer",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -136,6 +137,8 @@
|
|||||||
|
|
||||||
"@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-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-progress": ["@radix-ui/react-progress@1.1.16", "", { "dependencies": { "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10" }, "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-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg=="],
|
||||||
|
|
||||||
"@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-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-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=="],
|
||||||
|
|||||||
+301
-135
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { BarChart } from '@/components/ui/bar-chart'
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
import { LineChart } from '@/components/ui/line-chart'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { NodeGraph, type NodeGraphEdge, type NodeGraphNode } from '@/components/ui/node-graph'
|
||||||
import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel'
|
import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel'
|
||||||
import { ProgressRing } from '@/components/ui/progress-ring'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { StatCard } from '@/components/ui/stat-card'
|
import { StatCard } from '@/components/ui/stat-card'
|
||||||
import { StatusGrid, type SystemEntry } from '@/components/ui/status-grid'
|
import { StatusGrid, type SystemEntry } from '@/components/ui/status-grid'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
@@ -42,19 +43,82 @@ const shipSystems: SystemEntry[] = [
|
|||||||
{ name: 'COMMS', status: 'ACTIVE' },
|
{ name: 'COMMS', status: 'ACTIVE' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const bayOutput = [
|
// WORLD MAP — every node is a solar system, edges are jump routes.
|
||||||
{ label: 'PWR', value: 14 },
|
const worldMapNodes: NodeGraphNode[] = [
|
||||||
{ label: 'NAV', value: 9 },
|
{ id: 'kepler', label: 'Kepler Drift', x: 50, y: 48, status: 'ACTIVE', sublabel: 'CURRENT' },
|
||||||
{ label: 'COM', value: 6 },
|
{ id: 'vesper', label: 'Vesper Reach', x: 22, y: 26, status: 'NEUTRAL', sublabel: '3 WORLDS' },
|
||||||
{ label: 'WPN', value: 3 },
|
{ id: 'hollow', label: 'Hollow Gate', x: 74, y: 20, status: 'NEUTRAL', sublabel: '5 WORLDS' },
|
||||||
{ label: 'LSS', value: 12 },
|
{ id: 'cinder', label: 'Cinder Field', x: 86, y: 56, status: 'WARNING', sublabel: 'RADIATION' },
|
||||||
{ label: 'ENG', value: 11 },
|
{ id: 'lantern', label: 'Lantern Void', x: 30, y: 74, status: 'NEUTRAL', sublabel: '2 WORLDS' },
|
||||||
{ label: 'BAY', value: 7 },
|
{ id: 'sable', label: 'Sable Reach', x: 8, y: 46, status: 'OFFLINE', sublabel: 'UNKNOWN' },
|
||||||
{ label: 'MED', value: 5 },
|
{ id: 'orpheus', label: 'Orpheus Deep', x: 52, y: 10, status: 'NEUTRAL', sublabel: '7 WORLDS' },
|
||||||
|
{ id: 'mire', label: 'Mire Station', x: 68, y: 84, status: 'ACTIVE', sublabel: 'OUTPOST' },
|
||||||
|
{ id: 'thalassa', label: 'Thalassa Bend', x: 12, y: 86, status: 'NEUTRAL', sublabel: '4 WORLDS' },
|
||||||
|
{ id: 'kuiper', label: 'Kuiper Shard', x: 90, y: 30, status: 'CRITICAL', sublabel: 'WARZONE' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const hullTrack = [100, 98, 97, 96, 94, 95, 92, 90, 89, 87]
|
const worldMapEdges: NodeGraphEdge[] = [
|
||||||
const hullLabels = hullTrack.map((_, i) => `T-${hullTrack.length - 1 - i}`)
|
{ from: 'kepler', to: 'vesper', animated: true },
|
||||||
|
{ from: 'kepler', to: 'hollow', label: 'JUMP-2' },
|
||||||
|
{ from: 'kepler', to: 'lantern', animated: true },
|
||||||
|
{ from: 'kepler', to: 'mire' },
|
||||||
|
{ from: 'vesper', to: 'orpheus' },
|
||||||
|
{ from: 'hollow', to: 'kuiper', label: 'JUMP-1' },
|
||||||
|
{ from: 'cinder', to: 'kuiper' },
|
||||||
|
{ from: 'lantern', to: 'thalassa' },
|
||||||
|
{ from: 'sable', to: 'vesper' },
|
||||||
|
{ from: 'mire', to: 'cinder' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// LOCAL SYSTEM — the current system (regenerated per save after each jump, later).
|
||||||
|
const localSystemNodes: NodeGraphNode[] = [
|
||||||
|
{ id: 'star', label: 'Kepler-442', x: 50, y: 50, status: 'ACTIVE', sublabel: 'G2 STAR' },
|
||||||
|
{ id: 'aurelle', label: 'Aurelle', x: 18, y: 30, status: 'ACTIVE', sublabel: 'TERRESTRIAL' },
|
||||||
|
{ id: 'brimstone', label: 'Brimstone', x: 76, y: 24, status: 'NEUTRAL', sublabel: 'GAS GIANT' },
|
||||||
|
{ id: 'ashfall', label: 'Ashfall', x: 90, y: 52, status: 'WARNING', sublabel: 'MOON' },
|
||||||
|
{ id: 'cinderrock', label: 'Cinderrock', x: 74, y: 80, status: 'NEUTRAL', sublabel: 'ROCKY' },
|
||||||
|
{ id: 'driftband', label: 'Driftband', x: 30, y: 78, status: 'WARNING', sublabel: 'ASTEROID RING' },
|
||||||
|
{ id: 'palemoon', label: 'Palemoon', x: 8, y: 54, status: 'NEUTRAL', sublabel: 'MOON' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const localSystemEdges: NodeGraphEdge[] = [
|
||||||
|
{ from: 'star', to: 'aurelle' },
|
||||||
|
{ from: 'star', to: 'brimstone' },
|
||||||
|
{ from: 'brimstone', to: 'ashfall', label: 'MOON' },
|
||||||
|
{ from: 'star', to: 'cinderrock' },
|
||||||
|
{ from: 'star', to: 'driftband' },
|
||||||
|
{ from: 'aurelle', to: 'palemoon', label: 'MOON' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const explorablePlanets = [
|
||||||
|
{ name: 'AURELLE', type: 'TERRESTRIAL // OCEAN CONTINENTS', state: 'UNEXPLORED' as const },
|
||||||
|
{ name: 'BRIMSTONE', type: 'GAS GIANT // CLOUD DECKS', state: 'SCANNED' as const },
|
||||||
|
{ name: 'ASHFALL (MOON)', type: 'VOLCANIC MOON // SULFUR VEINS', state: 'UNEXPLORED' as const },
|
||||||
|
{ name: 'CINDERROCK', type: 'ROCKY // IRON RICH', state: 'UNEXPLORED' as const },
|
||||||
|
{ name: 'DRIFTBAND', type: 'ASTEROID FIELD // HAZARD', state: 'HAZARD' as const },
|
||||||
|
]
|
||||||
|
|
||||||
|
const crew = [
|
||||||
|
{ name: 'CMDR. E. VANCE', role: 'COMMANDER', good: 'STEADY UNDER PRESSURE', bad: 'RIGID — SLOW TO ADAPT' },
|
||||||
|
{ name: 'LT. M. OKONKWO', role: 'PILOT', good: 'RECKLESS COURAGE', bad: 'SHORT FUSE' },
|
||||||
|
{ name: 'DR. I. SORENSEN', role: 'MEDIC', good: 'CALM IN A CRISIS', bad: 'OVERCAUTIOUS' },
|
||||||
|
{ name: 'R. "SPARK" DELACRUZ', role: 'ENGINEER', good: 'FIXES ANYTHING', bad: 'CARELESS WITH FUSES' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const shipSystemCards = [
|
||||||
|
{ name: 'REACTOR', level: 'LV 2', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] },
|
||||||
|
{ name: 'LIFE SUPPORT', level: 'LV 1', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] },
|
||||||
|
{ name: 'NAV COMPUTER', level: 'LV 3', status: 'SCANNING' as const, actions: ['EXPAND', 'UPGRADE'] },
|
||||||
|
{ name: 'WEAPONS', level: 'OFFLINE', status: 'OFFLINE' as const, actions: ['BUILD NEW'] },
|
||||||
|
{ name: 'COMMS ARRAY', level: 'LV 1', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const skillCards = [
|
||||||
|
{ name: 'ADVANCED FUSION CORE', unlocks: 'REACTOR LV 4 + OVERCLOCK', state: 'AVAILABLE' as const },
|
||||||
|
{ name: 'HULL PLATING SCHEMATICS', unlocks: 'BUILD ARMOR BAY MODULES', state: 'AVAILABLE' as const },
|
||||||
|
{ name: 'CREW TRAINING PROTOCOLS', unlocks: 'RETRAIN CREW TRAITS', state: 'LOCKED' as const },
|
||||||
|
{ name: 'JUMP DRIVE TUNING', unlocks: 'SHORTER JUMP COOLDOWN', state: 'LOCKED' as const },
|
||||||
|
]
|
||||||
|
|
||||||
const encounters = [
|
const encounters = [
|
||||||
{ name: 'DERELICT FREIGHTER', when: 'T-07:14', id: '#E-102' },
|
{ name: 'DERELICT FREIGHTER', when: 'T-07:14', id: '#E-102' },
|
||||||
@@ -237,7 +301,7 @@ export function MissionControl() {
|
|||||||
background: 'var(--border)',
|
background: 'var(--border)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* ── LEFT: SHIP SYSTEMS + VESSEL METRICS ── */}
|
{/* ── LEFT: SHIP SYSTEMS + VESSEL METRICS (mirrors the SHIP SYSTEMS tab) ── */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--background)',
|
background: 'var(--background)',
|
||||||
@@ -264,64 +328,16 @@ export function MissionControl() {
|
|||||||
<PanelTitle>VESSEL METRICS</PanelTitle>
|
<PanelTitle>VESSEL METRICS</PanelTitle>
|
||||||
</PanelHeader>
|
</PanelHeader>
|
||||||
<PanelContent>
|
<PanelContent>
|
||||||
<div
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
|
||||||
style={{
|
<Progress label="HULL INTEGRITY" value={87} />
|
||||||
display: 'flex',
|
<Progress label="REACTOR OUTPUT" value={94} />
|
||||||
justifyContent: 'space-around',
|
<Progress label="CREW ROSTER / 6" value={50} />
|
||||||
paddingTop: '0.5rem',
|
|
||||||
paddingBottom: '0.25rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ProgressRing value={87} label="HULL" variant="ACTIVE" size={74} />
|
|
||||||
<ProgressRing value={94} label="REACTOR" variant="ACTIVE" size={74} />
|
|
||||||
<ProgressRing value={50} label="CREW / 6" variant="WARNING" size={74} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: '0.75rem',
|
|
||||||
paddingTop: '0.75rem',
|
|
||||||
borderTop: '1px solid var(--border)',
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: '1fr 1fr 1fr',
|
|
||||||
gap: '0.25rem',
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[
|
|
||||||
{ label: 'HULL INTEGRITY', note: 'last scan T-02' },
|
|
||||||
{ label: 'REACTOR OUTPUT', note: 'idle cycle' },
|
|
||||||
{ label: 'CREW ROSTER', note: 'of 6 berths' },
|
|
||||||
].map((item) => (
|
|
||||||
<div key={item.label}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '0.52rem',
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
letterSpacing: '0.08em',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '0.48rem',
|
|
||||||
color: 'var(--border)',
|
|
||||||
letterSpacing: '0.06em',
|
|
||||||
marginTop: '1px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.note}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</PanelContent>
|
</PanelContent>
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── CENTER: OPERATIONS TABS ── */}
|
{/* ── CENTER: GAME TEMPLATE TABS (visual only — no handlers, placeholder data) ── */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--background)',
|
background: 'var(--background)',
|
||||||
@@ -341,75 +357,237 @@ export function MissionControl() {
|
|||||||
</PanelHeader>
|
</PanelHeader>
|
||||||
|
|
||||||
<PanelContent style={{ padding: 0 }}>
|
<PanelContent style={{ padding: 0 }}>
|
||||||
<Tabs defaultValue="systems">
|
<Tabs defaultValue="world-map">
|
||||||
<TabsList
|
<TabsList
|
||||||
style={{ padding: '0 1rem', background: 'var(--surface-raised)' }}
|
style={{
|
||||||
|
padding: '0 1rem',
|
||||||
|
background: 'var(--surface-raised)',
|
||||||
|
overflowX: 'auto',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<TabsTrigger value="systems">SYSTEMS</TabsTrigger>
|
<TabsTrigger value="world-map">WORLD MAP</TabsTrigger>
|
||||||
<TabsTrigger value="next">NEXT MISSION</TabsTrigger>
|
<TabsTrigger value="local-system">LOCAL SYSTEM</TabsTrigger>
|
||||||
<TabsTrigger value="log">LOG</TabsTrigger>
|
<TabsTrigger value="crew">CREW</TabsTrigger>
|
||||||
<TabsTrigger value="hull">HULL TRACK</TabsTrigger>
|
<TabsTrigger value="ship-systems">SHIP SYSTEMS</TabsTrigger>
|
||||||
|
<TabsTrigger value="skill-tree">SKILL TREE</TabsTrigger>
|
||||||
|
<TabsTrigger value="missions">MISSIONS</TabsTrigger>
|
||||||
|
<TabsTrigger value="event-log">EVENT LOG</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* SYSTEMS TAB */}
|
{/* WORLD MAP TAB */}
|
||||||
<TabsContent value="systems" style={{ margin: 0, padding: '1rem' }}>
|
<TabsContent value="world-map" style={{ margin: 0, padding: '1rem' }}>
|
||||||
<BarChart
|
<NodeGraph
|
||||||
data={bayOutput}
|
nodes={worldMapNodes}
|
||||||
orientation="vertical"
|
edges={worldMapEdges}
|
||||||
variant="ACTIVE"
|
variant="ACTIVE"
|
||||||
title="MODULE OUTPUT BY BAY (PLACEHOLDER)"
|
title="GALACTIC CHART // JUMP ROUTES (PLACEHOLDER)"
|
||||||
|
height={380}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
<div
|
</TabsContent>
|
||||||
style={{
|
|
||||||
marginTop: '1rem',
|
{/* LOCAL SYSTEM TAB */}
|
||||||
display: 'grid',
|
<TabsContent value="local-system" style={{ margin: 0, padding: '1rem' }}>
|
||||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
gap: '0.625rem',
|
<NodeGraph
|
||||||
}}
|
nodes={localSystemNodes}
|
||||||
>
|
edges={localSystemEdges}
|
||||||
{[
|
variant="ACTIVE"
|
||||||
{ label: 'TOTAL MODULES', value: 34 },
|
title="KEPLER DRIFT // LOCAL SYSTEM (PLACEHOLDER)"
|
||||||
{ label: 'ONLINE', value: 29 },
|
height={280}
|
||||||
{ label: 'EFFICIENCY', value: '85%' },
|
style={{ width: '100%' }}
|
||||||
].map((s) => (
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
key={s.label}
|
|
||||||
style={{
|
style={{
|
||||||
padding: '0.75rem',
|
padding: '0.4rem 0.75rem',
|
||||||
background: 'var(--surface)',
|
borderBottom: '1px solid var(--border)',
|
||||||
border: '1px solid var(--border)',
|
background: 'var(--surface-raised)',
|
||||||
textAlign: 'center',
|
fontSize: '0.6rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
EXPLORABLE WORLDS // FIND THINGS HERE (PLACEHOLDER)
|
||||||
style={{
|
|
||||||
fontSize: '0.55rem',
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
letterSpacing: '0.12em',
|
|
||||||
marginBottom: '0.4rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{s.label}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '1.1rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: 'var(--color-green)',
|
|
||||||
textShadow: 'var(--text-glow-green)',
|
|
||||||
letterSpacing: '0.02em',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{s.value}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{explorablePlanets.map((planet, i) => (
|
||||||
|
<div
|
||||||
|
key={planet.name}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '0.6rem 0.75rem',
|
||||||
|
borderBottom:
|
||||||
|
i < explorablePlanets.length - 1
|
||||||
|
? '1px solid var(--border)'
|
||||||
|
: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ overflow: 'hidden' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.68rem',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{planet.name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.57rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
marginTop: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{planet.type}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
planet.state === 'UNEXPLORED'
|
||||||
|
? 'SCANNING'
|
||||||
|
: planet.state === 'HAZARD'
|
||||||
|
? 'WARNING'
|
||||||
|
: 'ACTIVE'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{planet.state}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* CREW TAB */}
|
||||||
|
<TabsContent value="crew" style={{ margin: 0, padding: '1rem' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)',
|
||||||
|
gap: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{crew.map((member) => (
|
||||||
|
<Card key={member.name}>
|
||||||
|
<CardHeader>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<CardTitle>{member.name}</CardTitle>
|
||||||
|
<Badge variant="ACTIVE" className="ml-auto">
|
||||||
|
ACTIVE
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{member.role}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||||
|
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
|
||||||
|
<span style={{ color: 'var(--color-green)' }}>+ </span>
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>{member.good}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
|
||||||
|
<span style={{ color: 'var(--color-red)' }}>- </span>
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>{member.bad}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* NEXT MISSION TAB */}
|
{/* SHIP SYSTEMS TAB */}
|
||||||
<TabsContent value="next" style={{ margin: 0, padding: '1rem' }}>
|
<TabsContent value="ship-systems" style={{ margin: 0, padding: '1rem' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)',
|
||||||
|
gap: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{shipSystemCards.map((system) => (
|
||||||
|
<Card key={system.name}>
|
||||||
|
<CardHeader>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<CardTitle>{system.name}</CardTitle>
|
||||||
|
<Badge variant={system.status} className="ml-auto">
|
||||||
|
{system.level}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p style={{ fontSize: '0.65rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||||||
|
MODULE BAY // PLACEHOLDER READOUT
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="gap-2">
|
||||||
|
{system.actions.map((action) => (
|
||||||
|
<Button key={action} variant="OUTLINE" size="SM">
|
||||||
|
{action}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* SKILL TREE TAB */}
|
||||||
|
<TabsContent value="skill-tree" style={{ margin: 0, padding: '1rem' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)',
|
||||||
|
gap: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{skillCards.map((skill) => (
|
||||||
|
<Card key={skill.name}>
|
||||||
|
<CardHeader>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<CardTitle>{skill.name}</CardTitle>
|
||||||
|
<Badge variant={skill.state === 'AVAILABLE' ? 'ACTIVE' : 'OFFLINE'} className="ml-auto">
|
||||||
|
{skill.state}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p style={{ fontSize: '0.65rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||||||
|
UNLOCKS: {skill.unlocks}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button variant="OUTLINE" size="SM">
|
||||||
|
RESEARCH
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* MISSIONS TAB */}
|
||||||
|
<TabsContent value="missions" style={{ margin: 0, padding: '1rem' }}>
|
||||||
<div
|
<div
|
||||||
style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}
|
style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}
|
||||||
>
|
>
|
||||||
@@ -466,8 +644,8 @@ export function MissionControl() {
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* LOG TAB */}
|
{/* EVENT LOG TAB */}
|
||||||
<TabsContent value="log" style={{ margin: 0, padding: '1rem' }}>
|
<TabsContent value="event-log" style={{ margin: 0, padding: '1rem' }}>
|
||||||
<Terminal
|
<Terminal
|
||||||
lines={logLines}
|
lines={logLines}
|
||||||
title="SHIP LOG // PLACEHOLDER FEED"
|
title="SHIP LOG // PLACEHOLDER FEED"
|
||||||
@@ -475,18 +653,6 @@ export function MissionControl() {
|
|||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* HULL TRACK TAB */}
|
|
||||||
<TabsContent value="hull" style={{ margin: 0, padding: '1rem' }}>
|
|
||||||
<LineChart
|
|
||||||
series={[{ id: 'hull', label: 'HULL INTEGRITY %', data: hullTrack }]}
|
|
||||||
labels={hullLabels}
|
|
||||||
title="HULL INTEGRITY TRACK // PLACEHOLDER"
|
|
||||||
showArea
|
|
||||||
animated={false}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</PanelContent>
|
</PanelContent>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type NodeGraphVariant = 'DEFAULT' | 'ACTIVE' | 'WARNING' | 'CRITICAL'
|
||||||
|
export type NodeStatus = 'ACTIVE' | 'OFFLINE' | 'WARNING' | 'CRITICAL' | 'NEUTRAL'
|
||||||
|
|
||||||
|
export interface NodeGraphNode {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
x: number // 0–100 coordinate space
|
||||||
|
y: number
|
||||||
|
status?: NodeStatus
|
||||||
|
sublabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeGraphEdge {
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
label?: string
|
||||||
|
animated?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeGraphProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
nodes: NodeGraphNode[]
|
||||||
|
edges: NodeGraphEdge[]
|
||||||
|
variant?: NodeGraphVariant
|
||||||
|
title?: string
|
||||||
|
height?: number
|
||||||
|
directed?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const VARIANT_COLORS: Record<NodeGraphVariant, string> = {
|
||||||
|
DEFAULT: 'var(--text-secondary)',
|
||||||
|
ACTIVE: 'var(--color-green)',
|
||||||
|
WARNING: 'var(--color-amber)',
|
||||||
|
CRITICAL: 'var(--color-red)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<NodeStatus, string> = {
|
||||||
|
ACTIVE: 'var(--color-green)',
|
||||||
|
OFFLINE: 'var(--border)',
|
||||||
|
WARNING: 'var(--color-amber)',
|
||||||
|
CRITICAL: 'var(--color-red)',
|
||||||
|
NEUTRAL: 'var(--text-muted)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const NODE_W = 100
|
||||||
|
const NODE_H = 36
|
||||||
|
|
||||||
|
const NodeGraph = React.forwardRef<HTMLDivElement, NodeGraphProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
variant = 'ACTIVE',
|
||||||
|
title,
|
||||||
|
height = 320,
|
||||||
|
directed = false,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const containerRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
const [svgW, setSvgW] = React.useState(600)
|
||||||
|
const [hovered, setHovered] = React.useState<string | null>(null)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!containerRef.current) return
|
||||||
|
const ro = new ResizeObserver(entries => {
|
||||||
|
setSvgW(entries[0].contentRect.width)
|
||||||
|
})
|
||||||
|
ro.observe(containerRef.current)
|
||||||
|
return () => ro.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const color = VARIANT_COLORS[variant]
|
||||||
|
const markerId = `arrow-${variant}`
|
||||||
|
|
||||||
|
function nodePx(node: NodeGraphNode) {
|
||||||
|
return (node.x / 100) * svgW
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodePy(node: NodeGraphNode) {
|
||||||
|
return (node.y / 100) * height
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgeEndpoints(fromNode: NodeGraphNode, toNode: NodeGraphNode) {
|
||||||
|
const fx = nodePx(fromNode)
|
||||||
|
const fy = nodePy(fromNode)
|
||||||
|
const tx = nodePx(toNode)
|
||||||
|
const ty = nodePy(toNode)
|
||||||
|
const dx = tx - fx
|
||||||
|
const dy = ty - fy
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
if (dist < 1) return { x1: fx, y1: fy, x2: tx, y2: ty }
|
||||||
|
|
||||||
|
const ux = dx / dist
|
||||||
|
const uy = dy / dist
|
||||||
|
const hw = NODE_W / 2
|
||||||
|
const hh = NODE_H / 2
|
||||||
|
|
||||||
|
const tW = Math.abs(ux) > 0.001 ? hw / Math.abs(ux) : Infinity
|
||||||
|
const tH = Math.abs(uy) > 0.001 ? hh / Math.abs(uy) : Infinity
|
||||||
|
const t = Math.min(tW, tH)
|
||||||
|
|
||||||
|
return {
|
||||||
|
x1: fx + ux * t,
|
||||||
|
y1: fy + uy * t,
|
||||||
|
x2: tx - ux * t,
|
||||||
|
y2: ty - uy * t,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeMap = new Map(nodes.map(n => [n.id, n]))
|
||||||
|
const hasAnimatedEdge = edges.some(e => e.animated)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(className)}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '0.4rem 0.75rem',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
background: 'var(--surface-raised)',
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={containerRef} style={{ width: '100%', height: `${height}px` }}>
|
||||||
|
<svg width={svgW} height={height} style={{ display: 'block' }}>
|
||||||
|
<defs>
|
||||||
|
{directed && (
|
||||||
|
<marker
|
||||||
|
id={markerId}
|
||||||
|
markerWidth="6"
|
||||||
|
markerHeight="6"
|
||||||
|
refX="6"
|
||||||
|
refY="3"
|
||||||
|
orient="auto"
|
||||||
|
>
|
||||||
|
<path d="M 0 0 L 6 3 L 0 6 Z" fill={color} />
|
||||||
|
</marker>
|
||||||
|
)}
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{hasAnimatedEdge && (
|
||||||
|
<style>{`
|
||||||
|
@keyframes nodeGraphDash { to { stroke-dashoffset: -8; } }
|
||||||
|
`}</style>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edges */}
|
||||||
|
{edges.map((edge, ei) => {
|
||||||
|
const fromNode = nodeMap.get(edge.from)
|
||||||
|
const toNode = nodeMap.get(edge.to)
|
||||||
|
if (!fromNode || !toNode) return null
|
||||||
|
|
||||||
|
const { x1, y1, x2, y2 } = edgeEndpoints(fromNode, toNode)
|
||||||
|
const mx = (x1 + x2) / 2
|
||||||
|
const my = (y1 + y2) / 2
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={ei}>
|
||||||
|
<line
|
||||||
|
x1={x1.toFixed(1)}
|
||||||
|
y1={y1.toFixed(1)}
|
||||||
|
x2={x2.toFixed(1)}
|
||||||
|
y2={y2.toFixed(1)}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={1}
|
||||||
|
opacity={0.5}
|
||||||
|
strokeDasharray={edge.animated ? '4 4' : undefined}
|
||||||
|
markerEnd={directed ? `url(#${markerId})` : undefined}
|
||||||
|
style={edge.animated
|
||||||
|
? { animation: 'nodeGraphDash 0.5s linear infinite' }
|
||||||
|
: undefined}
|
||||||
|
/>
|
||||||
|
{edge.label && (
|
||||||
|
<text
|
||||||
|
x={mx.toFixed(1)}
|
||||||
|
y={my.toFixed(1)}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
fontSize={8}
|
||||||
|
fill="var(--text-muted)"
|
||||||
|
fontFamily="var(--font-mono)"
|
||||||
|
>
|
||||||
|
{edge.label}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Nodes */}
|
||||||
|
{nodes.map(node => {
|
||||||
|
const cx = nodePx(node)
|
||||||
|
const cy = nodePy(node)
|
||||||
|
const isHovered = hovered === node.id
|
||||||
|
const statusColor = node.status ? STATUS_COLOR[node.status] : color
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={node.id}
|
||||||
|
onMouseEnter={() => setHovered(node.id)}
|
||||||
|
onMouseLeave={() => setHovered(null)}
|
||||||
|
style={{ cursor: 'default' }}
|
||||||
|
>
|
||||||
|
{/* Background */}
|
||||||
|
<rect
|
||||||
|
x={(cx - NODE_W / 2).toFixed(1)}
|
||||||
|
y={(cy - NODE_H / 2).toFixed(1)}
|
||||||
|
width={NODE_W}
|
||||||
|
height={NODE_H}
|
||||||
|
fill="var(--surface-raised)"
|
||||||
|
/>
|
||||||
|
{/* Hover tint */}
|
||||||
|
{isHovered && (
|
||||||
|
<rect
|
||||||
|
x={(cx - NODE_W / 2).toFixed(1)}
|
||||||
|
y={(cy - NODE_H / 2).toFixed(1)}
|
||||||
|
width={NODE_W}
|
||||||
|
height={NODE_H}
|
||||||
|
fill={statusColor}
|
||||||
|
fillOpacity={0.12}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Border */}
|
||||||
|
<rect
|
||||||
|
x={(cx - NODE_W / 2).toFixed(1)}
|
||||||
|
y={(cy - NODE_H / 2).toFixed(1)}
|
||||||
|
width={NODE_W}
|
||||||
|
height={NODE_H}
|
||||||
|
fill="none"
|
||||||
|
stroke={statusColor}
|
||||||
|
strokeWidth={isHovered ? 1.5 : 1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<text
|
||||||
|
x={cx.toFixed(1)}
|
||||||
|
y={(node.sublabel ? cy - 5 : cy).toFixed(1)}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
fontSize={10}
|
||||||
|
fill={statusColor}
|
||||||
|
fontFamily="var(--font-mono)"
|
||||||
|
fontWeight="600"
|
||||||
|
>
|
||||||
|
{node.label.toUpperCase()}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Sublabel */}
|
||||||
|
{node.sublabel && (
|
||||||
|
<text
|
||||||
|
x={cx.toFixed(1)}
|
||||||
|
y={(cy + 8).toFixed(1)}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
fontSize={8}
|
||||||
|
fill="var(--text-muted)"
|
||||||
|
fontFamily="var(--font-mono)"
|
||||||
|
>
|
||||||
|
{node.sublabel}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
NodeGraph.displayName = 'NodeGraph'
|
||||||
|
|
||||||
|
export { NodeGraph }
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as ProgressPrimitive from '@radix-ui/react-progress'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface ProgressProps
|
||||||
|
extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
|
||||||
|
showValue?: boolean
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Progress = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||||
|
ProgressProps
|
||||||
|
>(({ className, value = 0, showValue = true, label, ...props }, ref) => {
|
||||||
|
const pct = Math.min(Math.max(value ?? 0, 0), 100)
|
||||||
|
const filled = Math.round((pct / 100) * 20)
|
||||||
|
const empty = 20 - filled
|
||||||
|
const barText = '='.repeat(filled) + ' '.repeat(empty)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', width: '100%' }}>
|
||||||
|
{label && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn('relative w-full overflow-hidden rounded-none', className)}
|
||||||
|
value={value}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* Bracket-style visual */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.25rem',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--color-green)',
|
||||||
|
textShadow: 'var(--text-glow-green)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--text-muted)' }}>[</span>
|
||||||
|
<span style={{ letterSpacing: '-0.05em', whiteSpace: 'pre' }}>{barText}</span>
|
||||||
|
<span style={{ color: 'var(--text-muted)' }}>]</span>
|
||||||
|
{showValue && (
|
||||||
|
<span style={{ color: 'var(--text-muted)', fontSize: '0.65rem', marginLeft: '0.25rem' }}>
|
||||||
|
{Math.round(pct)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Accessible indicator (visually hidden, only for Radix a11y) */}
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
width: '1px',
|
||||||
|
height: '1px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
clip: 'rect(0,0,0,0)',
|
||||||
|
transform: `translateX(-${100 - pct}%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
+7
-6
@@ -5,18 +5,19 @@ test('landing page loads with the SpaceWanderer title', async ({ page }) => {
|
|||||||
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mission control layout renders scificn-ui components', async ({ page }) => {
|
test('game template tabs render (scificn-ui component layer wired in)', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
// Stat card, tab trigger and footer badge prove the copied component layer is wired in.
|
// Stat card + footer badge prove the layout shell; Radix Tabs.Trigger renders role="tab".
|
||||||
await expect(page.getByText('NEXT ENCOUNTER')).toBeVisible();
|
await expect(page.getByText('NEXT ENCOUNTER')).toBeVisible();
|
||||||
// Radix Tabs.Trigger renders role="tab", not a plain button.
|
await expect(page.getByRole('tab', { name: 'WORLD MAP' })).toBeVisible();
|
||||||
await expect(page.getByRole('tab', { name: 'SYSTEMS' })).toBeVisible();
|
await expect(page.getByRole('tab', { name: 'CREW' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('tab', { name: 'SKILL TREE' })).toBeVisible();
|
||||||
await expect(page.getByText('SYS ONLINE')).toBeVisible();
|
await expect(page.getByText('SYS ONLINE')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('operations tabs switch panels client-side', async ({ page }) => {
|
test('tabs switch panels client-side (event log terminal)', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.getByRole('tab', { name: 'LOG' }).click();
|
await page.getByRole('tab', { name: 'EVENT LOG' }).click();
|
||||||
await expect(page.getByText('SHIP LOG // PLACEHOLDER FEED')).toBeVisible();
|
await expect(page.getByText('SHIP LOG // PLACEHOLDER FEED')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "space-wanderer",
|
"name": "space-wanderer",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user