Full-screen map with draggable, collapsible floating HUD windows

- MapLibre map is now a fixed, full-viewport background layer (z-0)
- New FloatingWindow: drag by title bar (pointer capture, viewport-clamped,
  position persisted to localStorage), collapsible to title bar, bring-to-front
  z-index; semi-transparent slate window with amber/cyan accents
- Game screen re-laid out as floating windows over the map: slim top header
  (scenario, turn + date, player nation chip, Idle/Simulating status, Focus map
  collapse-all), dossier (top-left), event feed (right), Your move deck
  (bottom-center), legend (bottom-left), tiny zoom control (bottom-right)
- MapLibre default nav controls removed; custom floating zoom-in/out control
- Panels gain an inWindow mode (no card chrome, same testids)
- server: honor HISTORY_API_PORT as PORT fallback (dev proxy/e2e agreement)
- css: keep maplibre canvas base color and Radix portals above HUD windows
This commit is contained in:
2026-10-10 22:47:08 +00:00
parent e327e9aea0
commit 913d094e11
9 changed files with 994 additions and 363 deletions
+123 -104
View File
@@ -19,6 +19,11 @@ interface Props {
nations: Record<string, Nation>;
/** True when this is the nation the player is leading. */
isPlayer: boolean;
/**
* Render without the outer card chrome (the surrounding floating window
* provides the border, backdrop, and title bar). Same content, same testids.
*/
inWindow?: boolean;
}
/**
@@ -28,116 +33,130 @@ interface Props {
* relation (ally = green, rival = amber, war = red, neutral = slate). Used for
* the player (highlighted) or any inspected nation.
*/
export default function NationPanel({ nation, nations, isPlayer }: Props) {
export default function NationPanel({ nation, nations, isPlayer, inWindow = false }: Props) {
const color = nationColor(nation.id);
const relations = Object.entries(nation.relations).sort(([a], [b]) => a.localeCompare(b));
const regions = nation.territory;
const identity = (
<div className="flex items-center gap-3">
<span
className="inline-block size-4 shrink-0 rounded-md ring-2 ring-white/10"
style={{ background: color }}
data-testid="nation-color-chip"
/>
<div className="min-w-0 flex-1 space-y-0.5">
<CardTitle className="truncate text-lg">{nation.name}</CardTitle>
<CardDescription>
{nation.type} · {regions.length} region{regions.length === 1 ? '' : 's'}
</CardDescription>
</div>
{isPlayer && (
<Badge variant="default" className="shrink-0">
Your nation
</Badge>
)}
</div>
);
const sections = (
<div className="space-y-5">
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Status
</h3>
<div className="space-y-2">
<StatBar
label="Military"
value={nation.stats.military}
testId={isPlayer ? 'stat-military' : undefined}
/>
<StatBar
label="Economy"
value={nation.stats.economy}
testId={isPlayer ? 'stat-economy' : undefined}
/>
<StatBar
label="Stability"
value={nation.stats.stability}
testId={isPlayer ? 'stat-stability' : undefined}
/>
<StatBar
label="Population"
value={nation.stats.population}
testId={isPlayer ? 'stat-population' : undefined}
/>
</div>
</section>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Territory
</h3>
{regions.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{regions.map((code) => (
<span
key={code}
className="rounded border px-1.5 py-0.5 text-xs tabular-nums"
style={{ borderColor: `${color}66`, color, background: `${color}12` }}
>
{code}
</span>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No territory.</p>
)}
</section>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Diplomacy
</h3>
{relations.length > 0 ? (
<ul className="space-y-1.5" data-testid="relations-list">
{relations.map(([id, status]) => {
const other = nations[id];
const c = RELATION_COLORS[status] ?? '#94a3b8';
return (
<li
key={id}
className="flex items-center gap-2 text-sm"
data-testid="relation-row"
data-status={status}
>
<span className="inline-block size-2.5 shrink-0 rounded-full" style={{ background: c }} />
<span className="min-w-0 flex-1 truncate text-foreground/85">
{other?.name ?? id}
</span>
<span className="shrink-0 text-xs font-medium" style={{ color: c }}>
{RELATION_LABEL[status] ?? status}
</span>
</li>
);
})}
</ul>
) : (
<p className="text-sm text-muted-foreground">No diplomatic relations yet.</p>
)}
</section>
</div>
);
if (inWindow) {
return (
<div data-testid="nation-panel" className="space-y-4 p-4">
{identity}
{sections}
</div>
);
}
return (
<Card data-testid="nation-panel" className={isPlayer ? 'border-primary/40' : undefined}>
<CardHeader className="space-y-0">
<div className="flex items-center gap-3">
<span
className="inline-block size-4 shrink-0 rounded-md ring-2 ring-white/10"
style={{ background: color }}
data-testid="nation-color-chip"
/>
<div className="min-w-0 flex-1 space-y-0.5">
<CardTitle className="truncate text-lg">{nation.name}</CardTitle>
<CardDescription>
{nation.type} · {regions.length} region{regions.length === 1 ? '' : 's'}
</CardDescription>
</div>
{isPlayer && (
<Badge variant="default" className="shrink-0">
Your nation
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-5">
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Status
</h3>
<div className="space-y-2">
<StatBar
label="Military"
value={nation.stats.military}
testId={isPlayer ? 'stat-military' : undefined}
/>
<StatBar
label="Economy"
value={nation.stats.economy}
testId={isPlayer ? 'stat-economy' : undefined}
/>
<StatBar
label="Stability"
value={nation.stats.stability}
testId={isPlayer ? 'stat-stability' : undefined}
/>
<StatBar
label="Population"
value={nation.stats.population}
testId={isPlayer ? 'stat-population' : undefined}
/>
</div>
</section>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Territory
</h3>
{regions.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{regions.map((code) => (
<span
key={code}
className="rounded border px-1.5 py-0.5 text-xs tabular-nums"
style={{ borderColor: `${color}66`, color, background: `${color}12` }}
>
{code}
</span>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No territory.</p>
)}
</section>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Diplomacy
</h3>
{relations.length > 0 ? (
<ul className="space-y-1.5" data-testid="relations-list">
{relations.map(([id, status]) => {
const other = nations[id];
const c = RELATION_COLORS[status] ?? '#94a3b8';
return (
<li
key={id}
className="flex items-center gap-2 text-sm"
data-testid="relation-row"
data-status={status}
>
<span className="inline-block size-2.5 shrink-0 rounded-full" style={{ background: c }} />
<span className="min-w-0 flex-1 truncate text-foreground/85">
{other?.name ?? id}
</span>
<span className="shrink-0 text-xs font-medium" style={{ color: c }}>
{RELATION_LABEL[status] ?? status}
</span>
</li>
);
})}
</ul>
) : (
<p className="text-sm text-muted-foreground">No diplomatic relations yet.</p>
)}
</section>
</CardContent>
<CardHeader className="space-y-0">{identity}</CardHeader>
<CardContent>{sections}</CardContent>
</Card>
);
}