// DesignCanvas.jsx — Figma-ish design canvas wrapper
// Warm gray grid bg + Sections + Artboards + PostIt notes.
// Artboards are reorderable (grip-drag), labels/titles are inline-editable,
// and any artboard can be opened in a fullscreen focus overlay (←/→/Esc).
// State persists to a .design-canvas.state.json sidecar via the host
// bridge. No assets, no deps.
//
// Usage:
//   <DesignCanvas>
//     <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
//       <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
//       <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
//     </DCSection>
//   </DesignCanvas>

const DC = {
  bg: '#f0eee9',
  grid: 'rgba(0,0,0,0.06)',
  label: 'rgba(60,50,40,0.7)',
  title: 'rgba(40,30,20,0.85)',
  subtitle: 'rgba(60,50,40,0.6)',
  postitBg: '#fef4a8',
  postitText: '#5a4a2a',
  font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
};
const DC_STATE_FILE = '.design-canvas.state.json';
const DC_STATE_API_PATH = '/api/design-canvas-state';
const DC_POLL_INTERVAL_MS = 2000;
const DC_LOCAL_STATE_KEY = 'drafternotes.design-canvas-state';
let dcSharedStateWarningShown = false;
const DC_FALLBACK_USER = {
  id: 'guest',
  name: 'Guest',
  initials: 'GU',
  color: '#64748B',
  soft: '#E2E8F0',
  ink: '#334155',
  avatar: 'linear-gradient(135deg, #94A3B8 0%, #64748B 55%, #334155 100%)',
};
const DC_COMMENT_CURSOR = 'url("assets/CommentCursor.svg") 4 18, crosshair';

// One-time CSS injection (classes are dc-prefixed so they don't collide with
// the hosted design's own styles).
if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
  const s = document.createElement('style');
  s.id = 'dc-styles';
  s.textContent = [
    '.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
    '.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
    '[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
    '[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
    '[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
    '.dc-card{transition:box-shadow .15s,transform .15s}',
    '.dc-card[data-dc-selected="true"]{box-shadow:0 0 0 1.5px rgba(60,118,210,.3),0 0 0 4px rgba(60,118,210,.12),0 1px 3px rgba(0,0,0,.08),0 8px 28px rgba(0,0,0,.08)}',
    '.dc-card *{scrollbar-width:none}',
    '.dc-card *::-webkit-scrollbar{display:none}',
    '.dc-card-content{height:100%}',
    '[data-dc-comment-mode="true"] .dc-card-content{pointer-events:none}',
    '.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px}',
    '.dc-grip{cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s}',
    '.dc-grip:hover{background:rgba(0,0,0,.08)}',
    '.dc-grip:active{cursor:grabbing}',
    '.dc-delete{opacity:0;display:flex;align-items:center;justify-content:center;width:20px;height:20px;border:none;background:transparent;color:rgba(60,50,40,.6);border-radius:4px;cursor:pointer;transition:opacity .12s,background .12s,color .12s}',
    '.dc-delete:hover{background:rgba(0,0,0,.08);color:#2a251f}',
    '[data-dc-slot]:hover .dc-delete,[data-dc-slot][data-dc-selected="true"] .dc-delete{opacity:1}',
    '.dc-labeltext{cursor:pointer;border-radius:4px;padding:3px 6px;display:flex;align-items:center;transition:background .12s}',
    '.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
    '.dc-expand{position:absolute;bottom:100%;right:0;margin-bottom:5px;z-index:2;opacity:0;transition:opacity .12s,background .12s;',
    '  width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
    '  background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center}',
    '.dc-expand:hover{background:rgba(0,0,0,.06);color:#2a251f}',
    '[data-dc-slot]:hover .dc-expand{opacity:1}',
    '.dc-comments-hud{position:fixed;top:16px;left:16px;z-index:130;display:inline-flex;align-items:center;gap:2px;padding:4px;background:var(--gray-900);border-radius:var(--r-md);box-shadow:0 8px 22px rgba(0,0,0,0.18);color:var(--white)}',
    '.dc-comments-meta{display:flex;align-items:center}',
    '.dc-comments-meta span{font-family:var(--font-mono);font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:rgba(255,255,255,.45);white-space:nowrap;padding:0 10px;display:inline-flex;align-items:center;height:30px}',
    '.dc-comments-user{height:30px;padding:0 12px;display:inline-flex;align-items:center;font-family:var(--font-sans);font-size:12px;font-weight:500;color:rgba(255,255,255,.65);white-space:nowrap}',
    '.dc-comment-toggle,.dc-comment-eye{display:inline-flex;align-items:center;gap:8px;height:30px;padding:0 12px;border:none;border-radius:var(--r-sm);background:transparent;color:rgba(255,255,255,.65);font-family:var(--font-sans);font-size:12px;font-weight:500;cursor:pointer;transition:background .12s,color .12s}',
    '.dc-comment-toggle:hover,.dc-comment-eye:hover{color:var(--white)}',
    '.dc-comment-toggle[data-active="true"]{background:var(--white);color:var(--gray-900)}',
    '.dc-comment-eye[data-visible="false"]{color:rgba(255,255,255,.42)}',
    '.dc-comment-toggle svg,.dc-comment-eye svg{width:16px;height:16px}',
    '.dc-kbd{display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:20px;padding:0 6px;border-radius:999px;background:rgba(255,255,255,.12);font-size:11px;font-weight:700;letter-spacing:.02em;color:inherit}',
    '.dc-comment-toggle[data-active="true"] .dc-kbd{background:rgba(0,0,0,.08)}',
    '.dc-comments-status{font-family:var(--font-sans);font-size:12px;font-weight:500;color:rgba(255,255,255,.45);white-space:nowrap;padding:0 12px;display:inline-flex;align-items:center;height:30px}',
    '.dc-comments-layer{position:absolute;inset:0;pointer-events:none;z-index:24}',
    '.dc-comment-pin{position:absolute;transform:translate(-50%,-50%);pointer-events:auto}',
    '.dc-comment-pin button{width:36px;height:36px;border:3px solid #18a0fb;box-sizing:border-box;border-radius:999px;padding:0;display:grid;place-items:center;color:#111;font:800 13px/1 inherit;cursor:pointer;background:#fff;box-shadow:0 10px 20px rgba(0,0,0,.2),0 0 0 2px rgba(255,255,255,.7);transition:transform .12s,box-shadow .12s,border-color .12s}',
    '.dc-comment-pin button:hover{transform:translateY(-1px) scale(1.04);box-shadow:0 14px 28px rgba(0,0,0,.24),0 0 0 2px rgba(255,255,255,.84)}',
    '.dc-comment-pin button[data-open="true"]{background:#18a0fb;color:#fff;border-color:#096dd9;box-shadow:0 14px 30px rgba(24,160,251,.34),0 0 0 5px rgba(24,160,251,.18)}',
    '.dc-comment-pin button[data-muted="true"]{opacity:.48}',
    '.dc-comment-pin button[data-draft="true"]{animation:dc-pin-pulse 1.4s ease-in-out infinite}',
    '.dc-comment-pin-avatar{width:24px;height:24px;border-radius:999px;display:grid;place-items:center;font:800 11px/1 inherit;color:#fff;box-shadow:inset 0 0 0 1px rgba(255,255,255,.28)}',
    '.dc-comment-pin-preview,.dc-comment-cluster-preview{position:absolute;left:50%;bottom:44px;transform:translateX(-50%);width:250px;padding:10px 12px;border:1px solid rgba(0,0,0,.1);border-radius:10px;background:#fff;color:#222;box-shadow:0 14px 36px rgba(0,0,0,.18);opacity:0;pointer-events:none;transition:opacity .12s,transform .12s;font-family:var(--font-sans);font-size:12px;line-height:1.35}',
    '.dc-comment-pin:hover .dc-comment-pin-preview,.dc-comment-cluster:hover .dc-comment-cluster-preview{opacity:1;transform:translateX(-50%) translateY(-2px)}',
    '.dc-comment-pin-preview strong,.dc-comment-cluster-preview strong{display:block;margin-bottom:3px;font-size:12px;color:#111}',
    '.dc-comment-cluster button{min-width:42px;padding:0 8px;border-color:#0b7bd3;background:#18a0fb;color:#fff}',
    '.dc-comment-cluster-avatars{display:flex;margin-left:2px}',
    '.dc-comment-cluster-avatars span{width:18px;height:18px;border-radius:999px;margin-left:-4px;border:2px solid #18a0fb;display:grid;place-items:center;font:800 8px/1 inherit;color:#fff}',
    '@keyframes dc-pin-pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.08)}}',
    '.dc-comment-thread{position:absolute;pointer-events:auto;width:380px;max-width:380px;background:#fff;border:1px solid rgba(0,0,0,.12);border-radius:18px;box-shadow:0 24px 60px rgba(0,0,0,.24);overflow:hidden;color:#222}',
    '.dc-comment-thread-header{display:flex;align-items:center;gap:12px;padding:16px 18px;border-bottom:1px solid rgba(0,0,0,.1);cursor:grab}',
    '.dc-comment-thread-header:active{cursor:grabbing}',
    '.dc-comment-thread[data-dragging="true"]{box-shadow:0 28px 70px rgba(0,0,0,.3)}',
    '.dc-comment-thread-title{font-size:18px;font-weight:800;color:#1f1f1f}',
    '.dc-comment-thread-page{font-size:12px;font-weight:700;color:#888;margin-top:2px}',
    '.dc-comment-thread-tools{margin-left:auto;display:flex;align-items:center;gap:4px;position:relative}',
    '.dc-icon-btn{width:32px;height:32px;border:none;background:transparent;border-radius:999px;display:grid;place-items:center;color:#222;cursor:pointer;font:700 18px/1 inherit}',
    '.dc-icon-btn:hover{background:#f0f0f0}',
    '.dc-icon-btn[data-active="true"]{color:#0b7bd3;background:#e6f4ff}',
    '.dc-comment-menu{position:absolute;right:36px;top:36px;width:190px;padding:6px;background:#fff;border:1px solid rgba(0,0,0,.12);border-radius:10px;box-shadow:0 14px 34px rgba(0,0,0,.18);z-index:4}',
    '.dc-comment-menu button{display:flex;width:100%;border:none;background:transparent;border-radius:7px;padding:9px 10px;text-align:left;font:600 12px/1.2 var(--font-sans);color:#222;cursor:pointer}',
    '.dc-comment-menu button:hover{background:#f3f3f3}',
    '.dc-comment-menu button[data-danger="true"]{color:#cf3d20}',
    '.dc-comment-thread-body{display:flex;flex-direction:column;gap:16px;padding:16px 18px 18px;max-height:520px;overflow:auto}',
    '.dc-comment-thread-messages{display:flex;flex-direction:column;gap:12px}',
    '.dc-comment-message{display:flex;gap:10px;align-items:flex-start}',
    '.dc-comment-avatar{width:32px;height:32px;border-radius:999px;display:grid;place-items:center;color:#fff;font:800 12px/1 inherit;flex:none;box-shadow:inset 0 0 0 1px rgba(255,255,255,.28),0 5px 12px rgba(0,0,0,.1)}',
    '.dc-comment-message-main{min-width:0;flex:1}',
    '.dc-comment-message-name{display:flex;align-items:baseline;gap:8px;font-size:13px;font-weight:800;color:#222}',
    '.dc-comment-message-time{font-size:12px;color:#8a8a8a;font-weight:600}',
    '.dc-comment-message-text{margin-top:5px;font-size:14px;line-height:1.45;color:#222;white-space:pre-wrap}',
    '.dc-comment-message:hover .dc-message-actions{opacity:1}',
    '.dc-message-actions{opacity:0;display:flex;align-items:center;gap:4px;margin-top:8px;transition:opacity .12s}',
    '.dc-message-actions[data-has-reactions="true"]{opacity:1}',
    '.dc-reaction-pill{display:inline-flex;align-items:center;gap:4px;border:1px solid #18a0fb;background:#e8f4ff;color:#222;border-radius:8px;padding:5px 9px;font:800 12px/1 inherit;cursor:pointer}',
    '.dc-reaction-add,.dc-message-delete{border:none;background:transparent;border-radius:999px;color:#555;width:28px;height:28px;display:grid;place-items:center;cursor:pointer;font:700 16px/1 inherit}',
    '.dc-reaction-add:hover,.dc-message-delete:hover{background:#f0f0f0;color:#111}',
    '.dc-comment-composer{display:grid;grid-template-columns:32px 1fr;gap:10px;align-items:start}',
    '.dc-comment-composer-box{background:#f5f5f5;border-radius:16px;overflow:hidden;border:1px solid transparent}',
    '.dc-comment-composer-box:focus-within{border-color:#18a0fb;box-shadow:0 0 0 2px rgba(24,160,251,.16)}',
    '.dc-comment-thread textarea{width:100%;min-height:82px;max-height:180px;resize:vertical;border:none;padding:13px 14px;font:500 14px/1.45 inherit;color:#222;box-sizing:border-box;outline:none;background:transparent}',
    '.dc-comment-thread textarea::placeholder{color:#8f8f8f}',
    '.dc-comment-compose-actions{height:44px;border-top:1px solid rgba(0,0,0,.08);display:flex;align-items:center;padding:0 10px;gap:6px}',
    '.dc-comment-compose-spacer{flex:1}',
    '.dc-compose-tool{width:28px;height:28px;border:none;background:transparent;border-radius:999px;display:grid;place-items:center;color:#222;cursor:pointer;font:800 17px/1 inherit}',
    '.dc-compose-tool:hover{background:#e9e9e9}',
    '.dc-comment-thread button.dc-primary{width:34px;height:34px;border:none;background:#18a0fb;color:#fff;border-radius:999px;font:900 20px/1 inherit;cursor:pointer;display:grid;place-items:center}',
    '.dc-comment-thread button.dc-primary:hover{background:#0b8ee8}',
    '.dc-comment-thread button.dc-primary:disabled{opacity:.35;cursor:not-allowed}',
    '.dc-comment-thread-empty{font-size:13px;color:#666}',
    '.dc-draft-composer{position:absolute;pointer-events:auto;display:flex;align-items:center;gap:18px;transform:translate(22px,-50%)}',
    '.dc-draft-bubble{width:42px;height:42px;border-radius:999px 999px 999px 0;background:#18a0fb;box-shadow:0 10px 22px rgba(24,160,251,.28)}',
    '.dc-draft-input{width:360px;min-height:54px;background:#fff;border:1px solid rgba(0,0,0,.12);border-radius:18px;box-shadow:0 14px 34px rgba(0,0,0,.18);display:flex;align-items:center;padding:0 10px 0 18px;gap:10px}',
    '.dc-draft-input input{flex:1;border:none;outline:none;height:48px;font:500 22px/1 var(--font-sans);color:#222;min-width:0}',
    '.dc-draft-input input::placeholder{color:#aaa}',
    '.dc-draft-input button{width:40px;height:40px;border:none;border-radius:999px;background:#d9d9d9;color:#fff;display:grid;place-items:center;font:900 22px/1 inherit;cursor:pointer}',
    '.dc-draft-input button:not(:disabled){background:#18a0fb}',
    '.dc-draft-input button:disabled{cursor:not-allowed}',
    '.dc-comments-sidebar{position:fixed;top:0;right:0;bottom:0;width:360px;z-index:125;background:#fff;border-left:1px solid rgba(0,0,0,.1);box-shadow:-6px 0 24px rgba(0,0,0,.06);pointer-events:auto;display:flex;flex-direction:column;color:#222;font-family:var(--font-sans)}',
    '.dc-comments-sidebar-top{padding:14px 16px;border-bottom:1px solid rgba(0,0,0,.08);display:flex;align-items:center;gap:10px}',
    '.dc-comments-sidebar-avatar{width:32px;height:32px;border-radius:999px;display:grid;place-items:center;font:800 12px/1 inherit;color:#fff;flex:none;box-shadow:inset 0 0 0 1px rgba(255,255,255,.28),0 6px 14px rgba(0,0,0,.12)}',
    '.dc-comments-sidebar-title{font-size:15px;font-weight:800;color:#202020;letter-spacing:-.01em}',
    '.dc-comments-sidebar-zoom{margin-left:auto;font-size:12px;font-weight:700;color:#444}',
    '.dc-comments-sidebar-tools{padding:10px 16px;border-bottom:1px solid rgba(0,0,0,.08);display:flex;align-items:center;gap:8px;position:relative}',
    '.dc-sidebar-tool-btn{width:32px;height:32px;border:none;border-radius:8px;background:transparent;color:#222;display:grid;place-items:center;cursor:pointer}',
    '.dc-sidebar-tool-btn:hover,.dc-sidebar-tool-btn[data-active="true"]{background:#f2f2f2}',
    '.dc-comments-search{position:relative;flex:1}',
    '.dc-comments-search svg{position:absolute;left:10px;top:50%;transform:translateY(-50%);width:15px;height:15px;color:#555}',
    '.dc-comments-search input{width:100%;height:34px;border:none;background:#f4f4f4;border-radius:8px;padding:0 10px 0 32px;font:600 12px/1 var(--font-sans);outline:none;box-sizing:border-box;color:#222}',
    '.dc-comments-search input::placeholder{color:#8d8d8d}',
    '.dc-comments-sidebar-list{flex:1;min-height:0;overflow:auto;padding:8px 8px 20px}',
    '.dc-sidebar-thread{width:100%;border:1px solid transparent;background:transparent;text-align:left;border-radius:9px;padding:10px 10px 11px;cursor:pointer;color:#222;display:block;position:relative}',
    '.dc-sidebar-thread:hover{background:#f7f7f7;border-color:#eee}',
    '.dc-sidebar-thread[data-active="true"]{background:#eaf5ff;border-color:#d7ebff}',
    '.dc-sidebar-thread[data-resolved="true"]{opacity:.62}',
    '.dc-sidebar-avatars{display:flex;align-items:center;margin-bottom:7px}',
    '.dc-sidebar-avatars span{width:24px;height:24px;border-radius:999px;border:2px solid #fff;margin-left:-5px;display:grid;place-items:center;font:800 10px/1 inherit;color:#fff;box-shadow:0 3px 8px rgba(0,0,0,.12)}',
    '.dc-sidebar-avatars span:first-child{margin-left:0}',
    '.dc-sidebar-dot{position:absolute;right:12px;top:22px;width:7px;height:7px;border-radius:999px;background:#168eea}',
    '.dc-sidebar-thread-actions{position:absolute;right:8px;top:8px;display:flex;gap:2px}',
    '.dc-sidebar-thread-actions .dc-sidebar-tool-btn{width:26px;height:26px;border-radius:7px}',
    '.dc-sidebar-thread-actions svg{width:16px;height:16px}',
    '.dc-sidebar-meta{font-size:10px;font-weight:700;color:#8a8a8a;margin-bottom:4px}',
    '.dc-sidebar-name{font-size:12px;font-weight:800;color:#222;margin-bottom:3px;line-height:1.2}',
    '.dc-sidebar-name .dc-comment-message-time{font-size:11px;font-weight:600;color:#999}',
    '.dc-sidebar-snippet{font-size:12px;font-weight:500;line-height:1.35;color:#707070;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}',
    '.dc-sidebar-replies{margin-top:5px;font-size:11px;font-weight:800;color:#0b84e5}',
    '.dc-sidebar-menu{position:absolute;right:16px;top:48px;width:210px;background:#fff;border:1px solid rgba(0,0,0,.1);border-radius:10px;box-shadow:0 14px 36px rgba(0,0,0,.16);padding:6px;z-index:5}',
    '.dc-sidebar-menu button{width:100%;border:none;background:transparent;border-radius:7px;padding:8px 9px;text-align:left;font:700 12px/1.2 var(--font-sans);color:#222;cursor:pointer}',
    '.dc-sidebar-menu button:hover,.dc-sidebar-menu button[data-active="true"]{background:#f1f1f1}',
    '.dc-sidebar-empty{margin:24px 8px 0;padding:28px 22px;border:1px solid #eee;border-radius:14px;background:linear-gradient(180deg,#fafafa,#fff);color:#777;text-align:center}',
    '.dc-sidebar-empty-icon{width:34px;height:34px;margin:0 auto 12px;border-radius:999px;background:#f1f1f1;color:#555;display:grid;place-items:center;font:800 18px/1 var(--font-sans)}',
    '.dc-sidebar-empty-title{font-size:13px;font-weight:800;color:#252525;margin-bottom:6px;letter-spacing:-.01em}',
    '.dc-sidebar-empty-copy{font-size:12px;font-weight:500;line-height:1.45;color:#777;margin:0}',
    '.dc-sidebar-empty-hint{margin-top:12px;display:inline-flex;align-items:center;justify-content:center;height:26px;padding:0 10px;border-radius:999px;background:#f4f4f4;color:#555;font-size:11px;font-weight:700}',
  ].join('\n');
  document.head.appendChild(s);
}

const DCCtx = React.createContext(null);
function readLocalCanvasState() {
  try {
    const raw = window.localStorage.getItem(DC_LOCAL_STATE_KEY);
    return raw ? JSON.parse(raw) : {};
  } catch (err) {
    return {};
  }
}

function writeLocalCanvasState(nextState) {
  try {
    window.localStorage.setItem(DC_LOCAL_STATE_KEY, JSON.stringify(nextState));
  } catch (err) {
    // Best effort only.
  }
}

function normalizePersistedCanvasState(value = {}) {
  return {
    sections: value.sections && typeof value.sections === 'object' && !Array.isArray(value.sections) ? value.sections : {},
    comments: Array.isArray(value.comments) ? value.comments : [],
    hiddenSlotIds: Array.isArray(value.hiddenSlotIds) ? value.hiddenSlotIds : [],
  };
}

function serializePersistedCanvasState(value) {
  return JSON.stringify(normalizePersistedCanvasState(value));
}

function hasPersistedCanvasContent(value) {
  const normalized = normalizePersistedCanvasState(value);
  return (
    Object.keys(normalized.sections).length > 0 ||
    normalized.comments.length > 0 ||
    normalized.hiddenSlotIds.length > 0
  );
}

function warnSharedCanvasStateIssue(message, detail) {
  if (dcSharedStateWarningShown || typeof console === 'undefined' || !console.warn) return;
  dcSharedStateWarningShown = true;
  console.warn(message, detail);
}

async function readSharedCanvasState() {
  try {
    const response = await fetch(DC_STATE_API_PATH, {
      cache: 'no-store',
      headers: { 'Cache-Control': 'no-store' },
    });
    if (response.ok) {
      return {
        source: 'api',
        state: normalizePersistedCanvasState(await response.json()),
      };
    }
    warnSharedCanvasStateIssue('Shared canvas state API is unavailable, so comments may remain local to this browser.', { status: response.status });
  } catch (err) {}

  try {
    const response = await fetch(`./${DC_STATE_FILE}?t=${Date.now()}`, {
      cache: 'no-store',
      headers: { 'Cache-Control': 'no-store' },
    });
    if (response.ok) {
      return {
        source: 'file',
        state: normalizePersistedCanvasState(await response.json()),
      };
    }
  } catch (err) {}

  return null;
}

async function writeSharedCanvasState(nextState) {
  const normalized = normalizePersistedCanvasState(nextState);

  try {
    const response = await fetch(DC_STATE_API_PATH, {
      method: 'POST',
      cache: 'no-store',
      headers: {
        'Cache-Control': 'no-store',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(normalized),
    });
    if (response.ok) return true;
    warnSharedCanvasStateIssue('Unable to persist shared canvas comments to the API, so comments may remain local to this browser.', { status: response.status });
  } catch (err) {}

  if (window.omelette?.writeFile) {
    try {
      await window.omelette.writeFile(DC_STATE_FILE, JSON.stringify(normalized, null, 2));
      return true;
    } catch (err) {}
  }

  return false;
}

function makeDcId(prefix = 'dc') {
  return `${prefix}-${Math.random().toString(36).slice(2, 8)}-${Date.now().toString(36)}`;
}

function isEditableTarget(node) {
  return !!(node instanceof Element && node.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""], .dc-comment-thread, .dc-comments-sidebar, .dc-comment-menu, .dc-sidebar-menu'));
}

function hasScrollableOverflow(el, axis) {
  const style = window.getComputedStyle(el);
  const value = axis === 'y' ? style.overflowY : style.overflowX;
  return value === 'auto' || value === 'scroll' || value === 'overlay';
}

function canScrollElement(el, deltaX, deltaY) {
  if (!(el instanceof Element)) return false;

  if (deltaY && hasScrollableOverflow(el, 'y') && el.scrollHeight > el.clientHeight + 1) {
    const maxTop = el.scrollHeight - el.clientHeight;
    if ((deltaY < 0 && el.scrollTop > 0) || (deltaY > 0 && el.scrollTop < maxTop - 1)) {
      return true;
    }
  }

  if (deltaX && hasScrollableOverflow(el, 'x') && el.scrollWidth > el.clientWidth + 1) {
    const maxLeft = el.scrollWidth - el.clientWidth;
    if ((deltaX < 0 && el.scrollLeft > 0) || (deltaX > 0 && el.scrollLeft < maxLeft - 1)) {
      return true;
    }
  }

  return false;
}

function findScrollableAncestor(node, stopAt, deltaX, deltaY) {
  let el = node instanceof Element ? node : null;
  while (el && el !== stopAt && el !== document.body) {
    if (canScrollElement(el, deltaX, deltaY)) return el;
    el = el.parentElement;
  }
  return null;
}

function formatCommentRelativeTime(value) {
  const time = new Date(value).getTime();
  if (!Number.isFinite(time)) return '';
  const delta = Math.max(0, Date.now() - time);
  const minutes = Math.max(1, Math.round(delta / 60000));
  if (minutes < 60) return `${minutes} min. ago`;
  const hours = Math.round(minutes / 60);
  if (hours < 24) return `${hours} ${hours === 1 ? 'hour' : 'hours'} ago`;
  const days = Math.round(hours / 24);
  if (days < 14) return `${days} ${days === 1 ? 'day' : 'days'} ago`;
  return new Date(value).toLocaleDateString([], { month: 'short', day: 'numeric' });
}

function getCommentUser(usersById, userId) {
  return usersById[userId] || DC_FALLBACK_USER;
}

function getThreadMessages(thread) {
  return Array.isArray(thread?.messages) ? thread.messages : [];
}

function getThreadOwner(usersById, thread) {
  return getCommentUser(usersById, getThreadMessages(thread)[0]?.userId);
}

function getThreadPreview(thread) {
  return getThreadMessages(thread)[0]?.body || 'No comment text yet.';
}

function getThreadParticipantUsers(thread, usersById) {
  const seen = new Set();
  return getThreadMessages(thread)
    .map((message) => getCommentUser(usersById, message.userId))
    .filter((user) => {
      if (seen.has(user.id)) return false;
      seen.add(user.id);
      return true;
    });
}

function isThreadUnreadFor(thread, userId) {
  return Array.isArray(thread?.unreadBy) && thread.unreadBy.includes(userId);
}

function isThreadRelevantToUser(thread, userId) {
  if (!userId) return false;
  return getThreadMessages(thread).some((message) => message.userId === userId);
}

// ─────────────────────────────────────────────────────────────
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
// Owns runtime state (per-section order, renamed titles/labels, focused
// artboard). Order/titles/labels and comments persist to a
// .design-canvas.state.json sidecar via the host when available, with a
// localStorage fallback for plain browser previews. Focus, selection, and
// active comment UI are ephemeral.
// ─────────────────────────────────────────────────────────────
function DesignCanvas({ children, minScale, maxScale, style, currentUser, commentUsers, onChangeCurrentUser }) {
  const initialPersisted = normalizePersistedCanvasState(readLocalCanvasState());
  const [state, setState] = React.useState(() => ({
    sections: initialPersisted.sections,
    comments: initialPersisted.comments,
    hiddenSlotIds: initialPersisted.hiddenSlotIds,
    focus: null,
    selectedSlotId: null,
  }));
  const [ready, setReady] = React.useState(false);
  const [commentMode, setCommentMode] = React.useState(false);
  const [commentsVisible, setCommentsVisible] = React.useState(true);
  const [activeThreadId, setActiveThreadId] = React.useState(null);
  const [draftThread, setDraftThread] = React.useState(null);
  const didRead = React.useRef(false);
  const skipNextWrite = React.useRef(false);
  const persistedSnapshot = React.useRef(serializePersistedCanvasState(initialPersisted));
  // Tracks how many local persists are queued or in flight. While > 0, polls
  // skip applying remote state so an in-flight write can't be undone by a
  // simultaneous poll that read the pre-write blob.
  const pendingPersist = React.useRef(0);

  const resolvedUsers = React.useMemo(() => (
    Array.isArray(commentUsers) && commentUsers.length > 0 ? commentUsers : [currentUser || DC_FALLBACK_USER]
  ), [commentUsers, currentUser]);
  const usersById = React.useMemo(
    () => Object.fromEntries(resolvedUsers.map((user) => [user.id, user])),
    [resolvedUsers]
  );
  const activeUser = currentUser && usersById[currentUser.id]
    ? usersById[currentUser.id]
    : currentUser || resolvedUsers[0] || DC_FALLBACK_USER;

  React.useEffect(() => {
    let off = false;
    readSharedCanvasState()
      .then(async (shared) => {
        if (off) return;

        if (shared) {
          const sharedState = normalizePersistedCanvasState(shared.state);

          if (hasPersistedCanvasContent(sharedState)) {
            persistedSnapshot.current = serializePersistedCanvasState(sharedState);
            skipNextWrite.current = true;
            setState((s) => ({
              ...s,
              sections: sharedState.sections,
              comments: sharedState.comments,
              hiddenSlotIds: sharedState.hiddenSlotIds,
            }));
            return;
          }
        }

        if (hasPersistedCanvasContent(initialPersisted)) {
          persistedSnapshot.current = serializePersistedCanvasState(initialPersisted);
          await writeSharedCanvasState(initialPersisted);
        }
      })
      .catch(() => {})
      .finally(() => { didRead.current = true; if (!off) setReady(true); });
    const t = setTimeout(() => { if (!off) setReady(true); }, 150);
    return () => { off = true; clearTimeout(t); };
  }, []);

  React.useEffect(() => {
    if (!didRead.current) return;
    if (skipNextWrite.current) { skipNextWrite.current = false; return; }
    pendingPersist.current += 1;
    let settled = false;
    const settle = () => {
      if (settled) return;
      settled = true;
      pendingPersist.current = Math.max(0, pendingPersist.current - 1);
    };
    const t = setTimeout(async () => {
      if (settled) return;
      const persisted = normalizePersistedCanvasState({
        sections: state.sections,
        comments: state.comments,
        hiddenSlotIds: state.hiddenSlotIds,
      });
      const nextSnapshot = serializePersistedCanvasState(persisted);
      writeLocalCanvasState(persisted);
      try {
        // Defer updating persistedSnapshot until the blob actually has this
        // state. While the write is in flight, pendingPersist > 0 keeps polls
        // from comparing a NEW snapshot against the still-OLD blob and
        // reverting local state (which is the disappearing-comment bug).
        const ok = await writeSharedCanvasState(persisted);
        if (settled) return;
        if (ok) persistedSnapshot.current = nextSnapshot;
      } finally {
        settle();
      }
    }, 250);
    return () => { settle(); clearTimeout(t); };
  }, [state.sections, state.comments, state.hiddenSlotIds]);

  React.useEffect(() => {
    if (!ready) return;

    let disposed = false;

    const syncFromShared = async () => {
      // If a local write is queued or in flight, the blob hasn't caught up
      // yet — applying remote state now would clobber the user's just-typed
      // comment. Wait for the next tick.
      if (pendingPersist.current > 0) return;
      const shared = await readSharedCanvasState();
      if (disposed || !shared) return;
      // Re-check after the await — a write may have been queued during the
      // network round trip.
      if (pendingPersist.current > 0) return;

      const normalized = normalizePersistedCanvasState(shared.state);
      const nextSnapshot = serializePersistedCanvasState(normalized);

      if (nextSnapshot === persistedSnapshot.current) return;

      persistedSnapshot.current = nextSnapshot;
      skipNextWrite.current = true;
      setState((s) => ({
        ...s,
        sections: normalized.sections,
        comments: normalized.comments,
        hiddenSlotIds: normalized.hiddenSlotIds,
      }));
    };

    syncFromShared().catch(() => {});
    const intervalId = window.setInterval(() => {
      syncFromShared().catch(() => {});
    }, DC_POLL_INTERVAL_MS);

    return () => {
      disposed = true;
      window.clearInterval(intervalId);
    };
  }, [ready]);

  const registry = {};
  const sectionMeta = {};
  const sectionOrder = [];
  const hiddenSlotSet = new Set(state.hiddenSlotIds || []);
  React.Children.forEach(children, (sec) => {
    if (!sec || sec.type !== DCSection) return;
    const sid = sec.props.id ?? sec.props.title;
    if (!sid) return;
    const persisted = state.sections[sid] || {};
    const srcIds = [];
    React.Children.forEach(sec.props.children, (ab) => {
      if (!ab || ab.type !== DCArtboard) return;
      const aid = ab.props.id ?? ab.props.label;
      if (!aid) return;
      const slotId = `${sid}/${aid}`;
      if (hiddenSlotSet.has(slotId)) return;
      registry[slotId] = {
        slotId,
        sectionId: sid,
        artboard: ab,
        label: (persisted.labels || {})[aid] ?? ab.props.label,
        sectionTitle: persisted.title ?? sec.props.title,
      };
      srcIds.push(aid);
    });
    if (srcIds.length === 0) return;
    sectionOrder.push(sid);
    const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
    sectionMeta[sid] = {
      title: persisted.title ?? sec.props.title,
      subtitle: sec.props.subtitle,
      slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
    };
  });

  const closeActiveThread = React.useCallback(() => {
    setActiveThreadId(null);
  }, []);

  const hideSlot = React.useCallback((slotId) => {
    const entry = registry[slotId];
    if (!entry) return;
    const aid = entry.artboard.props.id ?? entry.artboard.props.label;
    const activeThread = state.comments.find((thread) => thread.id === activeThreadId);

    setState((s) => ({
      ...s,
      hiddenSlotIds: s.hiddenSlotIds.includes(slotId) ? s.hiddenSlotIds : [...s.hiddenSlotIds, slotId],
      sections: {
        ...s.sections,
        [entry.sectionId]: {
          ...(s.sections[entry.sectionId] || {}),
          order: ((s.sections[entry.sectionId] || {}).order || []).filter((id) => id !== aid),
        },
      },
      focus: s.focus === slotId ? null : s.focus,
      selectedSlotId: s.selectedSlotId === slotId ? null : s.selectedSlotId,
    }));

    if (draftThread?.slotId === slotId) {
      setDraftThread(null);
      setCommentMode(false);
    }

    if (activeThread?.slotId === slotId) {
      setActiveThreadId(null);
    }
  }, [activeThreadId, draftThread, registry, state.comments]);

  const cancelDraftThread = React.useCallback(() => {
    setDraftThread(null);
    setActiveThreadId(null);
    setCommentMode(false);
  }, []);

  const toggleCommentMode = React.useCallback(() => {
    setActiveThreadId(null);
    setDraftThread(null);
    setCommentMode((open) => !open);
  }, []);

  const toggleCommentsVisible = React.useCallback(() => {
    setCommentsVisible((visible) => !visible);
    setActiveThreadId(null);
    setDraftThread(null);
  }, []);

  const startDraftThread = React.useCallback(({ point, frame }) => {
    const fallbackFrame = state.selectedSlotId ? registry[state.selectedSlotId] : null;
    const activeFrame = frame?.slotId
      ? frame
      : fallbackFrame
        ? {
            slotId: fallbackFrame.slotId,
            sectionId: fallbackFrame.sectionId,
            artboardId: fallbackFrame.artboard.props.id ?? fallbackFrame.artboard.props.label,
            label: fallbackFrame.label,
          }
        : null;
    const registryEntry = activeFrame?.slotId ? registry[activeFrame.slotId] : null;
    const nextDraft = {
      id: makeDcId('thread'),
      x: point.x,
      y: point.y,
      slotId: activeFrame?.slotId || null,
      sectionId: activeFrame?.sectionId || registryEntry?.sectionId || null,
      artboardId: activeFrame?.artboardId || registryEntry?.artboard.props.id || null,
      frameLabel: activeFrame?.label || registryEntry?.label || null,
      sectionTitle: registryEntry?.sectionTitle || null,
      localX: activeFrame?.localX ?? null,
      localY: activeFrame?.localY ?? null,
    };
    setDraftThread(nextDraft);
    setActiveThreadId(nextDraft.id);
    setCommentMode(false);
    if (nextDraft.slotId) {
      setState((s) => ({ ...s, selectedSlotId: nextDraft.slotId }));
    }
  }, [registry, state.selectedSlotId]);

  const createThread = React.useCallback((body) => {
    const trimmed = body.trim();
    if (!trimmed || !draftThread) return;
    const now = new Date().toISOString();
    const nextThread = {
      ...draftThread,
      createdAt: now,
      updatedAt: now,
      resolvedAt: null,
      unreadBy: resolvedUsers.filter((user) => user.id !== activeUser.id).map((user) => user.id),
      messages: [
        {
          id: makeDcId('message'),
          userId: activeUser.id,
          body: trimmed,
          createdAt: now,
        }
      ]
    };
    setState((s) => ({
      ...s,
      comments: [...s.comments, nextThread],
      selectedSlotId: nextThread.slotId || s.selectedSlotId,
    }));
    setDraftThread(null);
    setActiveThreadId(nextThread.id);
  }, [activeUser.id, draftThread, resolvedUsers]);

  const replyToThread = React.useCallback((threadId, body) => {
    const trimmed = body.trim();
    if (!trimmed) return;
    const now = new Date().toISOString();
    const nextUnreadBy = resolvedUsers.filter((user) => user.id !== activeUser.id).map((user) => user.id);
    setState((s) => ({
      ...s,
      comments: s.comments.map((thread) => {
        if (thread.id !== threadId) return thread;
        return {
          ...thread,
          updatedAt: now,
          unreadBy: Array.from(new Set([...(thread.unreadBy || []), ...nextUnreadBy])),
          messages: [
            ...(thread.messages || []),
            {
              id: makeDcId('message'),
              userId: activeUser.id,
              body: trimmed,
              createdAt: now,
            }
          ],
        };
      }),
    }));
  }, [activeUser.id, resolvedUsers]);

  const deleteThread = React.useCallback((threadId) => {
    setState((s) => ({
      ...s,
      comments: s.comments.filter((thread) => thread.id !== threadId),
      selectedSlotId: s.selectedSlotId,
    }));
    setActiveThreadId((openId) => openId === threadId ? null : openId);
  }, []);

  const resolveThread = React.useCallback((threadId, resolved = true) => {
    const now = new Date().toISOString();
    setState((s) => ({
      ...s,
      comments: s.comments.map((thread) => {
        if (thread.id !== threadId) return thread;
        if (!resolved) {
          const nextThread = { ...thread, updatedAt: now };
          delete nextThread.resolvedAt;
          delete nextThread.resolvedBy;
          return nextThread;
        }
        return {
          ...thread,
          updatedAt: now,
          resolvedAt: now,
          resolvedBy: activeUser.id,
        };
      }),
    }));
    if (resolved) setActiveThreadId((openId) => openId === threadId ? null : openId);
  }, [activeUser.id]);

  const markThreadUnread = React.useCallback((threadId) => {
    setState((s) => ({
      ...s,
      comments: s.comments.map((thread) => {
        if (thread.id !== threadId) return thread;
        return {
          ...thread,
          unreadBy: Array.from(new Set([...(thread.unreadBy || []), activeUser.id])),
        };
      }),
    }));
  }, [activeUser.id]);

  const toggleReaction = React.useCallback((threadId, messageId, emoji = '+1') => {
    setState((s) => ({
      ...s,
      comments: s.comments.map((thread) => {
        if (thread.id !== threadId) return thread;
        return {
          ...thread,
          messages: getThreadMessages(thread).map((message) => {
            if (message.id !== messageId) return message;
            const reactions = { ...(message.reactions || {}) };
            const users = Array.isArray(reactions[emoji]) ? reactions[emoji] : [];
            reactions[emoji] = users.includes(activeUser.id)
              ? users.filter((id) => id !== activeUser.id)
              : [...users, activeUser.id];
            if (reactions[emoji].length === 0) delete reactions[emoji];
            return { ...message, reactions };
          }),
        };
      }),
    }));
  }, [activeUser.id]);

  const deleteMessage = React.useCallback((threadId, messageId) => {
    setState((s) => {
      const thread = s.comments.find((item) => item.id === threadId);
      const messages = getThreadMessages(thread);
      if (!thread || messages[0]?.id === messageId || messages.length <= 1) {
        return {
          ...s,
          comments: s.comments.filter((item) => item.id !== threadId),
        };
      }
      return {
        ...s,
        comments: s.comments.map((item) => item.id === threadId
          ? { ...item, messages: getThreadMessages(item).filter((message) => message.id !== messageId) }
          : item),
      };
    });
    setActiveThreadId((openId) => openId === threadId ? null : openId);
  }, []);

  const openThread = React.useCallback((threadId, options = {}) => {
    if (!options.keepCommentMode) setCommentMode(false);
    setDraftThread(null);
    setActiveThreadId((openId) => (options.forceOpen ? threadId : openId === threadId ? null : threadId));
    const thread = state.comments.find((item) => item.id === threadId);
    if (thread?.slotId) {
      setState((s) => ({
        ...s,
        selectedSlotId: thread.slotId,
        comments: s.comments.map((item) => item.id === threadId
          ? { ...item, unreadBy: (item.unreadBy || []).filter((id) => id !== activeUser.id) }
          : item),
      }));
    }
  }, [activeUser.id, state.comments]);

  const api = React.useMemo(() => ({
    state,
    commentMode,
    activeThreadId,
    draftThread,
    activeUser,
    section: (id) => state.sections[id] || {},
    patchSection: (id, p) => setState((s) => ({
      ...s,
      sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
    })),
    setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
    setSelection: (slotId) => setState((s) => ({ ...s, selectedSlotId: slotId })),
    hideSlot,
  }), [activeThreadId, activeUser, commentMode, draftThread, hideSlot, state]);

  React.useEffect(() => {
    const onKey = (e) => {
      const key = e.key.toLowerCase();
      const ae = document.activeElement;
      const isCanvasOwnedEditable = !!(ae instanceof Element && ae.closest('.dc-comment-thread, .dc-comments-sidebar, .dc-editable'));
      if (e.key === 'Escape') {
        if (draftThread) { e.preventDefault(); cancelDraftThread(); return; }
        if (activeThreadId) { e.preventDefault(); closeActiveThread(); return; }
        if (commentMode) { e.preventDefault(); setCommentMode(false); return; }
        if (state.focus) { e.preventDefault(); api.setFocus(null); return; }
        if (state.selectedSlotId) { e.preventDefault(); api.setSelection(null); }
        return;
      }
      if (key === 'c' && e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey && !state.focus && !isCanvasOwnedEditable) {
        e.preventDefault();
        e.stopPropagation();
        toggleCommentsVisible();
        return;
      }
      if (key === 'c' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey && !state.focus && !isCanvasOwnedEditable) {
        e.preventDefault();
        e.stopPropagation();
        if (ae && ae instanceof HTMLElement && ae !== document.body) ae.blur();
        toggleCommentMode();
      }
    };
    const onPd = (e) => {
      const ae = document.activeElement;
      if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
    };
    document.addEventListener('keydown', onKey, true);
    document.addEventListener('pointerdown', onPd, true);
    return () => {
      document.removeEventListener('keydown', onKey, true);
      document.removeEventListener('pointerdown', onPd, true);
    };
  }, [activeThreadId, api, cancelDraftThread, closeActiveThread, commentMode, draftThread, state.focus, state.selectedSlotId, toggleCommentMode, toggleCommentsVisible]);

  const registryKeys = React.useMemo(() => Object.keys(registry), [registry]);
  React.useEffect(() => {
    const available = new Set(registryKeys);
    if (state.focus && !available.has(state.focus)) {
      setState((s) => ({ ...s, focus: null }));
      return;
    }
    if (state.selectedSlotId && !available.has(state.selectedSlotId)) {
      setState((s) => ({ ...s, selectedSlotId: null }));
    }
  }, [registryKeys, state.focus, state.selectedSlotId]);

  return (
    <DCCtx.Provider value={api}>
      <DCViewport
        minScale={minScale}
        maxScale={maxScale}
        style={style}
        commentMode={commentMode}
        onCanvasClick={startDraftThread}
        onCanvasBackgroundClick={() => {
          closeActiveThread();
          setState((s) => ({ ...s, selectedSlotId: null }));
        }}
        overlay={(
          <>
            <DCCommentsHud
              currentUser={activeUser}
              commentUsers={resolvedUsers}
              onChangeCurrentUser={onChangeCurrentUser}
              commentMode={commentMode}
              commentsVisible={commentsVisible}
              commentCount={state.comments.filter((thread) => !thread.resolvedAt).length}
              onToggleCommentMode={toggleCommentMode}
              onToggleCommentsVisible={toggleCommentsVisible}
            />
            {commentMode && (
              <DCCommentsSidebar
                comments={state.comments}
                activeThreadId={activeThreadId}
                currentUser={activeUser}
                usersById={usersById}
                onOpenThread={openThread}
                onResolveThread={resolveThread}
                onDeleteThread={deleteThread}
              />
            )}
          </>
        )}
      >
        <>
          {ready && children}
          <DCCommentLayer
            comments={state.comments}
            hiddenSlotIds={state.hiddenSlotIds}
            draftThread={draftThread}
            activeThreadId={activeThreadId}
            currentUser={activeUser}
            usersById={usersById}
            commentsVisible={commentsVisible}
            commentMode={commentMode}
            onOpenThread={openThread}
            onCloseThread={closeActiveThread}
            onCancelDraft={cancelDraftThread}
            onDeleteThread={deleteThread}
            onResolveThread={resolveThread}
            onMarkThreadUnread={markThreadUnread}
            onToggleReaction={toggleReaction}
            onDeleteMessage={deleteMessage}
            onCreateThread={createThread}
            onReplyToThread={replyToThread}
          />
        </>
      </DCViewport>
      {state.focus && registry[state.focus] && (
        <DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
      )}
    </DCCtx.Provider>
  );
}

// ─────────────────────────────────────────────────────────────
// DCViewport — transform-based pan/zoom (internal)
//
// Input mapping (Figma-style):
//   • trackpad pinch  → zoom   (ctrlKey wheel; Safari gesture* events)
//   • trackpad scroll → pan    (two-finger)
//   • mouse wheel     → zoom   (notched; distinguished from trackpad scroll)
//   • middle-drag / primary-drag-on-bg → pan
//
// Transform state lives in a ref and is written straight to the DOM
// (translate3d + will-change) so wheel ticks don't go through React —
// keeps pans at 60fps on dense canvases.
// ─────────────────────────────────────────────────────────────
function DCViewport({ children, overlay, minScale = 0.1, maxScale = 8, style = {}, commentMode = false, onCanvasClick, onCanvasBackgroundClick }) {
  const vpRef = React.useRef(null);
  const worldRef = React.useRef(null);
  const tf = React.useRef({ x: 0, y: 0, scale: 1 });
  const suppressClickRef = React.useRef(false);
  const lastPointerRef = React.useRef(null);
  const gestureAnchorRef = React.useRef(null);

  const apply = React.useCallback(() => {
    const { x, y, scale } = tf.current;
    const el = worldRef.current;
    if (el) el.style.transform = `matrix(${scale}, 0, 0, ${scale}, ${x}, ${y})`;
  }, []);

  const toWorldPoint = React.useCallback((clientX, clientY) => {
    const vp = vpRef.current;
    if (!vp) return { x: 0, y: 0 };
    const rect = vp.getBoundingClientRect();
    const { x, y, scale } = tf.current;
    return {
      x: (clientX - rect.left - x) / scale,
      y: (clientY - rect.top - y) / scale,
    };
  }, []);

  React.useEffect(() => {
    const vp = vpRef.current;
    if (!vp) return;

    const getViewportRect = () => vp.getBoundingClientRect();

    const getViewportCenter = () => {
      const rect = getViewportRect();
      return {
        x: rect.left + rect.width / 2,
        y: rect.top + rect.height / 2,
      };
    };

    const resolveZoomAnchor = (clientX, clientY) => {
      const rect = getViewportRect();
      const isInsideViewport =
        Number.isFinite(clientX) &&
        Number.isFinite(clientY) &&
        clientX >= rect.left &&
        clientX <= rect.right &&
        clientY >= rect.top &&
        clientY <= rect.bottom;

      if (isInsideViewport) return { x: clientX, y: clientY };

      const lastPointer = lastPointerRef.current;
      const isInsideLastPointer =
        lastPointer &&
        lastPointer.x >= rect.left &&
        lastPointer.x <= rect.right &&
        lastPointer.y >= rect.top &&
        lastPointer.y <= rect.bottom;

      if (isInsideLastPointer) return lastPointer;
      return getViewportCenter();
    };

    const clampScale = (nextScale) => Math.min(maxScale, Math.max(minScale, nextScale));
    const clampZoomFactor = (factor) => Math.min(1.28, Math.max(0.78, factor));

    const setScaleAt = (cx, cy, nextScale) => {
      const anchor = resolveZoomAnchor(cx, cy);
      const r = getViewportRect();
      const px = anchor.x - r.left;
      const py = anchor.y - r.top;
      const t = tf.current;
      const next = clampScale(nextScale);
      if (next === t.scale) return;
      const worldX = (px - t.x) / t.scale;
      const worldY = (py - t.y) / t.scale;
      t.scale = next;
      // Keep the same world coordinate under the zoom anchor after scaling.
      t.x = px - worldX * next;
      t.y = py - worldY * next;
      apply();
    };

    const zoomAt = (cx, cy, factor) => {
      const t = tf.current;
      setScaleAt(cx, cy, t.scale * factor);
    };

    // Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
    // line-mode deltas (Firefox) or large integer pixel deltas with no X
    // component (Chrome/Safari, typically multiples of 100/120). Trackpad
    // two-finger scroll sends small/fractional pixel deltas, often with
    // non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
    const WHEEL_ZOOM_STEP = 0.12;
    const PINCH_ZOOM_SENSITIVITY = 0.0042;
    const GESTURE_ZOOM_LERP = 0.42;

    const isMouseWheel = (e) =>
      e.deltaMode !== 0 ||
      (e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);

    const onWheel = (e) => {
      lastPointerRef.current = { x: e.clientX, y: e.clientY };
      if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
      if (e.ctrlKey) {
        e.preventDefault();
        // trackpad pinch (or explicit ctrl+wheel)
        zoomAt(e.clientX, e.clientY, clampZoomFactor(Math.exp(-e.deltaY * PINCH_ZOOM_SENSITIVITY)));
        return;
      }

      const uiChromeTarget = e.target instanceof Element && e.target.closest('.dc-comment-thread, .dc-comments-hud, .dc-comments-sidebar, .dc-comment-menu, .dc-sidebar-menu, .dc-draft-composer, .dc-labelrow, .dc-grip, .dc-delete, .dc-expand');
      if (uiChromeTarget) return;

      const scrollable = findScrollableAncestor(e.target, vp, e.deltaX, e.deltaY);
      if (scrollable) {
        return;
      }

      e.preventDefault();
      if (isMouseWheel(e)) {
        // notched mouse wheel — fixed-ratio step per click
        zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * WHEEL_ZOOM_STEP));
      } else {
        // trackpad two-finger scroll — pan
        tf.current.x -= e.deltaX;
        tf.current.y -= e.deltaY;
        apply();
      }
    };

    // Safari sends native gesture* events for trackpad pinch with a smooth
    // e.scale; preferring these over the ctrl+wheel fallback gives a much
    // better feel there. No-ops on other browsers. Safari also fires
    // ctrlKey wheel events during the same pinch — isGesturing makes
    // onWheel drop those entirely so they neither zoom nor pan.
    let gsBase = 1;
    let isGesturing = false;
    const onGestureStart = (e) => {
      e.preventDefault();
      isGesturing = true;
      gsBase = tf.current.scale;
      gestureAnchorRef.current = lastPointerRef.current || getViewportCenter();
    };
    const onGestureChange = (e) => {
      e.preventDefault();
      const anchor = gestureAnchorRef.current || lastPointerRef.current || getViewportCenter();
      const targetScale = clampScale(gsBase * e.scale);
      const nextScale = tf.current.scale + (targetScale - tf.current.scale) * GESTURE_ZOOM_LERP;
      setScaleAt(anchor.x, anchor.y, nextScale);
    };
    const onGestureEnd = (e) => {
      e.preventDefault();
      isGesturing = false;
      gestureAnchorRef.current = null;
    };

    // Drag-pan: middle button anywhere, or primary button on canvas
    // background (anything that isn't an artboard or an inline editor).
    let drag = null;
    const onPointerDown = (e) => {
      lastPointerRef.current = { x: e.clientX, y: e.clientY };
      const onBg = !e.target.closest('[data-dc-slot], .dc-editable, .dc-comment-thread, .dc-comments-hud, .dc-comments-sidebar, .dc-comment-menu, .dc-sidebar-menu, .dc-draft-composer, .dc-comment-pin');
      if (!(e.button === 1 || (e.button === 0 && onBg))) return;
      if (commentMode && e.button === 0) return;
      e.preventDefault();
      vp.setPointerCapture(e.pointerId);
      drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY, moved: false };
      vp.style.cursor = 'grabbing';
    };
    const onPointerMove = (e) => {
      lastPointerRef.current = { x: e.clientX, y: e.clientY };
      if (!drag || e.pointerId !== drag.id) return;
      if (Math.abs(e.clientX - drag.lx) > 1 || Math.abs(e.clientY - drag.ly) > 1) drag.moved = true;
      tf.current.x += e.clientX - drag.lx;
      tf.current.y += e.clientY - drag.ly;
      drag.lx = e.clientX; drag.ly = e.clientY;
      apply();
    };
    const onPointerUp = (e) => {
      if (!drag || e.pointerId !== drag.id) return;
      vp.releasePointerCapture(e.pointerId);
      suppressClickRef.current = !!drag.moved;
      drag = null;
      vp.style.cursor = commentMode ? DC_COMMENT_CURSOR : '';
    };

    vp.addEventListener('wheel', onWheel, { passive: false });
    vp.addEventListener('gesturestart', onGestureStart, { passive: false });
    vp.addEventListener('gesturechange', onGestureChange, { passive: false });
    vp.addEventListener('gestureend', onGestureEnd, { passive: false });
    vp.addEventListener('pointerdown', onPointerDown);
    vp.addEventListener('pointermove', onPointerMove);
    vp.addEventListener('pointerup', onPointerUp);
    vp.addEventListener('pointercancel', onPointerUp);
    return () => {
      vp.removeEventListener('wheel', onWheel);
      vp.removeEventListener('gesturestart', onGestureStart);
      vp.removeEventListener('gesturechange', onGestureChange);
      vp.removeEventListener('gestureend', onGestureEnd);
      vp.removeEventListener('pointerdown', onPointerDown);
      vp.removeEventListener('pointermove', onPointerMove);
      vp.removeEventListener('pointerup', onPointerUp);
      vp.removeEventListener('pointercancel', onPointerUp);
    };
  }, [apply, commentMode, minScale, maxScale]);

  const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
  return (
    <div
      ref={vpRef}
      className="design-canvas"
      data-dc-comment-mode={commentMode ? 'true' : 'false'}
      onClick={(e) => {
        if (suppressClickRef.current) {
          suppressClickRef.current = false;
          return;
        }
        if (isEditableTarget(e.target)) return;
        if (e.target.closest('.dc-comments-hud, .dc-comments-sidebar, .dc-comment-thread, .dc-comment-menu, .dc-sidebar-menu, .dc-draft-composer, .dc-comment-pin, .dc-labelrow, .dc-grip, .dc-delete, .dc-expand')) return;
        const point = toWorldPoint(e.clientX, e.clientY);
        const slotEl = e.target.closest('[data-dc-slot]');
        const cardEl = e.target.closest('[data-dc-card]');
        let frame = null;
        if (slotEl && cardEl) {
          const scale = tf.current.scale || 1;
          const cardRect = cardEl.getBoundingClientRect();
          frame = {
            slotId: slotEl.dataset.dcSlotId || null,
            sectionId: slotEl.dataset.dcSectionId || null,
            artboardId: slotEl.dataset.dcArtboardId || null,
            label: slotEl.dataset.dcLabel || null,
            localX: (e.clientX - cardRect.left) / scale,
            localY: (e.clientY - cardRect.top) / scale,
          };
        }
        if (commentMode) {
          onCanvasClick && onCanvasClick({ point, frame });
          return;
        }
        if (!slotEl) onCanvasBackgroundClick && onCanvasBackgroundClick();
      }}
      style={{
        height: '100vh', width: '100vw',
        background: DC.bg,
        cursor: commentMode ? DC_COMMENT_CURSOR : undefined,
        overflow: 'hidden',
        overscrollBehavior: 'none',
        touchAction: 'none',
        position: 'relative',
        fontFamily: DC.font,
        boxSizing: 'border-box',
        ...style,
      }}
    >
      <div
        ref={worldRef}
        style={{
          position: 'absolute', top: 0, left: 0,
          transformOrigin: '0 0',
          willChange: 'transform',
          width: 'max-content', minWidth: '100%',
          minHeight: '100%',
          padding: '60px 0 80px',
        }}
      >
        <div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
        {children}
      </div>
      {overlay}
    </div>
  );
}

function DCCommentsHud({ currentUser, commentMode, commentsVisible, commentCount, onToggleCommentMode, onToggleCommentsVisible }) {
  return (
    <div className="dc-comments-hud" onClick={(e) => e.stopPropagation()}>
      <div className="dc-comments-meta">
        <span>Commenting as</span>
      </div>
      <div className="dc-comments-user">{currentUser.name}</div>
      <button className="dc-comment-toggle" data-active={commentMode ? 'true' : 'false'} type="button" onClick={onToggleCommentMode}>
        {commentMode ? 'Exit comments' : 'Comment'}
      </button>
      <button className="dc-comment-eye" data-visible={commentsVisible ? 'true' : 'false'} type="button" onClick={onToggleCommentsVisible}>
        {commentsVisible ? 'Hide' : 'Show'}
      </button>
      <div className="dc-comments-status">{commentCount} {commentCount === 1 ? 'open thread' : 'open threads'}</div>
    </div>
  );
}

function DCSearchGlyph() {
  return (
    <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
      <circle cx="8.5" cy="8.5" r="5.5" />
      <path d="m13 13 4 4" />
    </svg>
  );
}

function DCFilterGlyph() {
  return (
    <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M4 5h12" />
      <path d="M6 10h8" />
      <path d="M8 15h4" />
    </svg>
  );
}

function DCCheckGlyph() {
  return (
    <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="m4.5 10.5 3.2 3.2 7.8-8.2" />
    </svg>
  );
}

function DCCloseGlyph() {
  return (
    <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
      <path d="M5 5l10 10M15 5 5 15" />
    </svg>
  );
}

function DCSendGlyph() {
  return (
    <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M10 16V4" />
      <path d="M5.5 8.5 10 4l4.5 4.5" />
    </svg>
  );
}

function getClusterItems(threads, expandedClusterId, activeThreadId) {
  const groups = [];
  threads.forEach((thread) => {
    if (thread.id === activeThreadId) {
      groups.push({ x: thread.x, y: thread.y, threads: [thread], forceSingle: true });
      return;
    }
    const match = groups.find((group) => !group.forceSingle && Math.hypot(group.x - thread.x, group.y - thread.y) <= 78);
    if (!match) {
      groups.push({ x: thread.x, y: thread.y, threads: [thread] });
      return;
    }
    match.threads.push(thread);
    match.x = match.threads.reduce((sum, item) => sum + item.x, 0) / match.threads.length;
    match.y = match.threads.reduce((sum, item) => sum + item.y, 0) / match.threads.length;
  });

  return groups.flatMap((group) => {
    const id = group.threads.map((thread) => thread.id).sort().join('|');
    if (group.forceSingle || group.threads.length === 1 || id === expandedClusterId) {
      return group.threads.map((thread) => ({ type: 'thread', thread }));
    }
    return [{ type: 'cluster', id, x: group.x, y: group.y, threads: group.threads }];
  });
}

function DCCommentsSidebar({ comments, activeThreadId, currentUser, usersById, onOpenThread, onResolveThread, onDeleteThread }) {
  const [query, setQuery] = React.useState('');
  const [sortBy, setSortBy] = React.useState('date');
  const [showResolved, setShowResolved] = React.useState(false);
  const [onlyMine, setOnlyMine] = React.useState(false);
  const [menuOpen, setMenuOpen] = React.useState(false);
  const threadNumbers = React.useMemo(() => {
    const ordered = comments.slice().sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
    return new Map(ordered.map((thread, index) => [thread.id, index + 1]));
  }, [comments]);

  const visibleThreads = React.useMemo(() => {
    const normalizedQuery = query.trim().toLowerCase();
    return comments
      .filter((thread) => showResolved || !thread.resolvedAt)
      .filter((thread) => !onlyMine || isThreadRelevantToUser(thread, currentUser.id))
      .filter((thread) => {
        if (!normalizedQuery) return true;
        const haystack = [
          thread.frameLabel,
          thread.sectionTitle,
          getThreadOwner(usersById, thread).name,
          ...getThreadMessages(thread).map((message) => message.body),
        ].filter(Boolean).join(' ').toLowerCase();
        return haystack.includes(normalizedQuery);
      })
      .sort((a, b) => {
        const aUnread = isThreadUnreadFor(a, currentUser.id) ? 1 : 0;
        const bUnread = isThreadUnreadFor(b, currentUser.id) ? 1 : 0;
        if (sortBy === 'unread' && aUnread !== bUnread) return bUnread - aUnread;
        return new Date(b.updatedAt || b.createdAt || 0) - new Date(a.updatedAt || a.createdAt || 0);
      });
  }, [comments, currentUser.id, onlyMine, query, showResolved, sortBy, usersById]);
  const hasComments = comments.length > 0;

  return (
    <aside className="dc-comments-sidebar" onClick={(e) => e.stopPropagation()}>
      <div className="dc-comments-sidebar-top">
        <div className="dc-comments-sidebar-avatar" style={{ background: currentUser.avatar }}>{currentUser.initials}</div>
        <div className="dc-comments-sidebar-title">Comment</div>
        <div className="dc-comments-sidebar-zoom">100%</div>
      </div>
      {hasComments && (
        <div className="dc-comments-sidebar-tools">
          <label className="dc-comments-search">
            <DCSearchGlyph />
            <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search comments" />
          </label>
          <button className="dc-sidebar-tool-btn" data-active={menuOpen ? 'true' : 'false'} type="button" onClick={() => setMenuOpen((open) => !open)} aria-label="Sort and filter comments">
            <DCFilterGlyph />
          </button>
          {menuOpen && (
            <div className="dc-sidebar-menu">
              <button type="button" data-active={sortBy === 'date' ? 'true' : 'false'} onClick={() => setSortBy('date')}>Sort by date</button>
              <button type="button" data-active={sortBy === 'unread' ? 'true' : 'false'} onClick={() => setSortBy('unread')}>Sort by unread</button>
              <button type="button" data-active={showResolved ? 'true' : 'false'} onClick={() => setShowResolved((value) => !value)}>Show resolved comments</button>
              <button type="button" data-active={onlyMine ? 'true' : 'false'} onClick={() => setOnlyMine((value) => !value)}>Only your threads</button>
            </div>
          )}
        </div>
      )}
      <div className="dc-comments-sidebar-list">
        {!hasComments && (
          <div className="dc-sidebar-empty">
            <div className="dc-sidebar-empty-icon">+</div>
            <div className="dc-sidebar-empty-title">No comments yet</div>
            <p className="dc-sidebar-empty-copy">Click anywhere on the canvas to place the first comment. It will appear here for the whole team.</p>
            <div className="dc-sidebar-empty-hint">Comments are pinned to the canvas</div>
          </div>
        )}
        {hasComments && visibleThreads.length === 0 && (
          <div className="dc-sidebar-empty">
            <div className="dc-sidebar-empty-icon">?</div>
            <div className="dc-sidebar-empty-title">No matching comments</div>
            <p className="dc-sidebar-empty-copy">Try a different search, show resolved comments, or turn off "Only your threads."</p>
          </div>
        )}
        {visibleThreads.map((thread) => {
          const owner = getThreadOwner(usersById, thread);
          const messages = getThreadMessages(thread);
          const participants = getThreadParticipantUsers(thread, usersById);
          const active = activeThreadId === thread.id;
          const unread = isThreadUnreadFor(thread, currentUser.id);
          const replies = Math.max(0, messages.length - 1);
          return (
            <div
              key={thread.id}
              className="dc-sidebar-thread"
              role="button"
              tabIndex={0}
              data-active={active ? 'true' : 'false'}
              data-resolved={thread.resolvedAt ? 'true' : 'false'}
              onClick={() => onOpenThread && onOpenThread(thread.id, { keepCommentMode: true, forceOpen: true })}
              onKeyDown={(e) => {
                if (e.key === 'Enter' || e.key === ' ') {
                  e.preventDefault();
                  onOpenThread && onOpenThread(thread.id, { keepCommentMode: true, forceOpen: true });
                }
              }}
            >
              <div className="dc-sidebar-avatars">
                {participants.slice(0, 3).map((user) => (
                  <span key={user.id} style={{ background: user.avatar }}>{user.initials}</span>
                ))}
              </div>
              {unread && <span className="dc-sidebar-dot" />}
              {active && (
                <div className="dc-sidebar-thread-actions">
                  <button
                    className="dc-sidebar-tool-btn"
                    type="button"
                    title={thread.resolvedAt ? 'Unresolve comment' : 'Resolve comment'}
                    onClick={(e) => {
                      e.stopPropagation();
                      onResolveThread && onResolveThread(thread.id, !thread.resolvedAt);
                    }}
                  >
                    <DCCheckGlyph />
                  </button>
                  <button
                    className="dc-sidebar-tool-btn"
                    type="button"
                    title="Delete thread"
                    onClick={(e) => {
                      e.stopPropagation();
                      onDeleteThread && onDeleteThread(thread.id);
                    }}
                  >
                    <DCCloseGlyph />
                  </button>
                </div>
              )}
              <div className="dc-sidebar-meta">#{threadNumbers.get(thread.id) || 1} · Page 1</div>
              <div className="dc-sidebar-name">{owner.name} <span className="dc-comment-message-time">{formatCommentRelativeTime(thread.updatedAt || thread.createdAt)}</span></div>
              <div className="dc-sidebar-snippet">{getThreadPreview(thread)}</div>
              {replies > 0 && <div className="dc-sidebar-replies">{replies} {replies === 1 ? 'reply' : 'replies'}</div>}
            </div>
          );
        })}
      </div>
    </aside>
  );
}

function DCCommentLayer({
  comments,
  hiddenSlotIds = [],
  draftThread,
  activeThreadId,
  currentUser,
  usersById,
  commentsVisible,
  commentMode,
  onOpenThread,
  onCloseThread,
  onCancelDraft,
  onDeleteThread,
  onResolveThread,
  onMarkThreadUnread,
  onToggleReaction,
  onDeleteMessage,
  onCreateThread,
  onReplyToThread,
}) {
  const [expandedClusterId, setExpandedClusterId] = React.useState(null);
  const hiddenSlotSet = React.useMemo(() => new Set(hiddenSlotIds), [hiddenSlotIds]);
  const orderedComments = React.useMemo(() => (
    comments
      .filter((thread) => !thread.slotId || !hiddenSlotSet.has(thread.slotId))
      .slice()
      .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0))
  ), [comments, hiddenSlotSet]);
  const activeThread = draftThread && activeThreadId === draftThread.id
    ? draftThread
    : comments.find((thread) => thread.id === activeThreadId) || null;
  const visiblePins = orderedComments.filter((thread) => !thread.resolvedAt || thread.id === activeThreadId);
  const clusterItems = React.useMemo(
    () => getClusterItems(visiblePins, expandedClusterId, activeThreadId),
    [activeThreadId, expandedClusterId, visiblePins]
  );
  const threadNumbers = React.useMemo(
    () => new Map(orderedComments.map((thread, index) => [thread.id, index + 1])),
    [orderedComments]
  );
  const shouldShowPins = commentsVisible || commentMode || !!activeThreadId;

  return (
    <div className="dc-comments-layer">
      {shouldShowPins && clusterItems.map((item) => {
        if (item.type === 'cluster') {
          const participants = item.threads.flatMap((thread) => getThreadParticipantUsers(thread, usersById));
          const uniqueParticipants = participants.filter((user, index, all) => all.findIndex((candidate) => candidate.id === user.id) === index);
          return (
            <div key={item.id} className="dc-comment-pin dc-comment-cluster" style={{ left: item.x, top: item.y }}>
              <button
                type="button"
                onPointerDown={(e) => e.stopPropagation()}
                onClick={(e) => {
                  e.stopPropagation();
                  setExpandedClusterId(item.id);
                }}
                title={`${item.threads.length} comments here`}
              >
                {item.threads.length}
              </button>
              <div className="dc-comment-cluster-preview">
                <strong>{item.threads.length} comments close together</strong>
                <div className="dc-comment-cluster-avatars">
                  {uniqueParticipants.slice(0, 4).map((user) => (
                    <span key={user.id} style={{ background: user.avatar }}>{user.initials}</span>
                  ))}
                </div>
              </div>
            </div>
          );
        }

        const { thread } = item;
        const owner = getThreadOwner(usersById, thread);
        return (
          <div key={thread.id} className="dc-comment-pin" style={{ left: thread.x, top: thread.y }}>
            <button
              type="button"
              data-open={activeThreadId === thread.id ? 'true' : 'false'}
              data-muted={thread.resolvedAt ? 'true' : 'false'}
              onPointerDown={(e) => e.stopPropagation()}
              onClick={(e) => {
                e.stopPropagation();
                onOpenThread(thread.id);
              }}
              title={`${owner.name} left a comment here`}
            >
              <span className="dc-comment-pin-avatar" style={{ background: owner.avatar }}>{owner.initials}</span>
            </button>
            <div className="dc-comment-pin-preview">
              <strong>{owner.name} <span className="dc-comment-message-time">{formatCommentRelativeTime(thread.updatedAt || thread.createdAt)}</span></strong>
              <div>{getThreadPreview(thread)}</div>
            </div>
          </div>
        );
      })}

      {draftThread && (
        <DCCommentDraftComposer
          draftThread={draftThread}
          currentUser={currentUser}
          onCancel={onCancelDraft}
          onSubmit={onCreateThread}
        />
      )}

      {activeThread && !draftThread && (
        <DCCommentThread
          key={activeThread.id}
          thread={activeThread}
          index={threadNumbers.get(activeThread.id) || 1}
          currentUser={currentUser}
          usersById={usersById}
          onClose={onCloseThread}
          onDelete={() => onDeleteThread && onDeleteThread(activeThread.id)}
          onResolve={(resolved) => onResolveThread && onResolveThread(activeThread.id, resolved)}
          onMarkUnread={() => onMarkThreadUnread && onMarkThreadUnread(activeThread.id)}
          onToggleReaction={(messageId, emoji) => onToggleReaction && onToggleReaction(activeThread.id, messageId, emoji)}
          onDeleteMessage={(messageId) => onDeleteMessage && onDeleteMessage(activeThread.id, messageId)}
          onSubmit={(body) => onReplyToThread(activeThread.id, body)}
        />
      )}
    </div>
  );
}

function DCCommentDraftComposer({ draftThread, currentUser, onCancel, onSubmit }) {
  const [text, setText] = React.useState('');
  const inputRef = React.useRef(null);

  React.useEffect(() => {
    setText('');
    window.setTimeout(() => inputRef.current?.focus(), 0);
  }, [draftThread.id]);

  const submit = () => {
    const trimmed = text.trim();
    if (!trimmed) return;
    onSubmit && onSubmit(trimmed);
    setText('');
  };

  return (
    <form
      className="dc-draft-composer"
      style={{ left: draftThread.x, top: draftThread.y }}
      onClick={(e) => e.stopPropagation()}
      onSubmit={(e) => {
        e.preventDefault();
        submit();
      }}
    >
      <div className="dc-draft-bubble" title={`Commenting as ${currentUser.name}`} />
      <div className="dc-draft-input">
        <input
          ref={inputRef}
          value={text}
          placeholder="Add a comment"
          onChange={(e) => setText(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === 'Escape') {
              e.preventDefault();
              onCancel && onCancel();
            }
          }}
        />
        <button type="submit" disabled={!text.trim()} aria-label="Submit comment">
          <DCSendGlyph />
        </button>
      </div>
    </form>
  );
}

function DCCommentThread({ thread, index, currentUser, usersById, onClose, onDelete, onResolve, onMarkUnread, onToggleReaction, onDeleteMessage, onSubmit }) {
  const [text, setText] = React.useState('');
  const [menuOpen, setMenuOpen] = React.useState(false);
  const [offset, setOffset] = React.useState({ x: 0, y: 0 });
  const [dragging, setDragging] = React.useState(false);
  const dragRef = React.useRef(null);
  const messages = getThreadMessages(thread);
  const resolved = !!thread.resolvedAt;

  React.useEffect(() => {
    setText('');
    setMenuOpen(false);
    setOffset({ x: 0, y: 0 });
  }, [thread.id, currentUser.id]);

  const submit = () => {
    const trimmed = text.trim();
    if (!trimmed) return;
    onSubmit(trimmed);
    setText('');
  };

  const copyLink = () => {
    setMenuOpen(false);
    const href = typeof window !== 'undefined' ? `${window.location.href.split('#')[0]}#comment-${thread.id}` : `#comment-${thread.id}`;
    if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
      navigator.clipboard.writeText(href).catch(() => {});
    }
  };

  const startDrag = (e) => {
    if (e.target instanceof Element && e.target.closest('button, .dc-comment-menu')) return;
    e.preventDefault();
    e.stopPropagation();
    e.currentTarget.setPointerCapture?.(e.pointerId);
    dragRef.current = {
      pointerId: e.pointerId,
      startX: e.clientX,
      startY: e.clientY,
      startOffset: offset,
    };
    setDragging(true);
  };

  const moveDrag = (e) => {
    const drag = dragRef.current;
    if (!drag || drag.pointerId !== e.pointerId) return;
    setOffset({
      x: drag.startOffset.x + e.clientX - drag.startX,
      y: drag.startOffset.y + e.clientY - drag.startY,
    });
  };

  const endDrag = (e) => {
    const drag = dragRef.current;
    if (!drag || drag.pointerId !== e.pointerId) return;
    e.currentTarget.releasePointerCapture?.(e.pointerId);
    dragRef.current = null;
    setDragging(false);
  };

  return (
    <div
      className="dc-comment-thread"
      data-dragging={dragging ? 'true' : 'false'}
      style={{ left: thread.x + 30 + offset.x, top: thread.y - 18 + offset.y }}
      onClick={(e) => e.stopPropagation()}
    >
      <div
        className="dc-comment-thread-header"
        onPointerDown={startDrag}
        onPointerMove={moveDrag}
        onPointerUp={endDrag}
        onPointerCancel={endDrag}
      >
        <div>
          <div className="dc-comment-thread-title">Comment</div>
          <div className="dc-comment-thread-page">#{index} · Page 1</div>
        </div>
        <div className="dc-comment-thread-tools">
          <button className="dc-icon-btn" type="button" onClick={(e) => { e.stopPropagation(); setMenuOpen((open) => !open); }} aria-label="Comment actions">...</button>
          <button className="dc-icon-btn" data-active={resolved ? 'true' : 'false'} type="button" onClick={(e) => { e.stopPropagation(); onResolve && onResolve(!resolved); }} aria-label={resolved ? 'Unresolve comment' : 'Resolve comment'}>
            <DCCheckGlyph />
          </button>
          <button className="dc-icon-btn" type="button" onClick={(e) => { e.stopPropagation(); onClose && onClose(); }} aria-label="Close comment">
            <DCCloseGlyph />
          </button>
          {menuOpen && (
            <div className="dc-comment-menu">
              <button type="button" onClick={() => { setMenuOpen(false); onMarkUnread && onMarkUnread(); }}>Mark as unread</button>
              <button type="button" onClick={copyLink}>Copy link to comment</button>
              <button type="button" data-danger="true" onClick={() => { setMenuOpen(false); onDelete && onDelete(); }}>Delete thread</button>
            </div>
          )}
        </div>
      </div>

      <div className="dc-comment-thread-body">
        <div className="dc-comment-thread-messages">
          {messages.map((message) => {
            const user = getCommentUser(usersById, message.userId);
            const reactions = Object.entries(message.reactions || {}).filter(([, ids]) => Array.isArray(ids) && ids.length > 0);
            return (
              <div key={message.id} className="dc-comment-message">
                <div className="dc-comment-avatar" style={{ background: user.avatar }}>{user.initials}</div>
                <div className="dc-comment-message-main">
                  <div className="dc-comment-message-name">
                    <span>{user.name}</span>
                    <span className="dc-comment-message-time">{formatCommentRelativeTime(message.createdAt)}</span>
                  </div>
                  <div className="dc-comment-message-text">{message.body}</div>
                  <div className="dc-message-actions" data-has-reactions={reactions.length > 0 ? 'true' : 'false'}>
                    {reactions.map(([emoji, ids]) => (
                      <button key={emoji} className="dc-reaction-pill" type="button" onClick={() => onToggleReaction && onToggleReaction(message.id, emoji)}>
                        {emoji} {ids.length}
                      </button>
                    ))}
                    <button className="dc-reaction-add" type="button" title="React with +1" onClick={() => onToggleReaction && onToggleReaction(message.id, '+1')}>+1</button>
                    {message.userId === currentUser.id && (
                      <button className="dc-message-delete" type="button" title="Delete comment" onClick={() => onDeleteMessage && onDeleteMessage(message.id)}>
                        <DCCloseGlyph />
                      </button>
                    )}
                  </div>
                </div>
              </div>
            );
          })}
        </div>

        <form className="dc-comment-composer" onSubmit={(e) => { e.preventDefault(); submit(); }}>
          <div className="dc-comment-avatar" style={{ background: currentUser.avatar }}>{currentUser.initials}</div>
          <div className="dc-comment-composer-box">
            <textarea
              value={text}
              placeholder="Reply"
              onChange={(e) => setText(e.target.value)}
              onKeyDown={(e) => {
                if (e.key === 'Enter' && !e.shiftKey) {
                  e.preventDefault();
                  submit();
                }
                if (e.key === 'Escape') {
                  e.preventDefault();
                  onClose && onClose();
                }
              }}
            />
            <div className="dc-comment-compose-actions">
              <button className="dc-compose-tool" type="button" title="Add emoji">:)</button>
              <button className="dc-compose-tool" type="button" title="Mention someone">@</button>
              <button className="dc-compose-tool" type="button" title="Attach image">img</button>
              <span className="dc-comment-compose-spacer" />
              <button className="dc-primary" type="submit" disabled={!text.trim()} aria-label="Submit reply">
                <DCSendGlyph />
              </button>
            </div>
          </div>
        </form>
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// DCSection — editable title + h-row of artboards in persisted order
// ─────────────────────────────────────────────────────────────
function DCSection({ id, title, subtitle, children, gap = 48 }) {
  const ctx = React.useContext(DCCtx);
  const sid = id ?? title;
  const all = React.Children.toArray(children);
  const artboards = all.filter((c) => c && c.type === DCArtboard);
  const rest = all.filter((c) => !(c && c.type === DCArtboard));
  const hiddenSlotSet = React.useMemo(() => new Set(ctx?.state.hiddenSlotIds || []), [ctx?.state.hiddenSlotIds]);
  const visibleArtboards = artboards.filter((a) => {
    const aid = a.props.id ?? a.props.label;
    return !hiddenSlotSet.has(`${sid}/${aid}`);
  });
  const srcOrder = visibleArtboards.map((a) => a.props.id ?? a.props.label);
  const sec = (ctx && sid && ctx.section(sid)) || {};

  const order = React.useMemo(() => {
    const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
    return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
  }, [sec.order, srcOrder.join('|')]);

  const byId = Object.fromEntries(visibleArtboards.map((a) => [a.props.id ?? a.props.label, a]));

  if (srcOrder.length === 0 && rest.length === 0) return null;

  return (
    <div data-dc-section={sid} style={{ marginBottom: 80, position: 'relative' }}>
      <div style={{ padding: '0 60px 56px' }}>
        <DCEditable tag="div" value={sec.title ?? title}
          onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
          style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
        {subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
      </div>
      <div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
        {order.map((k) => (
          <DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
            label={(sec.labels || {})[k] ?? byId[k].props.label}
            onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
            onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
            onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
        ))}
      </div>
      {rest}
    </div>
  );
}

// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
function DCArtboard() { return null; }

function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus }) {
  const ctx = React.useContext(DCCtx);
  const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
  const id = rawId ?? rawLabel;
  const slotId = `${sectionId}/${id}`;
  const isSelected = ctx?.state.selectedSlotId === slotId;
  const ref = React.useRef(null);

  // Live drag-reorder: dragged card sticks to cursor; siblings slide into
  // their would-be slots in real time via transforms. DOM order only
  // changes on drop.
  const onGripDown = (e) => {
    e.preventDefault(); e.stopPropagation();
    const me = ref.current;
    // translateX is applied in local (pre-scale) space but pointer deltas and
    // getBoundingClientRect().left are screen-space — divide by the viewport's
    // current scale so the dragged card tracks the cursor at any zoom level.
    const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
    const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
    const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
    const slotXs = homes.map((h) => h.x);
    const startIdx = order.indexOf(id);
    const startX = e.clientX;
    let liveOrder = order.slice();
    me.classList.add('dc-dragging');

    const layout = () => {
      for (const h of homes) {
        if (h.id === id) continue;
        const slot = liveOrder.indexOf(h.id);
        h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
      }
    };

    const move = (ev) => {
      const dx = ev.clientX - startX;
      me.style.transform = `translateX(${dx / scale}px)`;
      const cur = homes[startIdx].x + dx;
      let nearest = 0, best = Infinity;
      for (let i = 0; i < slotXs.length; i++) {
        const d = Math.abs(slotXs[i] - cur);
        if (d < best) { best = d; nearest = i; }
      }
      if (liveOrder.indexOf(id) !== nearest) {
        liveOrder = order.filter((k) => k !== id);
        liveOrder.splice(nearest, 0, id);
        layout();
      }
    };

    const up = () => {
      document.removeEventListener('pointermove', move);
      document.removeEventListener('pointerup', up);
      const finalSlot = liveOrder.indexOf(id);
      me.classList.remove('dc-dragging');
      me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
      // After the settle transition, kill transitions + clear transforms +
      // commit the reorder in the same frame so there's no visual snap-back.
      setTimeout(() => {
        for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
        if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
        requestAnimationFrame(() => requestAnimationFrame(() => {
          for (const h of homes) h.el.style.transition = '';
        }));
      }, 180);
    };
    document.addEventListener('pointermove', move);
    document.addEventListener('pointerup', up);
  };

  return (
    <div
      ref={ref}
      data-dc-slot={id}
      data-dc-slot-id={slotId}
      data-dc-section-id={sectionId}
      data-dc-artboard-id={id}
      data-dc-label={label}
      data-dc-selected={isSelected ? 'true' : 'false'}
      style={{ position: 'relative', flexShrink: 0 }}
    >
      <div className="dc-labelrow" style={{ position: 'absolute', bottom: '100%', left: -4, marginBottom: 4, color: DC.label }}>
        <div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder this frame">
          <svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
        </div>
        <button
          className="dc-delete"
          type="button"
          title="Hide this frame"
          aria-label={`Hide ${label}`}
          onPointerDown={(e) => {
            e.preventDefault();
            e.stopPropagation();
          }}
          onClick={(e) => {
            e.preventDefault();
            e.stopPropagation();
            ctx?.hideSlot(slotId);
          }}
        >
          ×
        </button>
        <div className="dc-labeltext" onClick={() => { ctx?.setSelection(slotId); onFocus(); }} title="Focus this frame">
          <DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
            style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
        </div>
      </div>
      <button className="dc-expand" onClick={onFocus} onPointerDown={(e) => e.stopPropagation()} title="Open this frame">
        <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
      </button>
      <div
        className="dc-card"
        data-dc-card
        data-dc-selected={isSelected ? 'true' : 'false'}
        onClick={(e) => {
          if (ctx?.commentMode) return;
          e.stopPropagation();
          ctx?.setSelection(slotId);
        }}
        style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
        <div className="dc-card-content">
          {children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
        </div>
      </div>
    </div>
  );
}

// Inline rename — commits on blur or Enter.
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
  const T = tag;
  return (
    <T className="dc-editable" contentEditable suppressContentEditableWarning
      onClick={onClick}
      onPointerDown={(e) => e.stopPropagation()}
      onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
      onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
      style={style}>{value}</T>
  );
}

// ─────────────────────────────────────────────────────────────
// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
// sections, Esc or backdrop click to exit.
// ─────────────────────────────────────────────────────────────
function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
  const ctx = React.useContext(DCCtx);
  const { sectionId, artboard } = entry;
  const sec = ctx.section(sectionId);
  const meta = sectionMeta[sectionId];
  const peers = meta.slotIds;
  const aid = artboard.props.id ?? artboard.props.label;
  const idx = peers.indexOf(aid);
  const secIdx = sectionOrder.indexOf(sectionId);

  const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
  const goSection = (d) => {
    const ns = sectionOrder[(secIdx + d + sectionOrder.length) % sectionOrder.length];
    const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
    if (first) ctx.setFocus(`${ns}/${first}`);
  };

  React.useEffect(() => {
    const k = (e) => {
      if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
      if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
      if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
      if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
    };
    document.addEventListener('keydown', k);
    return () => document.removeEventListener('keydown', k);
  });

  const { width = 260, height = 480, children } = artboard.props;
  const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
  React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
  const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));

  const [ddOpen, setDd] = React.useState(false);
  const Arrow = ({ dir, onClick }) => (
    <button onClick={(e) => { e.stopPropagation(); onClick(); }}
      style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
        border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
        width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
        display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
      onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
      onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
      <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
        <path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
    </button>
  );

  // Portal to body so position:fixed is the real viewport regardless of any
  // transform on DesignCanvas's ancestors (including the canvas zoom itself).
  return ReactDOM.createPortal(
    <div onClick={() => ctx.setFocus(null)}
      onWheel={(e) => e.preventDefault()}
      style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
        fontFamily: DC.font, color: '#fff' }}>

      {/* top bar: section dropdown (left) · close (right) */}
      <div onClick={(e) => e.stopPropagation()}
        style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
        <div style={{ position: 'relative' }}>
          <button onClick={() => setDd((o) => !o)}
            style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
              borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
            <span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
              <span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
              <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
            </span>
            {meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
          </button>
          {ddOpen && (
            <div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
              boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
              {sectionOrder.map((sid) => (
                <button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
                  style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
                    background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
                    padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
                  {sectionMeta[sid].title}
                </button>
              ))}
            </div>
          )}
        </div>
        <div style={{ flex: 1 }} />
        <button onClick={() => ctx.setFocus(null)}
          onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
          onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
          style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
            borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
      </div>

      {/* card centered, label + index below — only the card itself stops
          propagation so any backdrop click (including the margins around
          the card) exits focus */}
      <div
        style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
        <div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
          <div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
            boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
            {children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
          </div>
        </div>
        <div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
          {(sec.labels || {})[aid] ?? artboard.props.label}
          <span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
        </div>
      </div>

      <Arrow dir="left" onClick={() => go(-1)} />
      <Arrow dir="right" onClick={() => go(1)} />

      {/* dots */}
      <div onClick={(e) => e.stopPropagation()}
        style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
        {peers.map((p, i) => (
          <button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
            style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
              background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
        ))}
      </div>
    </div>,
    document.body,
  );
}

// ─────────────────────────────────────────────────────────────
// Post-it — absolute-positioned sticky note
// ─────────────────────────────────────────────────────────────
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
  return (
    <div style={{
      position: 'absolute', top, left, right, bottom, width,
      background: DC.postitBg, padding: '14px 16px',
      fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
      fontSize: 14, lineHeight: 1.4, color: DC.postitText,
      boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
      transform: `rotate(${rotate}deg)`,
      zIndex: 5,
    }}>{children}</div>
  );
}

Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
