// <StickyNote/> — renders one note. Handles drag, rotate, resize.
// Visual styles:
//  - text/voice/doodle/sticker-cluster: square-ish sticky with optional tape
//  - photo/gif: white polaroid card with caption line
//
// Selection model: parent owns `selectedId`. Click selects; drag from body
// translates; drag from rotate handle rotates; drag from resize handle scales.
// All gestures dispatch onChange({x,y,rot,w,h}) on move and onCommit() on up.

const TAPE_COLORS = [
  "rgba(255, 240, 180, 0.62)",
  "rgba(200, 220, 255, 0.55)",
  "rgba(255, 200, 200, 0.55)",
  "rgba(220, 230, 200, 0.6)",
];
const tapeFor = (id) => {
  let h = 0; for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) | 0;
  return TAPE_COLORS[Math.abs(h) % TAPE_COLORS.length];
};

// Author chip — small initial circle + name
const Author = ({ author, size = 18, showName = true }) => {
  if (!author) return null;
  const initial = author.name.trim()[0] || "?";
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
      <span style={{
        width: size, height: size, borderRadius: "50%",
        background: author.hue, color: "#fff",
        display: "grid", placeItems: "center",
        fontSize: size * 0.55, fontWeight: 600, letterSpacing: 0,
        fontFamily: "var(--sans)",
      }}>{initial}</span>
      {showName && <span style={{
        fontSize: 11, color: "rgba(31,27,23,0.6)", fontWeight: 500,
        letterSpacing: 0,
      }}>{author.name}</span>}
    </div>
  );
};

// Tape strip — 2 randomized variants
const Tape = ({ id, variant = 0 }) => {
  const angle = variant === 0 ? -3 : 4;
  const left = variant === 0 ? "18%" : "62%";
  return (
    <div style={{
      position: "absolute", top: -10, left,
      width: 54, height: 18,
      background: tapeFor(id),
      transform: `rotate(${angle}deg)`,
      boxShadow: "0 2px 4px rgba(31,27,23,0.08)",
      mixBlendMode: "multiply",
      pointerEvents: "none",
      borderLeft: "1px dashed rgba(255,255,255,0.4)",
      borderRight: "1px dashed rgba(255,255,255,0.4)",
    }} />
  );
};

// Photo placeholder — striped band with label, hue-shifted by note
const PhotoPlaceholder = ({ hue = 200, label = "photo", animated = false }) => (
  <div style={{
    position: "relative", width: "100%", height: "100%",
    background: `linear-gradient(135deg, hsl(${hue} 35% 78%) 0%, hsl(${hue} 45% 64%) 100%)`,
    overflow: "hidden",
    display: "grid", placeItems: "center",
  }}>
    <div style={{
      position: "absolute", inset: 0,
      backgroundImage: `repeating-linear-gradient(45deg, rgba(255,255,255,0.18) 0 6px, transparent 6px 14px)`,
    }} />
    <div style={{
      fontFamily: "var(--mono)", fontSize: 10.5,
      color: "rgba(255,255,255,0.92)", letterSpacing: "0.08em",
      textTransform: "uppercase",
      padding: "4px 10px",
      background: "rgba(31,27,23,0.25)",
      borderRadius: 999,
      backdropFilter: "blur(2px)",
    }}>{label}{animated ? " · gif" : ""}</div>
  </div>
);

// ── Note inner contents by type ─────────────────────────────────────────────

const TextNoteBody = ({ note, author }) => (
  <div style={{ display: "flex", flexDirection: "column", height: "100%", padding: "18px 18px 14px" }}>
    <div style={{
      fontFamily: "var(--serif)",
      fontSize: Math.max(15, Math.min(22, note.w * 0.085)) * (note.fontScale || 1),
      lineHeight: 1.28, color: "var(--ink)", flex: 1,
      letterSpacing: "-0.005em", overflow: "hidden",
    }}>{note.body}</div>
    <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: 8 }}>
      <Author author={author} />
      {note.sticker && (
        <span style={{ fontSize: 18, lineHeight: 1, color: "var(--coral)" }}>{note.sticker}</span>
      )}
    </div>
  </div>
);

// Helper: a card may store one photo (legacy `photoSrc`) or many
// (`photos: [{src, name?, caption?}]`). Both shapes are normalized here.
const photosOf = (note) => {
  if (Array.isArray(note.photos) && note.photos.length > 0) return note.photos;
  if (note.photoSrc) return [{ src: note.photoSrc, name: note.photoLabel }];
  return [];
};

// Single image (or PhotoPlaceholder if no src). Reused by the photo bodies
// below so the cover-fit / pointer-events behavior stays consistent.
const PhotoFrame = ({ src, alt, hue, label, animated }) => (
  src ? (
    <img src={src} alt={alt || label || "photo"} draggable={false} style={{
      width: "100%", height: "100%",
      objectFit: "cover", display: "block",
      pointerEvents: "none",  // let the StickyNote drag handler win
    }} />
  ) : (
    <PhotoPlaceholder hue={hue} label={label || "photo"} animated={animated} />
  )
);

const PhotoNoteBody = ({ note, author }) => {
  const list = photosOf(note);
  const main = list[0];
  return (
    <div style={{
      display: "flex", flexDirection: "column", height: "100%",
      padding: 10, background: "#fff",
    }}>
      <div style={{ flex: 1, minHeight: 0, position: "relative", overflow: "hidden", borderRadius: 2 }}>
        <PhotoFrame
          src={main?.src}
          alt={note.caption}
          hue={note.photoHue}
          label={main?.name || note.photoLabel || "photo"}
          animated={note.type === "gif"}
        />
      </div>
      {(note.caption || author) && (
        <div style={{ paddingTop: 10, display: "flex", flexDirection: "column", gap: 6 }}>
          {note.caption && (
            <div style={{
              fontFamily: "var(--serif)", fontStyle: "italic",
              fontSize: 14 * (note.fontScale || 1), color: "var(--ink-2)", lineHeight: 1.25,
            }}>{note.caption}</div>
          )}
          <Author author={author} size={16} />
        </div>
      )}
    </div>
  );
};

// Photo + voice (corner variant from the design handoff): the photo fills
// the polaroid; a tilted pastel chip with play/pause + waveform + remaining
// time floats over the bottom-right edge like a sticky badge.
// Pastel palette used by the photo+voice chip. Roll once at creation
// (see pickAudioChipStyle) and persist on the note so cards keep their
// look across reloads instead of all collapsing to the same color.
const AUDIO_CHIP_BG_PALETTE = ["#E8D4DF", "#D4E2E8", "#E8E2D4", "#D4E8DC", "#E8DDD4", "#DDD4E8"];
const AUDIO_CHIP_EDGE_PAD = 20;
// Pick chip color, tilt, and position. Position is the chip *center* as a
// fraction (chipFx, chipFy) of the post dimensions so it tracks the post
// when the user resizes it. Sampled along an inset perimeter (20px from
// the edge), avoiding the bottom-left half where the caption sits.
function pickAudioChipStyle(w, h) {
  const bg = AUDIO_CHIP_BG_PALETTE[Math.floor(Math.random() * AUDIO_CHIP_BG_PALETTE.length)];
  const tilt = -16 + (Math.random() * 4 - 2); // -18 … -14
  const out = { audioChipBg: bg, audioTilt: tilt };
  if (typeof w === "number" && typeof h === "number" && w > 0 && h > 0) {
    const pad = AUDIO_CHIP_EDGE_PAD;
    const topLen = Math.max(0, w - 2 * pad);
    const rightLen = Math.max(0, h - 2 * pad);
    const botRightLen = Math.max(0, w / 2 - pad);
    const total = topLen + rightLen + botRightLen;
    let cx = w - pad, cy = h - pad;
    if (total > 0) {
      let t = Math.random() * total;
      if (t < topLen) {
        cx = pad + t; cy = pad;
      } else if (t < topLen + rightLen) {
        cx = w - pad; cy = pad + (t - topLen);
      } else {
        cx = w / 2 + (t - topLen - rightLen); cy = h - pad;
      }
    }
    out.chipFx = cx / w;
    out.chipFy = cy / h;
  }
  return out;
}

function PhotoVoiceCornerBody({ note, author, editable = false, scale = 1, onChange, onCommit }) {
  const { playing, progress, currentTime, totalTime, audioRef, toggle } = useAudioPlayer(note);
  const list = photosOf(note);
  const main = list[0];
  const hasStack = list.length > 1;
  const accent = note.audioAccent || "#B86A8A";
  // Older notes (created before chip styling was persisted) fall back to a
  // deterministic per-id seed so they don't all collapse to the default.
  const seed = React.useMemo(() => {
    const s = String(note.id || "");
    let h = 2166136261;
    for (let i = 0; i < s.length; i++) h = ((h ^ s.charCodeAt(i)) * 16777619) >>> 0;
    return h;
  }, [note.id]);
  const chipBg = note.audioChipBg || AUDIO_CHIP_BG_PALETTE[seed % AUDIO_CHIP_BG_PALETTE.length];
  const jitter = ((seed >>> 8) % 401) / 100 - 2;
  const chipTilt = typeof note.audioTilt === "number" ? note.audioTilt : -16 + jitter;
  // Chip center as a *fraction* of the post dimensions so the chip tracks
  // the post when it gets resized (otherwise an absolute coord would land
  // outside the new bounds). Two fallbacks for older shapes:
  //   - chipFx/chipFy missing but chipX/chipY present (pre-fractional
  //     iteration of this feature) → convert to fractions of current w/h
  //   - neither present (pre-feature notes) → bottom-right default
  const W = note.w || 280, H = note.h || 320;
  const fx = typeof note.chipFx === "number"
    ? note.chipFx
    : (typeof note.chipX === "number" ? note.chipX / W : (W - 30) / W);
  const fy = typeof note.chipFy === "number"
    ? note.chipFy
    : (typeof note.chipY === "number" ? note.chipY / H : (H - 40) / H);
  const chipFx = Math.max(0, Math.min(1, fx));
  const chipFy = Math.max(0, Math.min(1, fy));
  const chipCx = chipFx * W;
  const chipCy = chipFy * H;
  const bars = 22;
  const dur = totalTime || note.duration || 0;
  const remain = Math.max(0, Math.ceil(dur * (1 - progress)) || dur);

  const chipRef = React.useRef(null);
  const draggingRef = React.useRef(false);

  const startChipDrag = (e) => {
    if (!editable) return;
    if (e.button !== undefined && e.button !== 0) return;
    e.stopPropagation(); e.preventDefault();
    draggingRef.current = true;
    const startX = e.clientX, startY = e.clientY;
    const fx0 = chipFx, fy0 = chipFy;
    const noteRot = (note.rot || 0) * Math.PI / 180;
    const cos = Math.cos(noteRot), sin = Math.sin(noteRot);
    const move = (ev) => {
      if (window.__fwPinching) return;
      const dx = (ev.clientX - startX) / scale;
      const dy = (ev.clientY - startY) / scale;
      // Project screen-space delta into the note's local axes, then convert
      // to fractions of the note's current dimensions.
      const lx =  dx * cos + dy * sin;
      const ly = -dx * sin + dy * cos;
      const nfx = Math.max(0, Math.min(1, fx0 + lx / W));
      const nfy = Math.max(0, Math.min(1, fy0 + ly / H));
      onChange?.(note.id, { chipFx: nfx, chipFy: nfy });
    };
    const up = () => {
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", up);
      onCommit?.(note.id);
      // Defer clearing the flag so the suppressing click handler still sees
      // the active drag and stops the play-toggle from firing on release.
      setTimeout(() => { draggingRef.current = false; }, 0);
    };
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
  };

  const startChipRotate = (e) => {
    if (!editable) return;
    e.stopPropagation(); e.preventDefault();
    const rect = chipRef.current?.getBoundingClientRect();
    if (!rect) return;
    const cx = rect.left + rect.width / 2;
    const cy = rect.top + rect.height / 2;
    const a0 = Math.atan2(e.clientY - cy, e.clientX - cx) * 180 / Math.PI;
    const r0 = chipTilt;
    const move = (ev) => {
      if (window.__fwPinching) return;
      const a = Math.atan2(ev.clientY - cy, ev.clientX - cx) * 180 / Math.PI;
      onChange?.(note.id, { audioTilt: r0 + (a - a0) });
    };
    const up = () => {
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", up);
      onCommit?.(note.id);
    };
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
  };

  return (
    <div style={{
      display: "flex", flexDirection: "column", height: "100%",
      padding: 10, background: "#fff", position: "relative",
    }}>
      {hasStack ? (
        <PhotoStack photos={list} />
      ) : (
        <div style={{ flex: 1, minHeight: 0, position: "relative", overflow: "hidden", borderRadius: 2 }}>
          <PhotoFrame
            src={main?.src}
            alt={note.caption}
            hue={note.photoHue}
            label={main?.name || note.photoLabel || "photo"}
          />
        </div>
      )}
      {/* Caption row reserves room on the right for the tilted chip so
          a long caption wraps cleanly instead of running underneath. */}
      <div style={{
        paddingTop: 10, paddingRight: 100,
        display: "flex", flexDirection: "column", gap: 6,
      }}>
        {note.caption && (
          <div style={{
            fontFamily: "var(--serif)", fontStyle: "italic",
            fontSize: 14 * (note.fontScale || 1), color: "var(--ink-2)", lineHeight: 1.25,
            wordBreak: "break-word",
          }}>{note.caption}</div>
        )}
        <Author author={author} size={16} />
      </div>

      <audio ref={audioRef} src={note.audioSrc} preload="metadata" />
      <div
        ref={chipRef}
        onPointerDown={editable ? startChipDrag : (e) => e.stopPropagation()}
        onClick={(e) => e.stopPropagation()}
        style={{
          position: "absolute",
          left: chipCx, top: chipCy,
          transform: `translate(-50%, -50%) rotate(${chipTilt}deg)`,
          transformOrigin: "center",
          padding: "6px 12px 6px 6px",
          borderRadius: 999,
          background: chipBg,
          boxShadow: "0 6px 18px rgba(31,27,23,0.18), 0 1px 0 rgba(255,255,255,0.4) inset",
          display: "flex", alignItems: "center", gap: 8,
          touchAction: "none",
          cursor: editable ? "grab" : "default",
          // Sits above the stack's chevrons (z 30) and counter (z 31) when
          // the photo stack is expanded so it stays grabbable.
          zIndex: 40,
        }}
      >
        <button
          onClick={(e) => {
            e.stopPropagation();
            // A drag that ends over the chip fires a synthetic click on
            // pointerup; suppress play-toggle in that case.
            if (draggingRef.current) return;
            toggle();
          }}
          onPointerDown={(e) => e.stopPropagation()}
          aria-label={playing ? "Pause" : "Play"}
          style={{
            width: 26, height: 26, borderRadius: "50%",
            background: "var(--ink)", color: "#fff", border: 0,
            display: "grid", placeItems: "center",
            cursor: "pointer", padding: 0, flex: "none",
          }}
        >
          {playing ? <IconPause size={10} /> : <IconPlay size={10} />}
        </button>
        <div style={{ display: "flex", alignItems: "center", gap: 1.5, height: 20 }}>
          {Array.from({ length: bars }).map((_, i) => {
            const h = 3 + Math.abs(Math.sin(i * 1.9 + note.id.length * 2 + 1)) * 14;
            const isPlayed = (i / bars) < progress;
            return <span key={i} style={{
              width: 2, height: h, borderRadius: 2,
              background: isPlayed ? accent : "rgba(31,27,23,0.55)",
              transition: "background .12s",
            }} />;
          })}
        </div>
        <span style={{
          fontFamily: "var(--mono)", fontSize: 10, color: "var(--ink)",
          minWidth: 24, textAlign: "right",
        }}>
          0:{String(remain).padStart(2, "0")}
        </span>
        {editable && (
          <button
            onPointerDown={startChipRotate}
            onClick={(e) => e.stopPropagation()}
            title="Rotate"
            aria-label="Rotate audio chip"
            style={{
              position: "absolute",
              top: -10, right: -10,
              width: 18, height: 18, borderRadius: "50%",
              background: "#fff", border: "1px solid var(--line-2)",
              display: "grid", placeItems: "center",
              cursor: "grab", boxShadow: "var(--shadow-1)",
              padding: 0,
              touchAction: "none",
            }}
          >
            <IconRotate size={10} />
          </button>
        )}
      </div>
    </div>
  );
}

// Multi-photo stack: collapsed it's a fanned pile of polaroids with a
// count badge; tap expands into a horizontal fan past the card edges with
// chevrons. Auto-collapses after 3.5s of inactivity.
// Photo stack — collapsed it's a fanned pile of polaroids with a count badge;
// tapping expands into a horizontal fan past the card edges with chevrons.
// Auto-collapses after 3.5s of inactivity. Pulled out of PhotoStackBody so
// the photo+voice compound card can reuse the same affordance.
function PhotoStack({ photos, onActiveChange }) {
  const [expanded, setExpanded] = React.useState(false);
  const [active, setActive] = React.useState(0);
  const timer = React.useRef(null);

  const armCollapse = React.useCallback(() => {
    if (timer.current) clearTimeout(timer.current);
    timer.current = setTimeout(() => setExpanded(false), 3500);
  }, []);
  React.useEffect(() => () => timer.current && clearTimeout(timer.current), []);

  React.useEffect(() => {
    onActiveChange?.({ expanded, active });
  }, [expanded, active, onActiveChange]);

  const openStack = (e) => { e.stopPropagation(); setExpanded(true); armCollapse(); };
  const collapseStack = (e) => {
    e.stopPropagation();
    if (timer.current) clearTimeout(timer.current);
    setExpanded(false);
  };
  const step = (dir) => (e) => {
    e.stopPropagation();
    setActive((i) => (i + dir + photos.length) % photos.length);
    armCollapse();
  };

  // Pre-baked offsets so the collapsed pile reads as intentional.
  const fan = [
    { rot: -7,  dx: -10, dy: 6,  z: 2, scale: 0.94 },
    { rot:  5,  dx:   8, dy: -4, z: 3, scale: 0.98 },
    { rot: -2,  dx:   0, dy:  0, z: 4, scale: 1.00 },
    { rot:  9,  dx:  14, dy: 10, z: 1, scale: 0.91 },
    { rot: -11, dx: -16, dy: 12, z: 0, scale: 0.89 },
  ];

  return (
    <div style={{
      flex: 1, minHeight: 0, position: "relative",
      overflow: expanded ? "visible" : "hidden",
    }}>
        {!expanded && (
          <div onClick={openStack}
               onPointerDown={(e) => e.stopPropagation()}
               style={{
                 position: "absolute", inset: 0, cursor: "pointer",
                 display: "grid", placeItems: "center",
               }}>
            {photos.slice(0, 5).map((p, i) => {
              const f = fan[i] || fan[2];
              return (
                <div key={i} style={{
                  position: "absolute",
                  width: "78%", height: "82%",
                  transform: `translate(${f.dx}px, ${f.dy}px) rotate(${f.rot}deg) scale(${f.scale})`,
                  zIndex: f.z,
                  background: "#fff",
                  padding: 4,
                  boxShadow: "0 4px 14px rgba(31,27,23,0.18), 0 1px 0 rgba(255,255,255,0.6) inset",
                  borderRadius: 2,
                  transition: "transform .35s cubic-bezier(.2,.7,.2,1)",
                }}>
                  <div style={{ width: "100%", height: "100%", position: "relative", overflow: "hidden" }}>
                    <PhotoFrame src={p.src} hue={(i * 47) % 360} label={p.name || `photo ${i + 1}`} />
                  </div>
                </div>
              );
            })}
            <div style={{
              position: "absolute", right: 8, bottom: 8, zIndex: 10,
              padding: "4px 9px", borderRadius: 999,
              background: "rgba(31,27,23,0.78)", color: "#fff",
              fontFamily: "var(--mono)", fontSize: 10,
              letterSpacing: "0.05em",
              display: "inline-flex", alignItems: "center", gap: 5,
              backdropFilter: "blur(4px)", WebkitBackdropFilter: "blur(4px)",
            }}>
              <IconImage size={10} /> {photos.length} photos
            </div>
          </div>
        )}

        {expanded && (
          <div onClick={collapseStack}
               onPointerDown={(e) => e.stopPropagation()}
               style={{
                 position: "absolute", inset: 0, cursor: "zoom-out",
                 overflow: "visible",
               }}>
            {photos.map((p, i) => {
              let off = i - active;
              const n = photos.length;
              if (off >  n / 2) off -= n;
              if (off < -n / 2) off += n;
              const isActive = off === 0;
              const ax = Math.abs(off);
              const tx = off * 78;
              const ty = ax * 6;
              const rot = off * 7;
              const scale = isActive ? 1 : Math.max(0.62, 0.86 - ax * 0.08);
              const z = 10 - ax;
              return (
                <div
                  key={i}
                  onClick={(e) => {
                    e.stopPropagation();
                    // Active photo already fills the expanded area, so taps
                    // almost always land here — collapse instead of being a
                    // no-op setActive(currentActive). Inactive cards still
                    // promote-to-active so the side-fan acts as a picker.
                    if (isActive) {
                      if (timer.current) clearTimeout(timer.current);
                      setExpanded(false);
                    } else {
                      setActive(i);
                      armCollapse();
                    }
                  }}
                  style={{
                    position: "absolute", left: "50%", top: "50%",
                    width: "94%", height: "94%",
                    transform: `translate(-50%,-50%) translate(${tx}%, ${ty}%) rotate(${rot}deg) scale(${scale})`,
                    transformOrigin: "center",
                    transition: "transform .42s cubic-bezier(.2,.7,.2,1), filter .25s",
                    zIndex: z,
                    background: "#fff",
                    padding: 5,
                    boxShadow: isActive
                      ? "0 14px 30px rgba(31,27,23,0.28), 0 1px 0 rgba(255,255,255,0.6) inset"
                      : "0 6px 14px rgba(31,27,23,0.18), 0 1px 0 rgba(255,255,255,0.5) inset",
                    borderRadius: 2,
                    filter: isActive ? "none" : "saturate(0.92) brightness(0.97)",
                    cursor: isActive ? "zoom-out" : "pointer",
                  }}
                >
                  <div style={{ width: "100%", height: "100%", position: "relative", overflow: "hidden" }}>
                    <PhotoFrame src={p.src} hue={(i * 47) % 360} label={p.name || `photo ${i + 1}`} />
                  </div>
                </div>
              );
            })}
            <button onClick={step(-1)} aria-label="Previous photo" style={{
              position: "absolute", left: -14, top: "50%", transform: "translateY(-50%)",
              width: 32, height: 32, borderRadius: "50%",
              background: "#fff", color: "var(--ink)",
              border: "1px solid var(--line)", cursor: "pointer",
              display: "grid", placeItems: "center",
              boxShadow: "0 4px 12px rgba(31,27,23,0.22)", zIndex: 30,
            }}><IconChevronLeft size={14} /></button>
            <button onClick={step(1)} aria-label="Next photo" style={{
              position: "absolute", right: -14, top: "50%", transform: "translateY(-50%)",
              width: 32, height: 32, borderRadius: "50%",
              background: "#fff", color: "var(--ink)",
              border: "1px solid var(--line)", cursor: "pointer",
              display: "grid", placeItems: "center",
              boxShadow: "0 4px 12px rgba(31,27,23,0.22)", zIndex: 30,
            }}><IconChevronRight size={14} /></button>
            <div style={{
              position: "absolute", right: 8, top: 8, zIndex: 31,
              padding: "3px 8px", borderRadius: 999,
              background: "rgba(31,27,23,0.72)", color: "#fff",
              fontFamily: "var(--mono)", fontSize: 10, letterSpacing: "0.05em",
              backdropFilter: "blur(4px)", WebkitBackdropFilter: "blur(4px)",
            }}>{active + 1} / {photos.length}</div>
          </div>
        )}
    </div>
  );
}

function PhotoStackBody({ note, author }) {
  const photos = photosOf(note);
  const [stack, setStack] = React.useState({ expanded: false, active: 0 });
  const captionText = stack.expanded && photos[stack.active]?.caption
    ? photos[stack.active].caption
    : note.caption;
  return (
    <div style={{
      display: "flex", flexDirection: "column", height: "100%",
      padding: 10, background: "#fff", position: "relative",
    }}>
      <PhotoStack photos={photos} onActiveChange={setStack} />
      <div style={{ paddingTop: 10, display: "flex", flexDirection: "column", gap: 6 }}>
        {captionText && (
          <div style={{
            fontFamily: "var(--serif)", fontStyle: "italic",
            fontSize: 14 * (note.fontScale || 1), color: "var(--ink-2)", lineHeight: 1.25,
          }}>{captionText}</div>
        )}
        <Author author={author} size={16} />
      </div>
    </div>
  );
}

// Video note — board variant. Plays muted, looped, autoplay; tap the small
// chip to pause/resume. Sound is intentionally muted here so the board never
// becomes an inadvertent noise farm; the recipient/detail view enables audio
// and shows native controls.
function VideoNoteBody({ note, author }) {
  const videoRef = React.useRef(null);
  const [paused, setPaused] = React.useState(false);
  const togglePlay = (e) => {
    e.stopPropagation();
    const v = videoRef.current;
    if (!v) return;
    if (v.paused) { v.play().catch(() => {}); setPaused(false); }
    else { v.pause(); setPaused(true); }
  };
  return (
    <div style={{
      display: "flex", flexDirection: "column", height: "100%",
      padding: 10, background: "#fff", position: "relative",
    }}>
      <div style={{
        flex: 1, minHeight: 0, position: "relative",
        overflow: "hidden", borderRadius: 2, background: "#1F1B17",
      }}>
        {note.videoSrc ? (
          <video
            ref={videoRef}
            src={note.videoSrc}
            autoPlay loop muted playsInline
            // Pointer events on the video bubble up so dragging the note
            // still works; the play/pause chip handles its own clicks.
            style={{
              position: "absolute", inset: 0,
              width: "100%", height: "100%",
              objectFit: "contain", display: "block",
              pointerEvents: "none",
            }}
          />
        ) : (
          <div style={{
            position: "absolute", inset: 0, display: "grid", placeItems: "center",
            color: "rgba(255,255,255,0.6)", fontFamily: "var(--mono)",
            fontSize: 11, letterSpacing: "0.06em", textTransform: "uppercase",
          }}>
            <IconVideo size={22} />
          </div>
        )}
        <button
          onPointerDown={(e) => e.stopPropagation()}
          onClick={togglePlay}
          aria-label={paused ? "Play video" : "Pause video"}
          style={{
            position: "absolute", left: 10, bottom: 10,
            width: 34, height: 34, borderRadius: "50%",
            background: "rgba(31,27,23,0.65)", color: "#fff", border: 0,
            display: "grid", placeItems: "center", cursor: "pointer",
            backdropFilter: "blur(4px)", WebkitBackdropFilter: "blur(4px)",
          }}
        >
          {paused ? <IconPlay size={13} /> : <IconPause size={13} />}
        </button>
        <div style={{
          position: "absolute", right: 8, bottom: 8,
          padding: "3px 8px", borderRadius: 999,
          background: "rgba(31,27,23,0.65)", color: "#fff",
          fontFamily: "var(--mono)", fontSize: 10, letterSpacing: "0.06em",
          textTransform: "uppercase",
          backdropFilter: "blur(4px)", WebkitBackdropFilter: "blur(4px)",
        }}>video · muted</div>
      </div>
      {(note.caption || author) && (
        <div style={{ paddingTop: 10, display: "flex", flexDirection: "column", gap: 6 }}>
          {note.caption && (
            <div style={{
              fontFamily: "var(--serif)", fontStyle: "italic",
              fontSize: 14 * (note.fontScale || 1), color: "var(--ink-2)", lineHeight: 1.25,
            }}>{note.caption}</div>
          )}
          <Author author={author} size={16} />
        </div>
      )}
    </div>
  );
}

const VoiceNoteBody = ({ note, author }) => {
  const bars = 28;
  const { playing, progress, currentTime, totalTime, audioRef, toggle } = useAudioPlayer(note);
  const filled = Math.floor(bars * progress);
  // currentTime is the live elapsed second; totalTime falls back to the
  // authored note.duration so seeded notes still display sensibly.
  const seconds = playing || progress > 0
    ? currentTime
    : (totalTime || note.duration || 0);
  return (
    <div style={{ display: "flex", flexDirection: "column", height: "100%", padding: "16px 18px" }}>
      {note.audioSrc && <audio ref={audioRef} src={note.audioSrc} preload="metadata" />}
      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
        <button
          onPointerDown={note.audioSrc ? (e) => e.stopPropagation() : undefined}
          onClick={toggle}
          aria-label={playing ? "Pause" : "Play"}
          style={{
            width: 36, height: 36, borderRadius: "50%",
            background: "var(--ink)", color: "#fff", border: 0,
            display: "grid", placeItems: "center",
            cursor: note.audioSrc ? "pointer" : "inherit",
            opacity: note.audioSrc ? 1 : 0.55,
          }}>
          {playing ? <IconPause size={14} /> : <IconPlay size={14} />}
        </button>
        <div style={{ flex: 1, display: "flex", alignItems: "center", gap: 2, height: 28 }}>
          {Array.from({ length: bars }).map((_, i) => {
            const h = 6 + Math.abs(Math.sin(i * 1.7 + note.id.length)) * 22;
            const isFilled = note.audioSrc
              ? i < filled
              : i < bars * 0.32;  // decorative for fake voice notes
            return <span key={i} style={{
              width: 2.5, height: h, borderRadius: 2,
              background: isFilled ? "var(--coral)" : "rgba(31,27,23,0.25)",
            }} />;
          })}
        </div>
        <span style={{ fontFamily: "var(--mono)", fontSize: 11, color: "var(--ink-2)" }}>
          {fmtTime(seconds)}
        </span>
      </div>
      {note.transcript && (
        <div style={{
          fontFamily: "var(--serif)", fontStyle: "italic", fontSize: 13 * (note.fontScale || 1),
          color: "var(--ink-2)", lineHeight: 1.4, marginTop: 10, flex: 1, overflow: "hidden",
        }}>"{note.transcript}"</div>
      )}
      {!note.transcript && <div style={{ flex: 1 }} />}
      <Author author={author} />
    </div>
  );
};

// Small audio-playback hook used by both the on-board voice note and the
// recipient detail modal. Returns the live state plus a toggle handler.
function useAudioPlayer(note) {
  const audioRef = React.useRef(null);
  const [playing, setPlaying] = React.useState(false);
  const [currentTime, setCurrentTime] = React.useState(0);
  const [totalTime, setTotalTime] = React.useState(note.duration || 0);

  React.useEffect(() => {
    const a = audioRef.current; if (!a) return;
    const onTime = () => setCurrentTime(a.currentTime);
    const onMeta = () => {
      // Recorded webm blobs sometimes report Infinity until played through.
      if (isFinite(a.duration) && a.duration > 0) setTotalTime(a.duration);
    };
    const onEnd = () => { setPlaying(false); setCurrentTime(0); };
    a.addEventListener("timeupdate", onTime);
    a.addEventListener("loadedmetadata", onMeta);
    a.addEventListener("durationchange", onMeta);
    a.addEventListener("ended", onEnd);
    return () => {
      a.removeEventListener("timeupdate", onTime);
      a.removeEventListener("loadedmetadata", onMeta);
      a.removeEventListener("durationchange", onMeta);
      a.removeEventListener("ended", onEnd);
    };
  }, [note.audioSrc]);

  const toggle = (e) => {
    // No real audio (seed notes): no-op and let the click bubble so a
    // readOnly note can still open its detail modal.
    if (!note.audioSrc) return;
    e?.stopPropagation();
    const a = audioRef.current;
    if (!a) return;
    if (playing) {
      a.pause();
      setPlaying(false);
    } else {
      a.play().then(() => setPlaying(true)).catch(() => setPlaying(false));
    }
  };

  const denom = totalTime || note.duration || 1;
  const progress = Math.min(1, currentTime / denom);
  return { playing, progress, currentTime, totalTime, audioRef, toggle };
}

const fmtTime = (s) => {
  const sec = Math.max(0, Math.floor(s || 0));
  return `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, "0")}`;
};

// Doodle — user-drawn strokes (note.paths) over the board paper. Falls back
// to a placeholder squiggle for legacy notes without paths.
const DoodleNoteBody = ({ note, author }) => {
  const userPaths = Array.isArray(note.paths) ? note.paths : null;
  const hasUserDoodle = userPaths && userPaths.length > 0;
  const vw = note.doodleVW || 400;
  const vh = note.doodleVH || 240;
  return (
    <div style={{ display: "flex", flexDirection: "column", height: "100%", padding: "12px 14px 10px", position: "relative" }}>
      {hasUserDoodle ? (
        <svg viewBox={`0 0 ${vw} ${vh}`} preserveAspectRatio="xMidYMid meet" style={{
          position: "absolute", inset: 0, width: "100%", height: "100%",
          pointerEvents: "none",
        }}>
          {userPaths.map((p, i) => (
            p.kind === "ink" ? (
              // Ink-pen polygon: width is baked into the shape, so just fill.
              // A thin same-color stroke softens the polygon edge.
              <path key={i} d={p.d}
                    fill={p.color || "var(--ink)"}
                    stroke={p.color || "var(--ink)"} strokeWidth={0.5}
                    strokeLinejoin="round" />
            ) : (
              // Legacy constant-width strokes from older doodle notes.
              <path key={i} d={p.d}
                    stroke={p.color || "var(--ink)"} strokeWidth={p.width || 3}
                    fill="none" strokeLinecap="round" strokeLinejoin="round" />
            )
          ))}
        </svg>
      ) : (
        // Placeholder squiggle for legacy/seed doodle notes.
        <svg viewBox="0 0 240 160" preserveAspectRatio="none" style={{
          position: "absolute", inset: 0, width: "100%", height: "100%",
          pointerEvents: "none", opacity: 0.9,
        }}>
          <path d="M30 110 C 50 80, 70 60, 110 70 S 170 100, 200 70" stroke="var(--ink)" strokeWidth="2.2" fill="none" strokeLinecap="round" />
          <path d="M50 130 C 80 120, 130 130, 200 120" stroke="var(--ink)" strokeWidth="1.4" fill="none" strokeLinecap="round" opacity="0.6" />
          <path d="M180 40 l8 -10 l4 14 l10 -6" stroke="var(--coral)" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
        </svg>
      )}
      {note.body && (
        <div style={{
          fontFamily: "var(--serif)", fontStyle: "italic",
          fontSize: 18 * (note.fontScale || 1), lineHeight: 1.2, color: "var(--ink)",
          position: "relative", marginTop: 10,
          // Subtle white pill so the body reads when it sits over the doodle
          // and the board paper underneath.
          background: "rgba(255,255,255,0.78)",
          padding: "4px 8px", borderRadius: 6,
          alignSelf: "flex-start", maxWidth: "100%",
        }}>{note.body}</div>
      )}
      <div style={{
        marginTop: "auto", position: "relative",
        background: "rgba(255,255,255,0.78)", borderRadius: 999,
        alignSelf: "flex-start", padding: "2px 6px",
      }}>
        <Author author={author} />
      </div>
    </div>
  );
};

// Placeholder for notes the viewer isn't allowed to read. Same paper, same
// dimensions / rotation, with the author chip — so the wall still feels
// populated but the contents stay private.
const RedactedNoteBody = ({ note, author }) => (
  <div style={{
    display: "flex", flexDirection: "column", height: "100%",
    padding: "16px 18px",
    position: "relative", overflow: "hidden",
  }}>
    {/* a faint diagonal hatch hints "private" without screaming at you */}
    <div style={{
      position: "absolute", inset: 0,
      backgroundImage: `repeating-linear-gradient(45deg,
        rgba(31,27,23,0.04) 0 6px,
        transparent 6px 14px)`,
      pointerEvents: "none",
    }} />
    <div style={{
      flex: 1, display: "grid", placeItems: "center",
      color: "rgba(31,27,23,0.32)", fontFamily: "var(--mono)", fontSize: 10.5,
      letterSpacing: "0.14em", textTransform: "uppercase",
      position: "relative",
    }}>
      <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
        <IconLock size={11} /> private
      </span>
    </div>
    <div style={{ position: "relative" }}>
      <Author author={author} />
    </div>
  </div>
);

const StickerClusterBody = ({ note }) => (
  <div style={{ position: "relative", width: "100%", height: "100%" }}>
    {note.stickers.map((s, i) => {
      const angle = (i / note.stickers.length) * Math.PI * 2;
      const r = 40 + (i % 2) * 14;
      const x = 50 + Math.cos(angle) * r;
      const y = 50 + Math.sin(angle) * r;
      const rot = (i * 23) % 30 - 15;
      const sz = 28 + (i % 3) * 6;
      const colors = ["#E87E5A", "#C18A3B", "#3F8FA8", "#B86A8A", "#5C8B6B"];
      return (
        <span key={i} style={{
          position: "absolute", left: `${x}%`, top: `${y}%`,
          transform: `translate(-50%,-50%) rotate(${rot}deg)`,
          fontSize: sz, color: colors[i % colors.length],
          lineHeight: 1, filter: "drop-shadow(0 2px 4px rgba(31,27,23,0.12))",
        }}>{s}</span>
      );
    })}
  </div>
);

// ── Main note wrapper ──────────────────────────────────────────────────────

function StickyNote({
  note, author, selected, hovering,
  onSelect, onChange, onCommit, onOpen,
  readOnly = false,
  scale = 1,           // canvas zoom; gestures divide by this
  showHandles = true,
}) {
  const ref = React.useRef(null);
  const stateRef = React.useRef(null);  // active gesture

  const startDrag = (e) => {
    if (readOnly) return;
    if (e.button !== 0) return;
    onSelect?.(note.id);
    e.preventDefault();
    stateRef.current = {
      kind: "drag",
      x0: e.clientX, y0: e.clientY,
      nx: note.x, ny: note.y,
    };
    const move = (ev) => {
      const s = stateRef.current; if (!s) return;
      // A two-finger pinch took over the stage — stop dragging this note
      // so its position doesn't lurch with the first finger.
      if (window.__fwPinching) return;
      const dx = (ev.clientX - s.x0) / scale;
      const dy = (ev.clientY - s.y0) / scale;
      onChange?.(note.id, { x: s.nx + dx, y: s.ny + dy });
    };
    const up = () => {
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", up);
      stateRef.current = null;
      onCommit?.(note.id);
    };
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
  };

  const startRotate = (e) => {
    if (readOnly) return;
    e.stopPropagation(); e.preventDefault();
    const rect = ref.current.getBoundingClientRect();
    const cx = rect.left + rect.width / 2;
    const cy = rect.top + rect.height / 2;
    const a0 = Math.atan2(e.clientY - cy, e.clientX - cx) * 180 / Math.PI;
    const r0 = note.rot || 0;
    const move = (ev) => {
      if (window.__fwPinching) return;
      const a = Math.atan2(ev.clientY - cy, ev.clientX - cx) * 180 / Math.PI;
      onChange?.(note.id, { rot: r0 + (a - a0) });
    };
    const up = () => {
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", up);
      onCommit?.(note.id);
    };
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
  };

  const startResize = (e) => {
    if (readOnly) return;
    e.stopPropagation(); e.preventDefault();
    const w0 = note.w, h0 = note.h;
    const x0 = e.clientX, y0 = e.clientY;
    // resize along the local axes (rotation-aware)
    const rot = (note.rot || 0) * Math.PI / 180;
    const cos = Math.cos(rot), sin = Math.sin(rot);
    // Doodles can be drawn at any size inside the world canvas, so the
    // sticky-note caps would snap a big doodle down to 420×440 the moment
    // the user touched the resize handle. Give doodles a much wider range.
    const isDoodle = note.type === "doodle";
    const minW = isDoodle ? 60 : 140;
    const minH = isDoodle ? 60 : 120;
    const maxW = isDoodle ? 2200 : 420;
    const maxH = isDoodle ? 1100 : 440;
    const move = (ev) => {
      if (window.__fwPinching) return;
      const dx = (ev.clientX - x0) / scale;
      const dy = (ev.clientY - y0) / scale;
      const lx =  dx * cos + dy * sin;
      const ly = -dx * sin + dy * cos;
      const ratio = h0 / w0;
      const w = Math.max(minW, Math.min(maxW, w0 + lx));
      const h = Math.max(minH, Math.min(maxH, h0 + ly));
      // keep approximate ratio for photos
      if (note.type === "photo" || note.type === "gif" || note.type === "video") {
        onChange?.(note.id, { w, h: Math.round(w * ratio) });
      } else {
        onChange?.(note.id, { w, h });
      }
    };
    const up = () => {
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", up);
      onCommit?.(note.id);
    };
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
  };

  // Any note carrying a photo (incl. voice notes with a photo attached
  // and multi-photo stacks) gets the card chrome — slimmer corners, no
  // rotated paper tape — so the photo reads as a postcard rather than a
  // sticky.
  const noteHasPhotos = (Array.isArray(note.photos) && note.photos.length > 0) || !!note.photoSrc;
  const isCard = note.type === "photo" || note.type === "gif" || note.type === "video" || noteHasPhotos;
  const isCluster = note.type === "sticker-cluster";
  // Redacted notes always render as a paper rectangle (never as a transparent
  // sticker cluster), so the placeholder is recognisable.
  const isRedacted = !!note.redacted;
  const useClusterChrome = isCluster && !isRedacted;
  // Doodles sit straight on the board paper without a colored sticky behind
  // them; redacted doodles still get the paper card so the placeholder reads.
  const isTransparentDoodle = note.type === "doodle" && !isRedacted;
  const cursor = isRedacted
    ? "default"
    : readOnly
      ? (onOpen ? "pointer" : "default")
      : "grab";

  // Outer carries positioning + the user's resting rotation + pointer
  // handlers. The wind-jiggle wrapper goes on a separate inner div so the
  // sway composes on top of the rest rotation instead of replacing it.
  const baseStyle = {
    position: "absolute",
    left: note.x, top: note.y,
    width: note.w, height: note.h,
    transform: `rotate(${note.rot || 0}deg)`,
    transformOrigin: "center",
    transition: stateRef.current ? "none" : "transform .15s ease",
    cursor,
    userSelect: "none",
    touchAction: "none",
    pointerEvents: "auto",
  };

  // Per-note wind-jiggle params, seeded from a stable hash of note.id so the
  // same note always sways with the same rhythm (no flicker on remount).
  // Duration 9–14s, delay -0..-11s (negative so the cycle is already in
  // progress on first paint — that's what staggers the board). FNV-1a gives
  // a full 32-bit hash, and the fractional mods (500 / 1100, divided by 100)
  // keep the same ranges while opening hundreds of buckets per parameter so
  // notes don't collide on the same cycle/phase even on dense boards.
  const windParams = React.useMemo(() => {
    const id = note.id || "";
    let h = 2166136261 >>> 0;
    for (let i = 0; i < id.length; i++) {
      h = ((h ^ id.charCodeAt(i)) * 16777619) >>> 0;
    }
    const dur = 9 + (h % 500) / 100;             // 9.00 .. 13.99
    const delay = -((h >>> 9) % 1100) / 100;     // 0 .. -10.99 (decorrelated by shift)
    return { "--wind-dur": `${dur.toFixed(2)}s`, "--wind-delay": `${delay.toFixed(2)}s` };
  }, [note.id]);

  // Pause the breeze while the user is actively touching the note. `selected`
  // covers drag/rotate/resize since each of those gestures selects the note
  // first; redacted placeholders never animate (decorative only). Doodles
  // sit straight on the board paper without a sticky behind them — swaying
  // them would look like the ink itself is drifting, so skip the animation.
  const windEligible = !isRedacted && note.type !== "doodle";
  const isInteracting = !!selected && windEligible;

  const visualStyle = useClusterChrome ? {
    background: "transparent",
  } : isTransparentDoodle ? {
    background: "transparent",
    borderRadius: 10,
    // Only show a frame when selected/hovered — the doodle should otherwise
    // look like it's drawn straight onto the board.
    boxShadow: selected
      ? "0 0 0 2px rgba(232,126,90,0.85)"
      : (hovering
        ? "0 0 0 1px rgba(31,27,23,0.18)"
        : "none"),
  } : {
    background: isRedacted ? (note.color === "transparent" ? "#F2EDE2" : note.color) : note.color,
    borderRadius: isCard ? 4 : 10,
    boxShadow: selected
      ? "0 0 0 2px rgba(232,126,90,0.85), 0 6px 18px rgba(31,27,23,0.18), 0 20px 40px -16px rgba(31,27,23,0.28)"
      : (hovering
        ? "0 4px 10px rgba(31,27,23,0.1), 0 18px 32px -16px rgba(31,27,23,0.22)"
        : "0 1px 1px rgba(31,27,23,0.04), 0 10px 22px -14px rgba(31,27,23,0.22)"),
  };

  return (
    <div
      ref={ref}
      style={baseStyle}
      onPointerDown={startDrag}
      onClick={readOnly && onOpen && !isRedacted ? (e) => { e.stopPropagation(); onOpen(note.id); } : undefined}
      onDoubleClick={!isRedacted ? () => onOpen?.(note.id) : undefined}
      data-screen-label={`note ${note.id}`}
    >
      <div
        className={windEligible ? `wind-jiggle${isInteracting ? " is-active" : ""}` : undefined}
        style={{
          position: "absolute", inset: 0,
          transition: stateRef.current ? "none" : "box-shadow .15s ease",
          ...visualStyle,
          ...(windEligible ? windParams : null),
        }}
      >
      {!useClusterChrome && !isCard && note.type !== "doodle" && (
        <Tape id={note.id} variant={(note.id.charCodeAt(1) || 0) % 2} />
      )}
      {isRedacted ? (
        <RedactedNoteBody note={note} author={author} />
      ) : (() => {
        // Compound cards (per design handoff): a photo + voice note uses
        // the tilted-corner audio chip; a multi-photo post uses the
        // fanned stack. Single-photo and pure voice fall back to the
        // existing bodies.
        const photoCount = (Array.isArray(note.photos) ? note.photos.length : 0)
                         + (note.photoSrc && !Array.isArray(note.photos) ? 1 : 0);
        const hasAnyPhoto = photoCount > 0 || note.type === "photo" || note.type === "gif";
        if (note.type === "text") return <TextNoteBody note={note} author={author} />;
        if (note.type === "video") return <VideoNoteBody note={note} author={author} />;
        if (hasAnyPhoto && note.audioSrc) return (
          <PhotoVoiceCornerBody
            note={note} author={author}
            editable={!readOnly}
            scale={scale}
            onChange={onChange}
            onCommit={onCommit}
          />
        );
        if (photoCount > 1) return <PhotoStackBody note={note} author={author} />;
        if (hasAnyPhoto) return <PhotoNoteBody note={note} author={author} />;
        if (note.type === "voice") return <VoiceNoteBody note={note} author={author} />;
        if (note.type === "doodle") return <DoodleNoteBody note={note} author={author} />;
        if (note.type === "sticker-cluster") return <StickerClusterBody note={note} />;
        return null;
      })()}
      </div>

      {selected && showHandles && !readOnly && (
        <>
          {/* rotate handle */}
          <div
            onPointerDown={startRotate}
            title="Rotate"
            style={{
              position: "absolute", left: "50%", top: -28,
              transform: "translateX(-50%)",
              width: 22, height: 22, borderRadius: "50%",
              background: "#fff", border: "1px solid var(--line-2)",
              color: "var(--ink)", display: "grid", placeItems: "center",
              cursor: "grab", boxShadow: "var(--shadow-1)",
            }}
          >
            <IconRotate size={12} />
          </div>
          {/* resize handle */}
          <div
            onPointerDown={startResize}
            title="Resize"
            style={{
              position: "absolute", right: -10, bottom: -10,
              width: 22, height: 22, borderRadius: 6,
              background: "var(--ink)", color: "#fff",
              display: "grid", placeItems: "center",
              cursor: "nwse-resize", boxShadow: "var(--shadow-1)",
            }}
          >
            <IconResize size={11} />
          </div>
        </>
      )}
    </div>
  );
}

// Generic "pick any color" swatch. Renders a rainbow disc that opens the
// OS color picker on click and reports the picked hex via onPick. Each
// caller styles size + corner radius via the `style` prop so it can sit
// next to the existing preset swatches without looking out of place.
function CustomColorButton({ value, onPick, style, title = "Custom color" }) {
  const ref = React.useRef(null);
  // Native <input type="color"> only accepts a #RRGGBB hex; fall back to
  // white when the current color is a CSS var or other non-hex string so
  // React doesn't warn and the picker opens at a sensible starting point.
  const safe = typeof value === "string" && /^#[0-9a-fA-F]{6}$/.test(value)
    ? value
    : "#ffffff";
  return (
    <button
      type="button"
      onClick={() => ref.current?.click()}
      title={title}
      aria-label={title}
      style={{
        cursor: "pointer", padding: 0, border: "1px solid rgba(31,27,23,0.18)",
        background: "conic-gradient(from 180deg, #ff5f5f, #ffd45f, #5fff7a, #5fcfff, #b35fff, #ff5f8e, #ff5f5f)",
        position: "relative",
        boxShadow: "inset 0 0 0 2px #fff",
        ...style,
      }}
    >
      <input
        ref={ref}
        type="color"
        value={safe}
        onChange={(e) => onPick(e.target.value)}
        style={{
          position: "absolute", left: 0, top: 0,
          width: 1, height: 1, opacity: 0, pointerEvents: "none",
        }}
      />
    </button>
  );
}

Object.assign(window, {
  StickyNote, Author, PhotoPlaceholder, Tape,
  PhotoNoteBody, PhotoVoiceCornerBody, PhotoStackBody, PhotoStack, PhotoFrame, VideoNoteBody,
  useAudioPlayer, fmtTime, photosOf, CustomColorButton,
  pickAudioChipStyle,
});
