Files
ChatApp/docs/superpowers/plans/2026-05-16-phase4b-whiteboard.md
T

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) 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 <canvas> 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:

-- 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 — 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:

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 — 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:

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:
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.)

  1. Near the other useStates (around line 162 where pollDialogOpen lives), add:
const [openWhiteboardId, setOpenWhiteboardId] = useState<string | null>(null);
const [creatingWhiteboard, setCreatingWhiteboard] = useState(false);
  1. 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.

  1. 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.

  1. 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 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):

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)

  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<WhiteboardStrokePayload>. 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.