# Phase 4B — Whiteboard (Snapshot-Sync) > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Per-conversation collaborative whiteboard. Composer → "✏ Whiteboard" creates a new whiteboard which appears as a permanent message bubble in the chat. Clicking "Öffnen" launches a full-screen canvas modal where everyone in the conversation can draw with pen/eraser; every stroke is one row insert into `whiteboard_strokes` and arrives at every other participant via realtime postgres_changes. Bubble + history are permanent — anyone in the conv can reopen later. **Architecture:** - Two new public tables: `conversation_whiteboards (id, conversation_id, owner_user_id, created_at)` and `whiteboard_strokes (id, whiteboard_id, author_user_id, stroke_json jsonb, created_at)`. RLS via the existing `is_conversation_member(cid)` helper. Both tables added to `supabase_realtime` publication. - A new `MessagePayload` variant `WhiteboardPayload` (`{ v: 1, type: 'whiteboard', whiteboard_id: string }`). When the user clicks "✏ Whiteboard", the renderer INSERTs a row into `conversation_whiteboards`, then calls the existing `send()` with `createWhiteboardPayload(id)` — a normal message row is created with the encrypted payload, and `MessageBubble` dispatches on `parsed.kind === 'whiteboard'` to render the "Öffnen" bubble. - `WhiteboardModal` is a full-screen overlay with a `` rendering the base white background plus every stroke from `useWhiteboardStrokes(whiteboardId)` (initial fetch + realtime). Pen tool inserts strokes on `pointerup`; eraser tool inserts a stroke with `tool: 'eraser'` that the renderer paints in white over previous strokes. "Clear all" (with confirm) deletes every stroke in the whiteboard via a single `DELETE WHERE whiteboard_id = ?` round-trip. **Tech Stack:** PostgreSQL + RLS + Supabase Realtime (postgres_changes); React 18 + Canvas; reuses the existing message-bubble dispatch + `send()` pipeline; no new dependencies. **Non-goals:** - No CRDT / OT — last-writer-wins is fine because strokes are append-only and never edited. - No undo/redo (collaborative; each user can issue Clear if needed). - No stroke encryption (server reads stroke JSON; gated by RLS). - No live cursor presence (just the strokes themselves). - No image export/snapshot — the bubble shows a static "Whiteboard" tile, not a rendered preview. --- ## Pre-flight - [ ] **Verify clean working tree on `main`** Run: `cd "D:\Programmieren\ChatApp-Electron\chat-app" && git status` Expected: clean (ignored `.env.local` is fine). - [ ] **Confirm tooling is green** Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck && pnpm --filter @chat-app/shared test -- --run` Expected: all green; 33 shared tests pass. --- ## Task 1: SQL migration — whiteboard tables + RLS + realtime publication **Why:** Server-side authoritative state. RLS reuses the existing `is_conversation_member(cid)` helper so we don't duplicate access-control logic. **Files:** - Create: `supabase/migrations/20260516000006_whiteboards.sql` Must be idempotent — `create table if not exists`, `drop policy if exists` + `create policy`, `do $$ ... if not exists $$` for publication adds. - [ ] **Step 1: Write the SQL migration** Create `supabase/migrations/20260516000006_whiteboards.sql`: ```sql -- Phase 4B: per-conversation whiteboards. -- -- conversation_whiteboards: one row per whiteboard. Created by a member; the -- bubble that appears in the chat message timeline is a normal `messages` -- row whose plaintext payload is `{v:1, type:'whiteboard', whiteboard_id:}`. -- -- whiteboard_strokes: append-only stream of drawing operations. Each row is -- one user gesture (pen-down → pen-up); stroke_json carries the tool/color/ -- width/points. Realtime subscribers replay rows in created_at order. create table if not exists public.conversation_whiteboards ( id uuid primary key default gen_random_uuid(), conversation_id uuid not null references public.conversations(id) on delete cascade, owner_user_id uuid not null references auth.users(id) on delete cascade, created_at timestamptz not null default now() ); create index if not exists conversation_whiteboards_conv_idx on public.conversation_whiteboards(conversation_id, created_at desc); create table if not exists public.whiteboard_strokes ( id uuid primary key default gen_random_uuid(), whiteboard_id uuid not null references public.conversation_whiteboards(id) on delete cascade, author_user_id uuid not null references auth.users(id) on delete cascade, stroke_json jsonb not null, created_at timestamptz not null default now() ); create index if not exists whiteboard_strokes_board_idx on public.whiteboard_strokes(whiteboard_id, created_at asc); alter table public.conversation_whiteboards enable row level security; alter table public.whiteboard_strokes enable row level security; drop policy if exists conversation_whiteboards_select on public.conversation_whiteboards; drop policy if exists conversation_whiteboards_insert on public.conversation_whiteboards; drop policy if exists conversation_whiteboards_delete on public.conversation_whiteboards; drop policy if exists whiteboard_strokes_select on public.whiteboard_strokes; drop policy if exists whiteboard_strokes_insert on public.whiteboard_strokes; drop policy if exists whiteboard_strokes_delete on public.whiteboard_strokes; create policy conversation_whiteboards_select on public.conversation_whiteboards for select using (public.is_conversation_member(conversation_id)); create policy conversation_whiteboards_insert on public.conversation_whiteboards for insert with check ( public.is_conversation_member(conversation_id) and owner_user_id = auth.uid() ); create policy conversation_whiteboards_delete on public.conversation_whiteboards for delete using (owner_user_id = auth.uid()); create policy whiteboard_strokes_select on public.whiteboard_strokes for select using ( exists ( select 1 from public.conversation_whiteboards w where w.id = whiteboard_strokes.whiteboard_id and public.is_conversation_member(w.conversation_id) ) ); create policy whiteboard_strokes_insert on public.whiteboard_strokes for insert with check ( author_user_id = auth.uid() and exists ( select 1 from public.conversation_whiteboards w where w.id = whiteboard_id and public.is_conversation_member(w.conversation_id) ) ); create policy whiteboard_strokes_delete on public.whiteboard_strokes for delete using ( exists ( select 1 from public.conversation_whiteboards w where w.id = whiteboard_strokes.whiteboard_id and public.is_conversation_member(w.conversation_id) ) ); alter table public.conversation_whiteboards replica identity full; alter table public.whiteboard_strokes replica identity full; do $$ begin if not exists ( select 1 from pg_publication_tables where pubname = 'supabase_realtime' and schemaname = 'public' and tablename = 'conversation_whiteboards' ) then execute 'alter publication supabase_realtime add table public.conversation_whiteboards'; end if; if not exists ( select 1 from pg_publication_tables where pubname = 'supabase_realtime' and schemaname = 'public' and tablename = 'whiteboard_strokes' ) then execute 'alter publication supabase_realtime add table public.whiteboard_strokes'; end if; end $$; ``` - [ ] **Step 2: Commit + push to prod** ```bash cd "D:\Programmieren\ChatApp-Electron\chat-app" git add supabase/migrations/20260516000006_whiteboards.sql git commit -m "feat(P4B.T1): whiteboards + whiteboard_strokes tables with RLS + realtime" bash scripts/prod/push-migrations.sh whiteboards ``` Expect the push script to print `applying /tmp/migrations/20260516000006_whiteboards.sql` and end with `done.`. If it errors, STOP and report. --- ## Task 2: Shared payload type + wrapper helpers + tests **Why:** Centralize the wire format. `MessagePayload` union must include the whiteboard variant so `parseMessagePayload` doesn't fall back to text; helper functions wrap the queries so the renderer never builds raw SQL. **Files:** - Modify: `packages/shared/src/chat/attachments.ts` — add `WhiteboardPayload` to the union + `parseMessagePayload` branch - Create: `packages/shared/src/chat/whiteboards.ts` — CRUD wrappers - Create: `packages/shared/src/chat/whiteboards.test.ts` — unit tests - [ ] **Step 1: Extend the payload union in `packages/shared/src/chat/attachments.ts`** After the existing `PollPayload` interface (line ~72), add: ```ts export interface WhiteboardPayload { v: 1; type: 'whiteboard'; whiteboard_id: string; } ``` Extend the union (line ~79): ```ts export type MessagePayload = | TextMessagePayload | CallEventPayload | PollPayload | WhiteboardPayload; ``` Extend `ParsedMessagePayload` (line ~81) with: ```ts | { kind: 'whiteboard'; whiteboardId: string; } ``` In `parseMessagePayload` (line ~111), after the `obj.type === 'poll'` branch and BEFORE the text fallback, add: ```ts if (obj.type === 'whiteboard') { const p = obj as Partial; const id = typeof p.whiteboard_id === 'string' && p.whiteboard_id.length > 0 ? p.whiteboard_id : ''; return { kind: 'whiteboard', whiteboardId: id }; } ``` - [ ] **Step 2: Create the wrapper** Create `packages/shared/src/chat/whiteboards.ts`: ```ts import type { AppSupabaseClient } from '../supabase/client'; export interface Whiteboard { id: string; conversationId: string; ownerUserId: string; createdAt: string; } export interface WhiteboardStroke { id: string; whiteboardId: string; authorUserId: string; strokeJson: unknown; createdAt: string; } export async function createWhiteboard( client: AppSupabaseClient, conversationId: string, ): Promise { const { data: session } = await client.auth.getUser(); if (!session.user) throw new Error('not authenticated'); const { data, error } = await client .from('conversation_whiteboards') .insert({ conversation_id: conversationId, owner_user_id: session.user.id, }) .select('id, conversation_id, owner_user_id, created_at') .single(); if (error) throw error; return { id: data.id, conversationId: data.conversation_id, ownerUserId: data.owner_user_id, createdAt: data.created_at, }; } export async function listWhiteboardStrokes( client: AppSupabaseClient, whiteboardId: string, ): Promise { const { data, error } = await client .from('whiteboard_strokes') .select('id, whiteboard_id, author_user_id, stroke_json, created_at') .eq('whiteboard_id', whiteboardId) .order('created_at', { ascending: true }); if (error) throw error; return data.map((row) => ({ id: row.id, whiteboardId: row.whiteboard_id, authorUserId: row.author_user_id, strokeJson: row.stroke_json, createdAt: row.created_at, })); } export async function insertWhiteboardStroke( client: AppSupabaseClient, params: { whiteboardId: string; strokeJson: unknown }, ): Promise { const { data: session } = await client.auth.getUser(); if (!session.user) throw new Error('not authenticated'); const { data, error } = await client .from('whiteboard_strokes') .insert({ whiteboard_id: params.whiteboardId, author_user_id: session.user.id, stroke_json: params.strokeJson as object, }) .select('id, whiteboard_id, author_user_id, stroke_json, created_at') .single(); if (error) throw error; return { id: data.id, whiteboardId: data.whiteboard_id, authorUserId: data.author_user_id, strokeJson: data.stroke_json, createdAt: data.created_at, }; } export async function clearWhiteboardStrokes( client: AppSupabaseClient, whiteboardId: string, ): Promise { const { error } = await client .from('whiteboard_strokes') .delete() .eq('whiteboard_id', whiteboardId); if (error) throw error; } ``` - [ ] **Step 3: Write the tests** Create `packages/shared/src/chat/whiteboards.test.ts`: ```ts import { describe, expect, it, vi } from 'vitest'; import { clearWhiteboardStrokes, createWhiteboard, insertWhiteboardStroke, listWhiteboardStrokes, } from './whiteboards'; function makeClient(opts: { user?: { id: string } | null; insertReturn?: { data: unknown; error: unknown }; selectReturn?: { data: unknown[]; error: unknown }; deleteReturn?: { error: unknown }; }): any { const single = vi.fn().mockResolvedValue(opts.insertReturn ?? { data: {}, error: null }); const order = vi.fn().mockResolvedValue(opts.selectReturn ?? { data: [], error: null }); const eqDelete = vi.fn().mockResolvedValue(opts.deleteReturn ?? { error: null }); const insertSelect = vi.fn().mockReturnValue({ single }); const insertChain = vi.fn().mockReturnValue({ select: insertSelect }); const selectChain = vi.fn().mockReturnValue({ eq: vi.fn().mockReturnValue({ order }), }); const deleteChain = vi.fn().mockReturnValue({ eq: eqDelete }); const from = vi.fn().mockReturnValue({ insert: insertChain, select: selectChain, delete: deleteChain, }); return { auth: { getUser: vi.fn().mockResolvedValue({ data: { user: opts.user ?? { id: 'u-1' } } }) }, from, }; } describe('createWhiteboard', () => { it('inserts with owner = current user', async () => { const client = makeClient({ insertReturn: { data: { id: 'w-1', conversation_id: 'c-1', owner_user_id: 'u-1', created_at: '2026-05-16T00:00:00Z', }, error: null, }, }); const out = await createWhiteboard(client, 'c-1'); expect(out).toEqual({ id: 'w-1', conversationId: 'c-1', ownerUserId: 'u-1', createdAt: '2026-05-16T00:00:00Z', }); }); }); describe('listWhiteboardStrokes', () => { it('maps DB rows to camelCase + ascending order', async () => { const client = makeClient({ selectReturn: { data: [ { id: 's-1', whiteboard_id: 'w-1', author_user_id: 'u-1', stroke_json: { tool: 'pen' }, created_at: '2026-05-16T00:00:00Z', }, ], error: null, }, }); const out = await listWhiteboardStrokes(client, 'w-1'); expect(out).toEqual([ { id: 's-1', whiteboardId: 'w-1', authorUserId: 'u-1', strokeJson: { tool: 'pen' }, createdAt: '2026-05-16T00:00:00Z', }, ]); }); }); describe('insertWhiteboardStroke', () => { it('writes author + stroke_json', async () => { const client = makeClient({ insertReturn: { data: { id: 's-2', whiteboard_id: 'w-1', author_user_id: 'u-1', stroke_json: { tool: 'pen', points: [[0, 0, 0]] }, created_at: '2026-05-16T00:00:01Z', }, error: null, }, }); const out = await insertWhiteboardStroke(client, { whiteboardId: 'w-1', strokeJson: { tool: 'pen', points: [[0, 0, 0]] }, }); expect(out.id).toBe('s-2'); expect((out.strokeJson as { tool: string }).tool).toBe('pen'); }); }); describe('clearWhiteboardStrokes', () => { it('does not throw on a successful delete', async () => { const client = makeClient({ deleteReturn: { error: null } }); await expect(clearWhiteboardStrokes(client, 'w-1')).resolves.toBeUndefined(); }); it('throws when delete returns an error', async () => { const client = makeClient({ deleteReturn: { error: { message: 'rls denied' } as any } }); await expect(clearWhiteboardStrokes(client, 'w-1')).rejects.toBeTruthy(); }); }); ``` - [ ] **Step 4: Re-export from `@chat-app/shared/chat`** Find the chat index: `Glob packages/shared/src/chat/index.ts`. If it exists, add: ```ts export * from './whiteboards'; ``` If exports happen via `packages/shared/src/index.ts` instead, match the existing pattern there. Verify the renderer can import from `@chat-app/shared/chat` — match how `usePinnedMessages.ts` imports its wrapper (P2.T3): `Grep -n "from '@chat-app/shared" apps/desktop/src/hooks/usePinnedMessages.ts`. - [ ] **Step 5: Run tests + typecheck** ``` pnpm --filter @chat-app/shared test -- --run whiteboards pnpm --filter @chat-app/shared typecheck pnpm --filter @chat-app/shared test -- --run ``` Expected: 4 new tests pass, full suite at 37/37. - [ ] **Step 6: Commit** ```bash git add packages/shared/src/chat/attachments.ts packages/shared/src/chat/whiteboards.ts packages/shared/src/chat/whiteboards.test.ts # include the index file if you touched it git commit -m "feat(P4B.T2): WhiteboardPayload + shared whiteboards CRUD wrappers + tests" ``` --- ## Task 3: `useWhiteboardStrokes` hook — initial fetch + realtime INSERT/DELETE subscription + insert helper **Why:** The canvas needs a live, ordered stream of strokes. Following the `usePinnedMessages` (P2.T3) and `useOwnDevices` (P3.T5) patterns. **Files:** - Create: `apps/desktop/src/hooks/useWhiteboardStrokes.ts` - [ ] **Step 1: Write the hook** Create `apps/desktop/src/hooks/useWhiteboardStrokes.ts`: ```ts import { useCallback, useEffect, useState } from 'react'; import { clearWhiteboardStrokes, insertWhiteboardStroke, listWhiteboardStrokes, type WhiteboardStroke, } from '@chat-app/shared/chat'; import { supabase } from '../lib/supabase'; interface State { strokes: WhiteboardStroke[]; loading: boolean; error: string | null; } export function useWhiteboardStrokes(whiteboardId: string | null): { strokes: WhiteboardStroke[]; loading: boolean; error: string | null; insertStroke: (strokeJson: unknown) => Promise; clearAll: () => Promise; } { const [state, setState] = useState({ strokes: [], loading: true, error: null }); useEffect(() => { if (!whiteboardId) { setState({ strokes: [], loading: false, error: null }); return; } let cancelled = false; void (async () => { try { setState((s) => ({ ...s, loading: true, error: null })); const list = await listWhiteboardStrokes(supabase, whiteboardId); if (!cancelled) setState({ strokes: list, loading: false, error: null }); } catch (err) { if (!cancelled) { setState({ strokes: [], loading: false, error: err instanceof Error ? err.message : 'failed to load strokes', }); } } })(); const channel = supabase .channel('whiteboard:' + whiteboardId) .on( 'postgres_changes', { event: 'INSERT', schema: 'public', table: 'whiteboard_strokes', filter: 'whiteboard_id=eq.' + whiteboardId, }, (payload) => { const row = payload.new as { id?: string; whiteboard_id?: string; author_user_id?: string; stroke_json?: unknown; created_at?: string; } | null; if (!row?.id || !row.whiteboard_id || !row.author_user_id || !row.created_at) return; const next: WhiteboardStroke = { id: row.id, whiteboardId: row.whiteboard_id, authorUserId: row.author_user_id, strokeJson: row.stroke_json, createdAt: row.created_at, }; setState((s) => { if (s.strokes.some((x) => x.id === next.id)) return s; return { ...s, strokes: [...s.strokes, next] }; }); }, ) .on( 'postgres_changes', { event: 'DELETE', schema: 'public', table: 'whiteboard_strokes', filter: 'whiteboard_id=eq.' + whiteboardId, }, () => { // Bulk delete via "Clear all" — drop everything; future inserts // come back via the INSERT branch above. Simpler than tracking // per-id deletes. setState((s) => ({ ...s, strokes: [] })); }, ) .subscribe(); return () => { cancelled = true; void supabase.removeChannel(channel); }; }, [whiteboardId]); const insertStroke = useCallback( async (strokeJson: unknown) => { if (!whiteboardId) return; try { await insertWhiteboardStroke(supabase, { whiteboardId, strokeJson }); // No optimistic append — realtime echoes the row back in <150ms and // optimistic-then-echo creates duplicate-render races. } catch (err) { console.error('insertWhiteboardStroke failed', err); setState((s) => ({ ...s, error: err instanceof Error ? err.message : 'stroke insert failed', })); } }, [whiteboardId], ); const clearAll = useCallback(async () => { if (!whiteboardId) return; await clearWhiteboardStrokes(supabase, whiteboardId); }, [whiteboardId]); return { strokes: state.strokes, loading: state.loading, error: state.error, insertStroke, clearAll, }; } ``` - [ ] **Step 2: Typecheck** ``` pnpm --filter @chat-app/desktop typecheck ``` Expected: PASS. - [ ] **Step 3: Commit** ```bash git add apps/desktop/src/hooks/useWhiteboardStrokes.ts git commit -m "feat(P4B.T3): useWhiteboardStrokes hook with INSERT + bulk DELETE realtime" ``` --- ## Task 4: `WhiteboardCanvas` component — pen + eraser, 6 colors, 3 widths, replays strokes **Why:** Pure rendering + interaction. Knows nothing about networking — receives `strokes` + a `onStroke` callback. **Files:** - Create: `apps/desktop/src/components/WhiteboardCanvas.tsx` - [ ] **Step 1: Write the component** Create `apps/desktop/src/components/WhiteboardCanvas.tsx`: ```tsx import { useEffect, useRef, useState } from 'react'; import type { WhiteboardStroke } from '@chat-app/shared/chat'; export type WhiteboardTool = 'pen' | 'eraser'; export type WhiteboardColor = '#000000' | '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7'; export type WhiteboardWidth = 2 | 4 | 8; export interface WhiteboardStrokePayload { tool: WhiteboardTool; color: WhiteboardColor; width: WhiteboardWidth; // [x, y, t-ms-since-stroke-start] points: Array<[number, number, number]>; } interface Props { strokes: WhiteboardStroke[]; tool: WhiteboardTool; color: WhiteboardColor; width: WhiteboardWidth; onStroke: (payload: WhiteboardStrokePayload) => void; logicalWidth?: number; logicalHeight?: number; } const DEFAULT_LOGICAL_W = 1280; const DEFAULT_LOGICAL_H = 720; export function WhiteboardCanvas({ strokes, tool, color, width, onStroke, logicalWidth = DEFAULT_LOGICAL_W, logicalHeight = DEFAULT_LOGICAL_H, }: Props) { const canvasRef = useRef(null); const draftRef = useRef(null); const strokeStartRef = useRef(0); const [, forceTick] = useState(0); useEffect(() => { const cv = canvasRef.current; if (!cv) return; cv.width = logicalWidth; cv.height = logicalHeight; const ctx = cv.getContext('2d'); if (!ctx) return; ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, cv.width, cv.height); for (const s of strokes) { const payload = s.strokeJson as Partial | null; if (payload) renderStroke(ctx, payload); } if (draftRef.current) renderStroke(ctx, draftRef.current); }); function canvasPoint(e: React.PointerEvent): [number, number] { const cv = canvasRef.current!; const rect = cv.getBoundingClientRect(); const scaleX = cv.width / rect.width; const scaleY = cv.height / rect.height; return [(e.clientX - rect.left) * scaleX, (e.clientY - rect.top) * scaleY]; } const handlePointerDown = (e: React.PointerEvent) => { const cv = canvasRef.current; if (!cv) return; cv.setPointerCapture(e.pointerId); const [x, y] = canvasPoint(e); strokeStartRef.current = Date.now(); draftRef.current = { tool, color, width, points: [[x, y, 0]], }; forceTick((n) => n + 1); }; const handlePointerMove = (e: React.PointerEvent) => { if (!draftRef.current) return; const [x, y] = canvasPoint(e); draftRef.current.points.push([x, y, Date.now() - strokeStartRef.current]); forceTick((n) => n + 1); }; const handlePointerUp = (e: React.PointerEvent) => { const cv = canvasRef.current; if (cv && cv.hasPointerCapture(e.pointerId)) cv.releasePointerCapture(e.pointerId); const draft = draftRef.current; draftRef.current = null; if (!draft) return; if (draft.points.length < 2) { forceTick((n) => n + 1); return; } onStroke(draft); forceTick((n) => n + 1); }; return ( ); } function renderStroke( ctx: CanvasRenderingContext2D, s: Partial, ): void { const points = Array.isArray(s.points) ? s.points : null; if (!points || points.length < 1) return; ctx.save(); ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.lineWidth = typeof s.width === 'number' ? s.width : 4; if (s.tool === 'eraser') { ctx.strokeStyle = '#ffffff'; ctx.lineWidth = Math.max(8, (typeof s.width === 'number' ? s.width : 4) * 4); } else { ctx.strokeStyle = typeof s.color === 'string' ? s.color : '#000000'; } ctx.beginPath(); ctx.moveTo(points[0]![0], points[0]![1]); for (let i = 1; i < points.length; i++) { ctx.lineTo(points[i]![0], points[i]![1]); } ctx.stroke(); ctx.restore(); } ``` - [ ] **Step 2: Typecheck** ``` pnpm --filter @chat-app/desktop typecheck ``` Expected: PASS. - [ ] **Step 3: Commit** ```bash git add apps/desktop/src/components/WhiteboardCanvas.tsx git commit -m "feat(P4B.T4): WhiteboardCanvas — pen/eraser drawing engine" ``` --- ## Task 5: `WhiteboardModal` — fullscreen container with toolbar + Clear-all confirm **Why:** Wraps the canvas + toolbar + realtime hook. This is what the bubble's "Öffnen" button opens. **Files:** - Create: `apps/desktop/src/components/WhiteboardModal.tsx` - [ ] **Step 1: Write the modal** Create `apps/desktop/src/components/WhiteboardModal.tsx`: ```tsx import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useWhiteboardStrokes } from '../hooks/useWhiteboardStrokes'; import { XIcon } from './icons'; import { WhiteboardCanvas, type WhiteboardColor, type WhiteboardTool, type WhiteboardWidth, } from './WhiteboardCanvas'; interface Props { whiteboardId: string; onClose: () => void; } const COLORS: WhiteboardColor[] = ['#000000', '#ef4444', '#3b82f6', '#22c55e', '#facc15', '#a855f7']; const WIDTHS: WhiteboardWidth[] = [2, 4, 8]; export function WhiteboardModal({ whiteboardId, onClose }: Props) { const { t } = useTranslation(); const { strokes, loading, error, insertStroke, clearAll } = useWhiteboardStrokes(whiteboardId); const [tool, setTool] = useState('pen'); const [color, setColor] = useState('#000000'); const [width, setWidth] = useState(4); const [confirmClear, setConfirmClear] = useState(false); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose]); const handleConfirmClear = async () => { setConfirmClear(false); try { await clearAll(); } catch (err) { console.error('clearAll failed', err); } }; return (

{t('app:whiteboard.title', { defaultValue: 'Whiteboard' })}

{loading ? (

{t('app:whiteboard.loading', { defaultValue: 'Lädt…' })}

) : error ? (

{t('app:whiteboard.error', { defaultValue: 'Whiteboard konnte nicht geladen werden.' })}

) : ( void insertStroke(payload)} /> )}
{(['pen', 'eraser'] as WhiteboardTool[]).map((id) => { const label = id === 'pen' ? 'Stift' : 'Radierer'; const active = tool === id; return ( ); })}
{COLORS.map((c) => { const active = color === c; return (
{WIDTHS.map((w) => { const active = width === w; return ( ); })}
{confirmClear ? ( <> {t('app:whiteboard.confirm_clear', { defaultValue: 'Alles löschen?' })} ) : ( )}
); } ``` - [ ] **Step 2: Typecheck** ``` pnpm --filter @chat-app/desktop typecheck ``` Expected: PASS. - [ ] **Step 3: Commit** ```bash git add apps/desktop/src/components/WhiteboardModal.tsx git commit -m "feat(P4B.T5): WhiteboardModal — fullscreen container + toolbar + clear-confirm" ``` --- ## Task 6: Composer button + create flow — adds "Whiteboard" entry that creates + sends the bubble **Why:** Entry point from the composer. Reuses the existing `send()` pipeline so message delivery + read-receipts + everything else works unchanged. **Files:** - Modify: `apps/desktop/src/lib/conversationFeatures.ts` — add `createWhiteboardPayload` - Modify: `apps/desktop/src/pages/ConversationPage.tsx` — add a button next to the existing poll/GIF buttons; wire the click to create-board → send-bubble; track the active modal - [ ] **Step 1: Add a payload helper** Read `apps/desktop/src/lib/conversationFeatures.ts` first to find the existing `createPollPayload` so the style matches. Then append: ```ts export function createWhiteboardPayload(whiteboardId: string): WhiteboardPayload { return { v: 1, type: 'whiteboard', whiteboard_id: whiteboardId, }; } ``` Add `WhiteboardPayload` to the existing `@chat-app/shared` import (wherever `PollPayload` comes from in this file). - [ ] **Step 2: Wire the composer button** In `apps/desktop/src/pages/ConversationPage.tsx`: 1. Add imports at the top: ```ts import { createWhiteboardPayload } from '../lib/conversationFeatures'; import { WhiteboardModal } from '../components/WhiteboardModal'; import { createWhiteboard } from '@chat-app/shared/chat'; ``` (Adapt the shared import path to match how T2's wrapper is exported.) 2. Near the other `useState`s (around line 162 where `pollDialogOpen` lives), add: ```ts const [openWhiteboardId, setOpenWhiteboardId] = useState(null); const [creatingWhiteboard, setCreatingWhiteboard] = useState(false); ``` 3. Add the create-and-send handler near `handlePollSubmit` (around line 571): ```ts const handleCreateWhiteboard = useCallback(async () => { if (!id || creatingWhiteboard) return; setCreatingWhiteboard(true); try { const board = await createWhiteboard(supabase, id); const payload = createWhiteboardPayload(board.id); await send(payload, [], replyTo?.id ?? null); setReplyTo(null); setStickToBottom(true); setOpenWhiteboardId(board.id); } catch (err: unknown) { setSendError(err instanceof Error ? err.message : 'Whiteboard konnte nicht angelegt werden'); } finally { setCreatingWhiteboard(false); } }, [id, creatingWhiteboard, send, replyTo?.id]); ``` Verify `supabase` is already imported from `../lib/supabase` (very likely yes). If not, add the import. 4. Add a button next to the existing poll button (around line 976). The existing poll button JSX: ```tsx ``` Add immediately after: ```tsx ``` Add a small inline icon at the bottom of the file (close to where `AttachmentPreview` lives): ```tsx function WhiteboardIcon(props: React.SVGProps) { return ( ); } ``` If `Grep -n "WhiteboardIcon\b" apps/desktop/src/components/icons*` already finds an icon, import instead of inlining. 5. Render the modal at the page root. RIGHT BEFORE the outermost `` of the page return, add: ```tsx {openWhiteboardId && ( setOpenWhiteboardId(null)} /> )} ``` - [ ] **Step 3: Typecheck** ``` pnpm --filter @chat-app/desktop typecheck ``` Expected: PASS. - [ ] **Step 4: Commit** ```bash git add apps/desktop/src/lib/conversationFeatures.ts apps/desktop/src/pages/ConversationPage.tsx git commit -m "feat(P4B.T6): composer 'Whiteboard' button creates board + sends bubble" ``` --- ## Task 7: `MessageBubble` — render the whiteboard kind with "Öffnen" button **Why:** Currently a message with `parsed.kind === 'whiteboard'` would fall through to text rendering (empty body). This task wires the dispatch and signals upward to the page-owned modal state via a CustomEvent. **Files:** - Modify: `apps/desktop/src/components/MessageBubble.tsx` - Modify: `apps/desktop/src/pages/ConversationPage.tsx` (one new effect listening for the event) - [ ] **Step 1: Find the existing kind dispatch** ``` Grep -n "parsed.kind\|kind: 'poll'\|kind === 'poll'\|PollCard\|CallEventRow" apps/desktop/src/components/MessageBubble.tsx ``` Read the actual block to find a clean injection point: ``` Read apps/desktop/src/components/MessageBubble.tsx (offset 440, limit 30) ``` - [ ] **Step 2: Add a `whiteboard` render branch** Alongside the existing `parsed.kind === 'poll'` branch in `apps/desktop/src/components/MessageBubble.tsx`, add a new branch (place it just BEFORE the poll branch for grouping): ```tsx if (parsed.kind === 'whiteboard') { const id = parsed.whiteboardId; return (
Whiteboard
Gemeinsames Zeichnen
); } ``` - [ ] **Step 3: Make ConversationPage listen for the event** In `apps/desktop/src/pages/ConversationPage.tsx`, add an effect near other effects: ```ts useEffect(() => { const onOpen = (e: Event) => { const detail = (e as CustomEvent<{ id?: string }>).detail; if (detail?.id) setOpenWhiteboardId(detail.id); }; window.addEventListener('chatapp:open-whiteboard', onOpen); return () => window.removeEventListener('chatapp:open-whiteboard', onOpen); }, []); ``` - [ ] **Step 4: Typecheck** ``` pnpm --filter @chat-app/desktop typecheck ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add apps/desktop/src/components/MessageBubble.tsx apps/desktop/src/pages/ConversationPage.tsx git commit -m "feat(P4B.T7): MessageBubble renders whiteboard kind with Öffnen button" ``` --- ## Final gate - [ ] **Step 1: Typecheck both packages** Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck` Expected: both PASS. - [ ] **Step 2: Run shared tests** Run: `pnpm --filter @chat-app/shared test -- --run` Expected: PASS — 37 tests (33 baseline + 4 new in `whiteboards.test.ts`). - [ ] **Step 3: Verify no uncommitted changes** Run: `git status` Expected: clean working tree on `main`. - [ ] **Step 4: Report** Report: "Phase 4B (Whiteboard) code-complete on `main` and migration applied to prod. Restart dev: composer → Whiteboard icon → new bubble appears in chat → click Öffnen → fullscreen canvas; draw + another account sees strokes in realtime; 'Alles löschen' wipes all strokes. **No release** unless you say so." --- ## Self-review (resolved inline) 1. **Spec coverage** (`docs/superpowers/specs/2026-05-16-fifteen-features-design.md` lines 110-115): - "New tables `conversation_whiteboards` and `whiteboard_strokes`" → T1 - "composer menu → ✏ Whiteboard" → T6 button - "starts a new whiteboard inline in the chat as a bubble (preview + author + Öffnen-button)" → T6 INSERT + T7 bubble; author comes from existing MessageBubble shell - "Click → fullscreen modal with canvas" → T5 + T7 event hop - "Tools: pen, eraser, 6 colors, 3 widths" → T4 types + T5 toolbar - "Clear all with confirm dialog" → T5 confirm sequence - "Each pen stroke (on pointerup) inserts a row into `whiteboard_strokes`" → T4 onStroke + T3 insertStroke - "Realtime subscription on `whiteboard_id`" → T3 - "Strokes are JSON: {tool, color, width, points: [[x, y, t], …]}" → T4 `WhiteboardStrokePayload` - "Whiteboard is permanent — bubble in the chat stays, history is preserved, anyone in the conv can reopen it later" → T7 bubble dispatches by id; new opens fetch via T3 2. **Placeholders:** none. 3. **Type consistency:** - `WhiteboardStroke` (shared) ↔ `WhiteboardStrokePayload` (renderer): shared keeps `strokeJson: unknown`; renderer interprets via `as Partial`. Intentional — protects against future stroke fields without forcing a shared rebuild. - `WhiteboardTool`/`WhiteboardColor`/`WhiteboardWidth` defined T4, consumed T5. - `WhiteboardPayload.whiteboard_id` (snake_case wire) ↔ `ParsedMessagePayload.whiteboardId` (camelCase in-app): matches the existing `call_event` field pattern. - `createWhiteboardPayload` returns `WhiteboardPayload` which `send()` accepts (it accepts `MessagePayload`). 4. **Realtime gotcha:** the INSERT subscription echoes back the user's own inserts. Hook deliberately does NOT optimistically append — visible lag is one realtime round-trip (~50-150ms) which is acceptable for collaborative drawing.