← All components
Week 6 · 2026-10-08

Collaborative Whiteboard

A Miro-style infinite whiteboard: sticky notes, shapes, connectors, pen, frames, snapping, undo/redo, mini-map, templates, PNG/JSON export and live cursors across browser tabs.

Source

Demo.tsx
import Whiteboard from "./Whiteboard";

export default function Demo() {
  return <Whiteboard />;
}
Whiteboard.module.css
.root {
  --board: #f7f7f5;
  --dot: #cfcdc5;
  --panel: #ffffff;
  --ink: #1f2430;
  --ink-2: #5d5f66;
  --border: rgba(20, 20, 30, 0.1);
  --accent: #4262ff;
  --frame-bg: #ffffff;
  --frame-line: #d9d8d2;
  --mini-ink: #1f2430;

  position: relative;
  width: 100%;
  max-width: 1100px;
  height: 620px;
  overflow: hidden;
  border: 1px solid var(--border);
  border-radius: 16px;
  background: var(--board);
  color: var(--ink);
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
  outline: none;
  user-select: none;
  -webkit-user-select: none;
}
.root:focus-visible { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 40%, transparent); }
.root:fullscreen { max-width: none; height: 100vh; border-radius: 0; }

@media (prefers-color-scheme: dark) {
  .root {
    --board: #16161a;
    --dot: #34343c;
    --panel: #24242b;
    --ink: #f2f2f4;
    --ink-2: #b4b4bd;
    --border: rgba(255, 255, 255, 0.1);
    --accent: #7b8cff;
    --frame-bg: #202026;
    --frame-line: #3a3a44;
    --mini-ink: #f2f2f4;
  }
}

.canvas { display: block; width: 100%; height: 100%; touch-action: none; }
.dot { fill: var(--dot); }

/* Elements */
.item { cursor: inherit; }
.sticky { filter: url(#wb-shadow); }
.shape { stroke: #1f2430; stroke-width: 2; vector-effect: non-scaling-stroke; }
.item[data-hover="true"] > :first-child,
.frameBody[data-hover="true"] { stroke: var(--accent); stroke-width: 3; vector-effect: non-scaling-stroke; }
.noHit { pointer-events: none; }
.label {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  overflow: hidden;
  color: #1f2430;
  font-weight: 500;
  line-height: 1.22;
  text-align: center;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}
.freeText {
  color: var(--ink);
  font-size: 22px;
  font-weight: 600;
  line-height: 1.3;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}
.frameBody { fill: var(--frame-bg); stroke: var(--frame-line); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
.frameTitle { fill: var(--ink-2); font-weight: 600; }
.stroke { fill: none; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
.connector { stroke-width: 2.2; vector-effect: non-scaling-stroke; }
.hitStroke { fill: none; stroke: transparent; stroke-linecap: round; }

/* Selection chrome */
.selection { fill: none; stroke: var(--accent); stroke-width: 1.5; vector-effect: non-scaling-stroke; pointer-events: none; }
.handle { fill: #ffffff; stroke: var(--accent); }
.guide { stroke: #ff4f8b; stroke-width: 1; stroke-dasharray: 4 3; vector-effect: non-scaling-stroke; pointer-events: none; }
.marquee { fill: color-mix(in srgb, var(--accent) 10%, transparent); stroke: var(--accent); stroke-width: 1; vector-effect: non-scaling-stroke; }

/* Inline editors */
.editor,
.frameInput {
  position: absolute;
  z-index: 5;
  margin: 0;
  border: 0;
  outline: none;
  color: #1f2430;
  font-family: inherit;
  user-select: text;
}
.editor { padding: 12px; overflow: hidden; resize: none; font-weight: 500; line-height: 1.22; text-align: center; }
.editor[data-kind="sticky"],
.editor[data-kind="shape"] { display: block; }
.editor[data-kind="shape"] { padding: 12% 14%; }
.editor[data-kind="text"] {
  min-height: 36px;
  padding: 0;
  background: color-mix(in srgb, var(--accent) 6%, transparent);
  color: var(--ink);
  font-weight: 600;
  line-height: 1.3;
  text-align: left;
  box-shadow: 0 0 0 1.5px var(--accent);
}
.frameInput {
  padding: 4px 8px;
  border-radius: 6px;
  background: var(--panel);
  color: var(--ink);
  font-size: 14px;
  font-weight: 600;
  box-shadow: 0 0 0 1.5px var(--accent);
}

/* Floating panels */
.rail,
.topbar,
.zoombar,
.context {
  position: absolute;
  z-index: 6;
  display: flex;
  align-items: center;
  gap: 2px;
  padding: 4px;
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--panel);
  box-shadow: 0 6px 20px -6px rgba(0, 0, 0, 0.22);
}
.rail { top: 14px; left: 14px; flex-direction: column; }
.topbar { top: 14px; right: 14px; }
.zoombar { bottom: 14px; left: 14px; }
.context { transform: translateX(-50%); }

.tool,
.ctxBtn,
.zoomValue,
.fitBtn,
.invite {
  display: grid;
  place-items: center;
  min-width: 34px;
  height: 34px;
  padding: 0 6px;
  border: 0;
  border-radius: 8px;
  background: none;
  color: var(--ink);
  font: inherit;
  font-size: 15px;
  cursor: pointer;
}
.tool:hover,
.ctxBtn:hover:not(:disabled),
.zoomValue:hover,
.fitBtn:hover,
.invite:hover { background: color-mix(in srgb, var(--ink) 7%, transparent); }
.tool[aria-pressed="true"] { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); }
.ctxBtn:disabled { opacity: 0.35; cursor: default; }
.tool:focus-visible,
.ctxBtn:focus-visible,
.zoomValue:focus-visible,
.fitBtn:focus-visible,
.invite:focus-visible,
.swatch:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.icon { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.zoomValue { min-width: 52px; font-size: 12.5px; font-variant-numeric: tabular-nums; }
.fitBtn { font-size: 12.5px; font-weight: 600; }
.invite { padding: 0 10px; color: var(--accent); font-size: 12.5px; font-weight: 600; white-space: nowrap; }
.divider { width: 1px; height: 22px; margin: 0 4px; background: var(--border); }
.railDivider { width: 22px; height: 1px; margin: 4px 0; background: var(--border); }

.swatch {
  width: 22px;
  height: 22px;
  margin: 0 2px;
  padding: 0;
  border: 1.5px solid rgba(0, 0, 0, 0.15);
  border-radius: 50%;
  cursor: pointer;
}
.swatch:hover { transform: scale(1.12); }

.avatars { display: flex; padding-left: 6px; }
.avatar {
  display: grid;
  place-items: center;
  width: 26px;
  height: 26px;
  margin-left: -6px;
  border: 2px solid var(--panel);
  border-radius: 50%;
  color: #fff;
  font-size: 12px;
  font-weight: 700;
}

/* Menus */
.menu {
  position: absolute;
  bottom: 0;
  left: calc(100% + 8px);
  display: flex;
  flex-direction: column;
  min-width: 190px;
  padding: 6px;
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--panel);
  box-shadow: 0 10px 30px -8px rgba(0, 0, 0, 0.3);
}
.menu button,
.menuFile {
  position: relative;
  padding: 8px 10px;
  border: 0;
  border-radius: 8px;
  background: none;
  color: var(--ink);
  font: inherit;
  font-size: 13.5px;
  text-align: left;
  cursor: pointer;
}
.menu button:hover,
.menuFile:hover { background: color-mix(in srgb, var(--ink) 7%, transparent); }
.menuFile input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.menuTitle { padding: 6px 10px 4px; color: var(--ink-2); font-size: 11.5px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; }
.menuNote { padding: 4px 10px 2px; color: var(--ink-2); font-size: 11.5px; }

/* Remote cursors */
.peer { position: absolute; top: 0; left: 0; z-index: 7; pointer-events: none; transition: transform 0.08s linear; }
.peer span {
  position: absolute;
  top: 16px;
  left: 12px;
  padding: 2px 7px;
  border-radius: 6px;
  color: #fff;
  font-size: 11.5px;
  font-weight: 600;
  white-space: nowrap;
}

/* Mini-map */
.minimap {
  position: absolute;
  right: 14px;
  bottom: 14px;
  z-index: 6;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: color-mix(in srgb, var(--panel) 92%, transparent);
  box-shadow: 0 6px 20px -6px rgba(0, 0, 0, 0.22);
  cursor: pointer;
}
.miniView { fill: color-mix(in srgb, var(--accent) 12%, transparent); stroke: var(--accent); stroke-width: 1.5; }

.hint {
  position: absolute;
  bottom: 18px;
  left: 50%;
  z-index: 6;
  padding: 7px 14px;
  border-radius: 99px;
  background: rgba(20, 20, 30, 0.78);
  color: #fff;
  font-size: 12.5px;
  white-space: nowrap;
  transform: translateX(-50%);
  pointer-events: none;
}

@media (max-width: 640px) {
  .root { height: 520px; }
  .minimap { display: none; }
  .hint { max-width: calc(100% - 24px); white-space: normal; text-align: center; bottom: 64px; }
  .invite { display: none; }
}
Whiteboard.tsx
"use client";

import {
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent,
  type MouseEvent,
  type PointerEvent,
  type ReactNode,
} from "react";
import { exportPng } from "./exportPng";
import {
  bounds,
  center,
  connectorPoints,
  contains,
  intersects,
  move,
  pathBox,
  resize,
  smoothPath,
  snap,
  union,
  type Pt,
} from "./geometry";
import {
  fitFont,
  INK,
  isBox,
  SHAPE_COLORS,
  STICKY_COLORS,
  TEMPLATES,
  uid,
  type Box,
  type BoxEl,
  type Connector,
  type El,
  type End,
  type ShapeKind,
  type Tool,
} from "./model";
import { connect, makeIdentity, type Peer } from "./sync";
import styles from "./Whiteboard.module.css";

const STORAGE_KEY = "gallery-whiteboard-v1";
const CHANNEL = "gallery-whiteboard-v1";
const MIN_ZOOM = 0.1;
const MAX_ZOOM = 4;
const GRID = 24;
const HISTORY_LIMIT = 100;

type View = { x: number; y: number; zoom: number };
type Gesture =
  | { kind: "pan"; sx: number; sy: number; view: View }
  | { kind: "pinch"; dist: number; mid: Pt; view: View }
  | { kind: "move"; start: Pt; base: El[]; ids: Set<string>; moved: boolean }
  | { kind: "marquee"; start: Pt; base: Set<string> }
  | { kind: "resize"; id: string; corner: number; base: El[]; origin: Box }
  | { kind: "create"; start: Pt; id: string; base: El[]; shape: ShapeKind | "frame" }
  | { kind: "pen"; id: string; points: number[]; base: El[] }
  | { kind: "connect"; id: string; from: End; base: El[] };

const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
const normBox = (a: Pt, b: Pt): Box => ({ x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(a.x - b.x), h: Math.abs(a.y - b.y) });

const TOOLS: { key: Tool; label: string; shortcut: string; icon: ReactNode }[] = [
  { key: "select", label: "Select", shortcut: "V", icon: <path d="M5 3l14 8-6 1.5L10 19z" /> },
  { key: "hand", label: "Hand (hold Space)", shortcut: "H", icon: <path d="M8 11V5.5a1.5 1.5 0 0 1 3 0V10m0-4.5a1.5 1.5 0 0 1 3 0V10m0-3a1.5 1.5 0 0 1 3 0v6.5a6 6 0 0 1-6 6h-.5a6 6 0 0 1-5-2.7L3.8 14a1.5 1.5 0 0 1 2.5-1.7L8 14" /> },
  { key: "sticky", label: "Sticky note", shortcut: "N", icon: <path d="M5 4h14v10l-5 6H5zM14 20v-6h5" /> },
  { key: "text", label: "Text", shortcut: "T", icon: <path d="M5 6V4h14v2M12 4v16M9 20h6" /> },
  { key: "rect", label: "Rectangle", shortcut: "R", icon: <rect x="4" y="6" width="16" height="12" rx="2" /> },
  { key: "ellipse", label: "Ellipse", shortcut: "O", icon: <ellipse cx="12" cy="12" rx="8" ry="6" /> },
  { key: "diamond", label: "Diamond", shortcut: "D", icon: <path d="M12 3l9 9-9 9-9-9z" /> },
  { key: "connector", label: "Connector arrow", shortcut: "L", icon: <path d="M5 19L19 5m0 0h-7m7 0v7" /> },
  { key: "pen", label: "Pen", shortcut: "P", icon: <path d="M4 20l4-1 11-11-3-3L5 16zM14 6l3 3" /> },
  { key: "frame", label: "Frame", shortcut: "F", icon: <path d="M7 3v18M17 3v18M3 7h18M3 17h18" /> },
];
const TOOL_KEYS: Record<string, Tool> = { v: "select", h: "hand", n: "sticky", t: "text", r: "rect", o: "ellipse", d: "diamond", l: "connector", p: "pen", f: "frame" };

function Icon({ children }: { children: ReactNode }) {
  return (
    <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" className={styles.icon}>
      {children}
    </svg>
  );
}

function download(blob: Blob, name: string) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = name;
  a.click();
  setTimeout(() => URL.revokeObjectURL(url), 1000);
}

export default function Whiteboard() {
  const [elements, setElements] = useState<El[]>([]);
  const [selected, setSelected] = useState<Set<string>>(new Set());
  const [view, setView] = useState<View>({ x: 0, y: 0, zoom: 1 });
  const [tool, setTool] = useState<Tool>("select");
  const [editing, setEditing] = useState<string | null>(null);
  const [marquee, setMarquee] = useState<Box | null>(null);
  const [guides, setGuides] = useState<{ x?: number; y?: number }[]>([]);
  const [hoverTarget, setHoverTarget] = useState<string | null>(null);
  const [gestureKind, setGestureKind] = useState<Gesture["kind"] | null>(null);
  const [active, setActive] = useState(false);
  const [spaceDown, setSpaceDown] = useState(false);
  const [menu, setMenu] = useState<"templates" | "export" | null>(null);
  const [me, setMe] = useState<ReturnType<typeof makeIdentity> | null>(null);
  const [peers, setPeers] = useState<Peer[]>([]);
  const [size, setSize] = useState({ w: 900, h: 620 });
  const [canUndo, setCanUndo] = useState(false);
  const [canRedo, setCanRedo] = useState(false);
  const [dark, setDark] = useState(false);

  const root = useRef<HTMLDivElement>(null);
  const svg = useRef<SVGSVGElement>(null);
  const elementsRef = useRef<El[]>([]);
  const viewRef = useRef(view);
  viewRef.current = view;
  const selectedRef = useRef(selected);
  selectedRef.current = selected;
  const gesture = useRef<Gesture | null>(null);
  const pointers = useRef(new Map<number, Pt>());
  const history = useRef<{ past: El[][]; future: El[][] }>({ past: [], future: [] });
  const editBase = useRef<El[] | null>(null);
  const link = useRef<ReturnType<typeof connect> | null>(null);
  const timers = useRef<{ send?: ReturnType<typeof setTimeout>; save?: ReturnType<typeof setTimeout>; lastSend: number; lastCursor: number }>({
    lastSend: 0,
    lastCursor: 0,
  });

  const byId = useMemo(() => new Map(elements.map((e) => [e.id, e])), [elements]);

  // ── State plumbing: apply, persist, broadcast, history ─────────────────────────────
  const apply = useCallback((next: El[], opts: { broadcast?: boolean; save?: boolean } = {}) => {
    elementsRef.current = next;
    setElements(next);
    const t = timers.current;
    if (opts.save !== false) {
      clearTimeout(t.save);
      t.save = setTimeout(() => {
        try {
          localStorage.setItem(STORAGE_KEY, JSON.stringify(elementsRef.current));
        } catch {}
      }, 300);
    }
    if (opts.broadcast !== false) {
      // Throttled so drags stream to other tabs at ~25 fps.
      const send = () => {
        t.lastSend = Date.now();
        link.current?.sendState(elementsRef.current);
      };
      clearTimeout(t.send);
      if (Date.now() - t.lastSend > 40) send();
      else t.send = setTimeout(send, 40);
    }
  }, []);

  const syncHistoryFlags = () => {
    setCanUndo(history.current.past.length > 0);
    setCanRedo(history.current.future.length > 0);
  };

  const commit = useCallback(
    (next: El[], base: El[] = elementsRef.current) => {
      const h = history.current;
      h.past.push(base);
      if (h.past.length > HISTORY_LIMIT) h.past.shift();
      h.future = [];
      syncHistoryFlags();
      apply(next);
    },
    [apply],
  );

  function undo() {
    const h = history.current;
    const prev = h.past.pop();
    if (!prev) return;
    h.future.push(elementsRef.current);
    syncHistoryFlags();
    setSelected(new Set());
    apply(prev);
  }
  function redo() {
    const h = history.current;
    const next = h.future.pop();
    if (!next) return;
    h.past.push(elementsRef.current);
    syncHistoryFlags();
    setSelected(new Set());
    apply(next);
  }

  // ── Viewport helpers ────────────────────────────────────────────────────────────────
  const toWorld = useCallback((clientX: number, clientY: number): Pt => {
    const r = svg.current!.getBoundingClientRect();
    const v = viewRef.current;
    return { x: (clientX - r.left - v.x) / v.zoom, y: (clientY - r.top - v.y) / v.zoom };
  }, []);

  const zoomAt = useCallback((factor: number, sx: number, sy: number) => {
    setView((v) => {
      const zoom = clamp(v.zoom * factor, MIN_ZOOM, MAX_ZOOM);
      return { zoom, x: sx - ((sx - v.x) * zoom) / v.zoom, y: sy - ((sy - v.y) * zoom) / v.zoom };
    });
  }, []);

  const fit = useCallback((els: El[] = elementsRef.current) => {
    const r = svg.current?.getBoundingClientRect();
    const w = r?.width ?? 900;
    const h = r?.height ?? 620;
    const map = new Map(els.map((e) => [e.id, e]));
    const box = union(els.map((e) => bounds(e, map)));
    if (!box) {
      setView({ x: w / 2, y: h / 2, zoom: 1 });
      return;
    }
    const zoom = clamp(Math.min((w - 120) / Math.max(box.w, 1), (h - 120) / Math.max(box.h, 1)), MIN_ZOOM, 1.2);
    setView({ zoom, x: w / 2 - (box.x + box.w / 2) * zoom, y: h / 2 - (box.y + box.h / 2) * zoom });
  }, []);

  // ── Mount: restore, connect to other tabs, track size and theme ────────────────────
  useEffect(() => {
    let saved: El[] | null = null;
    try {
      const raw = localStorage.getItem(STORAGE_KEY);
      if (raw) saved = JSON.parse(raw);
    } catch {}
    const initial = Array.isArray(saved) ? saved : TEMPLATES[0].build();
    apply(initial, { broadcast: false });
    requestAnimationFrame(() => fit(initial));

    const identity = makeIdentity();
    setMe(identity);
    link.current = connect(CHANNEL, identity, {
      onState: (els) => apply(els, { broadcast: false }),
      onPeers: setPeers,
      getState: () => elementsRef.current,
    });

    const media = window.matchMedia("(prefers-color-scheme: dark)");
    setDark(media.matches);
    const onTheme = () => setDark(media.matches);
    media.addEventListener("change", onTheme);

    const observer = new ResizeObserver(([e]) => setSize({ w: e.contentRect.width, h: e.contentRect.height }));
    if (svg.current) observer.observe(svg.current);

    // Clicking anywhere outside the board deactivates wheel capture.
    const onDocDown = (e: globalThis.PointerEvent) => {
      if (!root.current?.contains(e.target as Node)) {
        setActive(false);
        setMenu(null);
      }
    };
    document.addEventListener("pointerdown", onDocDown, true);

    return () => {
      link.current?.close();
      media.removeEventListener("change", onTheme);
      observer.disconnect();
      document.removeEventListener("pointerdown", onDocDown, true);
    };
  }, [apply, fit]);

  // Put the caret in the inline editor as soon as it opens.
  useEffect(() => {
    if (!editing) return;
    const raf = requestAnimationFrame(() => {
      const field = root.current?.querySelector<HTMLTextAreaElement | HTMLInputElement>("[data-editor]");
      if (field && document.activeElement !== field) {
        field.focus({ preventScroll: true });
        field.setSelectionRange(field.value.length, field.value.length);
      }
    });
    return () => cancelAnimationFrame(raf);
  }, [editing]);

  // Wheel: pan, or zoom with Ctrl/⌘ (also trackpad pinch). Only while the board is active.
  useEffect(() => {
    const el = svg.current;
    if (!el) return;
    const onWheel = (e: WheelEvent) => {
      if (!active) return;
      e.preventDefault();
      const r = el.getBoundingClientRect();
      if (e.ctrlKey || e.metaKey) zoomAt(Math.exp(-e.deltaY * 0.0018), e.clientX - r.left, e.clientY - r.top);
      else setView((v) => ({ ...v, x: v.x - e.deltaX, y: v.y - e.deltaY }));
    };
    el.addEventListener("wheel", onWheel, { passive: false });
    return () => el.removeEventListener("wheel", onWheel);
  }, [active, zoomAt]);

  // ── Element helpers ─────────────────────────────────────────────────────────────────
  const hitId = (target: EventTarget | null) => (target as Element | null)?.closest?.("[data-id]")?.getAttribute("data-id") ?? null;

  function elementAt(clientX: number, clientY: number, exclude?: string) {
    for (const node of document.elementsFromPoint(clientX, clientY)) {
      const id = hitId(node);
      if (id && id !== exclude) {
        const el = elementsRef.current.find((e) => e.id === id);
        if (el && isBox(el) && el.type !== "path") return el;
      }
    }
    return null;
  }

  /** Selected ids plus everything sitting inside any selected frame. */
  function movingIds(ids: Set<string>) {
    const out = new Set(ids);
    for (const el of elementsRef.current) {
      if (el.type !== "frame" || !ids.has(el.id)) continue;
      for (const other of elementsRef.current) {
        if (other.id !== el.id && isBox(other) && other.type !== "frame" && contains(el, center(other))) out.add(other.id);
      }
    }
    return out;
  }

  function removeIds(ids: Set<string>) {
    const next = elementsRef.current.filter(
      (e) => !ids.has(e.id) && !(e.type === "connector" && ((e.from.id && ids.has(e.from.id)) || (e.to.id && ids.has(e.to.id)))),
    );
    commit(next);
    setSelected(new Set());
    // The context toolbar disappears with the selection; keep shortcuts working.
    root.current?.focus({ preventScroll: true });
  }

  /** Mouse clicks on toolbars shouldn't pull focus away from the board (keyboard Tab still works). */
  const keepBoardFocus = (e: PointerEvent<HTMLElement>) => {
    if (!(e.target as HTMLElement).closest("input, label")) e.preventDefault();
  };

  function duplicate(ids: Set<string>) {
    const remap = new Map<string, string>();
    const copies: El[] = [];
    for (const el of elementsRef.current) {
      if (!ids.has(el.id) || el.type === "connector") continue;
      const id = uid();
      remap.set(el.id, id);
      copies.push({ ...move(el, 24, 24), id } as El);
    }
    for (const el of elementsRef.current) {
      if (el.type !== "connector") continue;
      const fromOk = el.from.id ? remap.has(el.from.id) : ids.has(el.id);
      const toOk = el.to.id ? remap.has(el.to.id) : ids.has(el.id);
      if (!fromOk || !toOk || (!ids.has(el.id) && !(el.from.id && el.to.id))) continue;
      const shift = (e: End): End => (e.id ? { ...e, id: remap.get(e.id) } : { x: e.x + 24, y: e.y + 24 });
      copies.push({ ...el, id: uid(), from: shift(el.from), to: shift(el.to) });
    }
    commit([...elementsRef.current, ...copies]);
    setSelected(new Set(copies.map((c) => c.id)));
  }

  function reorder(ids: Set<string>, front: boolean) {
    const picked = elementsRef.current.filter((e) => ids.has(e.id));
    const rest = elementsRef.current.filter((e) => !ids.has(e.id));
    commit(front ? [...rest, ...picked] : [...picked, ...rest]);
  }

  function recolor(ids: Set<string>, color: string) {
    commit(elementsRef.current.map((e) => (ids.has(e.id) && (e.type === "sticky" || e.type === "shape") ? { ...e, color } : e)));
  }

  function startEditing(id: string) {
    editBase.current = elementsRef.current;
    setEditing(id);
    setSelected(new Set([id]));
  }

  function finishEditing() {
    if (!editing) return;
    const base = editBase.current;
    const el = elementsRef.current.find((e) => e.id === editing);
    setEditing(null);
    editBase.current = null;
    if (!base) return;
    // An emptied free-text element disappears.
    if (el?.type === "text" && !el.text.trim()) {
      commit(elementsRef.current.filter((e) => e.id !== el.id), base);
      return;
    }
    if (JSON.stringify(base) !== JSON.stringify(elementsRef.current)) commit(elementsRef.current, base);
  }

  function editText(value: string, height?: number) {
    apply(
      elementsRef.current.map((e) => {
        if (e.id !== editing) return e;
        if (e.type === "frame") return { ...e, title: value };
        if (e.type === "text") return { ...e, text: value, h: height ? Math.max(36, height) : e.h };
        if (e.type === "sticky" || e.type === "shape") return { ...e, text: value };
        return e;
      }),
    );
  }

  // ── Pointer handling ────────────────────────────────────────────────────────────────
  function onPointerDown(e: PointerEvent<SVGSVGElement>) {
    // Stops the browser's mousedown focus change, which would otherwise steal focus from a
    // text editor opened by this same click (clicks and double-clicks still fire).
    e.preventDefault();
    setActive(true);
    setMenu(null);
    root.current?.focus({ preventScroll: true });
    pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
    e.currentTarget.setPointerCapture(e.pointerId);

    if (pointers.current.size === 2) {
      const [a, b] = [...pointers.current.values()];
      gesture.current = { kind: "pinch", dist: Math.hypot(a.x - b.x, a.y - b.y), mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }, view: viewRef.current };
      setGestureKind("pinch");
      return;
    }
    if (editing) finishEditing();

    const p = toWorld(e.clientX, e.clientY);
    const id = hitId(e.target);
    const handle = (e.target as Element).getAttribute("data-handle");
    const els = elementsRef.current;

    if (e.button === 1 || tool === "hand" || spaceDown) {
      gesture.current = { kind: "pan", sx: e.clientX, sy: e.clientY, view: viewRef.current };
    } else if (tool === "select") {
      if (handle !== null && selectedRef.current.size === 1) {
        const target = els.find((x) => x.id === [...selectedRef.current][0]);
        if (target && isBox(target)) gesture.current = { kind: "resize", id: target.id, corner: Number(handle), base: els, origin: { x: target.x, y: target.y, w: target.w, h: target.h } };
      } else if (id) {
        let next = selectedRef.current;
        if (e.shiftKey) {
          next = new Set(next);
          if (next.has(id)) next.delete(id);
          else next.add(id);
        } else if (!next.has(id)) next = new Set([id]);
        setSelected(next);
        gesture.current = { kind: "move", start: p, base: els, ids: movingIds(next), moved: false };
      } else {
        if (!e.shiftKey) setSelected(new Set());
        gesture.current = { kind: "marquee", start: p, base: e.shiftKey ? new Set(selectedRef.current) : new Set() };
      }
    } else if (tool === "sticky" || tool === "text") {
      const el: El =
        tool === "sticky"
          ? { id: uid(), type: "sticky", x: p.x - 85, y: p.y - 75, w: 170, h: 150, text: "", color: STICKY_COLORS[0] }
          : { id: uid(), type: "text", x: p.x, y: p.y - 18, w: 260, h: 36, text: "" };
      apply([...els, el]);
      history.current.past.push(els);
      syncHistoryFlags();
      setTool("select");
      editBase.current = elementsRef.current;
      setEditing(el.id);
      setSelected(new Set([el.id]));
    } else if (tool === "rect" || tool === "ellipse" || tool === "diamond" || tool === "frame") {
      const newId = uid();
      const el: El =
        tool === "frame"
          ? { id: newId, type: "frame", x: p.x, y: p.y, w: 1, h: 1, title: "Frame" }
          : { id: newId, type: "shape", kind: tool, x: p.x, y: p.y, w: 1, h: 1, text: "", color: SHAPE_COLORS[0] };
      gesture.current = { kind: "create", start: p, id: newId, base: els, shape: tool };
      apply(tool === "frame" ? [el, ...els] : [...els, el]);
    } else if (tool === "pen") {
      const newId = uid();
      gesture.current = { kind: "pen", id: newId, points: [p.x, p.y], base: els };
      apply([...els, { id: newId, type: "path", points: [p.x, p.y], x: p.x, y: p.y, w: 1, h: 1, color: dark ? "#e8e6f0" : INK }]);
    } else if (tool === "connector") {
      const target = elementAt(e.clientX, e.clientY);
      const from: End = target ? { id: target.id, x: p.x, y: p.y } : { x: p.x, y: p.y };
      const newId = uid();
      gesture.current = { kind: "connect", id: newId, from, base: els };
      apply([...els, { id: newId, type: "connector", from, to: { x: p.x, y: p.y }, color: dark ? "#b0a8f5" : "#5b4fc7" }]);
    }
    setGestureKind(gesture.current?.kind ?? null);
  }

  function onPointerMove(e: PointerEvent<SVGSVGElement>) {
    const p = toWorld(e.clientX, e.clientY);
    const t = timers.current;
    if (Date.now() - t.lastCursor > 30) {
      t.lastCursor = Date.now();
      link.current?.sendCursor(p.x, p.y);
    }
    if (pointers.current.has(e.pointerId)) pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });

    const g = gesture.current;
    if (!g) return;
    if (g.kind === "pinch") {
      const [a, b] = [...pointers.current.values()];
      if (!a || !b) return;
      const r = svg.current!.getBoundingClientRect();
      const dist = Math.hypot(a.x - b.x, a.y - b.y);
      const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
      const zoom = clamp((g.view.zoom * dist) / g.dist, MIN_ZOOM, MAX_ZOOM);
      const sx = g.mid.x - r.left;
      const sy = g.mid.y - r.top;
      setView({
        zoom,
        x: sx - ((sx - g.view.x) * zoom) / g.view.zoom + (mid.x - g.mid.x),
        y: sy - ((sy - g.view.y) * zoom) / g.view.zoom + (mid.y - g.mid.y),
      });
    } else if (g.kind === "pan") {
      setView({ ...g.view, x: g.view.x + e.clientX - g.sx, y: g.view.y + e.clientY - g.sy });
    } else if (g.kind === "move") {
      let dx = p.x - g.start.x;
      let dy = p.y - g.start.y;
      if (!g.moved && Math.hypot(dx, dy) * viewRef.current.zoom < 3) return;
      g.moved = true;
      const map = new Map(g.base.map((el) => [el.id, el]));
      const movingBox = union(g.base.filter((el) => g.ids.has(el.id)).map((el) => bounds(el, map)));
      if (movingBox && !e.altKey) {
        const others = g.base.filter((el) => !g.ids.has(el.id) && isBox(el) && el.type !== "path") as BoxEl[];
        const s = snap({ ...movingBox, x: movingBox.x + dx, y: movingBox.y + dy }, others, 6 / viewRef.current.zoom);
        dx += s.dx;
        dy += s.dy;
        setGuides(s.guides);
      }
      apply(g.base.map((el) => (g.ids.has(el.id) ? move(el, dx, dy) : el)));
    } else if (g.kind === "marquee") {
      const box = normBox(g.start, p);
      setMarquee(box);
      const map = new Map(elementsRef.current.map((el) => [el.id, el]));
      const hit = elementsRef.current.filter((el) => intersects(box, bounds(el, map)) && !(el.type === "frame" && !(box.x <= el.x && box.y <= el.y && box.x + box.w >= el.x + el.w && box.y + box.h >= el.y + el.h)));
      setSelected(new Set([...g.base, ...hit.map((el) => el.id)]));
    } else if (g.kind === "resize") {
      const o = g.origin;
      const left = g.corner === 0 || g.corner === 3;
      const top = g.corner === 0 || g.corner === 1;
      const x0 = left ? Math.min(p.x, o.x + o.w - 24) : o.x;
      const y0 = top ? Math.min(p.y, o.y + o.h - 24) : o.y;
      const x1 = left ? o.x + o.w : Math.max(p.x, o.x + 24);
      const y1 = top ? o.y + o.h : Math.max(p.y, o.y + 24);
      apply(g.base.map((el) => (el.id === g.id && isBox(el) ? resize(el, { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }) : el)));
    } else if (g.kind === "create") {
      let box = normBox(g.start, p);
      if (e.shiftKey) box = { ...box, w: Math.max(box.w, box.h), h: Math.max(box.w, box.h) };
      apply(elementsRef.current.map((el) => (el.id === g.id ? ({ ...el, ...box } as El) : el)));
    } else if (g.kind === "pen") {
      const last = g.points.length - 2;
      if (Math.hypot(p.x - g.points[last], p.y - g.points[last + 1]) * viewRef.current.zoom < 2) return;
      g.points.push(p.x, p.y);
      const pts = [...g.points];
      apply(elementsRef.current.map((el) => (el.id === g.id ? { ...el, points: pts, ...pathBox(pts) } : el)));
    } else if (g.kind === "connect") {
      const target = elementAt(e.clientX, e.clientY, g.id);
      const validTarget = target && target.id !== g.from.id ? target : null;
      setHoverTarget(validTarget?.id ?? null);
      const to: End = validTarget ? { id: validTarget.id, x: p.x, y: p.y } : { x: p.x, y: p.y };
      apply(elementsRef.current.map((el) => (el.id === g.id && el.type === "connector" ? { ...el, to } : el)));
    }
  }

  function onPointerUp(e: PointerEvent<SVGSVGElement>) {
    pointers.current.delete(e.pointerId);
    const g = gesture.current;
    gesture.current = null;
    setGestureKind(null);
    setGuides([]);
    setMarquee(null);
    setHoverTarget(null);
    if (!g) return;

    if (g.kind === "move" && g.moved) commit(elementsRef.current, g.base);
    if (g.kind === "resize") commit(elementsRef.current, g.base);
    if (g.kind === "pen") {
      if (g.points.length >= 4) commit(elementsRef.current, g.base);
      else apply(g.base);
    }
    if (g.kind === "create") {
      const el = elementsRef.current.find((x) => x.id === g.id);
      if (!el || !isBox(el)) return;
      // A click without dragging drops a default-sized element.
      const tiny = el.w < 8 && el.h < 8;
      const sized = tiny
        ? g.shape === "frame"
          ? { x: g.start.x - 200, y: g.start.y - 150, w: 400, h: 300 }
          : { x: g.start.x - 80, y: g.start.y - 50, w: 160, h: 100 }
        : {};
      commit(elementsRef.current.map((x) => (x.id === g.id ? ({ ...x, ...sized } as El) : x)), g.base);
      setSelected(new Set([g.id]));
      setTool("select");
    }
    if (g.kind === "connect") {
      const c = elementsRef.current.find((x) => x.id === g.id) as Connector | undefined;
      const { a, b } = c ? connectorPoints(c, new Map(elementsRef.current.map((x) => [x.id, x]))) : { a: { x: 0, y: 0 }, b: { x: 0, y: 0 } };
      if (!c || Math.hypot(a.x - b.x, a.y - b.y) < 12) apply(g.base);
      else {
        commit(elementsRef.current, g.base);
        setSelected(new Set([g.id]));
      }
    }
  }

  function onDoubleClick(e: MouseEvent<SVGSVGElement>) {
    const id = hitId(e.target);
    const el = id ? elementsRef.current.find((x) => x.id === id) : null;
    if (el && (el.type === "sticky" || el.type === "shape" || el.type === "text" || el.type === "frame")) {
      startEditing(el.id);
      return;
    }
    if (!id && tool === "select") {
      const p = toWorld(e.clientX, e.clientY);
      const sticky: El = { id: uid(), type: "sticky", x: p.x - 85, y: p.y - 75, w: 170, h: 150, text: "", color: STICKY_COLORS[0] };
      commit([...elementsRef.current, sticky]);
      startEditing(sticky.id);
    }
  }

  // ── Keyboard ────────────────────────────────────────────────────────────────────────
  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    if ((e.target as HTMLElement).closest("textarea, input")) return;
    const mod = e.ctrlKey || e.metaKey;
    const key = e.key.toLowerCase();
    const sel = selectedRef.current;
    let handled = true;
    if (key === " ") setSpaceDown(true);
    else if (mod && key === "z" && !e.shiftKey) undo();
    else if (mod && (key === "y" || (key === "z" && e.shiftKey))) redo();
    else if (mod && key === "d") sel.size && duplicate(sel);
    else if (mod && key === "a") setSelected(new Set(elementsRef.current.map((x) => x.id)));
    else if ((key === "delete" || key === "backspace") && sel.size) removeIds(sel);
    else if (key === "escape") {
      setSelected(new Set());
      setTool("select");
    } else if (key === "enter" && sel.size === 1) startEditing([...sel][0]);
    else if (key.startsWith("arrow") && sel.size) {
      const step = e.shiftKey ? 10 : 1;
      const dx = key === "arrowleft" ? -step : key === "arrowright" ? step : 0;
      const dy = key === "arrowup" ? -step : key === "arrowdown" ? step : 0;
      const ids = movingIds(sel);
      commit(elementsRef.current.map((x) => (ids.has(x.id) ? move(x, dx, dy) : x)));
    } else if (!mod && (key === "+" || key === "=")) zoomAt(1.2, size.w / 2, size.h / 2);
    else if (!mod && key === "-") zoomAt(1 / 1.2, size.w / 2, size.h / 2);
    else if (!mod && key === "0") fit();
    else if (!mod && !e.altKey && TOOL_KEYS[key]) setTool(TOOL_KEYS[key]);
    else handled = false;
    if (handled) e.preventDefault();
  }

  // ── Toolbar actions ─────────────────────────────────────────────────────────────────
  function loadTemplate(key: string) {
    const t = TEMPLATES.find((x) => x.key === key);
    if (!t) return;
    const els = t.build();
    commit(els);
    setSelected(new Set());
    setMenu(null);
    requestAnimationFrame(() => fit(els));
  }

  async function exportImage() {
    setMenu(null);
    const blob = await exportPng(elementsRef.current, dark);
    if (blob) download(blob, "whiteboard.png");
  }

  function exportJson() {
    setMenu(null);
    download(new Blob([JSON.stringify({ version: 1, elements: elementsRef.current }, null, 2)], { type: "application/json" }), "whiteboard.json");
  }

  async function importJson(file: File) {
    setMenu(null);
    try {
      const data = JSON.parse(await file.text());
      const els = Array.isArray(data) ? data : data.elements;
      if (!Array.isArray(els) || !els.every((x) => x && typeof x.id === "string" && typeof x.type === "string")) throw new Error();
      commit(els);
      requestAnimationFrame(() => fit(els));
    } catch {
      window.alert("That file isn't a whiteboard export.");
    }
  }

  async function toggleFullscreen() {
    if (document.fullscreenElement) await document.exitFullscreen();
    else await root.current?.requestFullscreen?.();
    requestAnimationFrame(() => fit());
  }

  // ── Derived render data ─────────────────────────────────────────────────────────────
  const selectedEls = elements.filter((el) => selected.has(el.id));
  const selBox = union(selectedEls.map((el) => bounds(el, byId)));
  const single = selectedEls.length === 1 && isBox(selectedEls[0]) ? selectedEls[0] : null;
  const toScreen = (p: Pt) => ({ x: p.x * view.zoom + view.x, y: p.y * view.zoom + view.y });
  const editingEl = editing ? byId.get(editing) : undefined;
  const colorable = selectedEls.filter((el) => el.type === "sticky" || el.type === "shape");
  const palette = colorable.length && colorable.every((el) => el.type === "sticky") ? STICKY_COLORS : SHAPE_COLORS;
  const frames = elements.filter((el) => el.type === "frame");
  const others = elements.filter((el) => el.type !== "frame");
  const gridSize = GRID * view.zoom;
  const cursor = spaceDown || tool === "hand" ? (gestureKind === "pan" ? "grabbing" : "grab") : tool === "select" ? "default" : "crosshair";
  const handleSize = 10 / view.zoom;

  function renderEl(el: El) {
    const isSel = selected.has(el.id);
    const isEditing = editing === el.id;
    const hover = hoverTarget === el.id;
    if (el.type === "frame") {
      return (
        <g key={el.id} data-id={el.id} className={styles.frame}>
          <rect x={el.x} y={el.y} width={el.w} height={el.h} rx={6} className={styles.frameBody} data-hover={hover} />
          {!isEditing && (
            <text x={el.x} y={el.y - 10} className={styles.frameTitle} style={{ fontSize: Math.max(14, 14 / view.zoom) }}>
              {el.title || "Frame"}
            </text>
          )}
        </g>
      );
    }
    if (el.type === "sticky" || el.type === "shape") {
      const fs = el.type === "sticky" ? fitFont(el.text, el.w, el.h) : fitFont(el.text, el.w * 0.8, el.h * 0.8, 20);
      return (
        <g key={el.id} data-id={el.id} className={styles.item} data-hover={hover}>
          {el.type === "sticky" && <rect x={el.x} y={el.y} width={el.w} height={el.h} fill={el.color} className={styles.sticky} />}
          {el.type === "shape" && el.kind === "rect" && <rect x={el.x} y={el.y} width={el.w} height={el.h} rx={12} fill={el.color} className={styles.shape} />}
          {el.type === "shape" && el.kind === "ellipse" && (
            <ellipse cx={el.x + el.w / 2} cy={el.y + el.h / 2} rx={el.w / 2} ry={el.h / 2} fill={el.color} className={styles.shape} />
          )}
          {el.type === "shape" && el.kind === "diamond" && (
            <polygon
              points={`${el.x + el.w / 2},${el.y} ${el.x + el.w},${el.y + el.h / 2} ${el.x + el.w / 2},${el.y + el.h} ${el.x},${el.y + el.h / 2}`}
              fill={el.color}
              className={styles.shape}
            />
          )}
          {!isEditing && el.text && (
            <foreignObject x={el.x} y={el.y} width={el.w} height={el.h} className={styles.noHit}>
              <div className={styles.label} style={{ fontSize: fs, padding: el.type === "shape" ? "12% 14%" : 12 }}>
                {el.text}
              </div>
            </foreignObject>
          )}
        </g>
      );
    }
    if (el.type === "text") {
      return (
        <g key={el.id} data-id={el.id} className={styles.item}>
          <rect x={el.x} y={el.y} width={el.w} height={el.h} fill="transparent" />
          {!isEditing && (
            <foreignObject x={el.x} y={el.y} width={el.w} height={el.h} className={styles.noHit}>
              <div className={styles.freeText}>{el.text}</div>
            </foreignObject>
          )}
        </g>
      );
    }
    if (el.type === "path") {
      const d = smoothPath(el.points);
      return (
        <g key={el.id} data-id={el.id} className={styles.item}>
          <path d={d} className={styles.hitStroke} style={{ strokeWidth: 14 / view.zoom }} />
          <path d={d} stroke={el.color} className={styles.stroke} />
        </g>
      );
    }
    const { a, b } = connectorPoints(el, byId);
    const live = gesture.current?.kind === "connect" && gesture.current.id === el.id;
    return (
      <g key={el.id} data-id={live ? undefined : el.id} className={live ? styles.noHit : styles.item}>
        <line x1={a.x} y1={a.y} x2={b.x} y2={b.y} className={styles.hitStroke} style={{ strokeWidth: 14 / view.zoom }} />
        <line x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke={isSel ? "var(--accent)" : el.color} className={styles.connector} markerEnd="url(#wb-arrow)" style={{ color: isSel ? "var(--accent)" : el.color }} />
      </g>
    );
  }

  const peopleCount = peers.length + 1;
  const ctxPos = selBox && !editing && !gestureKind ? toScreen({ x: selBox.x + selBox.w / 2, y: selBox.y }) : null;

  return (
    <div
      ref={root}
      className={styles.root}
      tabIndex={0}
      data-active={active}
      onKeyDown={onKeyDown}
      onKeyUp={(e) => e.key === " " && setSpaceDown(false)}
      onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node) && setSpaceDown(false)}
      aria-label="Whiteboard. Click to start, scroll to pan, Ctrl plus scroll to zoom."
    >
      <svg
        ref={svg}
        className={styles.canvas}
        style={{ cursor }}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={onPointerUp}
        onPointerCancel={onPointerUp}
        onDoubleClick={onDoubleClick}
        onContextMenu={(e) => e.preventDefault()}
      >
        <defs>
          <pattern id="wb-grid" width={gridSize} height={gridSize} x={view.x % gridSize} y={view.y % gridSize} patternUnits="userSpaceOnUse">
            <circle cx={1} cy={1} r={view.zoom < 0.4 ? 0.6 : 1.1} className={styles.dot} />
          </pattern>
          <marker id="wb-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
            <path d="M0,0 L10,5 L0,10 z" fill="currentColor" />
          </marker>
          <filter id="wb-shadow" x="-10%" y="-10%" width="130%" height="140%">
            <feDropShadow dx="0" dy="3" stdDeviation="4" floodOpacity="0.18" />
          </filter>
        </defs>
        <rect width="100%" height="100%" fill="url(#wb-grid)" />

        <g transform={`translate(${view.x} ${view.y}) scale(${view.zoom})`}>
          {frames.map(renderEl)}
          {others.map(renderEl)}

          {selectedEls.map((el) => {
            const b = bounds(el, byId);
            return <rect key={`sel-${el.id}`} x={b.x - 3 / view.zoom} y={b.y - 3 / view.zoom} width={b.w + 6 / view.zoom} height={b.h + 6 / view.zoom} className={styles.selection} />;
          })}
          {single && !editing && !gestureKind &&
            [
              [single.x, single.y],
              [single.x + single.w, single.y],
              [single.x + single.w, single.y + single.h],
              [single.x, single.y + single.h],
            ].map(([x, y], i) => (
              <rect
                key={i}
                data-handle={i}
                x={x - handleSize / 2}
                y={y - handleSize / 2}
                width={handleSize}
                height={handleSize}
                className={styles.handle}
                style={{ cursor: i % 2 === 0 ? "nwse-resize" : "nesw-resize", strokeWidth: 1.5 / view.zoom }}
              />
            ))}
          {guides.map((g, i) =>
            g.x !== undefined ? (
              <line key={i} x1={g.x} x2={g.x} y1={-1e5} y2={1e5} className={styles.guide} />
            ) : (
              <line key={i} y1={g.y} y2={g.y} x1={-1e5} x2={1e5} className={styles.guide} />
            ),
          )}
          {marquee && <rect x={marquee.x} y={marquee.y} width={marquee.w} height={marquee.h} className={styles.marquee} />}
        </g>
      </svg>

      {/* Remote cursors */}
      {peers.map((p) => {
        if (p.x === undefined || p.y === undefined) return null;
        const s = toScreen({ x: p.x, y: p.y });
        return (
          <div key={p.id} className={styles.peer} style={{ transform: `translate(${s.x}px, ${s.y}px)`, color: p.color }}>
            <svg viewBox="0 0 16 16" width="18" height="18" aria-hidden="true">
              <path d="M1 1l5.5 14 2-6 6-2z" fill="currentColor" stroke="#fff" strokeWidth="1.2" />
            </svg>
            <span style={{ background: p.color }}>{p.name}</span>
          </div>
        );
      })}

      {/* Inline text editor */}
      {editingEl && isBox(editingEl) && (() => {
        const s = toScreen(editingEl);
        const isFrame = editingEl.type === "frame";
        const fs =
          editingEl.type === "sticky"
            ? fitFont(editingEl.text, editingEl.w, editingEl.h)
            : editingEl.type === "shape"
              ? fitFont(editingEl.text, editingEl.w * 0.8, editingEl.h * 0.8, 20)
              : 22;
        if (isFrame) {
          return (
            <input
              autoFocus
              data-editor
              className={styles.frameInput}
              style={{ left: s.x, top: s.y - 34, width: Math.max(160, editingEl.w * view.zoom) }}
              value={editingEl.title}
              onChange={(e) => editText(e.target.value)}
              onBlur={finishEditing}
              onKeyDown={(e) => (e.key === "Enter" || e.key === "Escape") && finishEditing()}
            />
          );
        }
        return (
          <textarea
            autoFocus
            data-editor
            className={styles.editor}
            data-kind={editingEl.type}
            style={{
              left: s.x,
              top: s.y,
              width: editingEl.w * view.zoom,
              height: editingEl.type === "text" ? undefined : editingEl.h * view.zoom,
              fontSize: fs * view.zoom,
              background: editingEl.type === "sticky" || editingEl.type === "shape" ? "transparent" : undefined,
            }}
            value={"text" in editingEl ? editingEl.text : ""}
            placeholder={editingEl.type === "text" ? "Type something" : ""}
            onChange={(e) => {
              const area = e.target;
              if (editingEl.type === "text") {
                area.style.height = "auto";
                area.style.height = `${area.scrollHeight}px`;
              }
              editText(area.value, editingEl.type === "text" ? area.scrollHeight / view.zoom : undefined);
            }}
            onBlur={finishEditing}
            onKeyDown={(e) => {
              if (e.key === "Escape" || (e.key === "Enter" && (e.ctrlKey || e.metaKey))) {
                e.preventDefault();
                finishEditing();
              }
            }}
          />
        );
      })()}

      {/* Floating context toolbar */}
      {ctxPos && selected.size > 0 && (
        <div
          className={styles.context}
          style={{ left: clamp(ctxPos.x, 140, size.w - 140), top: Math.max(8, ctxPos.y - 56) }}
          onPointerDown={keepBoardFocus}
        >
          {colorable.length > 0 &&
            palette.map((c) => (
              <button key={c} type="button" className={styles.swatch} style={{ background: c }} aria-label={`Colour ${c}`} onClick={() => recolor(selected, c)} />
            ))}
          {colorable.length > 0 && <span className={styles.divider} />}
          <button type="button" className={styles.ctxBtn} title="Bring to front" onClick={() => reorder(selected, true)}>
            <Icon>
              <path d="M8 8h12v12H8zM4 16V4h12" />
            </Icon>
          </button>
          <button type="button" className={styles.ctxBtn} title="Send to back" onClick={() => reorder(selected, false)}>
            <Icon>
              <path d="M4 4h12v12H4zM20 8v12H8" />
            </Icon>
          </button>
          <button type="button" className={styles.ctxBtn} title="Duplicate (Ctrl+D)" onClick={() => duplicate(selected)}>
            <Icon>
              <path d="M9 9h11v11H9zM5 15H4V4h11v1" />
            </Icon>
          </button>
          <button type="button" className={styles.ctxBtn} title="Delete (Del)" onClick={() => removeIds(selected)}>
            <Icon>
              <path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" />
            </Icon>
          </button>
        </div>
      )}

      {/* Left tool rail */}
      <div className={styles.rail} role="toolbar" aria-label="Tools" onPointerDown={keepBoardFocus}>
        {TOOLS.map((t) => (
          <button
            key={t.key}
            type="button"
            className={styles.tool}
            aria-pressed={tool === t.key}
            title={`${t.label} (${t.shortcut})`}
            onClick={() => {
              setTool(t.key);
              setActive(true);
            }}
          >
            <Icon>{t.icon}</Icon>
          </button>
        ))}
        <span className={styles.railDivider} />
        <button type="button" className={styles.tool} aria-expanded={menu === "templates"} title="Templates" onClick={() => setMenu(menu === "templates" ? null : "templates")}>
          <Icon>
            <path d="M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z" />
          </Icon>
        </button>
        <button type="button" className={styles.tool} aria-expanded={menu === "export"} title="Export / import" onClick={() => setMenu(menu === "export" ? null : "export")}>
          <Icon>
            <path d="M12 4v11m0 0-4-4m4 4 4-4M4 17v3h16v-3" />
          </Icon>
        </button>
        {menu === "templates" && (
          <div className={styles.menu}>
            <span className={styles.menuTitle}>Start from a template</span>
            {TEMPLATES.map((t) => (
              <button key={t.key} type="button" onClick={() => loadTemplate(t.key)}>
                {t.label}
              </button>
            ))}
            <span className={styles.menuNote}>Replaces the board · Undo with Ctrl+Z</span>
          </div>
        )}
        {menu === "export" && (
          <div className={styles.menu}>
            <button type="button" onClick={exportImage}>
              Download PNG
            </button>
            <button type="button" onClick={exportJson}>
              Download JSON
            </button>
            <label className={styles.menuFile}>
              Import JSON…
              <input type="file" accept="application/json,.json" onChange={(e) => e.target.files?.[0] && importJson(e.target.files[0])} />
            </label>
          </div>
        )}
      </div>

      {/* Top bar: undo/redo, people, fullscreen */}
      <div className={styles.topbar} onPointerDown={keepBoardFocus}>
        <button type="button" className={styles.ctxBtn} title="Undo (Ctrl+Z)" disabled={!canUndo} onClick={undo}>
          <Icon>
            <path d="M9 14 4 9l5-5M4 9h10a6 6 0 0 1 0 12h-3" />
          </Icon>
        </button>
        <button type="button" className={styles.ctxBtn} title="Redo (Ctrl+Y)" disabled={!canRedo} onClick={redo}>
          <Icon>
            <path d="m15 14 5-5-5-5m5 5H10a6 6 0 0 0 0 12h3" />
          </Icon>
        </button>
        <span className={styles.divider} />
        <div className={styles.avatars} title={`${peopleCount} ${peopleCount === 1 ? "person" : "people"} on this board`}>
          {me && (
            <span className={styles.avatar} style={{ background: me.color }} title={`${me.name} (you)`}>
              {me.name.split(" ")[1][0]}
            </span>
          )}
          {peers.slice(0, 4).map((p) => (
            <span key={p.id} className={styles.avatar} style={{ background: p.color }} title={p.name}>
              {p.name.split(" ")[1][0]}
            </span>
          ))}
        </div>
        <button
          type="button"
          className={styles.invite}
          title="Open this board in another tab to see live collaboration"
          onClick={() => window.open(window.location.href, "_blank", "noopener")}
        >
          {peers.length ? `${peopleCount} live` : "Invite (new tab)"}
        </button>
        <button type="button" className={styles.ctxBtn} title="Full screen" onClick={toggleFullscreen}>
          <Icon>
            <path d="M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5" />
          </Icon>
        </button>
      </div>

      {/* Zoom controls */}
      <div className={styles.zoombar} onPointerDown={keepBoardFocus}>
        <button type="button" className={styles.ctxBtn} title="Zoom out (−)" onClick={() => zoomAt(1 / 1.2, size.w / 2, size.h / 2)}>
          −
        </button>
        <button type="button" className={styles.zoomValue} title="Reset to 100%" onClick={() => zoomAt(1 / view.zoom, size.w / 2, size.h / 2)}>
          {Math.round(view.zoom * 100)}%
        </button>
        <button type="button" className={styles.ctxBtn} title="Zoom in (+)" onClick={() => zoomAt(1.2, size.w / 2, size.h / 2)}>
          +
        </button>
        <button type="button" className={styles.fitBtn} title="Fit to content (0)" onClick={() => fit()}>
          Fit
        </button>
      </div>

      <Minimap elements={elements} byId={byId} view={view} size={size} onCenter={(p) => setView((v) => ({ ...v, x: size.w / 2 - p.x * v.zoom, y: size.h / 2 - p.y * v.zoom }))} />

      {!active && (
        <div className={styles.hint} aria-hidden="true">
          Click the board to start · Scroll to pan · Ctrl + scroll to zoom · Double-click for a sticky
        </div>
      )}
    </div>
  );
}

function Minimap({
  elements,
  byId,
  view,
  size,
  onCenter,
}: {
  elements: El[];
  byId: Map<string, El>;
  view: View;
  size: { w: number; h: number };
  onCenter: (p: Pt) => void;
}) {
  const W = 180;
  const H = 116;
  const viewport: Box = { x: -view.x / view.zoom, y: -view.y / view.zoom, w: size.w / view.zoom, h: size.h / view.zoom };
  const content = union(elements.map((e) => bounds(e, byId)));
  const world = union(content ? [content, viewport] : [viewport])!;
  const pad = Math.max(world.w, world.h) * 0.08;
  const scale = Math.min(W / (world.w + pad * 2), H / (world.h + pad * 2));
  const ox = (W - (world.w + pad * 2) * scale) / 2 - (world.x - pad) * scale;
  const oy = (H - (world.h + pad * 2) * scale) / 2 - (world.y - pad) * scale;
  const dragging = useRef(false);

  const toWorld = (e: PointerEvent<SVGSVGElement>) => {
    const r = e.currentTarget.getBoundingClientRect();
    return { x: (e.clientX - r.left - ox) / scale, y: (e.clientY - r.top - oy) / scale };
  };

  return (
    <svg
      className={styles.minimap}
      width={W}
      height={H}
      aria-label="Mini map. Click to move the view."
      onPointerDown={(e) => {
        dragging.current = true;
        e.currentTarget.setPointerCapture(e.pointerId);
        onCenter(toWorld(e));
      }}
      onPointerMove={(e) => dragging.current && onCenter(toWorld(e))}
      onPointerUp={() => (dragging.current = false)}
    >
      <g transform={`translate(${ox} ${oy}) scale(${scale})`}>
        {elements.map((el) => {
          if (el.type === "connector") return null;
          const fill = el.type === "sticky" || el.type === "shape" ? el.color : el.type === "frame" ? "var(--frame-bg)" : "var(--mini-ink)";
          return <rect key={el.id} x={el.x} y={el.y} width={el.w} height={el.h} fill={fill} stroke="var(--mini-ink)" strokeOpacity={0.35} vectorEffect="non-scaling-stroke" />;
        })}
        <rect x={viewport.x} y={viewport.y} width={viewport.w} height={viewport.h} className={styles.miniView} vectorEffect="non-scaling-stroke" />
      </g>
    </svg>
  );
}
exportPng.ts
import { bounds, connectorPoints, union } from "./geometry";
import { fitFont, INK, type El } from "./model";

function wrap(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) {
  const lines: string[] = [];
  for (const para of text.split("\n")) {
    let line = "";
    for (const word of para.split(/(\s+)/)) {
      const next = line + word;
      if (ctx.measureText(next).width > maxWidth && line.trim()) {
        lines.push(line.trimEnd());
        line = word.trimStart();
      } else line = next;
    }
    lines.push(line);
  }
  return lines;
}

function drawText(
  ctx: CanvasRenderingContext2D,
  text: string,
  x: number,
  y: number,
  w: number,
  h: number,
  size: number,
  align: "center" | "left",
  color = INK,
) {
  ctx.font = `500 ${size}px system-ui, -apple-system, "Segoe UI", sans-serif`;
  ctx.fillStyle = color;
  ctx.textAlign = align;
  ctx.textBaseline = "middle";
  const lines = wrap(ctx, text, w - 16);
  const lh = size * 1.25;
  const top = align === "center" ? y + h / 2 - ((lines.length - 1) * lh) / 2 : y + size * 0.7;
  lines.forEach((l, i) => ctx.fillText(l, align === "center" ? x + w / 2 : x, top + i * lh));
}

/** Renders the board with the 2D canvas API (no foreignObject, so it never taints the canvas). */
export function exportPng(elements: El[], dark: boolean) {
  const byId = new Map(elements.map((e) => [e.id, e]));
  const box = union(elements.map((e) => bounds(e, byId)));
  if (!box) return Promise.resolve(null);
  const pad = 48;
  const scale = Math.min(2, 8000 / Math.max(box.w + pad * 2, box.h + pad * 2));
  const canvas = document.createElement("canvas");
  canvas.width = Math.ceil((box.w + pad * 2) * scale);
  canvas.height = Math.ceil((box.h + pad * 2) * scale);
  const ctx = canvas.getContext("2d")!;
  ctx.scale(scale, scale);
  ctx.translate(pad - box.x, pad - box.y);
  ctx.fillStyle = dark ? "#16161a" : "#f7f7f5";
  ctx.fillRect(box.x - pad, box.y - pad, box.w + pad * 2, box.h + pad * 2);

  const ordered = [...elements.filter((e) => e.type === "frame"), ...elements.filter((e) => e.type !== "frame")];
  for (const el of ordered) {
    ctx.save();
    if (el.type === "frame") {
      ctx.fillStyle = dark ? "#202026" : "#ffffff";
      ctx.strokeStyle = dark ? "#3a3a44" : "#d9d8d2";
      ctx.lineWidth = 1.5;
      ctx.fillRect(el.x, el.y, el.w, el.h);
      ctx.strokeRect(el.x, el.y, el.w, el.h);
      ctx.font = '600 15px system-ui, -apple-system, "Segoe UI", sans-serif';
      ctx.fillStyle = dark ? "#c3c2b7" : "#52514e";
      ctx.textBaseline = "bottom";
      ctx.fillText(el.title, el.x, el.y - 8);
    }
    if (el.type === "sticky") {
      ctx.shadowColor = "rgba(0,0,0,0.18)";
      ctx.shadowBlur = 10;
      ctx.shadowOffsetY = 4;
      ctx.fillStyle = el.color;
      ctx.fillRect(el.x, el.y, el.w, el.h);
      ctx.shadowColor = "transparent";
      drawText(ctx, el.text, el.x, el.y, el.w, el.h, fitFont(el.text, el.w, el.h), "center");
    }
    if (el.type === "shape") {
      ctx.fillStyle = el.color;
      ctx.strokeStyle = INK;
      ctx.lineWidth = 2;
      ctx.beginPath();
      if (el.kind === "rect") ctx.roundRect(el.x, el.y, el.w, el.h, 10);
      if (el.kind === "ellipse") ctx.ellipse(el.x + el.w / 2, el.y + el.h / 2, el.w / 2, el.h / 2, 0, 0, Math.PI * 2);
      if (el.kind === "diamond") {
        ctx.moveTo(el.x + el.w / 2, el.y);
        ctx.lineTo(el.x + el.w, el.y + el.h / 2);
        ctx.lineTo(el.x + el.w / 2, el.y + el.h);
        ctx.lineTo(el.x, el.y + el.h / 2);
        ctx.closePath();
      }
      ctx.fill();
      ctx.stroke();
      drawText(ctx, el.text, el.x, el.y, el.w, el.h, fitFont(el.text, el.w * 0.8, el.h * 0.8, 20), "center");
    }
    if (el.type === "text") drawText(ctx, el.text, el.x, el.y, el.w + 16, el.h, 22, "left", dark ? "#f4f4f5" : INK);
    if (el.type === "path") {
      ctx.strokeStyle = el.color;
      ctx.lineWidth = 3;
      ctx.lineCap = "round";
      ctx.lineJoin = "round";
      ctx.beginPath();
      for (let i = 0; i < el.points.length; i += 2) {
        if (i) ctx.lineTo(el.points[i], el.points[i + 1]);
        else ctx.moveTo(el.points[i], el.points[i + 1]);
      }
      ctx.stroke();
    }
    if (el.type === "connector") {
      const { a, b } = connectorPoints(el, byId);
      const ang = Math.atan2(b.y - a.y, b.x - a.x);
      ctx.strokeStyle = ctx.fillStyle = el.color;
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.moveTo(a.x, a.y);
      ctx.lineTo(b.x - Math.cos(ang) * 8, b.y - Math.sin(ang) * 8);
      ctx.stroke();
      ctx.beginPath();
      ctx.moveTo(b.x, b.y);
      ctx.lineTo(b.x - Math.cos(ang - 0.45) * 13, b.y - Math.sin(ang - 0.45) * 13);
      ctx.lineTo(b.x - Math.cos(ang + 0.45) * 13, b.y - Math.sin(ang + 0.45) * 13);
      ctx.closePath();
      ctx.fill();
    }
    ctx.restore();
  }
  return new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
}
geometry.ts
import { isBox, type Box, type BoxEl, type Connector, type El, type End } from "./model";

export type Pt = { x: number; y: number };

export const center = (b: Box): Pt => ({ x: b.x + b.w / 2, y: b.y + b.h / 2 });

export function union(boxes: Box[]): Box | null {
  if (!boxes.length) return null;
  let x0 = Infinity;
  let y0 = Infinity;
  let x1 = -Infinity;
  let y1 = -Infinity;
  for (const b of boxes) {
    x0 = Math.min(x0, b.x);
    y0 = Math.min(y0, b.y);
    x1 = Math.max(x1, b.x + b.w);
    y1 = Math.max(y1, b.y + b.h);
  }
  return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
}

export const intersects = (a: Box, b: Box) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
export const contains = (b: Box, p: Pt) => p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h;

/** Where a ray from the element's centre towards `toward` leaves its outline. */
export function boundaryPoint(el: BoxEl, toward: Pt): Pt {
  const c = center(el);
  const dx = toward.x - c.x;
  const dy = toward.y - c.y;
  if (!dx && !dy) return c;
  const rx = el.w / 2;
  const ry = el.h / 2;
  let t: number;
  if (el.type === "shape" && el.kind === "ellipse") t = 1 / Math.sqrt((dx / rx) ** 2 + (dy / ry) ** 2);
  else if (el.type === "shape" && el.kind === "diamond") t = 1 / (Math.abs(dx) / rx + Math.abs(dy) / ry);
  else t = Math.min(dx ? rx / Math.abs(dx) : Infinity, dy ? ry / Math.abs(dy) : Infinity);
  return { x: c.x + dx * t, y: c.y + dy * t };
}

/** Screen-ready endpoints of a connector, following any elements it is pinned to. */
export function connectorPoints(c: Connector, byId: Map<string, El>) {
  const pinned = (end: End) => {
    const el = end.id ? byId.get(end.id) : undefined;
    return el && isBox(el) ? el : undefined;
  };
  const a = pinned(c.from);
  const b = pinned(c.to);
  const aRef = a ? center(a) : { x: c.from.x, y: c.from.y };
  const bRef = b ? center(b) : { x: c.to.x, y: c.to.y };
  return { a: a ? boundaryPoint(a, bRef) : aRef, b: b ? boundaryPoint(b, aRef) : bRef };
}

export function bounds(el: El, byId: Map<string, El>): Box {
  if (isBox(el)) return el;
  const { a, b } = connectorPoints(el, byId);
  return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(a.x - b.x), h: Math.abs(a.y - b.y) };
}

export function move(el: El, dx: number, dy: number): El {
  if (el.type === "connector") {
    const shift = (e: End): End => (e.id ? e : { x: e.x + dx, y: e.y + dy });
    return { ...el, from: shift(el.from), to: shift(el.to) };
  }
  if (el.type === "path") {
    return { ...el, x: el.x + dx, y: el.y + dy, points: el.points.map((v, i) => v + (i % 2 ? dy : dx)) };
  }
  return { ...el, x: el.x + dx, y: el.y + dy };
}

export function resize(el: BoxEl, next: Box): BoxEl {
  if (el.type !== "path") return { ...el, ...next };
  const sx = el.w ? next.w / el.w : 1;
  const sy = el.h ? next.h / el.h : 1;
  return { ...el, ...next, points: el.points.map((v, i) => (i % 2 ? next.y + (v - el.y) * sy : next.x + (v - el.x) * sx)) };
}

export function pathBox(points: number[]): Box {
  const xs = points.filter((_, i) => i % 2 === 0);
  const ys = points.filter((_, i) => i % 2 === 1);
  const x = Math.min(...xs);
  const y = Math.min(...ys);
  return { x, y, w: Math.max(1, Math.max(...xs) - x), h: Math.max(1, Math.max(...ys) - y) };
}

/** Smooth SVG path through freehand points (quadratic curves through the midpoints). */
export function smoothPath(points: number[]) {
  if (points.length < 4) return points.length ? `M${points[0]},${points[1]} l0.1,0` : "";
  let d = `M${points[0]},${points[1]}`;
  for (let i = 2; i < points.length - 2; i += 2) {
    const mx = (points[i] + points[i + 2]) / 2;
    const my = (points[i + 1] + points[i + 3]) / 2;
    d += ` Q${points[i]},${points[i + 1]} ${mx},${my}`;
  }
  return `${d} L${points[points.length - 2]},${points[points.length - 1]}`;
}

/**
 * Snap a moving box to other boxes' edges and centres. Returns the corrected offset and the
 * guide lines to draw (world coordinates).
 */
export function snap(moving: Box, others: Box[], threshold: number) {
  const xs = (b: Box) => [b.x, b.x + b.w / 2, b.x + b.w];
  const ys = (b: Box) => [b.y, b.y + b.h / 2, b.y + b.h];
  // Closest edge/centre alignment on each axis.
  const best = (mine: number[], theirs: (b: Box) => number[]) => {
    let found: { d: number; v: number } | null = null;
    for (const o of others) {
      for (const a of mine) {
        for (const b of theirs(o)) {
          if (Math.abs(b - a) <= threshold && (!found || Math.abs(b - a) < Math.abs(found.d))) found = { d: b - a, v: b };
        }
      }
    }
    return found;
  };
  const sx = best(xs(moving), xs);
  const sy = best(ys(moving), ys);
  const guides: { x?: number; y?: number }[] = [];
  if (sx) guides.push({ x: sx.v });
  if (sy) guides.push({ y: sy.v });
  return { dx: sx?.d ?? 0, dy: sy?.d ?? 0, guides };
}
model.ts
export type Tool = "select" | "hand" | "sticky" | "text" | "rect" | "ellipse" | "diamond" | "connector" | "pen" | "frame";

export type Box = { x: number; y: number; w: number; h: number };
export type ShapeKind = "rect" | "ellipse" | "diamond";

export type Sticky = Box & { id: string; type: "sticky"; text: string; color: string };
export type Shape = Box & { id: string; type: "shape"; kind: ShapeKind; text: string; color: string };
export type TextEl = Box & { id: string; type: "text"; text: string };
export type Frame = Box & { id: string; type: "frame"; title: string };
/** Freehand stroke; points are flat world coordinates [x0, y0, x1, y1, …]. */
export type Path = Box & { id: string; type: "path"; points: number[]; color: string };
/** A connector end is either pinned to an element (by id) or a free point. */
export type End = { id?: string; x: number; y: number };
export type Connector = { id: string; type: "connector"; from: End; to: End; color: string };

export type El = Sticky | Shape | TextEl | Frame | Path | Connector;
export type BoxEl = Exclude<El, Connector>;

export const STICKY_COLORS = ["#fff3a8", "#ffd8a8", "#ffc4d6", "#e0ccff", "#bfe3ff", "#c6f0c2"];
export const SHAPE_COLORS = ["#ffffff", "#bfe3ff", "#c6f0c2", "#fff3a8", "#ffc4d6", "#e0ccff"];
export const INK = "#1f2430";

let counter = 0;
export const uid = () => `${Date.now().toString(36)}${(counter++).toString(36)}${Math.random().toString(36).slice(2, 6)}`;

export const isBox = (el: El): el is BoxEl => el.type !== "connector";

function sticky(x: number, y: number, text: string, color = STICKY_COLORS[0]): Sticky {
  return { id: uid(), type: "sticky", x, y, w: 170, h: 150, text, color };
}

/** Starter boards. */
export const TEMPLATES: { key: string; label: string; build: () => El[] }[] = [
  {
    key: "brainstorm",
    label: "Brainstorm",
    build: () => {
      const topic: Shape = { id: uid(), type: "shape", kind: "ellipse", x: -130, y: -60, w: 260, h: 120, text: "Team offsite 2026", color: "#e0ccff" };
      const ideas = [
        ["Cox's Bazar beach day", 0],
        ["Hackathon: 1 day", 1],
        ["Team dinner + awards", 2],
        ["Workshop: AI tools", 4],
        ["Budget: BDT 3 lakh", 5],
        ["Date: last week of Nov", 3],
      ] as const;
      const stickies = ideas.map(([text, c], i) => {
        const a = (i / ideas.length) * Math.PI * 2 - Math.PI / 2;
        return sticky(Math.cos(a) * 380 - 85, Math.sin(a) * 260 - 75, text, STICKY_COLORS[c]);
      });
      const links: Connector[] = stickies.map((s) => ({
        id: uid(),
        type: "connector",
        from: { id: topic.id, x: 0, y: 0 },
        to: { id: s.id, x: s.x, y: s.y },
        color: "#7c6fd6",
      }));
      const title: TextEl = { id: uid(), type: "text", x: -150, y: -400, w: 300, h: 40, text: "Brainstorm board" };
      return [title, topic, ...stickies, ...links];
    },
  },
  {
    key: "retro",
    label: "Retrospective",
    build: () => {
      const cols = [
        { title: "Went well", color: STICKY_COLORS[5], notes: ["Shipped the gallery on time", "Great pairing sessions"] },
        { title: "To improve", color: STICKY_COLORS[2], notes: ["Too many meetings", "Unclear priorities mid-sprint"] },
        { title: "Action items", color: STICKY_COLORS[4], notes: ["No-meeting Wednesdays", "Weekly priority check-in"] },
      ];
      const out: El[] = [];
      cols.forEach((col, i) => {
        const x = -620 + i * 420;
        out.push({ id: uid(), type: "frame", x, y: -260, w: 380, h: 560, title: col.title });
        col.notes.forEach((n, j) => out.push(sticky(x + 25 + (j % 2) * 175, -220 + Math.floor(j / 2) * 170, n, col.color)));
      });
      return out;
    },
  },
  { key: "blank", label: "Blank board", build: () => [] },
];

/** Font size that roughly fits `text` inside a w×h box. */
export function fitFont(text: string, w: number, h: number, max = 22, min = 9) {
  const chars = Math.max(8, text.length);
  return Math.max(min, Math.min(max, Math.sqrt((w * h * 0.42) / chars)));
}
sync.ts
import type { El } from "./model";

/** Someone else on the board. x/y are world coordinates of their cursor, once they have moved it. */
export type Peer = { id: string; name: string; color: string; x?: number; y?: number; seen: number };

type Identity = { id: string; name: string; color: string };
type Message =
  | { kind: "hello"; from: string; name: string; color: string }
  | { kind: "presence"; from: string; name: string; color: string }
  | { kind: "state"; from: string; elements: El[] }
  | { kind: "cursor"; from: string; name: string; color: string; x: number; y: number }
  | { kind: "bye"; from: string };

const NAMES = ["Fox", "Owl", "Otter", "Panda", "Tiger", "Koala", "Falcon", "Lynx"];
const COLORS = ["#e34948", "#2a78d6", "#1baf7a", "#eb6834", "#4a3aa7", "#e87ba4", "#0e9aa7", "#c98500"];
const HEARTBEAT = 2500;
const TIMEOUT = 8000;

export function makeIdentity(): Identity {
  return {
    id: Math.random().toString(36).slice(2, 10),
    name: `${["Blue", "Swift", "Calm", "Bright", "Bold", "Quiet"][Math.floor(Math.random() * 6)]} ${NAMES[Math.floor(Math.random() * NAMES.length)]}`,
    color: COLORS[Math.floor(Math.random() * COLORS.length)],
  };
}

/**
 * Same-device live collaboration between browser tabs via BroadcastChannel. The board is
 * last-writer-wins; presence is a heartbeat, and cursors are sent separately.
 */
export function connect(
  channelName: string,
  me: Identity,
  handlers: { onState: (elements: El[]) => void; onPeers: (peers: Peer[]) => void; getState: () => El[] },
) {
  if (typeof BroadcastChannel === "undefined") {
    return { sendState: () => {}, sendCursor: () => {}, close: () => {} };
  }
  const channel = new BroadcastChannel(channelName);
  const peers = new Map<string, Peer>();
  const publish = () => handlers.onPeers([...peers.values()]);
  const post = (m: Message) => channel.postMessage(m);
  const seen = (id: string, name: string, color: string, pos?: { x: number; y: number }) => {
    const prev = peers.get(id);
    peers.set(id, { id, name, color, x: pos?.x ?? prev?.x, y: pos?.y ?? prev?.y, seen: Date.now() });
    publish();
  };

  channel.onmessage = ({ data }: MessageEvent<Message>) => {
    if (!data || data.from === me.id) return;
    switch (data.kind) {
      case "hello":
        seen(data.from, data.name, data.color);
        // Introduce ourselves and hand the newcomer the current board.
        post({ kind: "presence", from: me.id, name: me.name, color: me.color });
        post({ kind: "state", from: me.id, elements: handlers.getState() });
        break;
      case "presence":
        seen(data.from, data.name, data.color);
        break;
      case "cursor":
        seen(data.from, data.name, data.color, { x: data.x, y: data.y });
        break;
      case "state":
        handlers.onState(data.elements);
        break;
      case "bye":
        if (peers.delete(data.from)) publish();
        break;
    }
  };

  const heartbeat = setInterval(() => {
    post({ kind: "presence", from: me.id, name: me.name, color: me.color });
    let changed = false;
    for (const [id, p] of peers) {
      if (Date.now() - p.seen > TIMEOUT) {
        peers.delete(id);
        changed = true;
      }
    }
    if (changed) publish();
  }, HEARTBEAT);

  const bye = () => post({ kind: "bye", from: me.id });
  window.addEventListener("pagehide", bye);
  post({ kind: "hello", from: me.id, name: me.name, color: me.color });

  return {
    sendState: (elements: El[]) => post({ kind: "state", from: me.id, elements }),
    sendCursor: (x: number, y: number) => post({ kind: "cursor", from: me.id, name: me.name, color: me.color, x, y }),
    close: () => {
      bye();
      clearInterval(heartbeat);
      window.removeEventListener("pagehide", bye);
      channel.close();
    },
  };
}