From ca3769eb6bb6fd75591bf48f58b86282e1df2118 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 14:26:00 +0200 Subject: [PATCH] Phase 1: PWA auto-update, player-view map fit fix, encounter picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PWA registerType autoUpdate + skipWaiting/clientsClaim/cleanupOutdatedCaches so a fresh deploy reaches browsers without a manual hard-reload (the old 'prompt' served the stale cached bundle, hiding shipped fixes like the polygon outline). - MapCanvas: always mount the outer container so the ResizeObserver binds even when the map image arrives late over WebSocket — fixes the networked player view showing the map at 100% instead of fit ("3x magnified"). - TokenPalette: encounter picker for planned AND active encounters (was active-only), with per-encounter "+ All" and counts. Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e/maps.spec.ts | 24 +++++++++++++++++++ src/features/world/map/MapCanvas.tsx | 32 +++++++++++++++---------- src/features/world/map/TokenPalette.tsx | 32 +++++++++++++++++++++---- vite.config.ts | 8 ++++++- 4 files changed, 79 insertions(+), 17 deletions(-) diff --git a/e2e/maps.spec.ts b/e2e/maps.spec.ts index 9fced83..ad8b9d6 100644 --- a/e2e/maps.spec.ts +++ b/e2e/maps.spec.ts @@ -64,6 +64,30 @@ test('maps: zoom controls scale the canvas and fit resets', async ({ page }) => expect(await scaleOf()).toBeCloseTo(fitZoom, 5); }); +test('maps: place a token from a planned encounter via the palette', async ({ page }) => { + await page.getByRole('button', { name: '+ New campaign' }).first().click(); + await page.locator('input[data-autofocus]').fill('Enc'); + await page.getByRole('button', { name: 'Create' }).click(); + + // A planned (not-yet-started) encounter with a monster. + await page.getByRole('link', { name: 'Combat' }).click(); + await page.getByRole('button', { name: '+ New encounter' }).first().click(); + await page.locator('input[data-autofocus]').fill('Ambush'); + await page.getByRole('button', { name: 'Create' }).click(); + await page.getByLabel('Name').fill('Bandit'); + await page.getByRole('button', { name: 'Add', exact: true }).click(); + + await page.getByRole('link', { name: 'Dashboard' }).click(); + await page.getByRole('link', { name: 'Maps' }).click(); + await page.locator('input[type="file"]').setInputFiles('public/pwa-512x512.png'); + + // The palette's Encounter section surfaces the planned encounter's combatant. + await expect(page.getByText('Encounter', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: /Bandit/ }).click(); + await expect(page.getByTestId('map-token')).toHaveCount(1); + await expect(page.getByTestId('map-token')).toContainText('Bandit'); +}); + test('maps: place a party token from the palette', async ({ page }) => { await page.getByRole('button', { name: '+ New campaign' }).first().click(); await page.locator('input[data-autofocus]').fill('Party'); diff --git a/src/features/world/map/MapCanvas.tsx b/src/features/world/map/MapCanvas.tsx index 8eba361..a73467f 100644 --- a/src/features/world/map/MapCanvas.tsx +++ b/src/features/world/map/MapCanvas.tsx @@ -173,24 +173,32 @@ export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog, const zoomCentered = (factor: number) => setVp((v) => zoomToPoint(v, factor, size.w / 2, size.h / 2)); const fit = () => { if (natural) setVp(fitViewport(size.w, size.h, natural.w, natural.h)); }; - if (!view.image) return

No image.

; - + // NOTE: always render the outer container (even with no image yet) so the + // ResizeObserver attaches. In the networked player view the map image arrives + // late over WebSocket; if we early-returned, the observer never bound, `size` + // stayed 0, fit() never ran, and the map showed at 100% instead of fit. return (
- {!natural &&

Loading map…

} + {!view.image + ?

No image.

+ : !natural + ?

Loading map…

+ : null} {/* pointer surface (below tokens so token drags win; tokens are small) */} -
{ if (interactive) onLeave?.(); }} - onContextMenu={(e) => e.preventDefault()} - /> + {view.image && ( +
{ if (interactive) onLeave?.(); }} + onContextMenu={(e) => e.preventDefault()} + /> + )} {/* tokens: DOM positioned in screen space (no CSS scale → labels stay crisp) */} {natural && ( diff --git a/src/features/world/map/TokenPalette.tsx b/src/features/world/map/TokenPalette.tsx index a4d3b6b..2093307 100644 --- a/src/features/world/map/TokenPalette.tsx +++ b/src/features/world/map/TokenPalette.tsx @@ -1,5 +1,7 @@ +import { useState } from 'react'; import type { Character, Combatant, Encounter, MapToken } from '@/lib/schemas'; import { Button } from '@/components/ui/Button'; +import { Select } from '@/components/ui/Input'; import { KIND_COLOR, baseSpec, unplacedPcs, type TokenSpec } from './tokens'; export type { TokenSpec }; @@ -14,7 +16,12 @@ export function TokenPalette({ characters, encounters, existingTokens, onPlace, onClose: () => void; }) { const pcs = characters.filter((c) => c.kind === 'pc'); - const activeEnc = encounters.find((e) => e.status === 'active') ?? encounters.find((e) => e.combatants.length > 0) ?? null; + // Any encounter that has combatants — planned OR active — can supply tokens. + const withCombatants = encounters.filter((e) => e.combatants.length > 0); + const [encId, setEncId] = useState(''); + const selectedEnc = withCombatants.find((e) => e.id === encId) + ?? encounters.find((e) => e.status === 'active' && e.combatants.length > 0) + ?? withCombatants[0] ?? null; const hasPc = (id: string) => existingTokens.some((t) => t.characterId === id); const hasCombatant = (cb: Combatant) => existingTokens.some((t) => t.combatantId === cb.id || (!t.combatantId && !t.characterId && t.label === cb.name)); @@ -58,11 +65,28 @@ export function TokenPalette({ characters, encounters, existingTokens, onPlace, )} - {activeEnc && activeEnc.combatants.length > 0 && ( + {selectedEnc && (
-
{activeEnc.name}
+
+ Encounter + +
+ {withCombatants.length > 1 ? ( + + ) : ( +
{selectedEnc.status === 'active' ? '● ' : ''}{selectedEnc.name}
+ )}
    - {activeEnc.combatants.map((cb) => { + {selectedEnc.combatants.map((cb) => { const placed = hasCombatant(cb); return (
  • diff --git a/vite.config.ts b/vite.config.ts index 47e359c..0b33327 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -32,7 +32,9 @@ export default defineConfig({ tailwindcss(), cspPlugin(), VitePWA({ - registerType: 'prompt', + // autoUpdate so a fresh deploy is fetched + activated without a manual + // hard-reload (the old 'prompt' kept serving the stale cached bundle). + registerType: 'autoUpdate', includeAssets: ['favicon.svg'], manifest: { name: 'TTRPG Manager', @@ -51,6 +53,10 @@ export default defineConfig({ globPatterns: ['**/*.{js,css,html,svg,png,woff2}'], // SRD data chunks can be large; allow them to be precached. maximumFileSizeToCacheInBytes: 6 * 1024 * 1024, + // Take over and drop stale precaches as soon as a new build is live. + skipWaiting: true, + clientsClaim: true, + cleanupOutdatedCaches: true, }, }), ],