hud chrome: remove Map window, pin top bar, dockable left/right panels (v0.5.0)
CI / build + unit + e2e (pull_request) Successful in 2m18s
CI / build + unit + e2e (pull_request) Successful in 2m18s
This commit is contained in:
+81
-57
@@ -7,8 +7,6 @@ import {
|
||||
ListTree,
|
||||
Loader2,
|
||||
Map as MapIcon,
|
||||
Minus,
|
||||
Plus,
|
||||
Radar,
|
||||
} from 'lucide-react';
|
||||
import { Toaster } from './components/ui/sonner';
|
||||
@@ -18,7 +16,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '.
|
||||
import { cn } from './lib/utils';
|
||||
import PresetPicker from './components/PresetPicker';
|
||||
import NationSelect from './components/NationSelect';
|
||||
import WorldMap, { type MapApi } from './components/WorldMap';
|
||||
import WorldMap from './components/WorldMap';
|
||||
import FloatingWindow from './components/FloatingWindow';
|
||||
import MapLegend from './components/MapLegend';
|
||||
import NationPanel from './components/NationPanel';
|
||||
@@ -46,13 +44,20 @@ function toastError(e: unknown): void {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Floating-window collapse persistence (positions live in FloatingWindow itself).
|
||||
// Floating-window collapse + dock persistence.
|
||||
// (Free-floating *positions* live in FloatingWindow itself, per id.)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FLOAT_COLLAPSED_KEY = 'history.floatwin.v1.collapsed';
|
||||
const FLOAT_DOCK_KEY = 'history.floatwin.v1.dock';
|
||||
|
||||
/** Every collapsible HUD panel (the slim header is never collapsed). */
|
||||
const PANEL_WINDOW_IDS = ['dossier', 'feed', 'action', 'legend', 'zoom'] as const;
|
||||
/**
|
||||
* Every collapsible HUD panel (the slim, pinned header is never collapsed).
|
||||
* The three substantive panels (dossier / feed / action) are also dockable;
|
||||
* the legend stays a small floating widget, and there is no longer a "Map"
|
||||
* window.
|
||||
*/
|
||||
const PANEL_WINDOW_IDS = ['dossier', 'feed', 'action', 'legend'] as const;
|
||||
|
||||
function loadCollapsedMap(): Record<string, boolean> {
|
||||
try {
|
||||
@@ -78,6 +83,33 @@ function persistCollapsedMap(m: Record<string, boolean>): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** A dock mode: a docked edge, or 'float' (free-floating, the default). */
|
||||
type DockMode = 'left' | 'right' | 'float';
|
||||
|
||||
function loadDockMap(): Record<string, DockMode> {
|
||||
try {
|
||||
const raw = localStorage.getItem(FLOAT_DOCK_KEY);
|
||||
if (raw == null) return {};
|
||||
const v: unknown = JSON.parse(raw);
|
||||
if (!v || typeof v !== 'object' || Array.isArray(v)) return {};
|
||||
const out: Record<string, DockMode> = {};
|
||||
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
|
||||
if (val === 'left' || val === 'right' || val === 'float') out[k] = val;
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function persistDockMap(m: Record<string, DockMode>): void {
|
||||
try {
|
||||
localStorage.setItem(FLOAT_DOCK_KEY, JSON.stringify(m));
|
||||
} catch {
|
||||
/* storage unavailable — dock choices just won't persist */
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active-game persistence. The id of the game the user is (or was last) playing
|
||||
// is remembered in localStorage, so a page refresh drops them straight back
|
||||
@@ -485,14 +517,17 @@ interface GameScreenProps {
|
||||
|
||||
/**
|
||||
* The main play view — a full-bleed world map with a floating HUD. The MapLibre
|
||||
* map is a fixed, full-viewport background (z-0); over it float draggable,
|
||||
* collapsible windows: a slim header bar (scenario, turn + date, player nation,
|
||||
* live status, "Focus map" collapse-all) at the top, the nation dossier
|
||||
* (top-left), the event feed (right), the "Your move" command deck
|
||||
* (bottom-center), the nation-color legend (bottom-left), and a tiny map zoom
|
||||
* control (bottom-right). Each window remembers its dragged position, and
|
||||
* clicking one raises it above its siblings. Pan/zoom/click on the map stays
|
||||
* fully available in the gaps between windows.
|
||||
* map is a fixed, full-viewport background (z-0) that the user drives directly
|
||||
* (wheel/trackpad zoom + drag pan — there is no floating zoom control). Over it:
|
||||
* a slim **pinned** header bar (fixed, non-movable, centered at the top a few
|
||||
* px down; scenario, turn + date, player nation, live status, "Focus map"
|
||||
* collapse-all); the nation dossier, the event feed, and the "Your move"
|
||||
* command deck — each free-floating by default (at the same first-run positions
|
||||
* as before) but **dockable** into a full-height left or right pane, the choice
|
||||
* being remembered; and the nation-color legend, which stays a small floating
|
||||
* widget (not dockable). Each floating window remembers its dragged position,
|
||||
* and clicking one raises it above its siblings. Pan/zoom/click on the map
|
||||
* stays fully available in the gaps between windows.
|
||||
*/
|
||||
function GameScreen({
|
||||
game,
|
||||
@@ -534,11 +569,23 @@ function GameScreen({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Tiny map API (zoom in/out) exposed by WorldMap for the floating control.
|
||||
const mapApiRef = useRef<MapApi | null>(null);
|
||||
const handleMapReady = useCallback((api: MapApi | null) => {
|
||||
mapApiRef.current = api;
|
||||
// Dock state for the three dockable panels (dossier / feed / action),
|
||||
// persisted per id (survives reloads). The default is 'float' — the current
|
||||
// first-run positions — so nothing visually changes until the user docks
|
||||
// something. The legend is not dockable; the header is pinned.
|
||||
const [dock, setDock] = useState<Record<string, DockMode>>(loadDockMap);
|
||||
const setDockFor = useCallback((id: string, mode: DockMode) => {
|
||||
setDock((prev) => {
|
||||
const next = { ...prev, [id]: mode };
|
||||
persistDockMap(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
// The `dock` prop expects a concrete edge, or `undefined` to free-float.
|
||||
const dockOf = (id: string) => {
|
||||
const m = dock[id];
|
||||
return m === 'left' || m === 'right' ? m : undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
@@ -549,13 +596,14 @@ function GameScreen({
|
||||
nations={game.nations}
|
||||
presetId={game.presetId}
|
||||
onFocusNation={setViewingId}
|
||||
onMapReady={handleMapReady}
|
||||
/>
|
||||
|
||||
{/* Slim floating header: brand, turn + date, player nation, live status, Focus map. */}
|
||||
{/* Pinned top bar: brand, turn + date, player nation, live status, Focus map.
|
||||
Fixed and non-movable, centered at the top a few px from the top edge. */}
|
||||
<FloatingWindow
|
||||
id="header"
|
||||
anchor="top-center"
|
||||
dataTestId="top-bar"
|
||||
pinned
|
||||
collapsible={false}
|
||||
title={
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
@@ -628,13 +676,16 @@ function GameScreen({
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Nation dossier, top-left. */}
|
||||
{/* Nation dossier, top-left by default; dockable into a left/right pane. */}
|
||||
<FloatingWindow
|
||||
id="dossier"
|
||||
dataTestId="panel-dossier"
|
||||
anchor="top-left"
|
||||
width={300}
|
||||
title="Dossier"
|
||||
titleIcon={<BookOpen className="size-4" />}
|
||||
dock={dockOf('dossier')}
|
||||
onDockChange={(m) => setDockFor('dossier', m)}
|
||||
collapsed={!!collapsed.dossier}
|
||||
onCollapsedChange={(c) => setCollapsedFor('dossier', c)}
|
||||
bodyClassName="max-h-[56vh] overflow-y-auto"
|
||||
@@ -659,9 +710,10 @@ function GameScreen({
|
||||
<NationPanel nation={viewed} nations={game.nations} isPlayer={viewingId === game.playerNationId} inWindow />
|
||||
</FloatingWindow>
|
||||
|
||||
{/* Event feed, right side. */}
|
||||
{/* Event feed, right side by default; dockable into a left/right pane. */}
|
||||
<FloatingWindow
|
||||
id="feed"
|
||||
dataTestId="panel-feed"
|
||||
anchor="right"
|
||||
width={330}
|
||||
title="Event feed"
|
||||
@@ -671,6 +723,8 @@ function GameScreen({
|
||||
{game.events.length}
|
||||
</Badge>
|
||||
}
|
||||
dock={dockOf('feed')}
|
||||
onDockChange={(m) => setDockFor('feed', m)}
|
||||
collapsed={!!collapsed.feed}
|
||||
onCollapsedChange={(c) => setCollapsedFor('feed', c)}
|
||||
bodyClassName="h-[52vh]"
|
||||
@@ -678,9 +732,10 @@ function GameScreen({
|
||||
<EventFeed events={game.events} nations={game.nations} inWindow />
|
||||
</FloatingWindow>
|
||||
|
||||
{/* Command deck ("Your move"), bottom-center — queue items, then commit. */}
|
||||
{/* Command deck ("Your move"), bottom-center by default; dockable. */}
|
||||
<FloatingWindow
|
||||
id="action"
|
||||
dataTestId="panel-action"
|
||||
anchor="bottom-center"
|
||||
width={370}
|
||||
title="Your move"
|
||||
@@ -692,6 +747,8 @@ function GameScreen({
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
dock={dockOf('action')}
|
||||
onDockChange={(m) => setDockFor('action', m)}
|
||||
collapsed={!!collapsed.action}
|
||||
onCollapsedChange={(c) => setCollapsedFor('action', c)}
|
||||
bodyClassName="max-h-[72vh] overflow-y-auto"
|
||||
@@ -721,39 +778,6 @@ function GameScreen({
|
||||
>
|
||||
<MapLegend regions={regions} />
|
||||
</FloatingWindow>
|
||||
|
||||
{/* Tiny floating zoom control, bottom-right (clear of the attribution). */}
|
||||
<FloatingWindow
|
||||
id="zoom"
|
||||
anchor="bottom-right"
|
||||
width={88}
|
||||
title="Map"
|
||||
titleIcon={<MapIcon className="size-4" />}
|
||||
offset={{ y: -28 }}
|
||||
collapsed={!!collapsed.zoom}
|
||||
onCollapsedChange={(c) => setCollapsedFor('zoom', c)}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1.5 p-2.5">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => mapApiRef.current?.zoomIn()}
|
||||
aria-label="Zoom in"
|
||||
data-testid="zoom-in"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => mapApiRef.current?.zoomOut()}
|
||||
aria-label="Zoom out"
|
||||
data-testid="zoom-out"
|
||||
>
|
||||
<Minus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</FloatingWindow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user