Files
ChatApp/docs/superpowers/plans/2026-05-16-phase4a-image-annotation.md
T

28 KiB

Phase 4A — Image Annotation vor Send

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: Let the user mark up an image attachment (pen, arrow, rectangle, circle, text, highlighter) BEFORE it's sent. The annotated copy replaces the original File in the composer's attachments[] state; the recipient sees the flattened PNG with the annotation baked in (no separate metadata, no decoding work on receive).

Architecture:

  • New self-contained component ImageAnnotator.tsx (~400 LOC, no external deps): a full-screen modal with a single <canvas> rendering the original image plus an in-memory "ops stack" of drawing commands. Every tool stroke is one op; undo pops from the stack into a redo buffer; redo moves it back. The canvas re-renders the whole stack on every change — simple, debuggable, fast at typical image sizes.
  • New AttachmentPreview prop onEdit?: () => void. When the preview is an image and onEdit is wired, a button overlays the thumb. Clicking it opens ImageAnnotator; on Save the modal calls onSave(newFile) and ConversationPage swaps the entry in attachments[].
  • Save flow: canvas.toBlob({ type: 'image/png' }) → wrap in new File([blob], original.name.replace(/\.\w+$/, '') + '-annotated.png', { type: 'image/png', lastModified: Date.now() }) → return through onSave.

Tech Stack: React 18 + TypeScript + HTML5 Canvas. Reuses Tailwind classes from the existing modal/toolbar code in the app. No new dependencies.

Non-goals:

  • No image-only crop / rotate / filter (just annotation).
  • No persistence of in-progress annotations between modal opens.
  • No collaboration / sharing of the op-stack (recipient sees flattened PNG only).
  • No SVG / vector output; PNG raster only.

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/desktop typecheck Expected: PASS. If it's red, STOP and report — don't start on a broken baseline.


Task 1: ImageAnnotator component skeleton + canvas mount + op-stack types

Why: Get the modal rendering with the image visible inside the canvas before adding any drawing logic. Establishes the file's structure (state, refs, types) that the next tasks fill in.

Files:

  • Create: apps/desktop/src/components/ImageAnnotator.tsx

  • Step 1: Write the file

Create apps/desktop/src/components/ImageAnnotator.tsx:

import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';

import { XIcon } from './icons';

export type AnnotatorTool = 'pen' | 'arrow' | 'rect' | 'circle' | 'text' | 'highlighter';
export type AnnotatorColor = '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7' | '#ffffff';
export type AnnotatorWidth = 2 | 4 | 8;

export interface AnnotatorOp {
  tool: AnnotatorTool;
  color: AnnotatorColor;
  width: AnnotatorWidth;
  points?: Array<{ x: number; y: number }>;
  from?: { x: number; y: number };
  to?: { x: number; y: number };
  text?: string;
  at?: { x: number; y: number };
}

interface Props {
  file: File;
  onCancel: () => void;
  onSave: (next: File) => void;
}

export function ImageAnnotator({ file, onCancel, onSave }: Props) {
  const { t } = useTranslation();
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const imageRef = useRef<HTMLImageElement | null>(null);
  const [imageLoaded, setImageLoaded] = useState(false);
  const [ops, setOps] = useState<AnnotatorOp[]>([]);
  const [redoStack, setRedoStack] = useState<AnnotatorOp[]>([]);
  const [tool, setTool] = useState<AnnotatorTool>('pen');
  const [color, setColor] = useState<AnnotatorColor>('#ef4444');
  const [width, setWidth] = useState<AnnotatorWidth>(4);

  useEffect(() => {
    const url = URL.createObjectURL(file);
    const img = new Image();
    img.onload = () => {
      imageRef.current = img;
      setImageLoaded(true);
    };
    img.onerror = () => {
      console.error('ImageAnnotator: failed to decode source image');
      onCancel();
    };
    img.src = url;
    return () => URL.revokeObjectURL(url);
  }, [file, onCancel]);

  useEffect(() => {
    if (!imageLoaded) return;
    const cv = canvasRef.current;
    const img = imageRef.current;
    if (!cv || !img) return;
    cv.width = img.naturalWidth;
    cv.height = img.naturalHeight;
    const ctx = cv.getContext('2d');
    if (!ctx) return;
    ctx.drawImage(img, 0, 0);
    for (const op of ops) {
      renderOp(ctx, op);
    }
  }, [imageLoaded, ops]);

  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onCancel();
      if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
        e.preventDefault();
        handleUndo();
      } else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
        e.preventDefault();
        handleRedo();
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  });

  const handleUndo = () => {
    setOps((cur) => {
      if (cur.length === 0) return cur;
      const next = cur.slice(0, -1);
      setRedoStack((r) => [...r, cur[cur.length - 1]!]);
      return next;
    });
  };

  const handleRedo = () => {
    setRedoStack((r) => {
      if (r.length === 0) return r;
      const top = r[r.length - 1]!;
      setOps((cur) => [...cur, top]);
      return r.slice(0, -1);
    });
  };

  const handleReset = () => {
    setOps([]);
    setRedoStack([]);
  };

  const handleSave = () => {
    const cv = canvasRef.current;
    if (!cv) return;
    cv.toBlob((blob) => {
      if (!blob) {
        console.error('ImageAnnotator: toBlob returned null');
        return;
      }
      const baseName = file.name.replace(/\.[^.]+$/, '');
      const next = new File([blob], baseName + '-annotated.png', {
        type: 'image/png',
        lastModified: Date.now(),
      });
      onSave(next);
    }, 'image/png');
  };

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-label={t('app:annotator.title', { defaultValue: 'Bild bearbeiten' })}
      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:annotator.title', { defaultValue: 'Bild bearbeiten' })}
        </h2>
        <button
          type="button"
          onClick={onCancel}
          aria-label={t('app:annotator.cancel', { defaultValue: 'Abbrechen' })}
          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">
        {imageLoaded ? (
          <canvas
            ref={canvasRef}
            className="max-h-full max-w-full cursor-crosshair rounded-lg border border-line/40 bg-black shadow-2xl"
          />
        ) : (
          <p className="text-sm text-fg-muted">
            {t('app:annotator.loading', { defaultValue: 'Bild wird geladen…' })}
          </p>
        )}
      </div>

      <footer className="flex shrink-0 items-center justify-between gap-3 border-t border-line/40 bg-surface-2 px-4 py-3">
        <div className="text-xs text-fg-muted">
          {ops.length === 0
            ? t('app:annotator.no_changes', { defaultValue: 'Keine Änderungen' })
            : ops.length + ' ' + t('app:annotator.changes', { defaultValue: 'Änderungen' })}
        </div>
        <div className="flex items-center gap-2">
          <button
            type="button"
            onClick={handleReset}
            disabled={ops.length === 0}
            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 disabled:cursor-not-allowed disabled:opacity-50"
          >
            {t('app:annotator.reset', { defaultValue: 'Zurücksetzen' })}
          </button>
          <button
            type="button"
            onClick={handleSave}
            disabled={!imageLoaded}
            className="cursor-pointer rounded-md bg-accent px-4 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
          >
            {t('app:annotator.save', { defaultValue: 'Speichern' })}
          </button>
        </div>
      </footer>
    </div>
  );
}

// renderOp is filled in by Task 2. Stub for now so the canvas-replay loop
// in the main useEffect compiles cleanly.
function renderOp(_ctx: CanvasRenderingContext2D, _op: AnnotatorOp): void {
  // implemented in Task 2
}

If text-accent-fg doesn't exist in this project's Tailwind, use the same class the existing accent buttons use — grep Grep -n "bg-accent " apps/desktop/src/components/RemoteRevokedScreen.tsx (P3.T7 wrote this very recently) to find the conventional pair.

  • Step 2: Typecheck

Run: pnpm --filter @chat-app/desktop typecheck Expected: PASS.

  • Step 3: Commit
cd "D:\Programmieren\ChatApp-Electron\chat-app"
git add apps/desktop/src/components/ImageAnnotator.tsx
git commit -m "feat(P4A.T1): ImageAnnotator modal skeleton with canvas mount + op-stack types"

Task 2: Drawing implementation — render all 6 tools + capture pointer events

Why: This is the drawing engine. After this task the user can free-hand-draw + shapes on the canvas with the defaults (pen / red / width 4). Tool/color/width pickers come in Task 3.

Files:

  • Modify: apps/desktop/src/components/ImageAnnotator.tsx (replace the renderOp stub + add pointer handlers + draft state)

  • Step 1: Replace the renderOp stub with the real renderer

At the bottom of the file, replace the stub with:

function renderOp(ctx: CanvasRenderingContext2D, op: AnnotatorOp): void {
  ctx.save();
  ctx.lineCap = 'round';
  ctx.lineJoin = 'round';
  ctx.strokeStyle = op.color;
  ctx.fillStyle = op.color;
  ctx.lineWidth = op.width;

  switch (op.tool) {
    case 'pen': {
      const pts = op.points;
      if (!pts || pts.length < 1) break;
      ctx.beginPath();
      ctx.moveTo(pts[0]!.x, pts[0]!.y);
      for (let i = 1; i < pts.length; i++) {
        ctx.lineTo(pts[i]!.x, pts[i]!.y);
      }
      ctx.stroke();
      break;
    }
    case 'highlighter': {
      const pts = op.points;
      if (!pts || pts.length < 1) break;
      ctx.globalAlpha = 0.35;
      ctx.lineWidth = op.width * 4;
      ctx.beginPath();
      ctx.moveTo(pts[0]!.x, pts[0]!.y);
      for (let i = 1; i < pts.length; i++) {
        ctx.lineTo(pts[i]!.x, pts[i]!.y);
      }
      ctx.stroke();
      break;
    }
    case 'rect': {
      const { from, to } = op;
      if (!from || !to) break;
      ctx.strokeRect(
        Math.min(from.x, to.x),
        Math.min(from.y, to.y),
        Math.abs(to.x - from.x),
        Math.abs(to.y - from.y),
      );
      break;
    }
    case 'circle': {
      const { from, to } = op;
      if (!from || !to) break;
      const cx = (from.x + to.x) / 2;
      const cy = (from.y + to.y) / 2;
      const rx = Math.abs(to.x - from.x) / 2;
      const ry = Math.abs(to.y - from.y) / 2;
      ctx.beginPath();
      ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
      ctx.stroke();
      break;
    }
    case 'arrow': {
      const { from, to } = op;
      if (!from || !to) break;
      ctx.beginPath();
      ctx.moveTo(from.x, from.y);
      ctx.lineTo(to.x, to.y);
      ctx.stroke();
      const angle = Math.atan2(to.y - from.y, to.x - from.x);
      const head = Math.max(12, op.width * 3);
      ctx.beginPath();
      ctx.moveTo(to.x, to.y);
      ctx.lineTo(
        to.x - head * Math.cos(angle - Math.PI / 6),
        to.y - head * Math.sin(angle - Math.PI / 6),
      );
      ctx.moveTo(to.x, to.y);
      ctx.lineTo(
        to.x - head * Math.cos(angle + Math.PI / 6),
        to.y - head * Math.sin(angle + Math.PI / 6),
      );
      ctx.stroke();
      break;
    }
    case 'text': {
      const { at, text } = op;
      if (!at || !text) break;
      const fontSize = Math.max(14, op.width * 6);
      ctx.font = '600 ' + fontSize + 'px Inter, system-ui, sans-serif';
      ctx.textBaseline = 'top';
      ctx.fillText(text, at.x, at.y);
      break;
    }
  }

  ctx.restore();
}
  • Step 2: Add pointer-event capture + draft state

Inside the component, near the other refs, add:

const draftRef = useRef<AnnotatorOp | null>(null);
const [draftTick, setDraftTick] = useState(0);

Replace the existing main render useEffect body so it ALSO draws the in-progress draft (live preview during pointer-down):

useEffect(() => {
  if (!imageLoaded) return;
  const cv = canvasRef.current;
  const img = imageRef.current;
  if (!cv || !img) return;
  cv.width = img.naturalWidth;
  cv.height = img.naturalHeight;
  const ctx = cv.getContext('2d');
  if (!ctx) return;
  ctx.drawImage(img, 0, 0);
  for (const op of ops) {
    renderOp(ctx, op);
  }
  if (draftRef.current) {
    renderOp(ctx, draftRef.current);
  }
}, [imageLoaded, ops, draftTick]);

Add this helper right above the return (to convert client coords to internal canvas coords — important because the canvas is scaled to fit):

function canvasPoint(e: React.PointerEvent<HTMLCanvasElement>): { x: number; y: number } {
  const cv = canvasRef.current!;
  const rect = cv.getBoundingClientRect();
  const scaleX = cv.width / rect.width;
  const scaleY = cv.height / rect.height;
  return {
    x: (e.clientX - rect.left) * scaleX,
    y: (e.clientY - rect.top) * scaleY,
  };
}

Add these handlers (also above the return):

const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
  if (!imageLoaded) return;
  const cv = canvasRef.current;
  if (!cv) return;
  cv.setPointerCapture(e.pointerId);
  const p = canvasPoint(e);

  if (tool === 'text') {
    const value = window.prompt(
      t('app:annotator.text_prompt', { defaultValue: 'Text eingeben:' }),
      '',
    );
    if (value !== null && value.trim().length > 0) {
      setOps((cur) => [...cur, { tool: 'text', color, width, text: value, at: p }]);
      setRedoStack([]);
    }
    return;
  }

  if (tool === 'pen' || tool === 'highlighter') {
    draftRef.current = { tool, color, width, points: [p] };
  } else {
    draftRef.current = { tool, color, width, from: p, to: p };
  }
  setDraftTick((n) => n + 1);
};

const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
  if (!draftRef.current) return;
  const p = canvasPoint(e);
  const cur = draftRef.current;
  if (cur.tool === 'pen' || cur.tool === 'highlighter') {
    cur.points = [...(cur.points ?? []), p];
  } else {
    cur.to = p;
  }
  setDraftTick((n) => n + 1);
};

const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
  const cv = canvasRef.current;
  if (cv && cv.hasPointerCapture(e.pointerId)) cv.releasePointerCapture(e.pointerId);
  const cur = draftRef.current;
  draftRef.current = null;
  if (!cur) return;
  const hasContent =
    (cur.tool === 'pen' || cur.tool === 'highlighter')
      ? (cur.points?.length ?? 0) >= 2
      : !!(cur.from && cur.to && (cur.from.x !== cur.to.x || cur.from.y !== cur.to.y));
  if (hasContent) {
    setOps((p) => [...p, cur]);
    setRedoStack([]);
  }
  setDraftTick((n) => n + 1);
};

Wire the handlers onto the <canvas> element — replace the existing <canvas ref={canvasRef} className="..." /> JSX with:

<canvas
  ref={canvasRef}
  onPointerDown={handlePointerDown}
  onPointerMove={handlePointerMove}
  onPointerUp={handlePointerUp}
  onPointerLeave={handlePointerUp}
  className="max-h-full max-w-full cursor-crosshair touch-none rounded-lg border border-line/40 bg-black shadow-2xl"
/>
  • Step 3: Typecheck

Run: pnpm --filter @chat-app/desktop typecheck Expected: PASS.

  • Step 4: Commit
git add apps/desktop/src/components/ImageAnnotator.tsx
git commit -m "feat(P4A.T2): annotator drawing engine — pen/arrow/rect/circle/text/highlighter"

Task 3: Toolbar — tool picker, color swatches, width swatches, undo/redo

Why: The user can already DRAW (default pen + red + width 4) — but can't change tool/color/width or visibly undo. This task adds the controls.

Files:

  • Modify: apps/desktop/src/components/ImageAnnotator.tsx (replace the footer)

  • Step 1: Add the tool/color/width palette to the footer

Replace the existing <footer> element with:

<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', 'highlighter', 'arrow', 'rect', 'circle', 'text'] as AnnotatorTool[]).map((id) => {
      const label = t('app:annotator.tool.' + id, {
        defaultValue:
          id === 'pen' ? 'Stift'
          : id === 'highlighter' ? 'Marker'
          : id === 'arrow' ? 'Pfeil'
          : id === 'rect' ? 'Rechteck'
          : id === 'circle' ? 'Kreis'
          : 'Text',
      });
      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')
          }
        >
          {toolGlyph(id)}
        </button>
      );
    })}
  </div>

  <div className="h-6 w-px bg-line/40" aria-hidden />

  <div className="flex items-center gap-1">
    {(['#ef4444', '#3b82f6', '#22c55e', '#facc15', '#a855f7', '#ffffff'] as AnnotatorColor[]).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">
    {([2, 4, 8] as AnnotatorWidth[]).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="h-6 w-px bg-line/40" aria-hidden />

  <div className="flex items-center gap-1">
    <button
      type="button"
      onClick={handleUndo}
      disabled={ops.length === 0}
      title={t('app:annotator.undo', { defaultValue: 'Rückgängig (Ctrl+Z)' })}
      className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md bg-surface-3 text-fg-muted transition hover:bg-surface hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
    >
      
    </button>
    <button
      type="button"
      onClick={handleRedo}
      disabled={redoStack.length === 0}
      title={t('app:annotator.redo', { defaultValue: 'Wiederholen (Ctrl+Y)' })}
      className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md bg-surface-3 text-fg-muted transition hover:bg-surface hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
    >
      
    </button>
  </div>

  <div className="ml-auto flex items-center gap-2">
    <button
      type="button"
      onClick={handleReset}
      disabled={ops.length === 0}
      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 disabled:cursor-not-allowed disabled:opacity-50"
    >
      {t('app:annotator.reset', { defaultValue: 'Zurücksetzen' })}
    </button>
    <button
      type="button"
      onClick={handleSave}
      disabled={!imageLoaded}
      className="cursor-pointer rounded-md bg-accent px-4 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
    >
      {t('app:annotator.save', { defaultValue: 'Speichern' })}
    </button>
  </div>
</footer>

And add this helper at the bottom of the file (after renderOp):

function toolGlyph(t: AnnotatorTool): string {
  switch (t) {
    case 'pen': return '✎';
    case 'highlighter': return '🖍';
    case 'arrow': return '↗';
    case 'rect': return '▭';
    case 'circle': return '◯';
    case 'text': return 'T';
  }
}
  • Step 2: Typecheck

Run: pnpm --filter @chat-app/desktop typecheck Expected: PASS.

  • Step 3: Commit
git add apps/desktop/src/components/ImageAnnotator.tsx
git commit -m "feat(P4A.T3): annotator toolbar — tool/color/width/undo/redo controls"

Task 4: Wire annotator into the composer — "✏" overlay on image previews + Save round-trip

Why: The annotator is fully functional but unreachable from the UI. This task adds the entry point.

Files:

  • Modify: apps/desktop/src/pages/ConversationPage.tsx (the AttachmentPreview function at line ~1393 + the call site at line ~915 + add ImageAnnotator import/state)

  • Step 1: Extend AttachmentPreview with an onEdit prop

Replace the existing AttachmentPreview function (line ~1393) with:

function AttachmentPreview({
  file,
  onRemove,
  onEdit,
}: {
  file: File;
  onRemove: () => void;
  onEdit?: () => void;
}) {
  const isImage = file.type.startsWith('image/');
  const [url, setUrl] = useState<string | null>(null);
  useEffect(() => {
    if (!isImage) return;
    const u = URL.createObjectURL(file);
    setUrl(u);
    return () => URL.revokeObjectURL(u);
  }, [file, isImage]);
  return (
    <div className="group relative overflow-hidden rounded-lg border border-line bg-surface-2 dark:bg-[#2b2d31]">
      {isImage && url ? (
        <img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
      ) : (
        <div className="flex h-20 w-32 flex-col justify-center gap-0.5 px-2 text-[10px]">
          <span className="truncate font-semibold text-fg" title={file.name}>
            {file.name || 'Datei'}
          </span>
          <span className="text-fg-muted">{file.type || 'unbekannt'}</span>
          <span className="text-fg-muted">{(file.size / 1024).toFixed(0)} KB</span>
        </div>
      )}
      {isImage && onEdit && (
        <button
          type="button"
          onClick={onEdit}
          aria-label="Bearbeiten"
          title="Bearbeiten"
          className="absolute bottom-1 left-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/70 text-white opacity-0 transition group-hover:opacity-100 hover:bg-accent/80"
        >
          <PencilIcon className="h-3 w-3" />
        </button>
      )}
      <button
        type="button"
        onClick={onRemove}
        aria-label="Entfernen"
        className="absolute right-1 top-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/70 text-white transition hover:bg-rose-500/80"
      >
        <XIcon className="h-3 w-3" />
      </button>
    </div>
  );
}

If PencilIcon isn't already exported from ./components/icons (grep first: Grep -n "PencilIcon\|EditIcon" apps/desktop/src/components/icons*), inline this small SVG below AttachmentPreview:

function PencilIcon(props: React.SVGProps<SVGSVGElement>) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
         strokeLinecap="round" strokeLinejoin="round" {...props}>
      <path d="M12 20h9" />
      <path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
    </svg>
  );
}
  • Step 2: Add ImageAnnotator state + import to ConversationPage

In the import block at the top of the file (alongside other ../components/... imports), add:

import { ImageAnnotator } from '../components/ImageAnnotator';

Near the top of ConversationPage where other useStates live (e.g. near attachments), add:

const [annotatingIndex, setAnnotatingIndex] = useState<number | null>(null);
  • Step 3: Pass onEdit to AttachmentPreview

In the attachments.map(...) at line ~915, change the JSX to:

{attachments.map((file, idx) => (
  <AttachmentPreview
    key={idx}
    file={file}
    onRemove={() => setAttachments((prev) => prev.filter((_, i) => i !== idx))}
    onEdit={
      file.type.startsWith('image/')
        ? () => setAnnotatingIndex(idx)
        : undefined
    }
  />
))}
  • Step 4: Render the annotator at the page root when annotatingIndex !== null

Place this near the end of the JSX return — just before the closing </div> of the page root:

{annotatingIndex !== null && attachments[annotatingIndex] && (
  <ImageAnnotator
    file={attachments[annotatingIndex]!}
    onCancel={() => setAnnotatingIndex(null)}
    onSave={(next) => {
      setAttachments((prev) => prev.map((f, i) => (i === annotatingIndex ? next : f)));
      setAnnotatingIndex(null);
    }}
  />
)}
  • Step 5: Typecheck

Run: pnpm --filter @chat-app/desktop typecheck Expected: PASS.

  • Step 6: Commit
git add apps/desktop/src/pages/ConversationPage.tsx
git commit -m "feat(P4A.T4): wire ImageAnnotator into composer attachment preview"

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 (sanity — these changes don't touch shared)

Run: pnpm --filter @chat-app/shared test -- --run Expected: PASS — same count as Phase 3 baseline (33 tests).

  • Step 3: Verify no uncommitted changes

Run: git status Expected: clean working tree on main.

  • Step 4: Report

Report: "Phase 4A (Image Annotation) code-complete on main. Restart dev, attach an image in any chat, hover the preview → ✏ button appears → click → annotator opens. Draw, choose tools/colors/widths, undo/redo, Save → preview updates with the annotated PNG, ready to send. No migration. Released? defer to user."


Self-review (resolved inline)

  1. Spec coverage (against docs/superpowers/specs/2026-05-16-fifteen-features-design.md lines 97-101):

    • "Attachment-picker for images shows a new ✏ Bearbeiten button before send" → T4 adds the overlay button (image-only via file.type.startsWith('image/')).
    • "Opens <ImageAnnotator> modal: canvas overlay on the image" → T1 mounts canvas at image's natural size.
    • "Tools: pen, arrow, rectangle, circle, text, highlighter" → T2 implements all 6 in renderOp.
    • "6 colors, 3 stroke widths" → T3 swatches use the exact AnnotatorColor/AnnotatorWidth tuples from T1.
    • "Undo/Redo stack, Reset, Save" → T1 callbacks + T3 toolbar buttons; Ctrl+Z / Ctrl+Y bindings in T1 keydown effect.
    • "On Save: canvas.toBlob({type: 'image/png'}) flattens" → T1's handleSave + T4's setAttachments(... map ... idx === annotatingIndex ? next : f).
  2. Placeholders: none. Every step has concrete code.

  3. Type consistency:

    • AnnotatorTool/AnnotatorColor/AnnotatorWidth defined in T1, consumed everywhere downstream.
    • AnnotatorOp fields match renderOp switch arms in T2.
    • ImageAnnotator props shape (file/onCancel/onSave) used identically in T4's call site.
    • AttachmentPreview extended with optional onEdit?: () => void in T4; existing callers passing only file/onRemove continue to compile because it's optional.
  4. One ambiguity surfaced + resolved: the spec doesn't say whether to overwrite the original File or keep a side-by-side copy. Plan replaces the slot with a <basename>-annotated.png File so the recipient sees the marked-up version with a clear filename hint.