Compare commits

...
8 Commits
Author SHA1 Message Date
kamilkrzywda 36ff412f0b Merge pull request 'v0.9.4 — top bar shows app version instead of ◄ SHIP (#23)' (#25) from topbar-version into master
CI / build + unit + e2e (push) Successful in 3m12s
2026-09-29 10:33:19 +02:00
kamilkrzywda c35edc60d0 feat(ui): top bar shows app version instead of ◄ SHIP label (#23)
CI / build + unit + e2e (pull_request) Successful in 2m44s
2026-09-29 08:30:35 +00:00
kamilkrzywda f545792c36 Merge pull request 'v0.9.3 — layout: only middle pane scrolls, side panels stay fixed (#21)' (#24) from middle-pane-scroll into master
CI / build + unit + e2e (push) Successful in 2m29s
2026-09-29 10:24:56 +02:00
kamilkrzywda 593cca5dce release v0.9.3
CI / build + unit + e2e (pull_request) Successful in 2m49s
2026-09-29 08:21:14 +00:00
kamilkrzywda d2fb252a98 feat(ui): only the middle column scrolls — side panes stay fixed (#21)
Pin the wide layout to the viewport (height:100vh + overflow:hidden on the
root), add min-height:0 to the BODY wrapper and 3-column grid so the flex
chain actually constrains, and make the OPERATIONS panel a flex column whose
PanelContent is the scroll area (header stays pinned; the Panel root's default
overflow:hidden would otherwise clip). Columns' existing overflow-y:auto now
scrolls inside their tracks. Narrow mode keeps natural body flow.
2026-09-29 08:21:14 +00:00
kamilkrzywda bf6d8714e6 Merge pull request 'v0.9.2 — WORLD MAP: click-to-jump on galactic chart, drop jump routes list (#19)' (#22) from world-map-click-jump into master
CI / build + unit + e2e (push) Successful in 2m19s
2026-09-29 10:06:05 +02:00
kamilkrzywda 32dda04e92 release v0.9.2
CI / build + unit + e2e (pull_request) Successful in 2m57s
2026-09-29 08:00:44 +00:00
kamilkrzywda ef1d282770 feat(ui): world map — jump by clicking chart fields (#19)
Remove the bottom JUMP ROUTES button list; systems adjacent to the current
one become clickable galactic-chart nodes (same POST /api/jump flow, same
state refreshes). Current + unreachable systems are dimmed and not
clickable. NodeGraph gains optional onNodeClick + per-node disabled
(backward-compatible); two smoke tests updated to click chart fields.
2026-09-29 08:00:44 +00:00
6 changed files with 73 additions and 55 deletions
+17
View File
@@ -1,5 +1,22 @@
# Changelog
## v0.9.4 — 2026-09-29 08:29
- Top bar (#23): the `◄ SHIP` label in the mission control header is replaced with the app version number (e.g. `v0.9.4`). The header reads it from `package.json` via the same `@/package.json` import that `/api/version` and the OpenAPI spec use, so there is a single source of truth — no second hardcoded copy.
- e2e: no spec changes needed — no spec asserted on the old `◄ SHIP` label (the landing-page test only checks the SpaceWanderer heading). All 15 specs compile unchanged.
## v0.9.3 — 2026-09-29 08:20
- Layout (#21): only the middle column scrolls now — the app is pinned to the viewport (`height: 100vh` + `overflow: hidden` on the root in wide mode) so body-level scrolling no longer moves the side panes. Left/right columns keep their existing `overflow-y: auto`, so any overflowing content (e.g. a long planet list) scrolls inside its own track instead.
- Added `min-height: 0` to the BODY wrapper and the 3-column grid — flex/grid items default to a content-based minimum size, which is what let the page grow past the viewport before.
- The middle OPERATIONS panel became a flex column with its PanelContent as the scroll area (`flex: 1; min-height: 0; overflow-y: auto`): long tab content scrolls inside the panel while the "OPERATIONS // SHIP COMMAND" header stays pinned — without this, the Panel root's default `overflow: hidden` would have clipped it.
- Narrow layout (<900px) is unchanged: stacked columns with natural body scroll (the viewport pin applies to wide mode only).
- e2e: no spec changes needed — none use mouse.wheel/scrollTo, and Playwright scrolls click targets into view within any scroll container. All 15 specs compile unchanged.
## v0.9.2 — 2026-09-29 08:00
- WORLD MAP (#19): the bottom JUMP ROUTES button list is gone — jumps now happen by clicking galactic chart fields. Systems adjacent to the current one (the same set as before, undiscovered included) are clickable nodes with a pointer cursor and a `// JUMP` sublabel; the current system stays bright green with CURRENT, unreachable systems are dimmed (OFFLINE) and not clickable.
- NodeGraph (`components/ui/node-graph.tsx`) gained two optional, backward-compatible additions: an `onNodeClick(id)` prop that turns non-disabled nodes into buttons (click + Enter/Space, role=button, aria-label, focusable), and a per-node `disabled` flag — while a jump is in flight every node locks, mirroring the old disabled-button behaviour.
- The jump flow itself is unchanged: same POST /api/jump call, world state refreshed from the response (turn counter, current system, new routes), event log entry written server-side as before.
- e2e: two smoke tests touched the removed buttons and were updated — "world map shows…" now asserts the adjacent systems render as clickable chart fields (role=button count matches API adjacency) and "jumping to an adjacent system" clicks the target node by its accessible name. All 15 specs compile; `jumpToFreshSystem` is API-driven and needed no change.
## v0.9.1 — 2026-09-29 06:47
- UI layout pass (visual only — no game logic or API changes): CARGO HOLD // INVENTORY moved out of the LOCAL SYSTEM tab into a persistent panel in the right column; the UPCOMING ENCOUNTERS placeholder was removed entirely; the SHIP DATA placeholder was merged into VESSEL METRICS.
- Grid: `260px / 1fr / 240px` → `320px / 1fr / 360px` (left pane ~+23%, right pane ~50% wider).
+12 -3
View File
@@ -18,6 +18,7 @@ import { CREW_CAPACITY } from '@/lib/game/ship-costs'
import type { CrewDto, SystemState } from '@/lib/game/save'
import type { ShipState } from '@/lib/game/ship'
import type { TechDto } from '@/lib/game/tech'
import pkg from '@/package.json'
// Interface base: layout + visual treatment rebuilt from the scificn-ui Mission Control
// showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui). All seven
@@ -107,6 +108,10 @@ export function MissionControl() {
<div
style={{
minHeight: '100vh',
// Wide layout is pinned to the viewport so only the columns scroll internally (#21);
// narrow keeps natural flow with body scroll.
height: narrow ? undefined : '100vh',
overflow: narrow ? undefined : 'hidden',
background: 'var(--background)',
display: 'flex',
flexDirection: 'column',
@@ -137,7 +142,7 @@ export function MissionControl() {
flexShrink: 0,
}}
>
◄ SHIP
v{pkg.version}
</span>
<span style={{ color: 'var(--border)', flexShrink: 0 }}>│</span>
@@ -196,6 +201,7 @@ export function MissionControl() {
<div
style={{
flex: 1,
minHeight: 0, // let the grid below be constrained to the viewport instead of growing it
display: 'flex',
flexDirection: 'column',
gap: '1px',
@@ -242,6 +248,7 @@ export function MissionControl() {
<div
style={{
flex: 1,
minHeight: 0, // same — the columns' overflowY:auto then scrolls inside their tracks
display: 'grid',
gridTemplateColumns: narrow ? '1fr' : '320px 1fr 360px',
gap: '1px',
@@ -338,7 +345,9 @@ export function MissionControl() {
overflowY: 'auto',
}}
>
<Panel notch="md" style={{ flex: 1 }}>
{/* flex column + minHeight:0 so the tab content scrolls inside the panel (#21) —
the Panel root's default overflow:hidden would otherwise clip it */}
<Panel notch="md" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<PanelHeader>
<PanelTitle>OPERATIONS // SHIP COMMAND</PanelTitle>
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem' }}>
@@ -346,7 +355,7 @@ export function MissionControl() {
</div>
</PanelHeader>
<PanelContent style={{ padding: 0 }}>
<PanelContent style={{ padding: 0, flex: 1, minHeight: 0, overflowY: 'auto' }}>
<Tabs defaultValue="world-map">
<TabsList
style={{
+12 -1
View File
@@ -13,6 +13,7 @@ export interface NodeGraphNode {
y: number
status?: NodeStatus
sublabel?: string
disabled?: boolean // not clickable even when onNodeClick is set (e.g. the current system)
}
export interface NodeGraphEdge {
@@ -29,6 +30,7 @@ export interface NodeGraphProps extends React.HTMLAttributes<HTMLDivElement> {
title?: string
height?: number
directed?: boolean
onNodeClick?: (id: string) => void // makes non-disabled nodes clickable buttons
}
const VARIANT_COLORS: Record<NodeGraphVariant, string> = {
@@ -59,6 +61,7 @@ const NodeGraph = React.forwardRef<HTMLDivElement, NodeGraphProps>(
title,
height = 320,
directed = false,
onNodeClick,
style,
...props
},
@@ -219,13 +222,21 @@ const NodeGraph = React.forwardRef<HTMLDivElement, NodeGraphProps>(
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)}
style={{ cursor: 'default' }}
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
+20 -45
View File
@@ -1,13 +1,12 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { NodeGraph } from '@/components/ui/node-graph'
import type { WorldState } from '@/lib/game/save'
// WORLD MAP tab — the first live game panel: renders real world state from /api/world and
// offers a jump button per system adjacent to the current one (NodeGraph nodes are not
// clickable upstream, so the affordance lives in this list). Turn-based: fetch on mount,
// WORLD MAP tab — renders real world state from /api/world. Clicking a system adjacent to the
// current one on the galactic chart jumps there (same POST /api/jump flow as before); the
// current and unreachable systems are dimmed and not clickable. Turn-based: fetch on mount,
// refetch via the jump response — no polling.
interface WorldMapPanelProps {
@@ -74,58 +73,34 @@ export function WorldMapPanel({ height = 380 }: WorldMapPanelProps) {
}
const current = world.systems.find((s) => s.current)!
const reachable = world.edges
.filter(([a, b]) => a === current.id || b === current.id)
.map(([a, b]) => (a === current.id ? b : a))
.map((id) => world.systems.find((s) => s.id === id)!)
// 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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<NodeGraph
nodes={world.systems.map((s) => ({
id: String(s.id),
label: s.name,
x: s.x,
y: s.y,
status: s.current ? 'ACTIVE' : s.discovered ? 'NEUTRAL' : 'OFFLINE',
sublabel: s.current ? 'CURRENT' : s.discovered ? 'DISCOVERED' : 'UNKNOWN',
}))}
nodes={world.systems.map((s) => {
const jumpable = !s.current && reachableIds.has(s.id)
return {
id: String(s.id),
label: s.name,
x: s.x,
y: s.y,
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
}
})}
edges={world.edges.map(([a, b]) => ({ from: String(a), to: String(b) }))}
variant="ACTIVE"
title={`GALACTIC CHART // JUMP ROUTES — TURN ${world.turn}`}
height={height}
style={{ width: '100%' }}
onNodeClick={(id) => void jump(Number(id))}
/>
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', overflow: 'hidden' }}>
<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',
}}
>
JUMP ROUTES // FROM {current.name.toUpperCase()}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', padding: '0.6rem 0.75rem' }}>
{reachable.map((s) => (
<Button
key={s.id}
variant="OUTLINE"
size="SM"
disabled={jumpingId !== null}
onClick={() => void jump(s.id)}
>
{jumpingId === s.id ? 'JUMPING…' : `${s.name.toUpperCase()} // JUMP`}
</Button>
))}
</div>
</div>
{error && (
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.5rem 0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>
+11 -5
View File
@@ -71,12 +71,17 @@ test('tabs switch panels client-side (event log terminal)', 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;
await page.goto('/');
// Live chart title carries the turn counter; a fresh save sits at the start with six jump routes.
// Live chart title carries the turn counter.
await expect(page.getByText(/GALACTIC CHART \/\/ JUMP ROUTES — TURN \d+/)).toBeVisible();
const jumpButtons = page.getByRole('button', { name: /\/\/ JUMP$/ });
await expect(jumpButtons.first()).toBeVisible();
await expect(jumpButtons).toHaveCount(6);
// The current system is marked, and every adjacent system is a clickable chart field (role=button).
const current = world.systems.find((s) => s.current)!;
await expect(page.getByText('CURRENT', { exact: true })).toBeVisible();
const panel = page.getByRole('tabpanel');
const adjacent = world.edges.filter(([a, b]) => a === current.id || b === current.id); // six on a fresh save
await expect(panel.getByRole('button')).toHaveCount(adjacent.length);
});
test('jumping to an adjacent system updates the world state and turn counter', async ({ page }) => {
@@ -87,7 +92,8 @@ test('jumping to an adjacent system updates the world state and turn counter', a
const targetName = before.systems.find((s) => s.id === targetId)!.name;
await page.goto('/');
await page.getByRole('button', { name: `${targetName.toUpperCase()} // JUMP` }).click();
// Click the target's chart field — clickable nodes are role=button with their name as accessible label.
await page.getByRole('tabpanel').getByRole('button', { name: targetName.toUpperCase(), exact: true }).click();
const after = (await (await page.request.get('/api/world')).json()) as WorldState;
expect(after.turn).toBe(before.turn + 1);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "space-wanderer",
"version": "0.9.1",
"version": "0.9.4",
"private": true,
"scripts": {
"dev": "next dev -p 3000",