From 6b167cb435fbadb69e45e47b00d895360e011827 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sun, 11 Oct 2026 17:40:01 +0000 Subject: [PATCH] hud chrome: remove Map window, pin top bar, dockable left/right panels (v0.5.0) --- AGENTS.md | 10 ++ CHANGELOG.md | 8 ++ e2e/sim.spec.ts | 98 +++++++++++++++ package.json | 2 +- src/App.tsx | 138 ++++++++++++--------- src/components/FloatingWindow.tsx | 192 ++++++++++++++++++++++++------ src/components/WorldMap.tsx | 31 +---- 7 files changed, 357 insertions(+), 122 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b7bc25a..8845c67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,7 @@ history/ ├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier ├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list (per-item trash button + confirm dialog) + "Jump forward" ├── EventFeed.tsx # scrolling event log (newest first) + ├── FloatingWindow.tsx # the HUD window primitive — free-floating (draggable, position persisted), or `pinned` (fixed top-center bar), or `dock`ed (full-height left/right column); renders dock controls when `onDockChange` is set ├── StatBar.tsx └── ui/ # 14 shadcn/ui primitives ``` @@ -142,6 +143,15 @@ history/ the player is allied with the first other nation (by key order in the `nations` record), and the date advances 2 days. Unit + e2e assertions rely on these exact values, so keep the stub deterministic. +- **HUD layout.** The game screen is a full-viewport map (z-0) with `FloatingWindow` overlays. The + top bar is **pinned** — a fixed, non-movable bar centered at the top a few px down (it re-centers + on resize and always floats above the other windows). The **dossier / event-feed / action** + panels are free-floating by default (the same first-run positions as before) but can each be + **docked** into a full-height left or right pane; the choice is persisted in `localStorage` + (`history.floatwin.v1.dock`) and restored on reload, so the default "float" reproduces the + original layout until the user docks something. The **legend** stays a small floating widget (not + dockable). There is **no "Map" window** — the map is zoomed/panned directly (wheel/trackpad + + drag). - Styling: Tailwind utility classes + shadcn/ui only; dark "command-center" theme (slate bg, amber primary, cyan accent). No CSS beyond the Tailwind import in `src/index.css`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 792794e..ff771c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to **History** are listed here, newest first. +## v0.5.0 — 2026-10-11 17:36 + +- **HUD revamp** — removed the "Map" window (the full-screen map is directly interactive via scroll/trackpad; no floating zoom control, and the now-dead `MapApi`/`onMapReady` map plumbing is removed from `WorldMap`); the top bar is now a fixed, non-movable element pinned to top-center a few px from the top (a new `pinned` mode on `FloatingWindow` — viewport-centered, no drag, no persisted position, always above other windows); and the dossier / event-feed / action panels can each be docked into a left or right full-height pane (a new `dock`/`onDockChange` mode on `FloatingWindow`, with small left/right/float controls in each title bar, choice persisted in `localStorage` and reproduced on reload; the default "float" reproduces the current first-run layout exactly). The legend stays a small floating widget. Fixes #4, Fixes #5. +- **`FloatingWindow`** — new optional props: `pinned` (top-center fixed bar), `dock` (`'left'`/`'right'` full-height columns) and `onDockChange` (renders the dock controls and reports the mode), plus `dataTestId` (a `data-testid` on the window root for e2e). `anchor` is now optional (defaults to `top-center`). +- **New `data-testid`s** — `top-bar` (pinned header), `panel-dossier` / `panel-feed` / `panel-action` (per-window roots), and `dock-left` / `dock-right` / `dock-float` (per-window dock controls). +- **New localStorage key** — `history.floatwin.v1.dock` (per-id dock mode); mirrors the existing `history.floatwin.v1.collapsed`. +- **E2E** — new "HUD revamp" suite: asserts the Map window and its zoom buttons are gone, the top bar is pinned and horizontally centered a few px from the top (and doesn't move under a drag attempt), the dossier docks to the left edge (full-height) and the feed to the right edge, and a dock choice survives a page reload — all deterministic under `LLM_STUB=1` (11 tests, up from 9). + ## v0.4.0 — 2026-10-11 16:02 - **Fix: queued items disappearing from the pending list (#10).** Root cause: SSE `state` events carried no freshness information, and the client's `onState`/`onTurn` handlers applied any incoming snapshot unconditionally. A stale snapshot — e.g. the SSE on-connect resync taken just before an add was saved, or an EventSource auto-reconnect — could arrive *after* the add's REST response and clobber it, so "add one: nothing happens, add another: both appear". Fix: every mutation now bumps a monotonic `GameState.version` (persisted in SQLite, started at 1 on create, bumped by `queueRoundItem`, the new `removeRoundItem`, and `applyTurnResult`; legacy games without the field are treated as version 0), and the client routes *every* state ingestion (SSE events, add/remove/jump responses, game loads) through a single "never go backwards" rule: a different game id is always adopted, the same game only when the incoming version is not older than the current one (compared inside the functional `setGame` updater, so it is stale-closure-proof). diff --git a/e2e/sim.spec.ts b/e2e/sim.spec.ts index f06736f..b02d746 100644 --- a/e2e/sim.spec.ts +++ b/e2e/sim.spec.ts @@ -419,3 +419,101 @@ test.describe('History — URL deep-linking (#/game/)', () => { await waitHash(page, '#/'); }); }); + +/** + * HUD revamp (issues #4 and #5). The floating "Map" zoom window is gone — the + * full-bleed map is driven directly by wheel/trackpad + drag. The top bar is a + * *pinned* element: fixed, non-movable, centered at the top a few px down. The + * dossier / feed / action panels can each be docked into a full-height left or + * right pane (persisted in `localStorage`); the legend stays a small floating + * widget. + */ +test.describe('History — HUD revamp (pinned top bar, dockable panels, no Map window)', () => { + test('Map window is gone; top bar is pinned top-center; panels dock left/right', async ({ + page, + }) => { + test.setTimeout(90000); + await createUkGame(page); + await expect(page.getByTestId('world-map')).toBeVisible(); + await expect(page.getByTestId('turn-count')).toHaveText('1'); + + const vw = page.viewportSize()!.width; + const vh = page.viewportSize()!.height; + + // --- #4: the "Map" zoom window is gone -------------------------------- + // No zoom window element, and no zoom-in/zoom-out buttons anywhere on the + // game screen. + await expect(page.locator('[data-float-window="zoom"]')).toHaveCount(0); + await expect(page.getByTestId('zoom-in')).toHaveCount(0); + await expect(page.getByTestId('zoom-out')).toHaveCount(0); + + // --- #5a: the top bar is pinned, centered at the top ------------------ + const topBar = page.getByTestId('top-bar'); + await expect(topBar).toBeVisible(); + const barBox = (await topBar.boundingBox())!; + // Horizontally centered: x ≈ (viewportWidth − width) / 2 … + expect(Math.abs(barBox.x - (vw - barBox.width) / 2)).toBeLessThanOrEqual(3); + // …and a few px down from the top edge (y ≈ 8). + expect(barBox.y).toBeGreaterThanOrEqual(6); + expect(barBox.y).toBeLessThanOrEqual(10); + + // A pointer-drag attempted on the pinned bar must not move it (it has no + // drag handlers; the map behind it never receives a mousedown). + await page.mouse.move(barBox.x + barBox.width / 2, barBox.y + barBox.height / 2); + await page.mouse.down(); + await page.mouse.move(barBox.x + barBox.width / 2 + 60, barBox.y + barBox.height / 2 + 40, { + steps: 6, + }); + await page.mouse.up(); + const barBoxAfter = (await topBar.boundingBox())!; + expect(Math.abs(barBoxAfter.x - barBox.x)).toBeLessThanOrEqual(1); + expect(Math.abs(barBoxAfter.y - barBox.y)).toBeLessThanOrEqual(1); + + // --- #5b: the dossier docks into a full-height left pane -------------- + const dossier = page.getByTestId('panel-dossier'); + await dossier.getByTestId('dock-left').click(); + const dBox = (await dossier.boundingBox())!; + // Pinned to the left edge, filling the viewport height. + expect(dBox.x).toBeLessThanOrEqual(2); + expect(dBox.y).toBeLessThanOrEqual(2); + expect(Math.abs(dBox.height - vh)).toBeLessThanOrEqual(3); + expect(Math.abs(dBox.width - 300)).toBeLessThanOrEqual(3); + + // --- #5b: the event feed docks into a full-height right pane ---------- + const feed = page.getByTestId('panel-feed'); + await feed.getByTestId('dock-right').click(); + const fBox = (await feed.boundingBox())!; + // Pinned to the right edge: its right side sits on the viewport edge. + expect(Math.abs(fBox.x + fBox.width - vw)).toBeLessThanOrEqual(3); + expect(Math.abs(fBox.height - vh)).toBeLessThanOrEqual(3); + + // The "Your move" deck is still a floating panel (we never docked it). + await expect(page.getByTestId('panel-action')).toBeVisible(); + }); + + test('a dock choice survives a page reload (persisted in localStorage)', async ({ + page, + }) => { + test.setTimeout(90000); + await createUkGame(page); + await expect(page.getByTestId('world-map')).toBeVisible(); + const vh = page.viewportSize()!.height; + + // Dock the dossier into the left pane. + const dossier = page.getByTestId('panel-dossier'); + await dossier.getByTestId('dock-left').click(); + const before = (await dossier.boundingBox())!; + expect(before.x).toBeLessThanOrEqual(2); + expect(Math.abs(before.height - vh)).toBeLessThanOrEqual(3); + + // Reload: the persisted dock choice must restore the docked layout, and the + // deep link restores the game. + await page.reload(); + await expect(page.getByTestId('world-map')).toBeVisible(); + await expect(page.getByTestId('turn-count')).toHaveText('1', { timeout: 20000 }); + const after = (await page.getByTestId('panel-dossier').boundingBox())!; + expect(after.x).toBeLessThanOrEqual(2); + expect(Math.abs(after.height - vh)).toBeLessThanOrEqual(3); + expect(Math.abs(after.width - 300)).toBeLessThanOrEqual(3); + }); +}); diff --git a/package.json b/package.json index 7ff77fb..ad5f415 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "history", - "version": "0.4.0", + "version": "0.5.0", "private": true, "type": "module", "description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).", diff --git a/src/App.tsx b/src/App.tsx index a6cadd5..847efa5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 { try { @@ -78,6 +83,33 @@ function persistCollapsedMap(m: Record): void { } } +/** A dock mode: a docked edge, or 'float' (free-floating, the default). */ +type DockMode = 'left' | 'right' | 'float'; + +function loadDockMap(): Record { + 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 = {}; + for (const [k, val] of Object.entries(v as Record)) { + if (val === 'left' || val === 'right' || val === 'float') out[k] = val; + } + return out; + } catch { + return {}; + } +} + +function persistDockMap(m: Record): 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(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>(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 (
@@ -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. */} @@ -628,13 +676,16 @@ function GameScreen({ } /> - {/* Nation dossier, top-left. */} + {/* Nation dossier, top-left by default; dockable into a left/right pane. */} } + 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({ - {/* Event feed, right side. */} + {/* Event feed, right side by default; dockable into a left/right pane. */} } + 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({ - {/* Command deck ("Your move"), bottom-center — queue items, then commit. */} + {/* Command deck ("Your move"), bottom-center by default; dockable. */} ) : 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({ > - - {/* Tiny floating zoom control, bottom-right (clear of the attribution). */} - } - offset={{ y: -28 }} - collapsed={!!collapsed.zoom} - onCollapsedChange={(c) => setCollapsedFor('zoom', c)} - > -
- - -
-
); } diff --git a/src/components/FloatingWindow.tsx b/src/components/FloatingWindow.tsx index ec10c56..a046e9a 100644 --- a/src/components/FloatingWindow.tsx +++ b/src/components/FloatingWindow.tsx @@ -6,7 +6,7 @@ import { type CSSProperties, type ReactNode, } from 'react'; -import { Minus, Plus } from 'lucide-react'; +import { AlignLeft, AlignRight, Maximize2, Minus, Plus } from 'lucide-react'; import { cn } from '@/lib/utils'; /** Edge anchor used to place a window on first run (before the user drags it). */ @@ -19,6 +19,11 @@ export type FloatAnchor = | 'bottom-center' | 'bottom-right'; +/** A docked column edge: pinned to a screen edge and filling the viewport height. */ +export type FloatDock = 'left' | 'right'; +/** A dock mode: a docked edge, or `'float'` (free-floating, the default). */ +export type FloatDockMode = FloatDock | 'float'; + const STORAGE_PREFIX = 'history.floatwin.v1.'; // Module-level z-index bookkeeping so sibling windows stack in click order: @@ -26,6 +31,9 @@ const STORAGE_PREFIX = 'history.floatwin.v1.'; // raised, the shared counter advances, so the newest on top always wins. const Z_BASE = 10; const Z_STEP = 10; +// A pinned window always floats above every docked/floating window, no matter +// how many times those are raised. +const Z_PINNED = 100000; let zSeq = 0; let zTopId: string | null = null; /** Returns the new z-index to use, or -1 if this window is already front-most. */ @@ -118,8 +126,31 @@ function clampPos(x: number, y: number, w: number, h: number): Pos { export interface FloatingWindowProps { /** Stable id — used for the localStorage key and as a data-attribute. */ id: string; - /** First-run placement. The last dragged position (persisted) takes over. */ - anchor: FloatAnchor; + /** Optional `data-testid` on the window root, for e2e targeting. */ + dataTestId?: string; + /** + * First-run placement for a free-floating window; the last dragged position + * (persisted) takes over. Ignored when `pinned` or `dock` is set. + */ + anchor?: FloatAnchor; + /** + * Pin the window to the top-center of the viewport: a fixed, non-movable bar + * a few px from the top that re-centers automatically on resize, always + * floats above docked/floating panels, and never reads/writes a persisted + * position. Used for the slim header. + */ + pinned?: boolean; + /** + * Dock the window into a full-height left or right column: non-movable, + * body scrolls, never reads/writes the persisted free-floating position. + * Absent (`undefined`) = free-floating as before. + */ + dock?: FloatDock; + /** + * When provided, render dock controls (left / right / float) in the title bar + * and report the chosen mode back to the parent, which persists it. + */ + onDockChange?: (mode: FloatDockMode) => void; /** Fixed window width in px (content is sized to it). Omit to fit content. */ width?: number; /** Extra px nudged away from the anchor position (e.g. clear the map attribution). */ @@ -128,7 +159,7 @@ export interface FloatingWindowProps { title?: ReactNode; /** Optional icon before the title. */ titleIcon?: ReactNode; - /** Right-side title-bar content, rendered before the collapse button. */ + /** Right-side title-bar content, rendered before the dock/collapse controls. */ titleExtra?: ReactNode; /** Render the collapse toggle (default true). */ collapsible?: boolean; @@ -143,16 +174,27 @@ export interface FloatingWindowProps { } /** - * A draggable, collapsible, position-persisted window that floats over the - * full-screen map. Dragging is limited to the title bar (via pointer capture) - * so inputs and buttons in the body keep working; the window is clamped so it - * can never be dragged fully off-screen, its position is remembered in - * localStorage, clicking any part of it raises it above siblings, and the - * collapse toggle shrinks it to just its title bar. + * A window that floats over the full-screen map. Three mutually-exclusive + * modes, set via props (absent = free-floating): + * • **free-floating** (default) — draggable by the title bar (pointer + * capture), clamped on-screen, position persisted in localStorage, raised + * above siblings on click, collapsible. + * • **pinned** — a fixed, non-movable bar centered at the top of the + * viewport a few px down; re-centers on resize; always above other + * windows; no position persisted. + * • **docked** (`left`/`right`) — a non-movable, full-height column pinned + * to a screen edge whose body scrolls; the persisted free-floating + * position is neither read nor written while docked. + * When `onDockChange` is provided, small dock/left/right/float controls render + * in the title bar and report the chosen mode back to the parent. */ export default function FloatingWindow({ id, - anchor, + dataTestId, + anchor = 'top-center', + pinned = false, + dock, + onDockChange, width, offset, title, @@ -165,6 +207,12 @@ export default function FloatingWindow({ bodyClassName, children, }: FloatingWindowProps) { + const isPinned = pinned; + const isDocked = dock === 'left' || dock === 'right'; + // Free-floating is the only mode that drags, persists a position, and needs + // viewport measurement/clamping. Pinned and docked are CSS-positioned. + const isFloating = !isPinned && !isDocked; + const rootRef = useRef(null); const sizeRef = useRef<{ w: number; h: number } | null>(null); const dragRef = useRef<{ dx: number; dy: number; pointerId: number } | null>(null); @@ -177,9 +225,11 @@ export default function FloatingWindow({ setPosState(p); }; - // First paint: measure, then place (restored position or anchor) and clamp. - // Runs before paint (useLayoutEffect), so there is no visible flash. + // First paint (free-floating only): measure, then place (restored position + // or anchor) and clamp. Runs before paint (useLayoutEffect), so no flash. + // Re-runs when the mode changes so a window returning from docked re-places. useLayoutEffect(() => { + if (!isFloating) return; if (posRef.current) return; const el = rootRef.current; if (!el) return; @@ -195,11 +245,12 @@ export default function FloatingWindow({ ); posRef.current = p; setPosState(p); - }, [id, anchor]); + }, [id, anchor, offset, isFloating]); // Re-measure + re-clamp whenever the body toggles hidden (expand can push a - // bottom-anchored window off the bottom edge). + // bottom-anchored window off the bottom edge). Free-floating only. useLayoutEffect(() => { + if (!isFloating) return; const el = rootRef.current; const p = posRef.current; if (!el || !p) return; @@ -208,10 +259,11 @@ export default function FloatingWindow({ sizeRef.current = { w: rect.width, h: rect.height }; const c = clampPos(p.x, p.y, rect.width, rect.height); if (c.x !== p.x || c.y !== p.y) setPos(c); - }, [collapsed]); + }, [collapsed, isFloating]); - // Keep the window on-screen when the viewport shrinks. + // Keep a free-floating window on-screen when the viewport shrinks. useEffect(() => { + if (!isFloating) return; const onResize = () => { const p = posRef.current; const s = sizeRef.current; @@ -221,7 +273,7 @@ export default function FloatingWindow({ }; window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); - }, []); + }, [isFloating]); const onHandleDown = (e: React.PointerEvent) => { if (e.button !== 0) return; @@ -258,18 +310,44 @@ export default function FloatingWindow({ if (p) savePos(id, p.x, p.y); }; - const style: CSSProperties = { - zIndex: z, - ...(pos ? { left: pos.x, top: pos.y } : {}), - }; + // Root placement: pinned → centered top bar; docked → full-height edge + // column; free-floating → last/initial pixel position (hidden until measured). + const rootStyle: CSSProperties = isPinned + ? { zIndex: Z_PINNED, top: 8, left: '50%', transform: 'translateX(-50%)' } + : isDocked + ? { + zIndex: z, + top: 0, + height: '100vh', + ...(dock === 'left' ? { left: 0 } : { right: 0 }), + } + : { zIndex: z, ...(pos ? { left: pos.x, top: pos.y } : {}) }; + + // The title bar is the drag handle only in free-floating mode. + const titleBarHandlers = isFloating + ? { + onPointerDown: onHandleDown, + onPointerMove: onHandleMove, + onPointerUp: endDrag, + onPointerCancel: endDrag, + } + : {}; + + // Docked: the body fills the remaining column height and scrolls, overriding + // any max-height the caller set for the floating layout. + const bodyClass = isDocked + ? cn('min-h-0 flex-1 overflow-y-auto', collapsed && 'hidden') + : cn('min-h-0', collapsed && 'hidden', bodyClassName); return (
{ + if (isPinned) return; const nz = nextZ(id); if (nz > 0) setZ(nz); }} @@ -277,21 +355,24 @@ export default function FloatingWindow({
{title != null && (
{titleIcon && ( @@ -302,6 +383,49 @@ export default function FloatingWindow({ {title} {titleExtra} + {onDockChange && ( + + + + + + )} {collapsibleProp && (
); diff --git a/src/components/WorldMap.tsx b/src/components/WorldMap.tsx index 202fa11..2de8fab 100644 --- a/src/components/WorldMap.tsx +++ b/src/components/WorldMap.tsx @@ -170,13 +170,6 @@ function clickHtml(countryName: string, region?: RegionOwner, nation?: Nation):
`; } -/** Minimal map API exposed to the floating zoom control (and any other UI). */ -export interface MapApi { - zoomIn: () => void; - zoomOut: () => void; - reset: () => void; -} - interface Props { regions: RegionMap; playerNationId: string; @@ -187,11 +180,6 @@ interface Props { className?: string; /** Called when a region is clicked, so the dossier can focus its owner. */ onFocusNation?: (id: string) => void; - /** - * Called with a minimal zoom/pan API once the map is ready (and with `null` - * on unmount), so the floating zoom control can drive it. - */ - onMapReady?: (api: MapApi | null) => void; } /** @@ -201,8 +189,8 @@ interface Props { * stable fills (the choropleth is the star), thin borders, a brighter/thicker * border for the player, hover + click popups with * owner + stats, a transient flash on territory changes, and a - * per-scenario initial camera. It exposes a tiny zoom/pan API so the floating - * zoom control can drive it (no MapLibre navigation controls are shown). + * per-scenario initial camera. Zoom and pan are driven directly by the user + * (MapLibre wheel/trackpad + drag) — no floating control and no external API. */ export default function WorldMap({ regions, @@ -211,7 +199,6 @@ export default function WorldMap({ presetId, className, onFocusNation, - onMapReady, }: Props) { const containerRef = useRef(null); const mapRef = useRef(null); @@ -222,13 +209,11 @@ export default function WorldMap({ const playerRef = useRef(playerNationId); const presetRef = useRef(presetId); const onFocusNationRef = useRef(onFocusNation); - const onMapReadyRef = useRef(onMapReady); regionsRef.current = regions; nationsRef.current = nations; playerRef.current = playerNationId; presetRef.current = presetId; onFocusNationRef.current = onFocusNation; - onMapReadyRef.current = onMapReady; // Diffing state for the transient territory-change flash (owned by this component // so a resync with identical data never re-flashes). @@ -257,17 +242,6 @@ export default function WorldMap({ // Purely for testability; has no effect on rendering or behavior. (containerRef.current as unknown as { __map?: maplibregl.Map }).__map = map; - // Expose a tiny zoom/pan API to the floating control (null on unmount). - const api: MapApi = { - zoomIn: () => map.zoomIn(), - zoomOut: () => map.zoomOut(), - reset: () => { - const c = cameraForPreset(presetRef.current); - map.easeTo({ center: c.center, zoom: c.zoom, duration: 600 }); - }, - }; - onMapReadyRef.current?.(api); - const hoverPopup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 10 }); const clickPopup = new maplibregl.Popup({ closeButton: true, closeOnClick: true, offset: 12 }); let lastHoverId: string | null = null; @@ -482,7 +456,6 @@ export default function WorldMap({ cancelled = true; cancelAnimationFrame(flashRaf); flashApiRef.current = null; - onMapReadyRef.current?.(null); map.remove(); mapRef.current = null; if (containerRef.current) {