// Recipient reveal — cinematic unveil for the person receiving the board.
// Stages: envelope intro → board reveal (notes settle in their real positions
// on the dotted canvas the contributors saw) → tap a note to expand it.

function RevealScreen({ palette, notes, onClose, onSave, recipientName, isRecipientShare }) {
  const firstName = (recipientName || "Gill").split(/\s+/)[0];
  const [stage, setStage] = React.useState(0);  // 0 envelope · 1 fly-in · 2 settled
  const [openId, setOpenId] = React.useState(null);
  const visible = stage > 0;

  React.useEffect(() => {
    if (stage === 1) {
      const t = setTimeout(() => setStage(2), 4200);
      return () => clearTimeout(t);
    }
  }, [stage]);

  const open = openId ? notes.find((n) => n.id === openId) : null;
  const authorOf = (note) => authorForNote(note);

  return (
    <div style={{
      position: "absolute", inset: 0, background: "#1F1B17",
      overflow: "hidden", color: "#fff",
    }}>
      {/* Warm vignette */}
      <div style={{
        position: "absolute", inset: 0,
        background: "radial-gradient(ellipse at 50% 40%, rgba(232,126,90,0.22), transparent 60%)",
        pointerEvents: "none",
      }} />

      {/* Stage 0 — envelope */}
      {stage === 0 && (
        <div style={{
          position: "absolute", inset: 0,
          display: "grid", placeItems: "center",
          animation: "fadeIn .4s ease",
        }}>
          <div style={{ textAlign: "center", color: "#fff", maxWidth: 540, padding: 24 }}>
            <div className="chip" style={{ color: "rgba(255,255,255,0.5)", marginBottom: 22 }}>
              <span style={{ color: palette.accent }}>●</span> a gift for you
            </div>
            <Envelope onOpen={() => setStage(1)} accent={palette.accent} />
            <h1 style={{ fontFamily: "var(--serif)", fontSize: 56, fontWeight: 400, margin: "30px 0 14px", lineHeight: 1.05, letterSpacing: "-0.01em" }}>
              <em>For {firstName},</em><br/>with love from <em>Atlas.</em>
            </h1>
            <p style={{ fontFamily: "var(--serif)", fontStyle: "italic", fontSize: 19, color: "rgba(255,255,255,0.7)", marginBottom: 30, lineHeight: 1.5 }}>
              14 of us got together and made you a wall. Tap the envelope when you're ready.
            </p>
            <button className="btn btn-coral" style={{ padding: "14px 26px", fontSize: 14 }}
                    onClick={() => setStage(1)}>
              Open <IconArrowRight size={14} />
            </button>
          </div>
        </div>
      )}

      {/* Stage 1+ — board reveal */}
      {visible && (
        <RevealBoard
          notes={notes}
          stage={stage}
          palette={palette}
          firstName={firstName}
          isRecipientShare={isRecipientShare}
          onSave={onSave}
          onReplay={() => { setStage(0); setTimeout(() => setStage(1), 200); }}
          onSkip={() => setStage(2)}
          onClose={onClose}
          onOpenNote={setOpenId}
        />
      )}

      {open && (
        <NoteDetail
          note={open}
          author={authorOf(open)}
          onClose={() => setOpenId(null)}
        />
      )}
    </div>
  );
}

function Envelope({ onOpen, accent }) {
  return (
    <button onClick={onOpen} style={{
      width: 200, height: 130, background: "transparent", border: 0, cursor: "pointer",
      position: "relative", padding: 0,
    }}>
      <svg viewBox="0 0 200 130" width="100%" height="100%">
        <defs>
          <linearGradient id="env-grad" x1="0" y1="0" x2="0" y2="1">
            <stop offset="0" stopColor="#FFF1DC" />
            <stop offset="1" stopColor="#F0D9B5" />
          </linearGradient>
        </defs>
        <rect x="6" y="20" width="188" height="100" rx="6" fill="url(#env-grad)" stroke="rgba(31,27,23,0.2)" />
        <path d="M6 26 L100 80 L194 26" stroke="rgba(31,27,23,0.2)" fill="none" strokeWidth="1.5" />
        <circle cx="100" cy="84" r="14" fill={accent} stroke="rgba(255,255,255,0.4)" strokeWidth="1.5" />
        <path d="M93 84 l4 4 l10 -10" stroke="#fff" strokeWidth="2.4" fill="none" strokeLinecap="round" strokeLinejoin="round" />
      </svg>
    </button>
  );
}

// RevealBoard — mirrors BoardScreen's layout: notes float in world coords
// on the dotted canvas. Auto-fit centers the union bbox of all notes so
// nothing is clipped, regardless of where contributors placed them.
function RevealBoard({ notes, stage, palette, firstName, isRecipientShare, onSave, onReplay, onSkip, onClose, onOpenNote }) {
  const stageRef = React.useRef(null);
  const [size, setSize] = React.useState({ w: 0, h: 0 });

  React.useEffect(() => {
    const fit = () => {
      const el = stageRef.current; if (!el) return;
      setSize({ w: el.clientWidth, h: el.clientHeight });
    };
    fit();
    window.addEventListener("resize", fit);
    return () => window.removeEventListener("resize", fit);
  }, []);

  // Bounding box of all notes (in their rotated extents) in world coords.
  // Falls back to a sane envelope when the board is empty so we still center.
  const bbox = React.useMemo(() => notesWorldBBox(notes), [notes]);

  // Reserve a bit of viewport padding for the brand + bottom buttons.
  const padX = 80, padY = 160;
  const scale = size.w && bbox.w > 0
    ? Math.min((size.w - padX) / bbox.w, (size.h - padY) / bbox.h)
    : 0.4;

  // Center the bbox centroid in the viewport.
  const tx = size.w / 2 - bbox.cx * scale;
  const ty = size.h / 2 - bbox.cy * scale;

  const authorOf = (note) => authorForNote(note);

  return (
    <div ref={stageRef} style={{ position: "absolute", inset: 0, animation: "fadeIn .35s ease" }}>
      {/* Top brand */}
      <div style={{ position: "absolute", top: 18, left: 22, display: "flex", alignItems: "center", gap: 10, zIndex: 5 }}>
        <span className="wordmark" style={{ fontSize: 22, color: "#fff" }}>
          <em>Fare<span style={{ color: palette.accent }}>,</span> well</em>
        </span>
      </div>

      {/* World container — scaled + translated to center the notes. */}
      <div style={{
        position: "absolute", left: 0, top: 0,
        transform: `translate(${tx}px, ${ty}px) scale(${scale})`,
        transformOrigin: "0 0",
      }}>
        {/* Notes — array order = z-stack, matching contributor view. Each
            wrapper sits over the world with pointer-events: none so its
            transform/opacity animation can lift the StickyNote inside
            without blocking clicks to other notes. */}
        {notes.map((n, i) => {
          const delay = stage === 1 ? 200 + i * 75 : 0;
          return (
            <div key={n.id} style={{
              position: "absolute", left: 0, top: 0,
              pointerEvents: "none",
              opacity: stage === 1 ? 0 : 1,
              animation: stage === 1
                ? `revealNoteIn .8s cubic-bezier(.18,.7,.2,1.05) ${delay}ms forwards`
                : "none",
            }}>
              <StickyNote
                note={n}
                author={authorOf(n)}
                selected={false}
                hovering={false}
                scale={scale}
                readOnly={true}
                onOpen={n.type === "doodle" ? undefined : onOpenNote}
                onSelect={() => {}}
                onChange={() => {}}
              />
            </div>
          );
        })}
      </div>

      <style>{`
        @keyframes revealNoteIn{
          0%   { opacity: 0; transform: translateY(60px) scale(.9); }
          100% { opacity: 1; transform: none; }
        }
      `}</style>

      {/* Hint */}
      {stage === 2 && (
        <div style={{
          position: "absolute", left: 0, right: 0, top: 22,
          textAlign: "center", color: "rgba(255,255,255,0.45)",
          fontFamily: "var(--mono)", fontSize: 10.5,
          letterSpacing: "0.1em", textTransform: "uppercase",
          pointerEvents: "none", animation: "fadeIn .6s ease",
        }}>tap any note to read it</div>
      )}

      {/* Ending UI */}
      {stage === 2 && (
        <div style={{
          position: "absolute", left: 0, right: 0, bottom: 28,
          display: "flex", justifyContent: "center", gap: 12,
          animation: "fadeIn .4s ease", zIndex: 5,
        }}>
          <button className="btn btn-soft" onClick={onReplay} style={{
            background: "rgba(255,255,255,0.1)", color: "#fff",
            border: "1px solid rgba(255,255,255,0.2)",
          }}>
            <IconRotate size={13} /> Watch again
          </button>
          <button className="btn btn-coral" onClick={onSave}>
            <IconDownload size={13} /> Save as keepsake
          </button>
          {!isRecipientShare && (
            <button className="btn btn-ghost" onClick={onClose} style={{ color: "rgba(255,255,255,0.7)" }}>
              Exit
            </button>
          )}
        </div>
      )}

      {/* Skip during fly-in */}
      {stage === 1 && (
        <button onClick={onSkip} style={{
          position: "absolute", right: 22, top: 22, background: "transparent", border: 0,
          color: "rgba(255,255,255,0.55)", cursor: "pointer", fontSize: 12, fontFamily: "var(--mono)",
          textTransform: "uppercase", letterSpacing: "0.08em", zIndex: 5,
        }}>skip ›</button>
      )}
    </div>
  );
}

// Detail modal — opens when the recipient taps a note. Renders a large,
// readable version of the note in the center of the screen with a backdrop.
function NoteDetail({ note, author, onClose }) {
  const hasPhoto = !!note.photoSrc || note.type === "photo" || note.type === "gif";
  const isCluster = note.type === "sticker-cluster";
  // Photo, video, and any note carrying a photo all get the white card chrome
  // so the media reads cleanly against a neutral backdrop.
  const isMedia = hasPhoto || note.type === "video";
  const bg = isCluster ? "#FAF7F2" : (isMedia ? "#FFFFFF" : note.color);

  React.useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose]);

  return (
    <div onClick={onClose} style={{
      position: "absolute", inset: 0, zIndex: 100,
      background: "rgba(15,12,10,0.55)",
      backdropFilter: "blur(12px)",
      WebkitBackdropFilter: "blur(12px)",
      animation: "fadeIn .22s ease",
      display: "grid", placeItems: "center",
      padding: 24,
    }}>
      <div onClick={(e) => e.stopPropagation()} style={{
        position: "relative",
        width: "min(680px, 92vw)",
        maxHeight: "84vh",
        background: bg,
        color: "var(--ink)",
        borderRadius: isMedia ? 6 : 18,
        boxShadow: "0 30px 80px -20px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.06)",
        animation: "popIn .32s cubic-bezier(.2,.7,.3,1.2)",
        overflow: "hidden",
        display: "flex", flexDirection: "column",
      }}>
        <button onClick={onClose} aria-label="Close" style={{
          position: "absolute", top: 14, right: 14, zIndex: 3,
          width: 34, height: 34, borderRadius: "50%",
          background: isMedia ? "rgba(31,27,23,0.55)" : "rgba(31,27,23,0.08)",
          color: isMedia ? "#fff" : "var(--ink)",
          border: 0, cursor: "pointer",
          display: "grid", placeItems: "center",
        }}>
          <IconClose size={14} />
        </button>
        <DetailContent note={note} author={author} />
      </div>
    </div>
  );
}

function DetailContent({ note, author }) {
  // Photo branch: covers every note that carries at least one image,
  // including multi-photo posts (carousel) and photo+voice (audio strip).
  const list = photosOf(note);
  const hasPhoto = list.length > 0 || note.type === "photo" || note.type === "gif";
  if (hasPhoto) {
    const isStack = list.length > 1;
    const tag = isStack
      ? `${list.length} photos${note.audioSrc ? " + voice note" : ""}`
      : note.type === "gif" ? "looped gif"
      : note.audioSrc ? "photo + voice note"
      : "photo";
    return (
      <div style={{
        background: "#fff", padding: 18,
        display: "flex", flexDirection: "column", gap: 16,
        overflow: "auto",
      }}>
        {isStack
          ? <DetailCarousel photos={list} captionFallback={note.caption} />
          : (
            <div style={{
              width: "100%", aspectRatio: "4 / 3", borderRadius: 4,
              overflow: "hidden", background: "#1F1B17",
              display: "flex", alignItems: "center", justifyContent: "center",
            }}>
              {list[0]?.src ? (
                <img src={list[0].src} alt={note.caption || "photo"}
                     draggable={false}
                     style={{ width: "100%", height: "100%", objectFit: "contain", display: "block" }} />
              ) : (
                <PhotoPlaceholder hue={note.photoHue} label={note.photoLabel || "photo"} animated={note.type === "gif"} />
              )}
            </div>
          )
        }
        {note.audioSrc && (
          <audio src={note.audioSrc} controls
                 style={{ width: "100%" }} />
        )}
        {!isStack && note.caption && (
          <div style={{
            fontFamily: "var(--serif)", fontStyle: "italic",
            fontSize: 22, lineHeight: 1.35, color: "var(--ink)",
          }}>{note.caption}</div>
        )}
        <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
          <Author author={author} size={22} />
          <span style={{ fontFamily: "var(--mono)", fontSize: 10.5, color: "var(--ink-3)", letterSpacing: "0.08em", textTransform: "uppercase" }}>
            {tag}
          </span>
        </div>
      </div>
    );
  }

  if (note.type === "video") {
    return (
      <div style={{
        background: "#fff", padding: 18,
        display: "flex", flexDirection: "column", gap: 16,
        overflow: "auto",
      }}>
        <div style={{
          width: "100%", aspectRatio: "16 / 9", borderRadius: 4,
          overflow: "hidden", background: "#1F1B17",
          display: "flex", alignItems: "center", justifyContent: "center",
        }}>
          {note.videoSrc ? (
            <video src={note.videoSrc}
                   controls autoPlay playsInline
                   style={{ width: "100%", height: "100%", objectFit: "contain", display: "block" }} />
          ) : (
            <div style={{ color: "rgba(255,255,255,0.6)", fontFamily: "var(--mono)", fontSize: 12 }}>
              video unavailable
            </div>
          )}
        </div>
        {note.caption && (
          <div style={{
            fontFamily: "var(--serif)", fontStyle: "italic",
            fontSize: 22, lineHeight: 1.35, color: "var(--ink)",
          }}>{note.caption}</div>
        )}
        <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
          <Author author={author} size={22} />
          <span style={{ fontFamily: "var(--mono)", fontSize: 10.5, color: "var(--ink-3)", letterSpacing: "0.08em", textTransform: "uppercase" }}>
            video
          </span>
        </div>
      </div>
    );
  }

  if (note.type === "voice") {
    return <VoiceDetail note={note} author={author} />;
  }

  if (note.type === "doodle") {
    return (
      <div style={{
        padding: "48px 40px 32px", minHeight: 360,
        display: "flex", flexDirection: "column", gap: 20,
        overflow: "auto", position: "relative",
      }}>
        <svg viewBox="0 0 240 160" preserveAspectRatio="none" style={{
          position: "absolute", inset: 0, width: "100%", height: "100%",
          pointerEvents: "none", opacity: 0.85,
        }}>
          <path d="M30 110 C 50 80, 70 60, 110 70 S 170 100, 200 70" stroke="var(--ink)" strokeWidth="1.4" fill="none" strokeLinecap="round" />
          <path d="M50 130 C 80 120, 130 130, 200 120" stroke="var(--ink)" strokeWidth="0.9" fill="none" strokeLinecap="round" opacity="0.6" />
          <path d="M180 40 l8 -10 l4 14 l10 -6" stroke="var(--coral)" strokeWidth="1.3" fill="none" strokeLinecap="round" strokeLinejoin="round" />
        </svg>
        <div style={{
          fontFamily: "var(--serif)", fontStyle: "italic",
          fontSize: 40, lineHeight: 1.2, color: "var(--ink)",
          position: "relative", marginTop: 50, flex: 1,
        }}>{note.body}</div>
        <div style={{ position: "relative" }}>
          <Author author={author} size={22} />
        </div>
      </div>
    );
  }

  if (note.type === "sticker-cluster") {
    return (
      <div style={{
        padding: 36, height: 460, position: "relative",
        background: "#FAF7F2",
      }}>
        <div style={{ position: "absolute", inset: 36, top: 36, bottom: 80 }}>
          <StickerClusterDetail stickers={note.stickers} />
        </div>
        <div style={{ position: "absolute", left: 36, bottom: 28 }}>
          <Author author={author} size={22} />
        </div>
      </div>
    );
  }

  // text default
  return (
    <div style={{
      padding: "48px 44px 32px",
      display: "flex", flexDirection: "column", gap: 24,
      overflow: "auto",
    }}>
      <div style={{
        fontFamily: "var(--serif)", fontSize: 28, lineHeight: 1.38,
        color: "var(--ink)", letterSpacing: "-0.005em",
      }}>{note.body}</div>
      <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: 8 }}>
        <Author author={author} size={22} />
        {note.sticker && (
          <span style={{ fontSize: 38, lineHeight: 1, color: "var(--coral)" }}>{note.sticker}</span>
        )}
      </div>
    </div>
  );
}

// Carousel for multi-photo notes in the recipient detail modal. One large
// preview + chevrons + a position chip + a thumbnail strip; per-photo
// captions show below the active image when present.
function DetailCarousel({ photos, captionFallback }) {
  const [active, setActive] = React.useState(0);
  const n = photos.length;
  const step = (dir) => setActive((i) => (i + dir + n) % n);
  const cur = photos[active] || {};
  const caption = cur.caption || captionFallback;
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
      <div style={{
        position: "relative",
        width: "100%", aspectRatio: "4 / 3", borderRadius: 4,
        overflow: "hidden", background: "#1F1B17",
        display: "flex", alignItems: "center", justifyContent: "center",
      }}>
        {cur.src
          ? <img src={cur.src} alt={cur.name || `photo ${active + 1}`}
                 draggable={false}
                 style={{ width: "100%", height: "100%", objectFit: "contain", display: "block" }} />
          : <PhotoPlaceholder hue={(active * 47) % 360} label={cur.name || `photo ${active + 1}`} />
        }
        <button onClick={() => step(-1)} aria-label="Previous"
                style={{
                  position: "absolute", left: 10, top: "50%", transform: "translateY(-50%)",
                  width: 36, height: 36, borderRadius: "50%",
                  background: "rgba(255,255,255,0.92)", color: "var(--ink)",
                  border: 0, cursor: "pointer",
                  display: "grid", placeItems: "center",
                  boxShadow: "0 4px 12px rgba(0,0,0,0.35)",
                }}>
          <IconChevronLeft size={16} />
        </button>
        <button onClick={() => step(1)} aria-label="Next"
                style={{
                  position: "absolute", right: 10, top: "50%", transform: "translateY(-50%)",
                  width: 36, height: 36, borderRadius: "50%",
                  background: "rgba(255,255,255,0.92)", color: "var(--ink)",
                  border: 0, cursor: "pointer",
                  display: "grid", placeItems: "center",
                  boxShadow: "0 4px 12px rgba(0,0,0,0.35)",
                }}>
          <IconChevronRight size={16} />
        </button>
        <div style={{
          position: "absolute", right: 12, top: 12,
          padding: "3px 10px", borderRadius: 999,
          background: "rgba(31,27,23,0.72)", color: "#fff",
          fontFamily: "var(--mono)", fontSize: 11, letterSpacing: "0.05em",
        }}>{active + 1} / {n}</div>
      </div>
      {caption && (
        <div style={{
          fontFamily: "var(--serif)", fontStyle: "italic",
          fontSize: 20, lineHeight: 1.35, color: "var(--ink)",
        }}>{caption}</div>
      )}
      {/* Thumbnail strip */}
      <div style={{
        display: "flex", gap: 6, overflowX: "auto", paddingBottom: 2,
        WebkitOverflowScrolling: "touch",
      }}>
        {photos.map((p, i) => (
          <button key={i} onClick={() => setActive(i)}
                  aria-label={`Show photo ${i + 1}`}
                  style={{
                    flex: "0 0 auto",
                    width: 56, height: 56, borderRadius: 4, overflow: "hidden",
                    border: i === active ? "2px solid var(--coral)" : "1px solid var(--line)",
                    background: "#1F1B17", padding: 0, cursor: "pointer",
                  }}>
            {p.src
              ? <img src={p.src} alt={p.name || `photo ${i + 1}`}
                     style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }} />
              : <PhotoPlaceholder hue={(i * 47) % 360} label="" />
            }
          </button>
        ))}
      </div>
    </div>
  );
}

function VoiceDetail({ note, author }) {
  const bars = 56;
  const { playing, progress, currentTime, totalTime, audioRef, toggle } = useAudioPlayer(note);
  const filled = Math.floor(bars * progress);
  const seconds = playing || progress > 0
    ? currentTime
    : (totalTime || note.duration || 0);
  return (
    <div style={{
      padding: "44px 36px 32px",
      display: "flex", flexDirection: "column", gap: 24,
      overflow: "auto",
    }}>
      {note.audioSrc && <audio ref={audioRef} src={note.audioSrc} preload="metadata" />}
      <div style={{ display: "flex", alignItems: "center", gap: 16 }}>
        <button onClick={toggle} disabled={!note.audioSrc} style={{
          width: 56, height: 56, borderRadius: "50%",
          background: "var(--ink)", color: "#fff", border: 0,
          display: "grid", placeItems: "center",
          cursor: note.audioSrc ? "pointer" : "default",
          opacity: note.audioSrc ? 1 : 0.6,
        }}>
          {playing ? <IconPause size={20} /> : <IconPlay size={20} />}
        </button>
        <div style={{ flex: 1, display: "flex", alignItems: "center", gap: 3, height: 40 }}>
          {Array.from({ length: bars }).map((_, i) => {
            const h = 8 + Math.abs(Math.sin(i * 1.7 + note.id.length)) * 32;
            const isFilled = note.audioSrc
              ? i < filled
              : i < bars * 0.32;
            return <span key={i} style={{
              width: 3, height: h, borderRadius: 2,
              background: isFilled ? "var(--coral)" : "rgba(31,27,23,0.25)",
            }} />;
          })}
        </div>
        <span style={{ fontFamily: "var(--mono)", fontSize: 13, color: "var(--ink-2)" }}>
          {fmtTime(seconds)}
        </span>
      </div>
      {note.transcript && (
        <div style={{
          fontFamily: "var(--serif)", fontStyle: "italic", fontSize: 26,
          lineHeight: 1.4, color: "var(--ink)",
        }}>"{note.transcript}"</div>
      )}
      <Author author={author} size={22} />
    </div>
  );
}

function StickerClusterDetail({ stickers }) {
  const colors = ["#E87E5A", "#C18A3B", "#3F8FA8", "#B86A8A", "#5C8B6B"];
  return (
    <div style={{ position: "relative", width: "100%", height: "100%" }}>
      {stickers.map((s, i) => {
        const angle = (i / stickers.length) * Math.PI * 2;
        const r = 30 + (i % 2) * 8;  // % of container
        const x = 50 + Math.cos(angle) * r;
        const y = 50 + Math.sin(angle) * r;
        const rot = (i * 23) % 30 - 15;
        const sz = 64 + (i % 3) * 12;
        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 4px 8px rgba(31,27,23,0.18))",
          }}>{s}</span>
        );
      })}
    </div>
  );
}

// World-space bounding box of every note (accounting for each note's own
// rotation). Used by RevealBoard to auto-fit the wall in the viewport.
// Falls back to a default envelope when there are no notes so the layout
// still has something to center.
function notesWorldBBox(notes) {
  let minX =  Infinity, minY =  Infinity;
  let maxX = -Infinity, maxY = -Infinity;

  for (const n of notes || []) {
    if (typeof n.x !== "number" || typeof n.y !== "number") continue;
    const w = n.w || 240, h = n.h || 200;
    // Rotated bbox dimensions in world coords.
    const rad = (n.rot || 0) * Math.PI / 180;
    const c = Math.abs(Math.cos(rad));
    const s = Math.abs(Math.sin(rad));
    const rw = w * c + h * s;
    const rh = w * s + h * c;
    const cx = n.x + w / 2;
    const cy = n.y + h / 2;
    const x0 = cx - rw / 2, y0 = cy - rh / 2;
    const x1 = cx + rw / 2, y1 = cy + rh / 2;
    if (x0 < minX) minX = x0;
    if (y0 < minY) minY = y0;
    if (x1 > maxX) maxX = x1;
    if (y1 > maxY) maxY = y1;
  }

  if (!isFinite(minX)) {
    // Empty wall — center on the default world envelope.
    minX = 0; minY = 0; maxX = 2200; maxY = 1100;
  }
  return {
    minX, minY, maxX, maxY,
    w: maxX - minX,
    h: maxY - minY,
    cx: (minX + maxX) / 2,
    cy: (minY + maxY) / 2,
  };
}

Object.assign(window, { RevealScreen });
