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
+135 -103
View File
@@ -18,6 +18,12 @@ interface Props {
onChat: (to: string, message: string) => void;
onAdvisor: (prompt?: string) => void;
onJump: () => void;
/**
* Render without the outer card chrome (the surrounding floating window
* provides the border, the "Your move" title, and the "Jump forward" button
* in its title bar). Same content, same testids.
*/
inWindow?: boolean;
}
/**
@@ -28,7 +34,16 @@ interface Props {
* that turns take a few seconds. A "Jump forward" button auto-simulates the
* world with no player move.
*/
export default function ActionPanel({ game, busy, advisors, onAction, onChat, onAdvisor, onJump }: Props) {
export default function ActionPanel({
game,
busy,
advisors,
onAction,
onChat,
onAdvisor,
onJump,
inWindow = false,
}: Props) {
const [tab, setTab] = useState('action');
const [action, setAction] = useState('');
const [target, setTarget] = useState('');
@@ -82,110 +97,127 @@ export default function ActionPanel({ game, busy, advisors, onAction, onChat, on
else onAdvisor(prompt.trim() || undefined);
};
// In window mode the surrounding FloatingWindow shows "Your move" and the
// "Jump forward" button in its own title bar, so omit both here.
const header = inWindow ? null : (
<CardHeader className="flex-row items-center justify-between space-y-0">
<div className="flex items-center gap-2">
<Radar className="size-4 text-primary" />
<CardTitle className="text-base">Your move</CardTitle>
</div>
<Button variant="secondary" size="sm" onClick={onJump} disabled={busy} data-testid="jump-forward">
{busy ? 'Simulating…' : 'Jump forward'}
</Button>
</CardHeader>
);
const body = (
<>
<Tabs value={tab} onValueChange={setTab} className="gap-3">
<TabsList className="grid w-full grid-cols-3" data-testid="action-tabs">
<TabsTrigger value="action" data-testid="tab-action">
Action
</TabsTrigger>
<TabsTrigger value="diplo" data-testid="tab-diplo">
Diplomacy
</TabsTrigger>
<TabsTrigger value="advisor" data-testid="tab-advisor">
Advisor
</TabsTrigger>
</TabsList>
<TabsContent value="action" className="space-y-2">
<Textarea
value={action}
onChange={(e) => setAction(e.target.value)}
placeholder="Describe your move — e.g. “I mobilize reserves and fortify the western border.”"
rows={3}
data-testid="action-textarea"
/>
</TabsContent>
<TabsContent value="diplo" className="space-y-2">
<Select value={target} onValueChange={setTarget}>
<SelectTrigger className="w-full" data-testid="chat-target">
<SelectValue placeholder="Choose a nation to address…" />
</SelectTrigger>
<SelectContent>
{others.map(([id, n]) => (
<SelectItem key={id} value={id}>
{n.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Your diplomatic message…"
rows={3}
data-testid="chat-message"
/>
</TabsContent>
<TabsContent value="advisor" className="space-y-2">
<Input
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Optional focus — e.g. “the nuclear standoff”"
data-testid="advisor-prompt"
/>
{advisors.length > 0 && (
<ul className="space-y-1.5" data-testid="advisor-list">
{advisors.map((a, i) => (
<li
key={i}
className="flex items-start gap-2 rounded-md border bg-card/50 p-2 text-sm"
data-testid="advisor-item"
>
<span className="flex-1 leading-snug">{a}</span>
<Button size="sm" variant="outline" onClick={() => applyIdea(a)}>
Use
</Button>
</li>
))}
</ul>
)}
</TabsContent>
</Tabs>
<div className="mt-3 space-y-2">
<Button className="h-11 w-full" onClick={send} disabled={sendDisabled} data-testid="action-submit">
{busy ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
{sendLabel}
</Button>
{busy && (
<p
className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground"
data-testid="sim-hint"
>
<span className="sim-pulse inline-block size-1.5 rounded-full bg-primary" />
The world is thinking — turns can take a few seconds.
</p>
)}
</div>
</>
);
if (inWindow) {
return (
<div data-testid="action-panel" className="p-4">
{body}
</div>
);
}
return (
<Card className="gap-4" data-testid="action-panel">
<CardHeader className="flex-row items-center justify-between space-y-0">
<div className="flex items-center gap-2">
<Radar className="size-4 text-primary" />
<CardTitle className="text-base">Your move</CardTitle>
</div>
<Button variant="secondary" size="sm" onClick={onJump} disabled={busy} data-testid="jump-forward">
{busy ? 'Simulating…' : 'Jump forward'}
</Button>
</CardHeader>
<CardContent>
<Tabs value={tab} onValueChange={setTab} className="gap-3">
<TabsList className="grid w-full grid-cols-3" data-testid="action-tabs">
<TabsTrigger value="action" data-testid="tab-action">
Action
</TabsTrigger>
<TabsTrigger value="diplo" data-testid="tab-diplo">
Diplomacy
</TabsTrigger>
<TabsTrigger value="advisor" data-testid="tab-advisor">
Advisor
</TabsTrigger>
</TabsList>
<TabsContent value="action" className="space-y-2">
<Textarea
value={action}
onChange={(e) => setAction(e.target.value)}
placeholder="Describe your move — e.g. “I mobilize reserves and fortify the western border.”"
rows={3}
data-testid="action-textarea"
/>
</TabsContent>
<TabsContent value="diplo" className="space-y-2">
<Select value={target} onValueChange={setTarget}>
<SelectTrigger className="w-full" data-testid="chat-target">
<SelectValue placeholder="Choose a nation to address…" />
</SelectTrigger>
<SelectContent>
{others.map(([id, n]) => (
<SelectItem key={id} value={id}>
{n.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Your diplomatic message…"
rows={3}
data-testid="chat-message"
/>
</TabsContent>
<TabsContent value="advisor" className="space-y-2">
<Input
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Optional focus — e.g. “the nuclear standoff”"
data-testid="advisor-prompt"
/>
{advisors.length > 0 && (
<ul className="space-y-1.5" data-testid="advisor-list">
{advisors.map((a, i) => (
<li
key={i}
className="flex items-start gap-2 rounded-md border bg-card/50 p-2 text-sm"
data-testid="advisor-item"
>
<span className="flex-1 leading-snug">{a}</span>
<Button size="sm" variant="outline" onClick={() => applyIdea(a)}>
Use
</Button>
</li>
))}
</ul>
)}
</TabsContent>
</Tabs>
<div className="mt-3 space-y-2">
<Button className="h-11 w-full" onClick={send} disabled={sendDisabled} data-testid="action-submit">
{busy ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
{sendLabel}
</Button>
{busy && (
<p
className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground"
data-testid="sim-hint"
>
<span className="sim-pulse inline-block size-1.5 rounded-full bg-primary" />
The world is thinking — turns can take a few seconds.
</p>
)}
</div>
</CardContent>
{header}
<CardContent>{body}</CardContent>
</Card>
);
}