diff --git a/docs/superpowers/plans/2026-05-16-phase4b-whiteboard.md b/docs/superpowers/plans/2026-05-16-phase4b-whiteboard.md new file mode 100644 index 0000000..afb88ea --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-phase4b-whiteboard.md @@ -0,0 +1,1313 @@ +# 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.