Compare commits

...
10 Commits
Author SHA1 Message Date
kamilkrzywda d083b5b708 Merge pull request 'v0.9.10 — world map fills entire tab area, no scrollbar (#36)' (#39) from world-map-full-area into master
CI / build + unit + e2e (push) Successful in 3m22s
2026-09-29 16:09:15 +02:00
kamilkrzywda c012b0fe56 fix(ui): keep the OPERATIONS tab bar intact when tall tabs overflow the pane (#36)
CI / build + unit + e2e (pull_request) Successful in 2m30s
2026-09-29 14:03:27 +00:00
kamilkrzywda 6483d510ef Merge pull request 'v0.9.10 — world map fills entire tab area, no scrollbar (#36)' (#37) from world-map-full-area into master
CI / build + unit + e2e (push) Failing after 13m40s
2026-09-29 15:03:00 +02:00
kamilkrzywda bc6bc7dd07 fix(ui): scale WORLD MAP to fill its tab pane without an internal scrollbar (#36)
CI / build + unit + e2e (pull_request) Failing after 13m30s
2026-09-29 12:59:16 +00:00
kamilkrzywda 3a786913df Merge pull request 'v0.9.9 — world map: force-directed graph with pan+zoom (#28)' (#34) from world-map-graph-viz into master
CI / build + unit + e2e (push) Successful in 3m10s
2026-09-29 13:27:17 +02:00
kamilkrzywda 978a1d119d feat(ui): render WORLD MAP as a force-directed d3 graph with pan + zoom (#28)
CI / build + unit + e2e (pull_request) Successful in 2m50s
2026-09-29 11:24:02 +00:00
kamilkrzywda a57c590bae Merge pull request 'v0.9.8 — pin tab bar at top while panel content scrolls (#31)' (#33) from pin-tab-bar into master
CI / build + unit + e2e (push) Successful in 2m49s
2026-09-29 12:25:01 +02:00
kamilkrzywda 1710e50b63 fix(ui): pin the OPERATIONS tab bar while panel content scrolls (#31)
CI / build + unit + e2e (pull_request) Successful in 2m47s
The TabsList is now position:sticky top:0 with a z-index above the panel
content. In wide mode it pins to the top of the OPERATIONS scroll area
(#21's center-pane scroller); in narrow mode, where the body scrolls and
there is no inner scroll container, it pins to the viewport. The existing
opaque background keeps scrolled content from showing through.
2026-09-29 10:22:14 +00:00
kamilkrzywda 1bb04d2a51 Merge pull request 'v0.9.7 — local system refreshes after jump, no page reload needed (#30)' (#32) from local-system-refresh into master
CI / build + unit + e2e (push) Successful in 3m7s
2026-09-29 12:03:41 +02:00
kamilkrzywda e27ff7015e fix(ui): refresh LOCAL SYSTEM after a jump instead of waiting for a page reload (#30)
CI / build + unit + e2e (pull_request) Successful in 2m39s
WorldMapPanel owns the world state and applied the /api/jump response
locally, so MissionControl's lifted /api/system state (LOCAL SYSTEM tab,
CARGO HOLD, SECTOR row) stayed on the previous system until a full refresh.
The panel now takes an onJumped callback that MissionControl wires to an
/api/system refetch after every successful jump.
2026-09-29 10:00:18 +00:00
10 changed files with 394 additions and 362 deletions
+16
View File
@@ -1,5 +1,21 @@
# Changelog # Changelog
## v0.9.10 — 2026-09-29 12:57
- The WORLD MAP now fills the entire available area of its tab (#36): in wide mode (pinned viewport, #21) the OPERATIONS Tabs are a full-height flex column and the world-map panel flexes to the bottom of the pane — the chart SVG sizes to the measured container instead of the old fixed 380px height, so there is no internal scrollbar on this tab at any window size. The scene keeps its viewBox and scales with the pane on resizes (pan/zoom stay accurate; re-fitting after a resize is intentionally not done). Narrow mode keeps the fixed fallback height since the page scrolls naturally there.
- Follow-up to that layout change (CI run 382): the OPERATIONS Tabs root uses `min-height: 100%` instead of `height: 100%`. With a capped height, a tall tab's content overflowed the flex column and shrank items down to their automatic minimum — the tablist's `overflow-x: auto` zeroes that minimum per CSS spec, so the bar collapsed to ~0px with its buttons clipped out of hit-testing (every subsequent tab click failed with "div intercepts pointer events"); a capped box also released the sticky pin on deep scrolls. With min-height the box grows to fit tall tabs — the bar keeps its natural height and stays pinned across the whole scroll range, while WORLD MAP still fills the pane exactly.
- e2e: the #31 spec no longer relies on WORLD MAP's overflow to force scrolling — it now switches to SHIP SYSTEMS, whose six always-present stacked system cards deterministically overflow the shrunk 1280×560 pane (waiting for the HULL card first), and asserts the same pinned tab bar.
## v0.9.9 — 2026-09-29 11:21
- The WORLD MAP chart is now a force-directed graph (d3-force) with pan + zoom (d3-zoom), replacing the hand-rolled static NodeGraph render (#28): nodes settle into a dynamic layout seeded from the API coordinates and visibly rearrange after every jump, then auto zoom-to-fit so the whole network stays framed in the chart box. Wheel/drag pans and zooms the scene; only systems adjacent to the current one remain clickable (same `role=button` + name contract as before), all nodes lock while a jump is in flight, and a real pan's trailing click never triggers an accidental jump. Jump flow (`POST /api/jump`, turn counter, `onJumped` refresh from #30) is unchanged; the now-orphaned hand-rolled `NodeGraph` component (world map was its only consumer) is deleted.
## v0.9.8 — 2026-09-29 10:20
- The OPERATIONS tab bar now stays pinned while its content scrolls (#31): the TabsList is `position: sticky; top: 0` with a z-index above the panel content, so it pins to the top of the OPERATIONS scroll area in wide mode (where only the center pane scrolls, #21) and to the viewport when the body scrolls in narrow mode. Its existing opaque background (`--surface-raised`) keeps scrolled content from showing through; no layout restructure needed — #21 behavior is untouched.
- e2e: new smoke spec — shrink the viewport so WORLD MAP's fixed-height chart overflows the pane, scroll the panel to the bottom (asserting a real offset), and check the tablist's bounding box hasn't moved.
## v0.9.7 — 2026-09-29 09:59
- LOCAL SYSTEM no longer shows the previous system after a jump (#30): WorldMapPanel owns the world state and applied the `/api/jump` response locally, so MissionControl's lifted `/api/system` state (which drives the LOCAL SYSTEM tab, CARGO HOLD and the SECTOR row) went stale until a page refresh. The panel now takes an `onJumped` callback that MissionControl wires to a `/api/system` refetch after every successful jump — same failure policy as the mount fetch (keep old state on error).
- e2e: new smoke spec — click-to-jump on WORLD MAP, wait for the chart's turn counter to update, switch to LOCAL SYSTEM and assert its header shows the NEW current system from `/api/world` (no `page.reload()` workaround; one jump per run/retry, same pattern as the existing jump test).
## v0.9.6 — 2026-09-29 09:41 ## v0.9.6 — 2026-09-29 09:41
- The active operations tab now survives a page refresh (#27): the selected tab ID is persisted to `localStorage` (`sw-active-tab`) on every change and restored on mount when it matches one of the seven valid tab IDs (unknown/stale values fall back to WORLD MAP). The Radix Tabs are controlled for this; SSR and first client render still agree on the default, so there is no hydration mismatch. - The active operations tab now survives a page refresh (#27): the selected tab ID is persisted to `localStorage` (`sw-active-tab`) on every change and restored on mount when it matches one of the seven valid tab IDs (unknown/stale values fall back to WORLD MAP). The Radix Tabs are controlled for this; SSR and first client render still agree on the default, so there is no hydration mismatch.
- e2e: new smoke spec — click CREW, reload, assert `aria-selected=true` again (fresh context per run/retry, no API state touched, idempotent). - e2e: new smoke spec — click CREW, reload, assert `aria-selected=true` again (fresh context per run/retry, no API state touched, idempotent).
+6
View File
@@ -160,6 +160,12 @@ body {
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
/* Radix Tabs hides inactive content via the `hidden` attribute, but inline display styles (the
#36 pane-fill flex chain on the WORLD MAP tab) outrank the UA stylesheet — restore semantics. */
[hidden] {
display: none !important;
}
#root { #root {
min-height: 100vh; min-height: 100vh;
} }
+38
View File
@@ -10,6 +10,9 @@
"@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",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"d3-force": "^3.0.0",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"next": "^16.3.5", "next": "^16.3.5",
"react": "^19.3.0", "react": "^19.3.0",
@@ -20,6 +23,9 @@
"devDependencies": { "devDependencies": {
"@playwright/test": "1.63.0", "@playwright/test": "1.63.0",
"@tailwindcss/postcss": "^4.2.0", "@tailwindcss/postcss": "^4.2.0",
"@types/d3-force": "^3.0.10",
"@types/d3-selection": "^3.0.12",
"@types/d3-zoom": "^3.0.9",
"@types/node": "^22.20.3", "@types/node": "^22.20.3",
"@types/react": "^19.3.0", "@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0", "@types/react-dom": "^19.3.0",
@@ -221,6 +227,16 @@
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-selection": ["@types/d3-selection@3.0.12", "", {}, "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw=="],
"@types/d3-zoom": ["@types/d3-zoom@3.0.9", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/emscripten": ["@types/emscripten@1.41.6", "", {}, "sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg=="], "@types/emscripten": ["@types/emscripten@1.41.6", "", {}, "sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg=="],
@@ -295,6 +311,28 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
"d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="],
"d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="],
"d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"drizzle-orm": ["drizzle-orm@0.45.3", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@netlify/db": ">=0.4.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@netlify/db", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-CAloER21cDdcgZ1OmjrZX82EeRNsdP+y0onX/eyoYDaYzki/adrAa91lzM5jWh7zZPK4qUbw/6LGv9J+A+uizA=="], "drizzle-orm": ["drizzle-orm@0.45.3", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@netlify/db": ">=0.4.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@netlify/db", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-CAloER21cDdcgZ1OmjrZX82EeRNsdP+y0onX/eyoYDaYzki/adrAa91lzM5jWh7zZPK4qUbw/6LGv9J+A+uizA=="],
+37 -11
View File
@@ -1,6 +1,6 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel' import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
@@ -112,6 +112,19 @@ export function MissionControl() {
if (stored && TAB_IDS.includes(stored)) setActiveTab(stored) if (stored && TAB_IDS.includes(stored)) setActiveTab(stored)
}, []) }, [])
// #30 — re-fetch /api/system after a jump: WorldMapPanel owns the world state, so without this
// the lifted system state stays on the previous system until a page refresh. Same failure
// policy as the mount fetch — keep the old state on error.
const refreshSystem = useCallback(() => {
fetch('/api/system')
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return (await res.json()) as SystemState
})
.then((s) => setSystem(s))
.catch(() => {})
}, [])
const timeStr = new Date().toLocaleTimeString('en-GB', { hour12: false }) const timeStr = new Date().toLocaleTimeString('en-GB', { hour12: false })
// Slugs of the researched techs — drives the effective caps + build gates in the SHIP SYSTEMS // Slugs of the researched techs — drives the effective caps + build gates in the SHIP SYSTEMS
@@ -370,20 +383,28 @@ export function MissionControl() {
</PanelHeader> </PanelHeader>
<PanelContent style={{ padding: 0, flex: 1, minHeight: 0, overflowY: 'auto' }}> <PanelContent style={{ padding: 0, flex: 1, minHeight: 0, overflowY: 'auto' }}>
{/* Flex column at least pane-tall so a tab can fill it (#36). min-height, not height:
tall tabs grow the box to their content, which keeps the sticky tab bar's containing
block spanning the whole scroll range (a capped height releases the pin on deep
scrolls) and means items never overflow the box, so nothing gets shrink-squashed. */}
<Tabs <Tabs
value={activeTab ?? 'world-map'} value={activeTab ?? 'world-map'}
onValueChange={(v) => { onValueChange={(v) => {
setActiveTab(v) setActiveTab(v)
window.localStorage.setItem(ACTIVE_TAB_KEY, v) // #27 — keep the tab across refreshes window.localStorage.setItem(ACTIVE_TAB_KEY, v) // #27 — keep the tab across refreshes
}} }}
style={{ display: 'flex', flexDirection: 'column', minHeight: '100%' }}
> >
<TabsList <TabsList
style={{ style={{
padding: '0 1rem', padding: '0 1rem',
background: 'var(--surface-raised)', background: 'var(--surface-raised)', // opaque — content scrolls under it (#31)
overflowX: 'auto', overflowX: 'auto',
}} position: 'sticky', // #31 — pinned in both modes: to the top of the OPERATIONS
> top: 0, // scroll area (wide) and to the viewport when the body scrolls (narrow)
zIndex: 1,
}}
>
<TabsTrigger value="world-map">WORLD MAP</TabsTrigger> <TabsTrigger value="world-map">WORLD MAP</TabsTrigger>
<TabsTrigger value="local-system">LOCAL SYSTEM</TabsTrigger> <TabsTrigger value="local-system">LOCAL SYSTEM</TabsTrigger>
<TabsTrigger value="crew">CREW</TabsTrigger> <TabsTrigger value="crew">CREW</TabsTrigger>
@@ -393,9 +414,14 @@ export function MissionControl() {
<TabsTrigger value="event-log">EVENT LOG</TabsTrigger> <TabsTrigger value="event-log">EVENT LOG</TabsTrigger>
</TabsList> </TabsList>
{/* WORLD MAP TAB — live data from /api/world */} {/* WORLD MAP TAB — live data from /api/world; fills the whole pane (#36). Wide mode:
<TabsContent value="world-map" style={{ margin: 0, padding: '1rem' }}> flex column so the panel can fill it with pure flex sizing (a min-height-only
<WorldMapPanel height={380} /> container breaks percentage resolution, so no height:'100%' anywhere in this chain). */}
<TabsContent
value="world-map"
style={{ margin: 0, padding: '1rem', ...(!narrow ? { display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 } : {}) }}
>
<WorldMapPanel height={380} narrow={narrow} onJumped={refreshSystem} />
</TabsContent> </TabsContent>
{/* LOCAL SYSTEM TAB — live data (state owned by MissionControl; scans report up) */} {/* LOCAL SYSTEM TAB — live data (state owned by MissionControl; scans report up) */}
-310
View File
@@ -1,310 +0,0 @@
'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
disabled?: boolean // not clickable even when onNodeClick is set (e.g. the current system)
}
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
onNodeClick?: (id: string) => void // makes non-disabled nodes clickable buttons
}
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,
onNodeClick,
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
const clickable = !!onNodeClick && !node.disabled
return (
<g
key={node.id}
onMouseEnter={() => setHovered(node.id)}
onMouseLeave={() => setHovered(null)}
onClick={clickable ? () => onNodeClick(node.id) : undefined}
onKeyDown={clickable
? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onNodeClick(node.id) } }
: undefined}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
aria-label={clickable ? node.label.toUpperCase() : undefined}
style={{ cursor: clickable ? 'pointer' : '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 }
+230 -37
View File
@@ -1,22 +1,58 @@
'use client' 'use client'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { NodeGraph } from '@/components/ui/node-graph' import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation } from 'd3-force'
import type { SimulationNodeDatum } from 'd3-force'
import { select } from 'd3-selection'
import { zoom, zoomIdentity } from 'd3-zoom'
import type { WorldState } from '@/lib/game/save' import type { WorldState } from '@/lib/game/save'
// WORLD MAP tab — renders real world state from /api/world. Clicking a system adjacent to the // WORLD MAP tab — real world state from /api/world on a force-directed chart (d3-force) with
// current one on the galactic chart jumps there (same POST /api/jump flow as before); the // pan + zoom (d3-zoom), per #28. Clicking a system adjacent to the current one jumps there
// current and unreachable systems are dimmed and not clickable. Turn-based: fetch on mount, // (same POST /api/jump flow as before); the current and unreachable systems are dimmed and not
// refetch via the jump response — no polling. // clickable, and every node is locked while a jump is in flight. Turn-based: fetch on mount,
// refetch via the jump response — no polling. In wide mode (#36) the chart fills the whole tab
// pane (no internal scrollbar); narrow keeps a fixed fallback height since the page scrolls naturally there.
interface WorldMapPanelProps { interface WorldMapPanelProps {
height?: number height?: number // fallback chart height when the pane gives no definite space (narrow mode)
narrow?: boolean
onJumped?: () => void // #30 — let the parent refresh /api/system after a successful jump
} }
export function WorldMapPanel({ height = 380 }: WorldMapPanelProps) { type NodeStatus = 'current' | 'jumpable' | 'offline'
// d3-force mutates these in place; the whole scene is rebuilt from scratch on every world change.
interface ChartNode extends SimulationNodeDatum {
id: number
name: string // already uppercased — doubles as the accessible label
status: NodeStatus
sublabel: string
}
// forceLink replaces the numeric ids with node references once initialized.
interface ChartLink {
source: number | ChartNode
target: number | ChartNode
}
const NODE_W = 100
const NODE_H = 36
// Palette carried over from the old hand-rolled chart.
const STATUS_COLOR: Record<NodeStatus, string> = {
current: 'var(--color-green)',
jumpable: 'var(--text-muted)',
offline: 'var(--border)',
}
export function WorldMapPanel({ height = 380, narrow = false, onJumped }: WorldMapPanelProps) {
// Wide mode: the flex chain (Tabs → TabsContent → this panel) provides a definite pane size to fill (#36).
const filling = !narrow
const [world, setWorld] = useState<WorldState | null>(null) const [world, setWorld] = useState<WorldState | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [jumpingId, setJumpingId] = useState<number | null>(null) const [jumpingId, setJumpingId] = useState<number | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -36,7 +72,12 @@ export function WorldMapPanel({ height = 380 }: WorldMapPanelProps) {
} }
}, []) }, [])
// Ref guard so a double-click can't start two jumps before the re-render locks the nodes.
const jumpingRef = useRef(false)
const jump = useCallback(async (systemId: number) => { const jump = useCallback(async (systemId: number) => {
if (jumpingRef.current) return
jumpingRef.current = true
setJumpingId(systemId) setJumpingId(systemId)
try { try {
const res = await fetch('/api/jump', { const res = await fetch('/api/jump', {
@@ -47,12 +88,164 @@ export function WorldMapPanel({ height = 380 }: WorldMapPanelProps) {
const body = (await res.json()) as WorldState & { error?: string } const body = (await res.json()) as WorldState & { error?: string }
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`) if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
setWorld(body) // fresh state straight from the response — no refetch needed setWorld(body) // fresh state straight from the response — no refetch needed
onJumped?.() // #30 — parent re-fetches /api/system (LOCAL SYSTEM + CARGO HOLD mirror it)
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err)) setError(err instanceof Error ? err.message : String(err))
} finally { } finally {
jumpingRef.current = false
setJumpingId(null) setJumpingId(null)
} }
}, []) }, [onJumped])
// Rebuild the chart on every world/lock change — the force layout re-settles from the API's
// coordinates, so nodes visibly rearrange after each jump (#28).
useEffect(() => {
const container = containerRef.current
if (!container || !world) return
// Measured pane size (#36): the flex chain gives a definite box in wide mode; `height` is the
// fallback when the container has no resolved size yet (narrow flow, or tab hidden on load).
const W = container.clientWidth || 800
const H = container.clientHeight || height
const current = world.systems.find((s) => s.current)!
// Systems one jump away — the only clickable chart fields (the same set as before).
const reachableIds = new Set(
world.edges.filter(([a, b]) => a === current.id || b === current.id).map(([a, b]) => (a === current.id ? b : a))
)
const nodes: ChartNode[] = world.systems.map((s) => ({
id: s.id,
name: s.name.toUpperCase(),
status: s.current ? 'current' : reachableIds.has(s.id) ? 'jumpable' : 'offline',
sublabel: s.current
? 'CURRENT'
: reachableIds.has(s.id)
? (s.discovered ? 'DISCOVERED // JUMP' : 'UNKNOWN // JUMP')
: s.discovered ? 'DISCOVERED' : 'UNKNOWN',
x: (s.x / 100) * W, // seed from the API coordinates so the layout settles near the old chart
y: (s.y / 100) * H,
}))
const links: ChartLink[] = world.edges.map(([a, b]) => ({ source: a, target: b }))
// width/height 100% + fixed viewBox: the scene scales with the pane on window resizes without
// rebuilding (pan/zoom stay accurate — d3 maps pointers through the SVG transform matrix).
const svg = select(container)
.append('svg')
.attr('width', '100%')
.attr('height', '100%')
.attr('viewBox', `0 0 ${W} ${H}`)
.style('display', 'block')
const root = svg.append('g')
const edgeLayer = root.append('g')
const nodeLayer = root.append('g')
// Pan + zoom (#28): wheel/drag on the chart, transform applied to the scene root. d3-zoom does
// NOT swallow the trailing click after a drag, so track real pans and ignore node clicks that
// end one (mousedown on a node, tiny drag, release over the same node).
let panned = false
const zoomBehavior = zoom<SVGSVGElement, unknown>()
.scaleExtent([0.15, 5])
.on('start', () => {
panned = false
})
.on('zoom', (event) => {
root.attr('transform', event.transform.toString())
// Any user gesture (drag or trackpad pan); a clean click's own mousedown re-fires
// 'start' and resets this before its click lands. Programmatic fits have no sourceEvent.
if (event.sourceEvent) panned = true
})
svg.call(zoomBehavior)
// The settled force layout is bigger than the chart box for large saves, so once the
// simulation stops, zoom-to-fit: frame every node inside the container with padding.
const fitToNodes = () => {
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const n of nodes) {
minX = Math.min(minX, (n.x ?? 0) - NODE_W / 2)
minY = Math.min(minY, (n.y ?? 0) - NODE_H / 2)
maxX = Math.max(maxX, (n.x ?? 0) + NODE_W / 2)
maxY = Math.max(maxY, (n.y ?? 0) + NODE_H / 2)
}
const pad = 16
const scale = Math.min(W / (maxX - minX + pad * 2), H / (maxY - minY + pad * 2))
const tx = W / 2 - scale * ((minX + maxX) / 2)
const ty = H / 2 - scale * ((minY + maxY) / 2)
svg.call(zoomBehavior.transform, zoomIdentity.translate(tx, ty).scale(scale))
}
const edgeSel = edgeLayer.selectAll('line').data(links).enter().append('line')
.attr('stroke', 'var(--color-green)')
.attr('stroke-width', 1)
.attr('opacity', 0.5)
const nodeSel = nodeLayer.selectAll<SVGGElement, ChartNode>('g').data(nodes).enter().append('g')
// Same accessible contract as the old chart: only jumpable nodes are role=button with their
// name as the accessible label (e2e clicks them by role/name); all locked while a jump is in flight.
nodeSel.filter((d) => d.status === 'jumpable' && jumpingId === null)
.attr('role', 'button')
.attr('tabindex', 0)
.attr('aria-label', (d) => d.name)
.style('cursor', 'pointer')
.on('click', (event: MouseEvent, d) => {
if (!panned) void jump(d.id) // a real pan ends with a click too — don't jump on that one
})
.on('keydown', (event: KeyboardEvent, d) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
void jump(d.id)
}
})
nodeSel.each(function (d) {
const g = select(this)
const color = STATUS_COLOR[d.status]
g.append('rect')
.attr('x', -NODE_W / 2).attr('y', -NODE_H / 2)
.attr('width', NODE_W).attr('height', NODE_H)
.attr('fill', 'var(--surface-raised)')
g.append('rect')
.attr('x', -NODE_W / 2).attr('y', -NODE_H / 2)
.attr('width', NODE_W).attr('height', NODE_H)
.attr('fill', 'none').attr('stroke', color).attr('stroke-width', 1)
g.append('text')
.attr('x', 0).attr('y', -5)
.attr('text-anchor', 'middle').attr('dominant-baseline', 'middle')
.attr('font-size', 10).attr('fill', color)
.style('font-family', 'var(--font-mono)').style('font-weight', '600')
.text(d.name)
g.append('text')
.attr('x', 0).attr('y', 8)
.attr('text-anchor', 'middle').attr('dominant-baseline', 'middle')
.attr('font-size', 8).attr('fill', 'var(--text-muted)')
.style('font-family', 'var(--font-mono)')
.text(d.sublabel)
})
const simulation = forceSimulation(nodes)
.force('link', forceLink<ChartNode, ChartLink>(links).id((d) => d.id).distance(130).strength(0.25))
.force('charge', forceManyBody().strength(-420))
.force('center', forceCenter(W / 2, H / 2))
.force('collide', forceCollide(60))
.on('end', fitToNodes)
.on('tick', () => {
edgeSel
.attr('x1', (d) => (d.source as ChartNode).x ?? 0)
.attr('y1', (d) => (d.source as ChartNode).y ?? 0)
.attr('x2', (d) => (d.target as ChartNode).x ?? 0)
.attr('y2', (d) => (d.target as ChartNode).y ?? 0)
nodeSel.attr('transform', (d) => `translate(${d.x ?? 0},${d.y ?? 0})`)
})
return () => {
simulation.stop()
svg.remove()
}
}, [world, jumpingId, jump, height])
if (error && !world) { if (error && !world) {
return ( return (
@@ -67,39 +260,39 @@ export function WorldMapPanel({ height = 380 }: WorldMapPanelProps) {
if (!world) { if (!world) {
return ( return (
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}> <div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>SCANNING CHART…</span> <span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>SCANNING CHART…</span>
</div> </div>
) )
} }
const current = world.systems.find((s) => s.current)! // flex (not height:'100%'): the pane chain is sized by min-height, where percentages don't resolve
// Systems one jump away — the only clickable chart fields (the same set as the old JUMP ROUTES list).
const reachableIds = new Set(
world.edges.filter(([a, b]) => a === current.id || b === current.id).map(([a, b]) => (a === current.id ? b : a))
)
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', ...(filling ? { flex: 1, minHeight: 0 } : {}) }}>
<NodeGraph {/* Wrapper + title bar; in wide mode the box flexes to fill the whole pane (#36). d3 owns everything inside it. */}
nodes={world.systems.map((s) => { <div
const jumpable = !s.current && reachableIds.has(s.id) style={{
return { border: '1px solid var(--border)',
id: String(s.id), background: 'var(--surface)',
label: s.name, fontFamily: 'var(--font-mono)',
x: s.x, overflow: 'hidden',
y: s.y, ...(filling ? { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' } : {}),
status: s.current ? 'ACTIVE' : jumpable ? 'NEUTRAL' : 'OFFLINE', }}
sublabel: s.current ? 'CURRENT' : jumpable ? (s.discovered ? 'DISCOVERED // JUMP' : 'UNKNOWN // JUMP') : s.discovered ? 'DISCOVERED' : 'UNKNOWN', >
disabled: !jumpable || jumpingId !== null, // dimmed + not clickable; all locked while a jump is in flight <div
} style={{
})} padding: '0.4rem 0.75rem',
edges={world.edges.map(([a, b]) => ({ from: String(a), to: String(b) }))} borderBottom: '1px solid var(--border)',
variant="ACTIVE" background: 'var(--surface-raised)',
title={`GALACTIC CHART // JUMP ROUTES — TURN ${world.turn}`} fontSize: '0.6rem',
height={height} color: 'var(--text-muted)',
style={{ width: '100%' }} letterSpacing: '0.12em',
onNodeClick={(id) => void jump(Number(id))} textTransform: 'uppercase',
/> }}
>
GALACTIC CHART // JUMP ROUTES — TURN {world.turn}
</div>
<div ref={containerRef} style={{ width: '100%', ...(filling ? { flex: 1, minHeight: 0 } : { height: `${height}px` }) }} />
</div>
{error && ( {error && (
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.5rem 0.75rem' }}> <div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.5rem 0.75rem' }}>
+57
View File
@@ -82,6 +82,39 @@ test('active tab survives a page reload (#27)', async ({ page }) => {
await expect(page.getByRole('tab', { name: 'CREW' })).toHaveAttribute('aria-selected', 'true'); await expect(page.getByRole('tab', { name: 'CREW' })).toHaveAttribute('aria-selected', 'true');
}); });
test('tab bar stays pinned while panel content scrolls (#31)', async ({ page }) => {
await page.goto('/');
// Shrink the viewport so the center pane's content overflows its scroll area regardless of save state.
await page.setViewportSize({ width: 1280, height: 560 });
// SHIP SYSTEMS always renders six stacked system cards (seeded at start, never removed) — tall
// enough to overflow the shrunk pane on any save state, deterministic across retries. (#36 made
// WORLD MAP fill its pane instead of overflowing it, so it can no longer force the scroll.)
await page.getByRole('tab', { name: 'SHIP SYSTEMS' }).click();
await expect(page.getByRole('heading', { name: 'HULL' })).toBeVisible();
const tablist = page.getByRole('tablist');
await expect(tablist).toBeVisible();
const before = await tablist.boundingBox();
expect(before).not.toBeNull();
// Scroll the nearest scroll container above the tab list all the way down.
const scrolled = await page.evaluate(() => {
let el = document.querySelector('[role="tablist"]')?.parentElement ?? null;
while (el && !['auto', 'scroll'].includes(getComputedStyle(el).overflowY)) el = el.parentElement;
if (!el) throw new Error('no scroll container above the tab list');
el.scrollTop = el.scrollHeight;
return el.scrollTop;
});
expect(scrolled).toBeGreaterThan(50); // content really overflowed — guards against a vacuous pass
await expect(tablist).toBeVisible();
const after = await tablist.boundingBox();
expect(after).not.toBeNull();
// Pinned: the tab bar's viewport position is unchanged even though the content scrolled away (#31).
expect(Math.abs(after!.y - before!.y)).toBeLessThan(2);
});
test('world map shows the generated starting system and its neighbours', async ({ page }) => { test('world map shows the generated starting system and its neighbours', async ({ page }) => {
const world = (await (await page.request.get('/api/world')).json()) as WorldState; const world = (await (await page.request.get('/api/world')).json()) as WorldState;
@@ -112,6 +145,30 @@ test('jumping to an adjacent system updates the world state and turn counter', a
expect(after.systems.find((s) => s.current)?.id).toBe(targetId); expect(after.systems.find((s) => s.current)?.id).toBe(targetId);
}); });
test('local system shows the new current system after a jump, without a page reload (#30)', async ({ page }) => {
const before = (await (await page.request.get('/api/world')).json()) as WorldState;
const currentId = before.systems.find((s) => s.current)!.id;
const edge = before.edges.find(([a, b]) => a === currentId || b === currentId)!;
const targetId = edge[0] === currentId ? edge[1] : edge[0];
const targetName = before.systems.find((s) => s.id === targetId)!.name;
await page.goto('/');
// Jump through the UI: click the target's chart field on WORLD MAP (the default tab).
await page.getByRole('tabpanel').getByRole('button', { name: targetName.toUpperCase(), exact: true }).click();
// Wait until the jump response is applied to the chart — server state is committed by then, so
// the /api/world read below can't race the in-flight POST.
await expect(page.getByText(`GALACTIC CHART // JUMP ROUTES — TURN ${before.turn + 1}`)).toBeVisible();
const after = (await (await page.request.get('/api/world')).json()) as WorldState;
const newCurrent = after.systems.find((s) => s.current)!;
expect(newCurrent.id).toBe(targetId); // the jump landed where we aimed
// Switch to LOCAL SYSTEM — it must show the NEW current system without a page reload (#30).
await page.getByRole('tab', { name: 'LOCAL SYSTEM' }).click();
await expect(page.getByText(`EXPLORABLE WORLDS // ${newCurrent.name.toUpperCase()} — TURN ${after.turn}`)).toBeVisible();
});
test('local system shows the current system bodies with staggered moons and explore buttons', async ({ page }) => { test('local system shows the current system bodies with staggered moons and explore buttons', async ({ page }) => {
const sys = (await (await page.request.get('/api/system')).json()) as SystemState; const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
+1 -1
View File
@@ -20,7 +20,7 @@ export const solarSystems = sqliteTable('solar_systems', {
id: integer('id').primaryKey({ autoIncrement: true }), id: integer('id').primaryKey({ autoIncrement: true }),
saveId: integer('save_id').notNull().references(() => saves.id), saveId: integer('save_id').notNull().references(() => saves.id),
name: text('name').notNull(), name: text('name').notNull(),
// 0–100 coordinate space, rendered directly by the NodeGraph component. // 0–100 coordinate space; seeds the force-directed WORLD MAP layout (components/world-map.tsx).
x: real('x').notNull(), x: real('x').notNull(),
y: real('y').notNull(), y: real('y').notNull(),
discovered: integer('discovered', { mode: 'boolean' }).notNull().default(false), discovered: integer('discovered', { mode: 'boolean' }).notNull().default(false),
+1 -1
View File
@@ -3,7 +3,7 @@
export interface WorldSystem { export interface WorldSystem {
name: string name: string
x: number // 0–100 coordinate space, rendered directly by NodeGraph x: number // 0–100 coordinate space; seeds the force-directed WORLD MAP layout
y: number y: number
} }
+8 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "space-wanderer", "name": "space-wanderer",
"version": "0.9.6", "version": "0.9.10",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000", "dev": "next dev -p 3000",
@@ -15,6 +15,9 @@
"@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",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"d3-force": "^3.0.0",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"next": "^16.3.5", "next": "^16.3.5",
"react": "^19.3.0", "react": "^19.3.0",
@@ -25,11 +28,14 @@
"devDependencies": { "devDependencies": {
"@playwright/test": "1.63.0", "@playwright/test": "1.63.0",
"@tailwindcss/postcss": "^4.2.0", "@tailwindcss/postcss": "^4.2.0",
"tailwindcss": "^4.2.0", "@types/d3-force": "^3.0.10",
"@types/d3-selection": "^3.0.12",
"@types/d3-zoom": "^3.0.9",
"@types/node": "^22.20.3", "@types/node": "^22.20.3",
"@types/react": "^19.3.0", "@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0", "@types/react-dom": "^19.3.0",
"@types/sql.js": "^1.4.11", "@types/sql.js": "^1.4.11",
"tailwindcss": "^4.2.0",
"typescript": "^7.0.2", "typescript": "^7.0.2",
"vitest": "^5.0.1" "vitest": "^5.0.1"
} }