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) */}
-
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,
},
}),
],