Phase 8: session tooling — handouts to players

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 15:48:23 +02:00
parent f923375ebb
commit 7f1ab8f094
10 changed files with 125 additions and 6 deletions
+16
View File
@@ -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();
});
+2
View File
@@ -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() {
})}
</nav>
<div className="ml-auto flex items-center gap-2">
{activeCampaign && <HandoutControl />}
<SessionControl />
<Button size="sm" variant="ghost" onClick={() => setPaletteOpen(true)} title="Command palette (Ctrl/Cmd+K)" aria-label="Open command palette">
K
+48
View File
@@ -0,0 +1,48 @@
import { useState } from 'react';
import { useUiStore } from '@/stores/uiStore';
import { fileToDataUrl, resizeToMax } from '@/lib/img/resize';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { Input, Textarea } from '@/components/ui/Input';
/** GM control: compose a handout (text + optional image) shown to players. */
export function HandoutControl() {
const active = useUiStore((s) => s.activeHandout);
const setActive = useUiStore((s) => s.setActiveHandout);
const [open, setOpen] = useState(false);
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [image, setImage] = useState<string | undefined>(undefined);
const openComposer = () => { setTitle(active?.title ?? ''); setBody(active?.body ?? ''); setImage(active?.image); setOpen(true); };
const show = () => { setActive({ title: title.trim() || 'Handout', body: body.trim(), ...(image ? { image } : {}) }); setOpen(false); };
const onImage = async (file: File) => setImage(await resizeToMax(await fileToDataUrl(file), 1280));
return (
<>
<Button size="sm" variant={active ? 'primary' : 'ghost'} onClick={openComposer} title="Show a handout (text / image) to players">
{active ? '📣 Handout ✓' : '📣 Handout'}
</Button>
{active && <Button size="sm" variant="ghost" className="px-1 text-danger" onClick={() => setActive(null)} title="Hide handout"></Button>}
{open && (
<Modal open onClose={() => setOpen(false)} title="Handout for players"
footer={<>
<Button variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
{active && <Button variant="ghost" className="text-danger" onClick={() => { setActive(null); setOpen(false); }}>Hide current</Button>}
<Button variant="primary" onClick={show}>Show to players</Button>
</>}>
<div className="space-y-3">
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Title (e.g. A Mysterious Letter)" aria-label="Handout title" />
<Textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Text shown to players…" rows={5} aria-label="Handout text" />
<div className="flex items-center gap-2">
{image && <img src={image} alt="" className="h-16 w-16 rounded border border-line object-cover" />}
<label className="cursor-pointer rounded-md border border-line px-2 py-1 text-xs text-ink hover:border-accent">Image<input type="file" accept="image/*" className="hidden" aria-label="Handout image" onChange={(e) => { const f = e.target.files?.[0]; if (f) void onImage(f); e.target.value = ''; }} /></label>
{image && <Button size="sm" variant="ghost" onClick={() => setImage(undefined)}>Remove image</Button>}
</div>
<p className="text-xs text-muted">Players see this on the Player View and live, if you're hosting a session.</p>
</div>
</Modal>
)}
</>
);
}
+3 -2
View File
@@ -25,13 +25,14 @@ export function useSessionBroadcaster(campaign: Campaign | null): void {
const calendar = useCalendar(cid);
const activeEncounterId = useUiStore((s) => s.activeEncounterId);
const activeMapId = useUiStore((s) => s.activeMapId);
const activeHandout = useUiStore((s) => s.activeHandout);
const debouncedPush = useDebouncedCallback((s: Snapshot) => pushSnapshot(s), 250);
useEffect(() => {
if (role !== 'gm' || status !== 'connected' || !campaign) return;
const { snapshot, images } = buildSnapshot({ campaign, characters, encounters, maps, calendar: calendar ?? null, activeEncounterId, activeMapId });
const { snapshot, images } = buildSnapshot({ campaign, characters, encounters, maps, calendar: calendar ?? null, activeEncounterId, activeMapId, handout: activeHandout });
debouncedPush(snapshot);
for (const [id, dataUrl] of Object.entries(images)) pushImage(id, dataUrl);
}, [role, status, campaign, characters, encounters, maps, calendar, activeEncounterId, activeMapId, debouncedPush]);
}, [role, status, campaign, characters, encounters, maps, calendar, activeEncounterId, activeMapId, activeHandout, debouncedPush]);
}
+10 -1
View File
@@ -6,9 +6,18 @@ import { PlayerMapView } from './PlayerMapView';
/** Presentational player view: battle map + party + initiative, from a snapshot. */
export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot; image?: string; images?: Record<string, string> }) {
const { party, encounter, map } = snapshot;
const { party, encounter, map, handout } = snapshot;
const handoutImage = handout?.imageId ? images?.[handout.imageId] : undefined;
return (
<>
{handout && (
<section className="mb-6 rounded-xl border border-accent/50 bg-accent/5 p-4">
<h2 className="font-display text-2xl font-bold text-accent">{handout.title || 'Handout'}</h2>
{handout.body && <p className="mt-1 whitespace-pre-wrap text-ink">{handout.body}</p>}
{handoutImage && <img src={handoutImage} alt="" className="mt-3 max-h-[60vh] w-auto rounded-lg border border-line" />}
</section>
)}
{map && (
<section className="mb-6">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">Battle map</h2>
+2 -1
View File
@@ -52,8 +52,9 @@ function LocalPlayerView({ campaign }: { campaign: Campaign }) {
const maps = useMaps(campaign.id);
const activeEncounterId = useUiStore((s) => s.activeEncounterId);
const activeMapId = useUiStore((s) => s.activeMapId);
const activeHandout = useUiStore((s) => s.activeHandout);
const { snapshot, images } = buildSnapshot({ campaign, characters, encounters, maps, calendar: calendar ?? null, activeEncounterId, activeMapId });
const { snapshot, images } = buildSnapshot({ campaign, characters, encounters, maps, calendar: calendar ?? null, activeEncounterId, activeMapId, handout: activeHandout });
const image = snapshot.mapImageId ? images[snapshot.mapImageId] : undefined;
return (
+20
View File
@@ -16,6 +16,26 @@ function loadImage(src: string): Promise<HTMLImageElement> {
});
}
/** Downscale an image to fit within maxDim (preserving aspect), as a JPEG data URL. */
export async function resizeToMax(src: string, maxDim = 1024, quality = 0.85): Promise<string> {
try {
const img = await loadImage(src);
const scale = Math.min(1, maxDim / Math.max(img.naturalWidth, img.naturalHeight));
const w = Math.max(1, Math.round(img.naturalWidth * scale));
const h = Math.max(1, Math.round(img.naturalHeight * scale));
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) return src;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img, 0, 0, w, h);
return canvas.toDataURL('image/jpeg', quality);
} catch {
return src;
}
}
/** Natural pixel size of an image data URL. */
export function imageSize(src: string): Promise<{ w: number; h: number }> {
return loadImage(src).then((img) => ({ w: img.naturalWidth, h: img.naturalHeight }));
+7
View File
@@ -56,6 +56,12 @@ export const playerMapSchema = z.object({
tokens: z.array(playerTokenSchema), drawings: z.array(playerDrawingSchema), hasImage: z.boolean(),
});
export const handoutSchema = z.object({
title: z.string(),
body: z.string(),
imageId: z.string().nullable(),
});
export const snapshotSchema = z.object({
campaignName: z.string(),
calendarDay: int.nullable(),
@@ -63,6 +69,7 @@ export const snapshotSchema = z.object({
encounter: playerEncounterSchema.nullable(),
map: playerMapSchema.nullable(),
mapImageId: z.string().nullable(),
handout: handoutSchema.nullable().optional(),
});
export type Snapshot = z.infer<typeof snapshotSchema>;
+9 -1
View File
@@ -24,8 +24,9 @@ export function buildSnapshot(input: {
calendar: Calendar | null;
activeEncounterId: string | null;
activeMapId: string | null;
handout?: { title: string; body: string; image?: string } | null;
}): SnapshotResult {
const { campaign, characters, encounters, maps, calendar, activeEncounterId, activeMapId } = input;
const { campaign, characters, encounters, maps, calendar, activeEncounterId, activeMapId, handout } = input;
const sys = getSystem(campaign.system);
const images: Record<string, string> = {};
const byId = new Map(characters.map((c) => [c.id, c]));
@@ -70,6 +71,12 @@ export function buildSnapshot(input: {
if (activeMap?.image) images[activeMap.id] = activeMap.image;
let handoutOut: { title: string; body: string; imageId: string | null } | null = null;
if (handout) {
if (handout.image) images['handout'] = handout.image;
handoutOut = { title: handout.title, body: handout.body, imageId: handout.image ? 'handout' : null };
}
return {
snapshot: {
campaignName: campaign.name,
@@ -78,6 +85,7 @@ export function buildSnapshot(input: {
encounter: activeEnc ? toPlayerEncounter(activeEnc) : null,
map: projection,
mapImageId: activeMap && activeMap.image ? activeMap.id : null,
handout: handoutOut,
},
images,
};
+8 -1
View File
@@ -3,6 +3,8 @@ import { persist } from 'zustand/middleware';
export type Theme = 'dark' | 'light';
export interface Handout { title: string; body: string; image?: string }
interface UiState {
theme: Theme;
/** id of the campaign currently in focus across feature screens */
@@ -11,11 +13,14 @@ interface UiState {
activeEncounterId: string | null;
/** map currently projected to the player view */
activeMapId: string | null;
/** a handout currently shown to players (title/body/optional image) */
activeHandout: Handout | null;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
setActiveCampaign: (id: string | null) => void;
setActiveEncounter: (id: string | null) => void;
setActiveMap: (id: string | null) => void;
setActiveHandout: (h: Handout | null) => void;
}
export const useUiStore = create<UiState>()(
@@ -25,14 +30,16 @@ export const useUiStore = create<UiState>()(
activeCampaignId: null,
activeEncounterId: null,
activeMapId: null,
activeHandout: null,
setTheme: (theme) => {
applyTheme(theme);
set({ theme });
},
toggleTheme: () => get().setTheme(get().theme === 'dark' ? 'light' : 'dark'),
setActiveCampaign: (id) => set({ activeCampaignId: id, activeEncounterId: null, activeMapId: null }),
setActiveCampaign: (id) => set({ activeCampaignId: id, activeEncounterId: null, activeMapId: null, activeHandout: null }),
setActiveEncounter: (id) => set({ activeEncounterId: id }),
setActiveMap: (id) => set({ activeMapId: id }),
setActiveHandout: (activeHandout) => set({ activeHandout }),
}),
{
name: 'ttrpg-ui',