44 KiB
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)andwhiteboard_strokes (id, whiteboard_id, author_user_id, stroke_json jsonb, created_at). RLS via the existingis_conversation_member(cid)helper. Both tables added tosupabase_realtimepublication. - A new
MessagePayloadvariantWhiteboardPayload({ v: 1, type: 'whiteboard', whiteboard_id: string }). When the user clicks "✏ Whiteboard", the renderer INSERTs a row intoconversation_whiteboards, then calls the existingsend()withcreateWhiteboardPayload(id)— a normal message row is created with the encrypted payload, andMessageBubbledispatches onparsed.kind === 'whiteboard'to render the "Öffnen" bubble. WhiteboardModalis a full-screen overlay with a<canvas>rendering the base white background plus every stroke fromuseWhiteboardStrokes(whiteboardId)(initial fetch + realtime). Pen tool inserts strokes onpointerup; eraser tool inserts a stroke withtool: 'eraser'that the renderer paints in white over previous strokes. "Clear all" (with confirm) deletes every stroke in the whiteboard via a singleDELETE 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:
-- 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:<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
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— addWhiteboardPayloadto the union +parseMessagePayloadbranch -
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:
export interface WhiteboardPayload {
v: 1;
type: 'whiteboard';
whiteboard_id: string;
}
Extend the union (line ~79):
export type MessagePayload =
| TextMessagePayload
| CallEventPayload
| PollPayload
| WhiteboardPayload;
Extend ParsedMessagePayload (line ~81) with:
| {
kind: 'whiteboard';
whiteboardId: string;
}
In parseMessagePayload (line ~111), after the obj.type === 'poll' branch and BEFORE the text fallback, add:
if (obj.type === 'whiteboard') {
const p = obj as Partial<WhiteboardPayload>;
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:
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<Whiteboard> {
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<WhiteboardStroke[]> {
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<WhiteboardStroke> {
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<void> {
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:
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:
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
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:
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<void>;
clearAll: () => Promise<void>;
} {
const [state, setState] = useState<State>({ 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
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:
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<HTMLCanvasElement | null>(null);
const draftRef = useRef<WhiteboardStrokePayload | null>(null);
const strokeStartRef = useRef<number>(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<WhiteboardStrokePayload> | null;
if (payload) renderStroke(ctx, payload);
}
if (draftRef.current) renderStroke(ctx, draftRef.current);
});
function canvasPoint(e: React.PointerEvent<HTMLCanvasElement>): [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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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 (
<canvas
ref={canvasRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
className="block max-h-full max-w-full cursor-crosshair touch-none rounded-lg border border-line/40 bg-white shadow-2xl"
style={{ aspectRatio: logicalWidth + ' / ' + logicalHeight, width: '100%' }}
/>
);
}
function renderStroke(
ctx: CanvasRenderingContext2D,
s: Partial<WhiteboardStrokePayload>,
): 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
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:
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<WhiteboardTool>('pen');
const [color, setColor] = useState<WhiteboardColor>('#000000');
const [width, setWidth] = useState<WhiteboardWidth>(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 (
<div
role="dialog"
aria-modal="true"
aria-label={t('app:whiteboard.title', { defaultValue: 'Whiteboard' })}
className="fixed inset-0 z-[80] flex flex-col bg-ink-950/95"
>
<header className="flex shrink-0 items-center justify-between border-b border-line/40 bg-surface-2 px-4 py-2">
<h2 className="font-display text-sm font-semibold text-fg">
{t('app:whiteboard.title', { defaultValue: 'Whiteboard' })}
</h2>
<button
type="button"
onClick={onClose}
aria-label={t('app:whiteboard.close', { defaultValue: 'Schließen' })}
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
>
<XIcon className="h-3.5 w-3.5" />
</button>
</header>
<div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden p-6">
{loading ? (
<p className="text-sm text-fg-muted">
{t('app:whiteboard.loading', { defaultValue: 'Lädt…' })}
</p>
) : error ? (
<p className="text-sm text-rose-400">
{t('app:whiteboard.error', { defaultValue: 'Whiteboard konnte nicht geladen werden.' })}
</p>
) : (
<WhiteboardCanvas
strokes={strokes}
tool={tool}
color={color}
width={width}
onStroke={(payload) => void insertStroke(payload)}
/>
)}
</div>
<footer className="flex shrink-0 flex-wrap items-center gap-4 border-t border-line/40 bg-surface-2 px-4 py-3">
<div className="flex items-center gap-1">
{(['pen', 'eraser'] as WhiteboardTool[]).map((id) => {
const label = id === 'pen' ? 'Stift' : 'Radierer';
const active = tool === id;
return (
<button
key={id}
type="button"
onClick={() => setTool(id)}
aria-pressed={active}
title={label}
className={
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-xs font-semibold transition ' +
(active
? 'bg-accent/20 text-accent ring-2 ring-accent/40'
: 'bg-surface-3 text-fg-muted hover:bg-surface hover:text-fg')
}
>
{id === 'pen' ? '✎' : '⌫'}
</button>
);
})}
</div>
<div className="h-6 w-px bg-line/40" aria-hidden />
<div className="flex items-center gap-1">
{COLORS.map((c) => {
const active = color === c;
return (
<button
key={c}
type="button"
onClick={() => setColor(c)}
aria-pressed={active}
aria-label={c}
className={
'h-6 w-6 cursor-pointer rounded-full border-2 transition ' +
(active ? 'border-fg scale-110' : 'border-line/40 hover:scale-105')
}
style={{ backgroundColor: c }}
/>
);
})}
</div>
<div className="h-6 w-px bg-line/40" aria-hidden />
<div className="flex items-center gap-1">
{WIDTHS.map((w) => {
const active = width === w;
return (
<button
key={w}
type="button"
onClick={() => setWidth(w)}
aria-pressed={active}
title={w + 'px'}
className={
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md transition ' +
(active
? 'bg-accent/20 ring-2 ring-accent/40'
: 'bg-surface-3 hover:bg-surface')
}
>
<div
className="rounded-full bg-fg"
style={{ width: w + 2 + 'px', height: w + 2 + 'px' }}
/>
</button>
);
})}
</div>
<div className="ml-auto flex items-center gap-2">
{confirmClear ? (
<>
<span className="text-xs text-fg-muted">
{t('app:whiteboard.confirm_clear', { defaultValue: 'Alles löschen?' })}
</span>
<button
type="button"
onClick={() => setConfirmClear(false)}
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted transition hover:bg-surface"
>
{t('app:whiteboard.cancel', { defaultValue: 'Abbrechen' })}
</button>
<button
type="button"
onClick={() => void handleConfirmClear()}
className="cursor-pointer rounded-md bg-rose-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-rose-500/90"
>
{t('app:whiteboard.confirm', { defaultValue: 'Ja, löschen' })}
</button>
</>
) : (
<button
type="button"
onClick={() => setConfirmClear(true)}
disabled={strokes.length === 0}
className="cursor-pointer rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
>
{t('app:whiteboard.clear_all', { defaultValue: 'Alles löschen' })}
</button>
)}
</div>
</footer>
</div>
);
}
- Step 2: Typecheck
pnpm --filter @chat-app/desktop typecheck
Expected: PASS.
- Step 3: Commit
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— addcreateWhiteboardPayload -
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:
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:
- Add imports at the top:
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.)
- Near the other
useStates (around line 162 wherepollDialogOpenlives), add:
const [openWhiteboardId, setOpenWhiteboardId] = useState<string | null>(null);
const [creatingWhiteboard, setCreatingWhiteboard] = useState(false);
- Add the create-and-send handler near
handlePollSubmit(around line 571):
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.
- Add a button next to the existing poll button (around line 976). The existing poll button JSX:
<button type="button" onClick={() => setPollDialogOpen(true)} title="Umfrage">
<PollIcon className="..." />
</button>
Add immediately after:
<button
type="button"
onClick={() => void handleCreateWhiteboard()}
disabled={creatingWhiteboard}
title={t('app:composer.whiteboard', { defaultValue: 'Whiteboard' })}
aria-label={t('app:composer.whiteboard', { defaultValue: 'Whiteboard' })}
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-50"
>
<WhiteboardIcon className="h-4 w-4" />
</button>
Add a small inline icon at the bottom of the file (close to where AttachmentPreview lives):
function WhiteboardIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
strokeLinecap="round" strokeLinejoin="round" {...props}>
<rect x="3" y="4" width="18" height="13" rx="2" />
<path d="M8 21h8M12 17v4" />
</svg>
);
}
If Grep -n "WhiteboardIcon\b" apps/desktop/src/components/icons* already finds an icon, import instead of inlining.
- Render the modal at the page root. RIGHT BEFORE the outermost
</div>of the page return, add:
{openWhiteboardId && (
<WhiteboardModal
whiteboardId={openWhiteboardId}
onClose={() => setOpenWhiteboardId(null)}
/>
)}
- Step 3: Typecheck
pnpm --filter @chat-app/desktop typecheck
Expected: PASS.
- Step 4: Commit
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
whiteboardrender 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):
if (parsed.kind === 'whiteboard') {
const id = parsed.whiteboardId;
return (
<div className="flex items-center gap-3 rounded-2xl border border-accent/30 bg-accent/5 px-4 py-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/20 text-accent">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
<rect x="3" y="4" width="18" height="13" rx="2" />
<path d="M8 21h8M12 17v4" />
</svg>
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-fg">Whiteboard</div>
<div className="text-xs text-fg-muted">Gemeinsames Zeichnen</div>
</div>
<button
type="button"
onClick={() => {
window.dispatchEvent(
new CustomEvent('chatapp:open-whiteboard', { detail: { id } }),
);
}}
disabled={!id}
className="cursor-pointer rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
>
Öffnen
</button>
</div>
);
}
- Step 3: Make ConversationPage listen for the event
In apps/desktop/src/pages/ConversationPage.tsx, add an effect near other effects:
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
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)
-
Spec coverage (
docs/superpowers/specs/2026-05-16-fifteen-features-design.mdlines 110-115):- "New tables
conversation_whiteboardsandwhiteboard_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
- "New tables
-
Placeholders: none.
-
Type consistency:
WhiteboardStroke(shared) ↔WhiteboardStrokePayload(renderer): shared keepsstrokeJson: unknown; renderer interprets viaas Partial<WhiteboardStrokePayload>. Intentional — protects against future stroke fields without forcing a shared rebuild.WhiteboardTool/WhiteboardColor/WhiteboardWidthdefined T4, consumed T5.WhiteboardPayload.whiteboard_id(snake_case wire) ↔ParsedMessagePayload.whiteboardId(camelCase in-app): matches the existingcall_eventfield pattern.createWhiteboardPayloadreturnsWhiteboardPayloadwhichsend()accepts (it acceptsMessagePayload).
-
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.