From 7f1ab8f09485f70992bafd768d8ee7ebed757e18 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 15:48:23 +0200 Subject: [PATCH] =?UTF-8?q?Phase=208:=20session=20tooling=20=E2=80=94=20ha?= =?UTF-8?q?ndouts=20to=20players?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GM "📣 Handout" composer in the header (title + body + optional image) sets uiStore.activeHandout; a clear button hides it. - snapshot gains an optional handout {title, body, imageId}; buildSnapshot streams the handout image over the existing image channel (id "handout"). - Player view (local + networked) shows the handout as a prominent card above the boards. resizeToMax() keeps handout images compact. - (Live scene/map switching already works via "Show to players".) Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e/player-view.spec.ts | 16 ++++++++ src/app/RootLayout.tsx | 2 + src/features/play/HandoutControl.tsx | 48 ++++++++++++++++++++++ src/features/play/useSessionBroadcaster.ts | 5 ++- src/features/player/PlayerBoards.tsx | 11 ++++- src/features/player/PlayerViewPage.tsx | 3 +- src/lib/img/resize.ts | 20 +++++++++ src/lib/sync/messages.ts | 7 ++++ src/lib/sync/snapshot.ts | 10 ++++- src/stores/uiStore.ts | 9 +++- 10 files changed, 125 insertions(+), 6 deletions(-) create mode 100644 src/features/play/HandoutControl.tsx diff --git a/e2e/player-view.spec.ts b/e2e/player-view.spec.ts index 87a2f90..3196906 100644 --- a/e2e/player-view.spec.ts +++ b/e2e/player-view.spec.ts @@ -39,3 +39,19 @@ test('player view shows the party and hides enemy HP numbers', async ({ page }) await expect(page.getByText('Bandit')).toBeVisible(); await expect(page.getByText(/Healthy|Bloodied|Down/)).toBeVisible(); }); + +test('handout: GM pushes a handout to the player view', async ({ page }) => { + await page.getByRole('button', { name: '+ New campaign' }).first().click(); + await page.locator('input[data-autofocus]').fill('Handout Camp'); + await page.getByRole('button', { name: 'Create' }).click(); + + await page.getByRole('button', { name: /Handout/ }).click(); + await page.getByLabel('Handout title').fill('A Mysterious Letter'); + await page.getByLabel('Handout text').fill('Meet me at midnight.'); + await page.getByRole('button', { name: 'Show to players' }).click(); + + await page.getByRole('link', { name: 'Dashboard' }).click(); + await page.getByRole('link', { name: 'Player View' }).click(); + await expect(page.getByRole('heading', { name: 'A Mysterious Letter' })).toBeVisible(); + await expect(page.getByText('Meet me at midnight.')).toBeVisible(); +}); diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx index 9631d90..7435725 100644 --- a/src/app/RootLayout.tsx +++ b/src/app/RootLayout.tsx @@ -9,6 +9,7 @@ import { RollTray } from '@/components/ui/RollTray'; import { Select } from '@/components/ui/Input'; import { Button } from '@/components/ui/Button'; import { SessionControl } from '@/features/play/SessionControl'; +import { HandoutControl } from '@/features/play/HandoutControl'; import { useSessionBroadcaster } from '@/features/play/useSessionBroadcaster'; const NAV = [ @@ -98,6 +99,7 @@ export function RootLayout() { })}
+ {activeCampaign && } + {active && } + {open && ( + setOpen(false)} title="Handout for players" + footer={<> + + {active && } + + }> +
+ setTitle(e.target.value)} placeholder="Title (e.g. A Mysterious Letter)" aria-label="Handout title" /> +