// drawer.jsx — Task detail drawer with full inline editing

const DRAWER_CSS = `
.drawer-head {
  padding: 14px 18px 14px 22px;
  border-bottom: 1px solid var(--border);
  display: flex; align-items: center; gap: 10px;
  background: var(--bg-subtle);
}
.drawer-head .meta { flex: 1; min-width: 0; }
.drawer-breadcrumb {
  font-size: 12px; color: var(--ink-muted);
  display: flex; align-items: center; gap: 5px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.drawer-breadcrumb svg { width: 10px; height: 10px; flex-shrink: 0; opacity: .6; }
.drawer-breadcrumb b { color: var(--ink-strong); font-weight: 600; }
.drawer-id {
  font-size: 11px; color: var(--ink-faint); margin-top: 3px;
  letter-spacing: 0.06em; font-variant-numeric: tabular-nums;
  font-family: ui-monospace, "SF Mono", Menlo, monospace;
}
.drawer-head-actions { display: flex; gap: 2px; align-items: center; flex-shrink: 0; }
.drawer-head-actions .icon-btn {
  width: 28px; height: 28px; border-radius: 6px;
  display: grid; place-items: center; cursor: pointer;
  background: transparent; border: none; color: var(--ink-muted);
  transition: background .12s, color .12s;
}
.drawer-head-actions .icon-btn:hover { background: rgba(15, 23, 41, 0.06); color: var(--ink-strong); }
.drawer-head-divider { width: 1px; height: 18px; background: var(--border); margin: 0 4px; }

/* Type chip in the drawer head — click to open a Popover that lets the
   user re-classify the task (task / bug / chore / spike). Bug
   BG_02E81DCE4B. Compact pill, type-specific color theme. */
.drawer-type-chip {
  display: inline-flex; align-items: center; gap: 5px;
  padding: 3px 9px; border-radius: 999px;
  font-size: 11px; font-weight: 700;
  letter-spacing: .04em; text-transform: uppercase;
  font: inherit; cursor: pointer;
  border: 1px solid transparent;
  background: rgba(103, 104, 121, .10);
  color: var(--ink-muted, #676879);
  transition: filter .12s, transform .08s;
}
.drawer-type-chip:hover { filter: brightness(1.05); transform: translateY(-1px); }
.drawer-type-chip-emoji { font-size: 12px; line-height: 1; }
.drawer-type-chip-label { font: inherit; }
.drawer-type-task  {
  background: rgba(0, 115, 234, .10); color: #0061c0;
  border-color: rgba(0, 115, 234, .25);
}
.drawer-type-bug   {
  background: rgba(226, 68, 92, .12); color: #c0223a;
  border-color: rgba(226, 68, 92, .30);
}
.drawer-type-chore {
  background: rgba(103, 104, 121, .12); color: #565666;
  border-color: rgba(103, 104, 121, .28);
}
.drawer-type-spike {
  background: rgba(253, 171, 61, .15); color: #b66f00;
  border-color: rgba(253, 171, 61, .32);
}
.drawer-body { padding: 18px 22px 40px; overflow-y: auto; flex: 1; }

.drawer-task-title {
  font-size: 22px; font-weight: 700; color: var(--ink-strong);
  letter-spacing: -0.02em; margin: 0 0 20px;
  border: 1.5px solid transparent; border-radius: 6px;
  padding: 4px 8px; margin-left: -8px; cursor: text;
  min-height: 32px;
}
.drawer-task-title:hover { background: #f8f9fb; }
.drawer-task-title:focus {
  background: white; border-color: var(--brand);
  outline: none;
  box-shadow: 0 0 0 3px rgba(162, 93, 220, 0.12);
}

.drawer-grid {
  display: grid; grid-template-columns: 92px 1fr; gap: 0 14px;
  padding: 8px 0; border-top: 1px solid var(--border-row); border-bottom: 1px solid var(--border-row);
  margin-bottom: 14px;
}
/* Big drawer → pair Details fields two-up. The label/value cells
   land in source order, so the dl <dt><dd>... pairs flow naturally
   into two columns. Kicks in alongside the wider drawer breakpoint
   in app.css (>= 1100px viewport). */
@media (min-width: 1100px) {
  .drawer-grid { grid-template-columns: 92px minmax(0,1fr) 92px minmax(0,1fr); column-gap: 20px; }
}
.drawer-grid dt {
  font-size: 11.5px; color: var(--ink-muted); font-weight: 500;
  align-self: center; padding: 1px 0; line-height: 1.4;
}
.drawer-grid dd {
  margin: 0; font-size: 12.5px; display: flex; align-items: center; gap: 6px;
  padding: 1px 0; min-height: 0;
}
.drawer-edit {
  display: flex; align-items: center; gap: 6px;
  padding: 2px 6px; border-radius: 4px; cursor: pointer;
  border: 1px solid transparent;
  min-height: 22px;
  flex: 1;
}
.drawer-edit:hover { background: #f8f9fb; border-color: #e6e9ef; }
/* Pills inside the Details grid — make them noticeably less chunky
   so the rows stack tightly without losing tap targets. */
.drawer-grid .pill,
.drawer-grid .status-pill,
.drawer-grid .priority-pill { padding: 2px 9px; font-size: 11.5px; }
.drawer-edit-empty { color: #a3a8b6; font-style: italic; }

.drawer-sec-title {
  font-size: 13px; font-weight: 600; color: var(--ink-strong);
  margin: 22px 0 10px;
  display: flex; align-items: center; gap: 8px;
}
.drawer-sec-count {
  font-size: 11px; color: var(--ink-muted); font-weight: 500;
}
.drawer-sec-actions { margin-left: auto; display: flex; gap: 4px; }
.drawer-sec-btn {
  font-size: 11px; font-weight: 500; color: var(--ink-muted);
  background: transparent; border: 1px solid #e6e9ef; border-radius: 4px;
  padding: 3px 7px; cursor: pointer;
  display: inline-flex; align-items: center; gap: 4px;
}
.drawer-sec-btn:hover { background: #f0f1f4; color: var(--ink-strong); }

.drawer-desc {
  font-size: 13.5px; color: var(--ink-body); line-height: 1.55;
  background: var(--bg-subtle); border-radius: var(--r-md);
  padding: 12px 14px;
  border: 1.5px solid transparent; cursor: text;
  min-height: 50px; white-space: pre-wrap;
}
.drawer-desc:hover { background: #f0f1f4; }
.drawer-desc:focus {
  background: white; border-color: var(--brand); outline: none;
  box-shadow: 0 0 0 3px rgba(162, 93, 220, 0.12);
}
.drawer-desc-empty { color: #a3a8b6; font-style: italic; }
.drawer-desc.is-empty:empty:not(:focus)::before {
  content: attr(data-placeholder);
  color: #a3a8b6; font-style: italic; pointer-events: none;
}
.drawer-desc img, .drawer-desc-img {
  max-width: 100%; height: auto;
  border-radius: 6px; margin: 8px 0;
  display: block;
  box-shadow: 0 1px 3px rgba(0,0,0,.12);
  border: 1px solid var(--border-soft);
}

.drawer-desc-head {
  display: flex; align-items: center; justify-content: space-between;
  gap: 8px;
}
.drawer-desc-tools { display: inline-flex; gap: 6px; }
.drawer-desc-tool {
  display: inline-flex; align-items: center; gap: 5px;
  font: inherit; font-size: 11.5px; font-weight: 600;
  color: var(--ink-muted);
  background: transparent; border: 1px solid var(--border);
  padding: 4px 8px; border-radius: 6px; cursor: pointer;
  transition: background .12s ease, color .12s ease, border-color .12s ease;
}
.drawer-desc-tool:hover {
  color: var(--ink-strong); background: #f0f1f4;
  border-color: var(--border-strong);
}

.subtask-list { display: flex; flex-direction: column; gap: 4px; }
.subtask-row {
  display: flex; align-items: flex-start; gap: 10px;
  padding: 7px 10px; border-radius: var(--r-sm);
  background: white; border: 1px solid var(--border);
  font-size: 13px;
  position: relative;
}
.subtask-row:hover { border-color: #d1d5e0; }
.subtask-row:hover .subtask-delete { opacity: 1; }
.subtask-row:hover .subtask-grip { opacity: 1; }
.subtask-grip {
  opacity: 0; color: #a3a8b6; cursor: grab;
  margin-top: 2px; flex: none; transition: opacity 0.1s;
  display: flex; align-items: center;
}
.subtask-row.is-drop-above { box-shadow: inset 0 3px 0 var(--brand); }
.subtask-row.is-drop-below { box-shadow: inset 0 -3px 0 var(--brand); }
.subtask-row.is-dragging { opacity: 0.45; transform: scale(0.99); cursor: grabbing; }
.subtask-row.is-just-dropped { animation: fb-sub-flash 0.7s ease-out; }
@keyframes fb-sub-flash {
  0%   { box-shadow: 0 0 0 2px var(--brand), 0 6px 16px rgba(162,93,220,.28); transform: scale(1.01); }
  60%  { box-shadow: 0 0 0 1px var(--brand); transform: scale(1); }
  100% { box-shadow: none; transform: scale(1); }
}
.subtask-row .checkbox {
  width: 16px; height: 16px; border-radius: 3px;
  border: 1.5px solid var(--border-strong);
  display: flex; align-items: center; justify-content: center;
  flex: none; cursor: pointer;
  background: white;
  margin-top: 2px;
}
.subtask-row .checkbox:hover { border-color: var(--status-done); }
.subtask-row.done .checkbox { background: var(--status-done); border-color: var(--status-done); color: white; }
.subtask-row.done .subtask-name { color: var(--ink-muted); text-decoration: line-through; }

/* Status pill inside a subtask row — same global .pill / .pill-sm
   classes as task rows so the colour scheme matches everything else. */
.subtask-status-pill {
  cursor: pointer;
  flex-shrink: 0;
  white-space: nowrap;
  font-size: 10.5px;
  padding: 2px 8px;
}

/* Due-date chip — small, only takes color when overdue. */
.subtask-due-chip {
  display: inline-flex; align-items: center; gap: 4px;
  font-size: 11px;
  font-weight: 500;
  color: var(--ink-muted);
  padding: 2px 8px;
  border-radius: 999px;
  background: rgba(0,0,0,.04);
  cursor: pointer;
  flex-shrink: 0;
  white-space: nowrap;
  transition: background .12s ease, color .12s ease;
}
.subtask-due-chip:hover { background: rgba(0,0,0,.08); color: var(--ink-strong); }
.subtask-due-chip.is-overdue {
  background: rgba(226,68,92,.10);
  color: #8a1024;
  font-weight: 600;
}
.subtask-due-chip.is-overdue:hover { background: rgba(226,68,92,.16); }

/* "Open in drawer" button — appears on hover next to the delete. */
.subtask-open {
  background: transparent; border: 0;
  color: var(--ink-muted);
  cursor: pointer;
  width: 22px; height: 22px;
  border-radius: 4px;
  display: inline-flex; align-items: center; justify-content: center;
  opacity: 0;
  transition: opacity .12s ease, background .12s ease, color .12s ease;
}
.subtask-row:hover .subtask-open { opacity: 1; }
.subtask-open:hover { background: rgba(0,115,234,.10); color: var(--brand); }

/* Empty state shown when the parent task has no subtasks yet. */
.subtask-empty {
  padding: 14px 16px;
  margin: 8px 0 4px 0;
  background: var(--bg-subtle, #fafbfd);
  border: 1px dashed var(--border, #e6e9ef);
  border-radius: 8px;
  font-size: 12px;
  color: var(--ink-muted);
  text-align: center;
}
.subtask-name {
  flex: 1 1 auto; min-width: 0;
  cursor: text; padding: 2px 4px; margin: -2px -4px; border-radius: 3px;
  overflow-wrap: anywhere; word-break: break-word;
}
.subtask-name:hover { background: #f8f9fb; }
.subtask-name:focus { background: white; outline: 1px solid var(--brand); }
/* CRITICAL: the global .drawer-edit rule sets flex:1, which is fine for
   the dt/dd rows in the details grid, but inside a subtask row each
   EditField wrapper (status pill / due chip / owners) would steal flex
   from .subtask-name — collapsing the title to a sliver and, for short
   rows, making it disappear entirely behind the chips. Pin those
   wrappers to flex:none so the name keeps the full remaining width.
   NOTE: do not use backticks in this comment — the whole stylesheet
   lives inside a JS template literal and a stray backtick blows up
   parsing and white-screens the app. */
.subtask-row .drawer-edit {
  flex: 0 0 auto;
  padding: 0;
  min-height: 0;
  border: 0;
}
.subtask-row .drawer-edit:hover { background: transparent; border-color: transparent; }
.subtask-delete {
  opacity: 0; background: transparent; border: none; color: #a3a8b6;
  cursor: pointer; padding: 2px; border-radius: 3px; display: flex;
  transition: opacity 0.1s;
}
.subtask-delete:hover { background: #fee; color: #e2445c; }

.subtask-owners {
  display: inline-flex; align-items: center; gap: 6px;
  flex: none;
  max-width: 160px;
  margin-top: 1px; /* align with first line of name */
  padding: 2px 2px; /* keeps EditField hit target tall enough */
}
.subtask-owner-label {
  font-size: 12px; color: var(--ink-body);
  white-space: nowrap;
  overflow: hidden; text-overflow: ellipsis;
  min-width: 0;
}
.subtask-owner-label--empty {
  color: #a3a8b6; font-style: italic;
}
.subtask-delete { margin-top: 2px; }

.subtask-add {
  display: flex; align-items: center; gap: 10px;
  padding: 7px 10px; border-radius: var(--r-sm);
  border: 1px dashed var(--border-strong);
  color: var(--ink-muted); font-size: 13px;
  cursor: text;
}
.subtask-add input {
  flex: 1; border: none; outline: none; background: transparent;
  font: inherit; color: var(--ink-body);
}

.subtask-progress {
  height: 4px; background: #eceef2; border-radius: 999px;
  margin-bottom: 10px; overflow: hidden;
}
.subtask-progress-fill {
  height: 100%; background: var(--status-done);
  transition: width 0.3s;
}

/* Comments */
.comment-composer {
  display: flex; gap: 10px; padding: 12px;
  border: 1px solid var(--border); border-radius: var(--r-md);
  background: white; align-items: flex-start;
}
.comment-composer textarea {
  flex: 1; border: none; outline: none; background: transparent;
  font: inherit; color: var(--ink-body); resize: none;
  font-size: 13px; min-height: 22px; padding: 4px 0;
  font-family: inherit;
}
.comment-composer-footer {
  display: flex; align-items: center; gap: 6px; margin-top: 8px;
}
.comment-composer-footer .tip { font-size: 11px; color: var(--ink-faint); margin-right: auto; }

.comment-list { display: flex; flex-direction: column; gap: 14px; margin-top: 16px; }
.comment-item {
  display: flex; gap: 10px;
}
.comment-body { flex: 1; }
.comment-header {
  display: flex; align-items: baseline; gap: 8px; margin-bottom: 2px;
}
.comment-author { font-weight: 600; font-size: 13px; color: var(--ink-strong); }
.comment-when { font-size: 11px; color: var(--ink-faint); }
.comment-text {
  font-size: 13px; color: var(--ink-body); line-height: 1.5;
  background: var(--bg-subtle); padding: 10px 12px; border-radius: 10px;
  border-bottom-left-radius: 2px;
  white-space: pre-wrap;
}

/* Activity */
.activity-item {
  display: flex; gap: 10px; padding: 8px 0;
  font-size: 12.5px; color: var(--ink-body);
}
.activity-dot {
  width: 20px; height: 20px; border-radius: 50%;
  background: #f0f1f4; display: flex; align-items: center; justify-content: center;
  color: var(--ink-muted); flex: none;
}
.activity-when { color: var(--ink-faint); font-size: 11px; margin-top: 1px; }
/* Due-date history panel — pulls all kind=due activity entries out
   of the general feed and shows them as their own card at the top
   of the Activity tab. Amber-tinted because a moved deadline is the
   single most consequential audit-log entry on a task. */
.activity-due-panel {
  background: linear-gradient(180deg, rgba(255, 175, 61, .10), rgba(255, 175, 61, .04));
  border: 1px solid rgba(253, 171, 61, .35);
  border-radius: 10px;
  padding: 10px 14px 6px;
  margin-bottom: 14px;
}
.activity-due-head {
  display: flex; align-items: center; gap: 8px;
  padding-bottom: 6px;
  border-bottom: 1px dashed rgba(253, 171, 61, .35);
  margin-bottom: 6px;
}
.activity-due-icon  { font-size: 14px; line-height: 1; }
.activity-due-title { font-size: 12.5px; font-weight: 700; color: #8a4a00; letter-spacing: .01em; }
.activity-due-count {
  font-size: 11px; font-weight: 700; color: #8a4a00;
  background: rgba(253, 171, 61, .25);
  padding: 1px 7px; border-radius: 999px;
  margin-left: auto;
}
.activity-due-list { display: flex; flex-direction: column; gap: 0; }
.activity-due-row {
  display: flex; gap: 10px; padding: 6px 0;
  font-size: 12.5px; color: var(--ink-body);
  border-bottom: 1px dashed rgba(253, 171, 61, .25);
}
.activity-due-row:last-child { border-bottom: none; }
.activity-due-row .activity-when { color: #b66f00; }
/* Divider above the general "All activity" stream when both
   sections are present. */
.activity-section-divider {
  font-size: 10.5px; font-weight: 700;
  color: var(--ink-muted);
  text-transform: uppercase; letter-spacing: .08em;
  padding: 4px 0 6px;
  border-bottom: 1px solid var(--border-row);
  margin-bottom: 4px;
}

.drawer-tabs {
  display: flex; gap: 4px; border-bottom: 1px solid var(--border);
  margin: 20px 0 0;
}
.drawer-tab {
  padding: 8px 14px; font-size: 13px; font-weight: 500;
  color: var(--ink-muted); background: none; border: none; cursor: pointer;
  border-bottom: 2px solid transparent; margin-bottom: -1px;
}
.drawer-tab.is-active { color: var(--ink-strong); border-bottom-color: var(--brand); font-weight: 600; }
.drawer-tab:hover:not(.is-active) { color: var(--ink-strong); }

/* ── Attachments pane ───────────────────────────────────────── */
.drawer-pane-attachments { font-size: 13px; }

.att-dropzone {
  display: flex; flex-direction: column; align-items: center; gap: 4px;
  padding: 22px 16px;
  border: 1.5px dashed var(--border, #e6e9ef);
  border-radius: 10px;
  background: var(--bg-subtle, #f5f6f8);
  cursor: pointer;
  text-align: center;
  transition: background .15s, border-color .15s;
}
.att-dropzone:hover {
  background: rgba(162, 93, 220, .04);
  border-color: rgba(162, 93, 220, .35);
}
.att-dropzone.is-drag {
  background: rgba(162, 93, 220, .08);
  border-color: var(--brand, #a25ddc);
  border-style: solid;
}
.att-dropzone-emoji { font-size: 24px; line-height: 1; }
.att-dropzone-title {
  font-size: 13px; font-weight: 600; color: var(--ink-strong);
}
.att-dropzone-hint {
  font-size: 11.5px; color: var(--ink-muted);
}

.att-list { display: flex; flex-direction: column; gap: 6px; }
.att-item {
  display: flex; align-items: center; gap: 10px;
  padding: 8px 10px;
  border: 1px solid var(--border, #e6e9ef);
  border-radius: 8px;
  background: white;
  transition: background .12s, border-color .12s;
}
.att-item:hover {
  background: var(--bg-subtle, #f7f8fa);
  border-color: var(--border-strong, #d8dce4);
}
.att-icon {
  font-size: 22px; line-height: 1; flex: none;
  font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
}
.att-meta {
  flex: 1 1 auto; min-width: 0;
  display: flex; flex-direction: column; gap: 2px;
  text-decoration: none; color: inherit;
}
a.att-meta:hover .att-name { text-decoration: underline; color: var(--brand, #a25ddc); }
.att-name {
  font-size: 13px; font-weight: 600; color: var(--ink-strong);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.att-sub {
  font-size: 11px; color: var(--ink-muted);
  display: inline-flex; align-items: center; gap: 6px;
}
.att-dot { opacity: .5; }
.att-action {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; border-radius: 6px;
  background: transparent; border: none; cursor: pointer;
  color: var(--ink-muted); text-decoration: none;
  flex: none;
  transition: background .12s, color .12s;
}
.att-action:hover { background: rgba(15, 23, 41, .06); color: var(--ink-strong); }
.att-action-del:hover { background: rgba(226, 68, 92, .10); color: #c0223a; }

.att-uploading {
  background: rgba(162, 93, 220, .05);
  border-color: rgba(162, 93, 220, .25);
}
.att-uploading .att-name { color: var(--ink-body); }
.att-error {
  background: rgba(226, 68, 92, .06);
  border-color: rgba(226, 68, 92, .30);
}
`;

if (typeof document !== 'undefined' && !document.getElementById('drawer-css')) {
  const s = document.createElement('style');
  s.id = 'drawer-css'; s.textContent = DRAWER_CSS;
  document.head.appendChild(s);
}

// ── Editable-field wrapper (click to open popover) ────────────────
function EditField({ children, render, onClose }) {
  const [anchor, setAnchor] = React.useState(null);
  return (
    <>
      <div className="drawer-edit" onClick={(e) => setAnchor(e.currentTarget)}>
        {children}
      </div>
      {anchor && (
        <Popover anchor={anchor} onClose={() => { setAnchor(null); onClose && onClose(); }}>
          {render(() => setAnchor(null))}
        </Popover>
      )}
    </>
  );
}

// ─────────────────────────────────────────────────────────────────
// CopyTaskLinkButton — header icon that copies a shareable URL to
// the current task. The URL uses the hash form `#/tasks/<id>` so:
//   • It can be pasted into any tab and pushed to teammates;
//   • The page-load handler in app.jsx reads the hash and dispatches
//     `flowboard:nav`, which switches projects + opens the drawer
//     deterministically — no extra round-trip needed.
//
// Falls back to a hidden <textarea> + execCommand("copy") path on
// the rare browsers / contexts where navigator.clipboard is missing
// (Safari iframes, http://, very old Edge).
// ─────────────────────────────────────────────────────────────────
function CopyTaskLinkButton({ task, onToast }) {
  const [copied, setCopied] = React.useState(false);
  if (!task || !task.id) return null;

  function buildLink() {
    if (typeof window === "undefined") return "";
    const { origin, pathname } = window.location;
    return `${origin}${pathname}#/tasks/${encodeURIComponent(task.id)}`;
  }

  async function copy() {
    const link = buildLink();
    let ok = false;
    try {
      if (navigator.clipboard && navigator.clipboard.writeText) {
        await navigator.clipboard.writeText(link);
        ok = true;
      }
    } catch { /* fall through */ }
    if (!ok) {
      try {
        const ta = document.createElement("textarea");
        ta.value = link;
        ta.setAttribute("readonly", "");
        ta.style.position = "fixed";
        ta.style.opacity = "0";
        document.body.appendChild(ta);
        ta.select();
        ok = document.execCommand("copy");
        document.body.removeChild(ta);
      } catch { ok = false; }
    }
    if (ok) {
      setCopied(true);
      // Toast routing: prefer the prop (drawer wires this), fall
      // back to the app-wide window.fbToast (used by Full view +
      // anywhere the prop wasn't threaded through). The button's
      // own green-check feedback covers the case where neither is
      // available.
      const msg = "Link copied — share it with anyone in this workspace";
      if (onToast) onToast(msg);
      else if (typeof window.fbToast === "function") window.fbToast(msg);
      setTimeout(() => setCopied(false), 1400);
    } else {
      // Last-ditch surface — at least let the user grab it manually.
      try { window.prompt("Copy this link to share the task:", link); } catch {}
    }
  }

  return (
    <button
      type="button"
      className="icon-btn"
      onClick={copy}
      title={copied ? "Link copied!" : "Copy a shareable link to this task"}
      aria-label="Copy task link"
      style={copied ? { color: "#1f8a48" } : undefined}>
      {copied ? (
        <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
          <path d="M2.5 7.5L6 11l5.5-7"/>
        </svg>
      ) : (
        <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
          {/* Two interlocking links — the universal "share / link" glyph. */}
          <path d="M5.7 8.3a2.5 2.5 0 0 0 3.5 0l2-2a2.5 2.5 0 0 0-3.5-3.5l-.6.6"/>
          <path d="M8.3 5.7a2.5 2.5 0 0 0-3.5 0l-2 2a2.5 2.5 0 0 0 3.5 3.5l.6-.6"/>
        </svg>
      )}
    </button>
  );
}

// ── Task type chip ─────────────────────────────────────────────
// Small clickable chip in the drawer head that shows the current task
// type (task / bug / chore / spike) and opens a portal Popover to pick
// a new one. Reads TASK_TYPES from data.jsx so it stays in lockstep
// with the New Task modal and the Kanban/table chips.
// Bug BG_02E81DCE4B — the New Task modal could set the type at
// creation, but the drawer couldn't change it afterwards. Now it can.
function TaskTypeChip({ task, onChange }) {
  const [anchor, setAnchor] = React.useState(null);
  const types = (typeof TASK_TYPES !== "undefined" && Array.isArray(TASK_TYPES))
    ? TASK_TYPES
    : [{ id: "task", label: "Task", emoji: "📋" }];
  const current = (typeof taskTypeMeta === "function") ? taskTypeMeta(task) : types[0];
  return (
    <>
      <button type="button"
              className={`drawer-type-chip drawer-type-${current.id}`}
              title="Click to change task type"
              style={{ marginTop: 6 }}
              onClick={(e) => setAnchor(anchor ? null : e.currentTarget)}>
        <span className="drawer-type-chip-emoji" aria-hidden="true">{current.emoji}</span>
        <span className="drawer-type-chip-label">{current.label}</span>
      </button>
      {anchor && typeof Popover !== "undefined" && (
        <Popover anchor={anchor} onClose={() => setAnchor(null)}>
          <div style={{ minWidth: 180, padding: 4 }}>
            <div style={{ fontSize: 11, color: "var(--ink-muted)", fontWeight: 700, letterSpacing: ".05em", textTransform: "uppercase", padding: "6px 10px 4px" }}>
              Change task type
            </div>
            {types.map(t => {
              const on = t.id === current.id;
              return (
                <div key={t.id} className="popover-item"
                     onClick={() => { if (!on && onChange) onChange(t.id); setAnchor(null); }}>
                  <span style={{ fontSize: 14, width: 18, textAlign: "center" }}>{t.emoji}</span>
                  <span style={{ flex: 1, color: t.color, fontWeight: 600 }}>{t.label}</span>
                  {on && <Icons.Check size={13} style={{ color: "var(--brand)" }}/>}
                </div>
              );
            })}
          </div>
        </Popover>
      )}
    </>
  );
}

// ── StoryReviewButtons ──────────────────────────────────────────
// Inline Approve / Request Changes pair shown next to the story chip
// on the task drawer header. Mirrors the row-level buttons in the task
// table (StoryRow in table.jsx) so reviewers can act without opening
// the full StoryDrawer. Patches window.USER_STORIES in place + fires
// the same flowboard:rt:stories.updated event so the rest of the app
// stays consistent. Request-changes opens a small popover that takes
// an optional note; Shift+click skips it (power-user shortcut).
function StoryReviewButtons({ story, canRequestChanges, onToast }) {
  const [acting, setActing]               = React.useState(false);
  const [reqNoteAnchor, setReqNoteAnchor] = React.useState(null);
  const [reqNoteText, setReqNoteText]     = React.useState("");

  function _patchStoryInPlace(updated) {
    if (!updated || !updated.id) return;
    try {
      const all = window.USER_STORIES;
      if (!Array.isArray(all)) return;
      const idx = all.findIndex(s => s && s.id === updated.id);
      if (idx === -1) return;
      all[idx] = Object.assign({}, all[idx], updated);
      try {
        window.dispatchEvent(new CustomEvent("flowboard:rt:stories.updated", {
          detail: { id: updated.id, status: updated.status, local: true },
        }));
      } catch {}
    } catch {}
  }

  async function approve(e) {
    if (e) e.stopPropagation();
    if (acting) return;
    setActing(true);
    try {
      const updated = await window.api.stories.approve(story.id);
      _patchStoryInPlace(updated || { id: story.id, status: "done" });
      onToast && onToast("Approved — thanks for the sign-off.");
    } catch (err) {
      const msg = (err && err.body && (err.body.message || err.body.error)) || (err && err.message) || "network error";
      onToast && onToast("Couldn't approve: " + msg, 4500);
    } finally { setActing(false); }
  }

  async function runRequestChanges(note) {
    if (acting) return;
    setActing(true); setReqNoteAnchor(null);
    try {
      const updated = await window.api.stories.requestChanges(story.id, note || "");
      _patchStoryInPlace(updated || { id: story.id, status: "changes_req" });
      if (typeof window.flowboardLoad === "function") {
        window.flowboardLoad().catch(() => {});
      }
      onToast && onToast(
        note ? "Changes requested — owners notified."
             : "Changes requested — child tasks reopened.",
        4000
      );
    } catch (err) {
      const msg = (err && err.body && (err.body.message || err.body.error)) || (err && err.message) || "network error";
      onToast && onToast("Couldn't request changes: " + msg, 4500);
    } finally { setActing(false); setReqNoteText(""); }
  }

  function openRequestChanges(e) {
    if (e) e.stopPropagation();
    if (acting || !canRequestChanges) return;
    if (e && e.shiftKey) { runRequestChanges(""); return; }
    setReqNoteText("");
    setReqNoteAnchor(e ? e.currentTarget : null);
  }

  return (
    <span className="story-review-btns" style={{ display: "inline-flex", gap: 6 }}>
      <button type="button"
              className="story-row-action-btn is-approve"
              disabled={acting}
              onClick={approve}
              title="Approve & mark story done">
        <Icons.Check size={11}/> Approve
      </button>
      <button type="button"
              className="story-row-action-btn is-reject"
              disabled={acting || !canRequestChanges}
              onClick={openRequestChanges}
              title={canRequestChanges
                ? "Request changes — reverts every Done child task to Reopened. Shift+click to skip the note."
                : "Already in Changes Requested — wait for the dev to re-finish, then Approve."}>
        <Icons.Close size={11}/> Request changes
      </button>
      {reqNoteAnchor && typeof Popover !== "undefined" && (
        <Popover anchor={reqNoteAnchor} onClose={() => setReqNoteAnchor(null)}>
          <div style={{ padding: 10, minWidth: 280 }}
               onClick={(e) => e.stopPropagation()}>
            <div style={{ fontSize: 11, fontWeight: 700, letterSpacing: ".05em", textTransform: "uppercase", color: "var(--ink-muted)", marginBottom: 6 }}>
              Request changes
            </div>
            <div style={{ fontSize: 12, color: "var(--ink-body)", lineHeight: 1.4, marginBottom: 8 }}>
              Every Done child task reverts to Reopened. Owners are notified.
            </div>
            <textarea
              autoFocus
              value={reqNoteText}
              onChange={(e) => setReqNoteText(e.target.value)}
              onKeyDown={(e) => {
                if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
                  e.preventDefault();
                  runRequestChanges(reqNoteText.trim());
                }
                if (e.key === "Escape") { e.preventDefault(); setReqNoteAnchor(null); }
              }}
              placeholder="What needs to change? (optional)"
              style={{
                width: "100%", minHeight: 56, padding: "7px 8px",
                border: "1px solid var(--border)", borderRadius: 6,
                fontFamily: "inherit", fontSize: 12.5, resize: "vertical",
                outline: "none",
              }}/>
            <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 8 }}>
              <span style={{ fontSize: 10.5, color: "var(--ink-faint)" }}>⌘/Ctrl + Enter to send</span>
              <div style={{ display: "flex", gap: 6 }}>
                <button type="button" className="btn"
                        onClick={() => setReqNoteAnchor(null)}>Cancel</button>
                <button type="button" className="btn"
                        disabled={acting}
                        onClick={() => runRequestChanges(reqNoteText.trim())}
                        style={{ background: "#e2445c", borderColor: "#c0223a", color: "white", fontWeight: 700 }}>
                  Request & revert
                </button>
              </div>
            </div>
          </div>
        </Popover>
      )}
    </span>
  );
}

// ── Drawer ──────────────────────────────────────────────────────
function TaskDrawer({ task, open, onClose, onUpdate, sprints = SPRINTS, epics, onOpenFullView, currentUserId = "ay", onToast, onDelete, projectAccess, onOpenStory, onOpenTask }) {
  const epicList = Array.isArray(epics) ? epics : EPICS;
  const [tab, setTab] = React.useState("details"); // details | activity
  const [description, setDescription] = React.useState("");
  const [subtasks, setSubtasks] = React.useState([]);
  const [comments, setComments] = React.useState([]);
  const [activity, setActivity] = React.useState([]);
  // task_attachments rows for this task — fetched on open. Any file
  // type accepted (multer config in middleware/upload.js has no
  // fileFilter), default size cap 10 MB (override via UPLOAD_MAX_BYTES
  // env var on the server).
  const [attachments, setAttachments] = React.useState([]);
  // Per-file upload state — { id, name, progress, error }. Keyed by
  // a temp id so multiple parallel uploads can show progress at once.
  const [uploadingFiles, setUploadingFiles] = React.useState([]);
  const [attDragActive, setAttDragActive] = React.useState(false);
  const [newSubtask, setNewSubtask] = React.useState("");
  const [commentDraft, setCommentDraft] = React.useState("");
  const [completeModalOpen, setCompleteModalOpen] = React.useState(false);
  const [stDropTarget, setStDropTarget] = React.useState(null); // { id, side }
  const [stDraggingId, setStDraggingId] = React.useState(null);
  const [stJustDroppedId, setStJustDroppedId] = React.useState(null);
  const stDragRef = React.useRef(null);
  const stFlashTimer = React.useRef(null);

  // ── Subtask "Hide done" toggle ─────────────────────────────────
  // CRITICAL: this hook MUST live up here (above the `if (!task)
  // return null` early return). React's rules require hooks to be
  // called in the same order on every render — a hook declared
  // after a conditional return will be called on render N+1 but
  // not on render N (when task was null), tripping the "Rendered
  // more hooks than during the previous render" error and a white
  // screen on first task open.
  const [hideDoneSubs, setHideDoneSubs] = React.useState(() => {
    try { return localStorage.getItem("fb.drawer.subHideDone") === "1"; }
    catch { return false; }
  });
  React.useEffect(() => {
    try { localStorage.setItem("fb.drawer.subHideDone", hideDoneSubs ? "1" : "0"); } catch {}
  }, [hideDoneSubs]);

  function endStDrag() {
    setStDropTarget(null);
    setStDraggingId(null);
    stDragRef.current = null;
  }
  function celebrateSubtask(id) {
    setStJustDroppedId(id);
    if (stFlashTimer.current) clearTimeout(stFlashTimer.current);
    stFlashTimer.current = setTimeout(() => {
      setStJustDroppedId(j => j === id ? null : j);
      stFlashTimer.current = null;
    }, 700);
  }

  // Seed per-task state when drawer opens with a new task. Comments
  // and Activity start EMPTY (used to seed with hardcoded placeholders
  // that flashed before the API came back). Subtasks still seed from
  // the row's own subtasks shape because that's already real data
  // riding on the task object — nothing fake about it.
  React.useEffect(() => {
    if (!task) return;
    setDescription(task.description || task._description || "");
    setSubtasks(task._subtasks || seedSubtasks(task));
    setComments(task._comments || []);
    setActivity([]);
    setAttachments([]);
    setUploadingFiles([]);
    setTab("details");

    // Fetch real comments + activity in parallel. Subtasks come back on
    // the task.get() shape too.
    if (window.api && typeof task.id === "string" && !task.id.startsWith("tmp_")) {
      api.tasks.listComments(task.id)
        .then(rows => {
          if (!Array.isArray(rows)) return;
          setComments(rows.map(c => ({
            id: c.id,
            who: c.user_id,
            text: c.body,
            when: typeof fmtRelative === "function" ? fmtRelative(c.created_at) : "—",
          })));
        })
        .catch(() => {});

      api.tasks.get(task.id)
        .then(full => {
          if (!full) return;
          if (Array.isArray(full.subtasks)) {
            // Capture status/due/priority too so the drawer's subtask
            // list can render real status pills + due chips, not just
            // a binary checkbox. `done` is derived from status so the
            // checkbox and pill never drift.
            setSubtasks(full.subtasks.map(st => ({
              id: st.id,
              name: st.name,
              status: st.status || "todo",
              priority: st.priority || "none",
              due_date: st.due_date || null,
              owners: (st.owners || []).map(o => o.id || o),
            })));
          }
        })
        .catch(() => {});

      // Real audit-log feed from /api/tasks/:id/activity. Falls back to
      // an empty list if migration 012 hasn't run yet — the user just
      // sees "No activity yet" instead of fake events.
      if (api.tasks.listActivity) {
        api.tasks.listActivity(task.id)
          .then(rows => {
            if (!Array.isArray(rows)) return;
            setActivity(rows.map(r => ({
              id: r.id,
              who: r.user_id,
              kind: r.kind,
              icon: _kindToIcon(r.kind),
              text: r.body || _kindToText(r.kind),
              when: typeof fmtRelative === "function" ? fmtRelative(r.created_at) : "—",
            })));
          })
          .catch(() => {});
      }

      // Task attachments. Backend returns rows of:
      //   { id, task_id, filename, original_name, size_bytes, mime_type,
      //     created_at, uploaded_by }
      // Filename is the on-disk randomized name; original_name is what
      // the user sees. The download URL is /uploads/<filename>.
      if (api.tasks.listAttachments) {
        api.tasks.listAttachments(task.id)
          .then(rows => { if (Array.isArray(rows)) setAttachments(rows); })
          .catch(() => {});
      }
    }
  }, [task?.id]);

  // ── Attachment upload + delete ──
  // Accepts any file type. Server-side multer enforces fileSize via
  // UPLOAD_MAX_BYTES (default 10 MB). We parallel-upload multiple files
  // dropped at once, but track each upload's state independently so
  // partial failures don't block siblings.
  async function uploadAttachmentFiles(fileList) {
    if (!task || !window.api || typeof task.id !== "string" || task.id.startsWith("tmp_")) return;
    const files = Array.from(fileList || []).filter(Boolean);
    if (!files.length) return;
    // Stamp each file with a temp id so the in-flight UI can show a
    // skeleton row per upload. Removed from uploadingFiles on success
    // (the real row from /attachments takes its place) or on error.
    const stamped = files.map(f => ({
      tempId: "u_" + Date.now() + "_" + Math.random().toString(36).slice(2, 7),
      file: f,
      name: f.name,
      size: f.size,
      type: f.type,
      progress: 0,
      error: null,
    }));
    setUploadingFiles(prev => [...prev, ...stamped]);
    // Fire all uploads in parallel. Each settles independently.
    await Promise.all(stamped.map(async (it) => {
      try {
        await api.tasks.uploadAttachment(task.id, it.file);
        // Refresh the list from the server so we render the canonical
        // row (with real id + created_at + uploaded_by). Cheaper than
        // synthesizing locally.
        if (api.tasks.listAttachments) {
          const fresh = await api.tasks.listAttachments(task.id).catch(() => null);
          if (Array.isArray(fresh)) setAttachments(fresh);
        }
        setUploadingFiles(prev => prev.filter(x => x.tempId !== it.tempId));
        if (onToast) onToast(`Uploaded "${it.name}"`);
      } catch (e) {
        const msg = (e && e.body && (e.body.error || e.body.message))
          || (e && e.message) || "upload failed";
        // Surface "too large" specially since that's the most common
        // self-inflicted failure on default config.
        const big = /file.*too.*large|413|payload/i.test(String(e && e.status) + " " + msg);
        setUploadingFiles(prev => prev.map(x =>
          x.tempId === it.tempId ? { ...x, error: big ? "Too large (max ~10 MB)" : msg } : x
        ));
        if (onToast) onToast(big
          ? `"${it.name}" is too large — max ~10 MB per file`
          : `Couldn't upload "${it.name}": ${msg}`,
          5000);
      }
    }));
  }
  async function deleteAttachment(att) {
    if (!task || !att || !att.id) return;
    if (!window.confirm(`Delete "${att.original_name || att.filename}"?`)) return;
    const prev = attachments;
    setAttachments(a => a.filter(x => x.id !== att.id));
    try {
      await api.tasks.removeAttachment(task.id, att.id);
      if (onToast) onToast("Attachment deleted");
    } catch (e) {
      setAttachments(prev);
      if (onToast) onToast("Couldn't delete attachment: " + ((e && e.message) || "network error"), 4500);
    }
  }
  // Drag-and-drop onto the attachments pane.
  function onAttDragOver(e) {
    e.preventDefault(); e.stopPropagation();
    if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
    if (!attDragActive) setAttDragActive(true);
  }
  function onAttDragLeave(e) {
    e.preventDefault(); e.stopPropagation();
    setAttDragActive(false);
  }
  function onAttDrop(e) {
    e.preventDefault(); e.stopPropagation();
    setAttDragActive(false);
    const files = e.dataTransfer && e.dataTransfer.files;
    if (files && files.length) uploadAttachmentFiles(files);
  }
  // Format file size cleanly: "423 KB", "12.4 MB".
  function _fmtAttSize(bytes) {
    const n = Number(bytes || 0);
    if (n < 1024) return n + " B";
    if (n < 1024 * 1024) return Math.round(n / 1024) + " KB";
    return (n / 1024 / 1024).toFixed(n < 10 * 1024 * 1024 ? 1 : 0) + " MB";
  }
  // Pick a compact icon glyph based on MIME type so the list reads at a
  // glance — image vs PDF vs spreadsheet vs document vs archive vs
  // generic file. Single Unicode glyph so it works without an icon font.
  function _attIcon(mime) {
    const m = String(mime || "").toLowerCase();
    if (m.startsWith("image/"))                                return "🖼";
    if (m === "application/pdf")                               return "📕";
    if (m.includes("spreadsheet") || m === "text/csv" || m === "application/vnd.ms-excel") return "📊";
    if (m.includes("presentation"))                            return "📈";
    if (m.includes("word") || m === "application/rtf")         return "📝";
    if (m.startsWith("video/"))                                return "🎬";
    if (m.startsWith("audio/"))                                return "🎵";
    if (m.includes("zip") || m.includes("rar") || m.includes("7z") || m.includes("tar")) return "🗜";
    if (m.startsWith("text/") || m === "application/json" || m === "application/xml") return "📄";
    return "📎";
  }

  // ── Description rich-content refs (must be declared before any early return
  //    to satisfy React's Rules of Hooks) ──
  const descRef = React.useRef(null);
  const descFileRef = React.useRef(null);

  // Seed innerHTML once per task (avoids React clobbering caret on re-render)
  React.useEffect(() => {
    if (descRef.current && task) {
      descRef.current.innerHTML = task.description || task._description || "";
    }
  }, [task?.id]);

  if (!task) return null;

  const epic = task.epicId ? (epicList.find(e => e.id === task.epicId) || EPICS.find(e => e.id === task.epicId)) : null;
  const sprintObj = sprints.find(s => s.id === task.sprint);

  // Sprint picker is scoped to the task's own project so a Checkout
  // task can't accidentally be moved into a CRM-only sprint. Falls
  // back to the full list when the task isn't tied to a project (e.g.
  // legacy data) so users still see options.
  const taskProjectId = task.projectId || task.project_id || null;
  const projectSprints = taskProjectId
    ? sprints.filter(s => s.project_id === taskProjectId || s.projectId === taskProjectId)
    : sprints;

  // Owner picker is scoped to project members. We read project_access
  // (mirrored from the React `access` map in app.jsx) so updates after
  // adding/removing a member through the project-access modal flow
  // through immediately. Currently-assigned owners stay visible even
  // if they're not project members so a stale row never silently drops
  // them — they're just rendered with a small "(no longer in project)"
  // hint underneath.
  const accessMap = projectAccess || (typeof window !== "undefined" ? window.PROJECT_ACCESS : null) || {};
  const projectMemberIds = taskProjectId
    ? new Set((accessMap[taskProjectId]?.members || []).map(m => m.id))
    : null;
  const assignablePeople = projectMemberIds
    ? PEOPLE.filter(p => projectMemberIds.has(p.id) || (task.owners || []).includes(p.id))
    : PEOPLE;

  const logActivity = (entry) => setActivity(a => [{ ...entry, when: "just now" }, ...a]);

  // ── Actions ──
  const update = (patch, activityEntry) => {
    onUpdate && onUpdate(task.id, patch);
    if (activityEntry) logActivity(activityEntry);
  };
  const renameTask = (val) => {
    if (val !== task.name) {
      update({ name: val }, { who: currentUserId || "ay", icon: "edit", text: `renamed task` });
    }
  };
  const saveDescription = (val) => {
    setDescription(val);
    const current = task.description || task._description || "";
    if (val !== current) {
      update({ _description: val, description: val }, { who: currentUserId || "ay", icon: "edit", text: `updated description` });
    }
  };

  function commitDescriptionFromDOM() {
    if (!descRef.current) return;
    saveDescription(descRef.current.innerHTML);
  }
  function insertHtmlAtCaret(html) {
    if (!descRef.current) return;
    descRef.current.focus();
    const sel = window.getSelection();
    let range;
    if (sel && sel.rangeCount > 0 && descRef.current.contains(sel.anchorNode)) {
      range = sel.getRangeAt(0);
    } else {
      range = document.createRange();
      range.selectNodeContents(descRef.current);
      range.collapse(false);
    }
    range.deleteContents();
    const tpl = document.createElement("template");
    tpl.innerHTML = html;
    const frag = tpl.content;
    const lastNode = frag.lastChild;
    range.insertNode(frag);
    if (lastNode && sel) {
      const newRange = document.createRange();
      newRange.setStartAfter(lastNode);
      newRange.collapse(true);
      sel.removeAllRanges();
      sel.addRange(newRange);
    }
    commitDescriptionFromDOM();
  }
  // Downscale + recompress before turning the file into a data: URL.
  // Bug BG_9D39977B23 — pasting a screenshot from a 4K monitor produces
  // a 4–6 MB base64 string, which (a) blows past the 25 MB express body
  // limit on older deploys, (b) makes the task's description column
  // bloat, and (c) silently fails to save on slow links. Resizing to
  // max 1600px on the longest side at JPEG quality 0.85 turns a 6 MB
  // PNG paste into ~200 KB — well within every constraint, and visually
  // indistinguishable for task documentation. PNG with transparency is
  // preserved when the image is small (≤500px) so icons / logos with
  // alpha don't get flattened to a JPEG background.
  function _resizeImageToDataUrl(file, { maxSide = 1600, quality = 0.85 } = {}) {
    return new Promise((resolve, reject) => {
      try {
        const reader = new FileReader();
        reader.onload = () => {
          const img = new Image();
          img.onload = () => {
            try {
              const w = img.naturalWidth || img.width;
              const h = img.naturalHeight || img.height;
              if (!w || !h) return reject(new Error("zero-dim image"));
              // Already small? Hand back the original data URL (preserves
              // any animated GIF / SVG / icon transparency.)
              if (w <= maxSide && h <= maxSide && (file.size || 0) < 300 * 1024) {
                return resolve(String(reader.result || ""));
              }
              const scale = Math.min(1, maxSide / Math.max(w, h));
              const tw = Math.max(1, Math.round(w * scale));
              const th = Math.max(1, Math.round(h * scale));
              const cv = document.createElement("canvas");
              cv.width = tw; cv.height = th;
              const ctx = cv.getContext("2d");
              if (!ctx) return reject(new Error("no canvas ctx"));
              ctx.drawImage(img, 0, 0, tw, th);
              // Preserve PNG (with alpha) for small icons; JPEG everywhere
              // else for the 10x smaller filesize win.
              const out = (file.type === "image/png" && Math.max(tw, th) <= 500)
                ? cv.toDataURL("image/png")
                : cv.toDataURL("image/jpeg", quality);
              resolve(out);
            } catch (e) { reject(e); }
          };
          img.onerror = () => reject(new Error("image decode failed"));
          img.src = String(reader.result || "");
        };
        reader.onerror = () => reject(new Error("file read failed"));
        reader.readAsDataURL(file);
      } catch (e) { reject(e); }
    });
  }
  async function readImageFile(file) {
    if (!file || !file.type || !file.type.startsWith("image/")) return;
    let dataUrl;
    try {
      dataUrl = await _resizeImageToDataUrl(file);
    } catch (_) {
      // Fall back to the raw bytes — better an oversized save attempt
      // than a silent drop. Toast below will surface the failure if the
      // server rejects it.
      dataUrl = await new Promise((res) => {
        const r = new FileReader();
        r.onload = () => res(String(r.result || ""));
        r.onerror = () => res("");
        r.readAsDataURL(file);
      });
    }
    if (!dataUrl) {
      if (typeof onToast === "function") onToast("Couldn't read that image — try a smaller file.", 4500);
      return;
    }
    const safeName = (file.name || "image").replace(/"/g, "");
    insertHtmlAtCaret(`<img class="drawer-desc-img" src="${dataUrl}" alt="${safeName}" />`);
  }
  function onPasteDesc(e) {
    const items = (e.clipboardData && e.clipboardData.items) || [];
    for (const it of items) {
      if (it && it.type && it.type.startsWith("image/")) {
        const f = it.getAsFile();
        if (f) {
          e.preventDefault();
          readImageFile(f);
          return;
        }
      }
    }
  }
  function onDropDesc(e) {
    const files = e.dataTransfer && e.dataTransfer.files;
    if (!files || !files.length) return;
    const f = Array.from(files).find(x => x.type && x.type.startsWith("image/"));
    if (f) {
      e.preventDefault();
      readImageFile(f);
    }
  }

  const isPersistedSubtaskId = (id) => typeof id === "number" || (typeof id === "string" && !id.startsWith("tmp_"));
  const isPersistedTaskId = task && typeof task.id === "string" && !task.id.startsWith("tmp_");

  const toggleSubtask = (id) => {
    const sub = subtasks.find(s => s.id === id);
    if (!sub) return;
    // Done is derived from status now — the checkbox flips between
    // "done" and "todo" so the pill stays in sync without us
    // tracking a separate `done` field.
    const nextStatus = sub.status === "done" ? "todo" : "done";
    setSubtaskStatus(id, nextStatus);
  };

  const setSubtaskStatus = (id, status) => {
    const sub = subtasks.find(s => s.id === id);
    if (!sub || sub.status === status) return;
    const prev = subtasks;
    setSubtasks(list => list.map(s => s.id === id ? { ...s, status } : s));
    if (window.api && isPersistedSubtaskId(id)) {
      api.tasks.patch(id, { status })
        .catch(e => {
          setSubtasks(prev);
          onToast && onToast("Couldn't update subtask: " + (e.message || "network error"), 4000);
        });
    }
  };

  const setSubtaskDue = (id, due_date) => {
    const sub = subtasks.find(s => s.id === id);
    if (!sub) return;
    const norm = due_date || null;
    if ((sub.due_date || null) === norm) return;
    const prev = subtasks;
    setSubtasks(list => list.map(s => s.id === id ? { ...s, due_date: norm } : s));
    if (window.api && isPersistedSubtaskId(id)) {
      api.tasks.patch(id, { due_date: norm })
        .catch(e => {
          setSubtasks(prev);
          onToast && onToast("Couldn't update due date: " + (e.message || "network error"), 4000);
        });
    }
  };
  const renameSubtask = (id, name) => {
    const sub = subtasks.find(s => s.id === id);
    if (!sub || sub.name === name) return;
    const prev = subtasks;
    setSubtasks(list => list.map(s => s.id === id ? { ...s, name } : s));
    if (window.api && isPersistedSubtaskId(id)) {
      api.tasks.patch(id, { name })
        .catch(e => {
          setSubtasks(prev);
          onToast && onToast("Couldn't rename subtask: " + (e.message || "network error"), 4000);
        });
    }
  };
  const deleteSubtask = (id) => {
    const prev = subtasks;
    setSubtasks(list => list.filter(s => s.id !== id));
    if (window.api && isPersistedSubtaskId(id)) {
      api.tasks.remove(id)
        .then(() => onToast && onToast("Subtask deleted"))
        .catch(e => {
          setSubtasks(prev);
          onToast && onToast("Couldn't delete subtask: " + (e.message || "network error"), 4000);
        });
    }
  };
  const toggleSubtaskOwner = (id, who) => {
    const sub = subtasks.find(s => s.id === id);
    if (!sub) return;
    const current = sub.owners || [];
    const next = current.includes(who) ? current.filter(o => o !== who) : [...current, who];
    const prev = subtasks;
    setSubtasks(list => list.map(s => s.id === id ? { ...s, owners: next } : s));
    const person = PEOPLE.find(p => p.id === who);
    const verb = current.includes(who) ? "unassigned" : "assigned";
    logActivity({
      who: currentUserId, icon: "owner",
      text: `${verb} ${person?.name ?? who} ${verb === "assigned" ? "to" : "from"} subtask "${sub.name}"`,
    });
    if (window.api && isPersistedSubtaskId(id)) {
      api.tasks.assignees(id, next)
        .catch(e => {
          setSubtasks(prev);
          onToast && onToast("Couldn't update subtask owners: " + (e.message || "network error"), 4000);
        });
    }
  };
  const reorderSubtasks = (sourceId, targetId, side) => {
    if (!sourceId || sourceId === targetId) return;
    const prev = subtasks;
    const list = subtasks.slice();
    const fromIdx = list.findIndex(s => s.id === sourceId);
    if (fromIdx === -1) return;
    const [moved] = list.splice(fromIdx, 1);
    let toIdx = list.findIndex(s => s.id === targetId);
    if (toIdx === -1) toIdx = list.length;
    if (side === "below") toIdx += 1;
    list.splice(toIdx, 0, moved);
    setSubtasks(list);
    // Persist if all subtasks have real ids and parent task is persisted
    if (window.api && isPersistedTaskId && list.every(s => isPersistedSubtaskId(s.id))) {
      api.tasks.reorderSubtasks(task.id, list.map(s => s.id))
        .catch(e => {
          setSubtasks(prev);
          onToast && onToast("Couldn't reorder subtasks: " + (e.message || "network error"), 4000);
        });
    }
  };

  const addSubtask = () => {
    const n = newSubtask.trim();
    if (!n) return;
    const defaultOwners = (task.owners && task.owners.length) ? [task.owners[0]] : [currentUserId];
    const tempId = "tmp_st_" + Date.now();
    // IMPORTANT: shape MUST match the upgraded subtask list (status / priority /
    // due_date), not the legacy `done` field. Otherwise the row gets pushed
    // into state but the renderer's status pill / due chip / Hide-done filter
    // can't find the fields they expect — and on some renders the row simply
    // doesn't surface because intermediate helpers (e.g. visibleSubs) treat the
    // field as missing/undefined and the row blends in with stale data — the
    // user reported "I have to refresh to see the new subtask" because of this.
    setSubtasks(list => [...list, {
      id: tempId,
      name: n,
      status: "todo",
      priority: "medium",
      due_date: null,
      owners: defaultOwners,
    }]);
    setNewSubtask("");
    logActivity({ who: currentUserId, icon: "plus", text: `added subtask "${n}"` });

    if (window.api && isPersistedTaskId) {
      // Mark a local edit so realtime.js's tasks.created echo doesn't trigger
      // an extra api.tasks.get round-trip that races our id-swap below.
      try {
        if (window.flowboardRealtime && window.flowboardRealtime.markLocalEdit) {
          window.flowboardRealtime.markLocalEdit(tempId);
        }
      } catch {}
      api.tasks.create({
        project_id: task.projectId || null,
        parent_task_id: task.id,
        name: n,
        status: "todo",
        priority: "medium",
        points: 0,
        owners: defaultOwners,
      })
        .then(r => {
          if (r && r.id != null) {
            // Swap tempId → realId AND mark the real id as locally-edited so
            // the SSE echo for tasks.created/updated doesn't double-patch.
            try {
              if (window.flowboardRealtime && window.flowboardRealtime.markLocalEdit) {
                window.flowboardRealtime.markLocalEdit(r.id);
              }
            } catch {}
            setSubtasks(list => list.map(s => s.id === tempId ? { ...s, id: r.id } : s));
            onToast && onToast("Subtask added");
          }
        })
        .catch(e => {
          setSubtasks(list => list.filter(s => s.id !== tempId));
          onToast && onToast("Couldn't add subtask: " + (e.message || "network error"), 4000);
        });
    }
  };

  const postComment = () => {
    const text = commentDraft.trim();
    if (!text) return;
    const tempId = "tmp_c_" + Date.now();
    const optimistic = { id: tempId, who: currentUserId, text, when: "just now" };
    setComments(cs => [...cs, optimistic]);
    setCommentDraft("");
    logActivity({ who: currentUserId, icon: "chat", text: `commented` });

    if (window.api && typeof task.id === "string" && !task.id.startsWith("tmp_")) {
      api.tasks.addComment(task.id, { body: text })
        .then(r => {
          // swap temp id with real numeric id from server
          if (r && r.id != null) {
            setComments(cs => cs.map(c => c.id === tempId ? { ...c, id: r.id } : c));
          }
          onToast && onToast("Comment posted");
        })
        .catch(e => {
          // rollback
          setComments(cs => cs.filter(c => c.id !== tempId));
          onToast && onToast("Couldn't post comment: " + (e.message || "network error"), 4000);
        });
    }
  };

  const doneCount = subtasks.filter(s => s.status === "done").length;
  // Overdue = open subtask whose due_date is in the past. Surfaces a
  // small red chip in the section header so the parent task's
  // owner sees at a glance how much is slipping.
  const overdueCount = (() => {
    const today = new Date(); today.setHours(0,0,0,0);
    let n = 0;
    for (const s of subtasks) {
      if (s.status === "done") continue;
      if (!s.due_date) continue;
      const d = new Date(String(s.due_date).split(" @ ")[0]);
      if (!isNaN(d) && d < today) n++;
    }
    return n;
  })();
  // hideDoneSubs is declared up at the top (above the early return)
  // so React's hook order stays stable. Just compute the derived
  // list here.
  const visibleSubs = hideDoneSubs ? subtasks.filter(s => s.status !== "done") : subtasks;
  const subPct = subtasks.length ? (doneCount / subtasks.length) * 100 : 0;

  return (
    <>
      <div className={`drawer-backdrop ${open ? "is-open" : ""}`} onClick={onClose}/>
      <div className={`drawer ${open ? "is-open" : ""}`}>
        <div className="drawer-head">
          <div className="meta">
            <div className="drawer-breadcrumb">
              Checkout v2 <Icons.ChevronRt/> <b>{epic?.title || "Quick tasks"}</b>
            </div>
            <div className="drawer-id">#{task?.id?.toUpperCase()}</div>
            {/* Task type chip — click to re-classify (task / bug /
                chore / spike). Bug BG_02E81DCE4B — Nandana asked for a
                way to mark existing rows as bugs; the New Task modal
                already had this picker but the drawer didn't, so any
                task created without specifying type was permanently
                stuck on "task". */}
            {task && (
              <TaskTypeChip task={task}
                            onChange={(next) => update({ type: next },
                              { who: currentUserId || "ay", icon: "edit", text: `changed type to ${next}` })}/>
            )}
            {/* Parent-task chip — visible only when THIS task is a
                subtask. Click jumps the drawer to the parent so the
                user can navigate up the tree without closing first.
                Falls back gracefully if the parent isn't loaded. */}
            {task && task.parentTaskId && (() => {
              const all = (typeof window !== "undefined" && Array.isArray(window.ALL_TASKS))
                ? window.ALL_TASKS : [];
              const parent = all.find(t => t && t.id === task.parentTaskId);
              if (!parent) return null;
              return (
                <button type="button"
                        className="parent-chip"
                        title={`Parent task · ${parent.name}`}
                        style={{ marginTop: 6 }}
                        onClick={() => onOpenTask ? onOpenTask(parent) : (onOpenFullView && onOpenFullView(parent))}>
                  <span className="parent-chip-glyph" aria-hidden="true">↳</span>
                  <span className="parent-chip-label">in</span>
                  <span className="parent-chip-name">{parent.name}</span>
                </button>
              );
            })()}
            {/* Story chip — visible only when this task is attached to
                a story. Click opens the story drawer (Approve / Request
                changes UI lives there). */}
            {task && task.userStoryId && (() => {
              const allStories = (typeof USER_STORIES !== "undefined" && Array.isArray(USER_STORIES))
                ? USER_STORIES : [];
              const s = allStories.find(x => x.id === task.userStoryId);
              if (!s) return null;
              const stat = STORY_STATUSES.find(x => x.id === s.status) || STORY_STATUSES[0];
              const reviewerIds = Array.isArray(s.reviewers) ? s.reviewers : [];
              const isReviewer  = !!(currentUserId && reviewerIds.includes(currentUserId));
              // Approve / Request changes show inline on the task
              // drawer whenever the parent story wants the current
              // user's attention. Without this, reviewers had to
              // click the story chip → wait for the StoryDrawer to
              // mount → only then could they act. Saves a step on
              // the most common reviewer workflow (open the task that
              // notified them and decide right there).
              const storyNeedsReview = isReviewer && (s.status === "review" || s.status === "changes_req");
              const canRequestChanges = storyNeedsReview && s.status === "review";
              return (
                <div className="task-story-block" style={{ display: "flex", flexWrap: "wrap", gap: 6, alignItems: "center", marginTop: 6 }}>
                  <button type="button"
                          className="story-chip"
                          title={`User story · ${stat.label}`}
                          onClick={() => onOpenStory && onOpenStory(s.id)}>
                    <span className="story-chip-emoji" aria-hidden="true">📖</span>
                    <span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>{s.title}</span>
                    {(s.status === "review" || s.status === "changes_req" || s.status === "done") && (
                      <span className={`story-chip-status is-${s.status}`}>
                        {stat.label}
                      </span>
                    )}
                  </button>
                  {storyNeedsReview && (
                    <StoryReviewButtons
                      story={s}
                      canRequestChanges={canRequestChanges}
                      onToast={onToast}/>
                  )}
                </div>
              );
            })()}
          </div>
          <div className="drawer-head-actions">
            {/* Copy a shareable link to this task. The hash form
                #/tasks/<id> is recognised by the global page-load
                handler in app.jsx — paste it into another tab or
                send it to a teammate and clicking it lands them
                directly on this task drawer. */}
            <CopyTaskLinkButton task={task} onToast={onToast}/>
            <button className="icon-btn" onClick={() => onOpenFullView && onOpenFullView(task)} title="Open full view">
              <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M8.5 2H12v3.5M5.5 12H2V8.5M12 2L8 6M2 12l4-4"/></svg>
            </button>
            <button className="icon-btn" title="Move task to Bin (kept for 30 days)"
                    onClick={() => {
                      if (!onDelete) return;
                      // Use the in-app modal when available, otherwise fall
                      // back to the native confirm. Wrapped in a regular
                      // function (no async/await) — we Promise.then on the
                      // result so any error or unmount mid-flow can't take
                      // the drawer down with it.
                      const ask = window.fbConfirm
                        ? window.fbConfirm({
                            title: `Move "${task.name}" to Bin?`,
                            body: "It'll stay there for 30 days — restore anytime from the Bin in the sidebar, or use the Undo toast that pops up after.",
                            confirmLabel: "Move to Bin",
                            danger: true,
                            icon: "🗑",
                          })
                        : Promise.resolve(window.confirm(`Move "${task.name}" to Bin?`));
                      Promise.resolve(ask)
                        .then((ok) => { if (ok) onDelete(task.id); })
                        .catch((err) => console.warn("[drawer] confirm failed:", err));
                    }}
                    style={{ color: "#c0223a" }}>
              <Icons.Trash size={14}/>
            </button>
            <span className="drawer-head-divider"/>
            <button className="icon-btn" onClick={onClose} title="Close"><Icons.Close/></button>
          </div>
        </div>

        <div className="drawer-body">
          <h2 className="drawer-task-title"
              contentEditable suppressContentEditableWarning
              onBlur={(e) => renameTask(e.currentTarget.textContent.trim())}
              onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); e.currentTarget.blur(); } }}>
            {task.name}
          </h2>

          {/* Top-of-body tab strip — Details / Activity / Comment. Used
              to live at the BOTTOM of the drawer (after subtasks) with
              only two tabs, where the "Details" label was misleadingly
              applied to the Comments view. Moving the tabs up promotes
              the activity log + comment thread to first-class peers of
              the task fields, and renames the labels to match what each
              pane actually shows. */}
          <div className="drawer-tabs" style={{ marginTop: 4, marginBottom: 12 }}>
            <button className={`drawer-tab ${tab === "details" ? "is-active" : ""}`}
                    onClick={() => setTab("details")}>
              <Icons.Note size={13}/> Details
            </button>
            <button className={`drawer-tab ${tab === "activity" ? "is-active" : ""}`}
                    onClick={() => setTab("activity")}>
              <Icons.Activity size={13}/> Activity
              {activity.length > 0 && (
                <span style={{ fontSize: 11, color: "var(--ink-muted)", marginLeft: 4 }}>{activity.length}</span>
              )}
            </button>
            <button className={`drawer-tab ${tab === "comments" ? "is-active" : ""}`}
                    onClick={() => setTab("comments")}>
              <Icons.MessageSq size={13}/> Comment
              {comments.length > 0 && (
                <span style={{ fontSize: 11, color: "var(--ink-muted)", marginLeft: 4 }}>{comments.length}</span>
              )}
            </button>
            <button className={`drawer-tab ${tab === "attachments" ? "is-active" : ""}`}
                    onClick={() => setTab("attachments")}>
              <Icons.Paperclip size={13}/> Attachment
              {(attachments.length + uploadingFiles.length) > 0 && (
                <span style={{ fontSize: 11, color: "var(--ink-muted)", marginLeft: 4 }}>
                  {attachments.length + uploadingFiles.length}
                </span>
              )}
            </button>
          </div>

          {tab === "details" && (
          <div className="drawer-pane-details">

          <dl className="drawer-grid">
            <dt>Status</dt>
            <dd>
              <EditField render={(close) => (
                STATUSES.map(s => (
                  <div key={s.id} className="popover-item" onClick={(e) => {
                    if (s.id === "done" && task.status !== "done") {
                      // Brief celebration on the row, then either open complete modal or just update
                      const el = e.currentTarget;
                      el.classList.add("is-celebrating");
                      setTimeout(() => {
                        if (typeof CompleteTaskModal !== "undefined") {
                          setCompleteModalOpen(true);
                        } else {
                          update({ status: s.id }, { who: currentUserId || "ay", icon: "status", text: `changed status to ${s.label}` });
                        }
                        close();
                      }, 520);
                      return;
                    }
                    update({ status: s.id }, { who: currentUserId || "ay", icon: "status", text: `changed status to ${s.label}` });
                    close();
                  }}>
                    <span className={`pill pill-sm ${s.cls}`} style={{ minWidth: 80 }}>{s.label}</span>
                    {s.id === "done" && (
                      <span className="popover-spark" aria-hidden="true">
                        <span/><span/><span/><span/><span/><span/>
                      </span>
                    )}
                  </div>
                ))
              )}>
                <StatusPill status={task.status} fill={false}/>
              </EditField>
            </dd>

            <dt>Priority</dt>
            <dd>
              <EditField render={(close) => (
                PRIORITIES.map(p => (
                  <div key={p.id} className="popover-item" onClick={() => {
                    update({ prio: p.id }, { who: currentUserId || "ay", icon: "flag", text: `set priority to ${p.label}` });
                    close();
                  }}>
                    <span className={`pill pill-sm ${p.cls}`} style={{ minWidth: 70 }}>{p.label}</span>
                  </div>
                ))
              )}>
                <PriorityPill prio={task.prio} fill={false}/>
              </EditField>
            </dd>

            {/* Channel — only surfaced when the parent project has
                content-calendar mode turned on. The list of channels
                comes from window.CHANNELS (defined in calendar.jsx). */}
            {(() => {
              const proj = (typeof PROJECTS !== "undefined")
                ? PROJECTS.find(p => p.id === task.projectId)
                : null;
              if (!proj || !proj.isContentCalendar) return null;
              const channels = (typeof window !== "undefined" && Array.isArray(window.CHANNELS))
                ? window.CHANNELS : [];
              const current = task.channel
                ? channels.find(c => c.id === task.channel) : null;
              return (
                <>
                  <dt>Channel</dt>
                  <dd>
                    <EditField render={(close) => (
                      <div style={{ maxHeight: 320, overflow: "auto" }}>
                        <div className="popover-item" onClick={() => {
                          update({ channel: null }, { who: currentUserId || "ay", icon: "flag", text: "cleared the channel" });
                          close();
                        }}>
                          <span style={{
                            display: "inline-flex", alignItems: "center", justifyContent: "center",
                            width: 18, height: 18, borderRadius: 999,
                            background: "#e6e9ef", color: "#676879",
                            fontWeight: 700, fontSize: 9
                          }}>—</span>
                          <span style={{ flex: 1 }}>No channel</span>
                          {!task.channel && <Icons.Check size={13} style={{ color: "var(--brand)" }}/>}
                        </div>
                        <div style={{ height: 1, background: "var(--border)", margin: "4px 0" }}/>
                        {channels.map(c => (
                          <div key={c.id} className="popover-item" onClick={() => {
                            update({ channel: c.id }, { who: currentUserId || "ay", icon: "flag", text: `tagged as ${c.label}` });
                            close();
                          }}>
                            {typeof ChannelPill === "function"
                              ? <ChannelPill id={c.id} size="lg"/>
                              : <span style={{
                                  display: "inline-flex", alignItems: "center", justifyContent: "center",
                                  width: 18, height: 18, borderRadius: 999,
                                  background: c.color, color: "#fff",
                                  fontWeight: 800, fontSize: 9
                                }}>{c.short}</span>}
                            <span style={{ flex: 1 }}>{c.label}</span>
                            {task.channel === c.id && <Icons.Check size={13} style={{ color: "var(--brand)" }}/>}
                          </div>
                        ))}
                      </div>
                    )}>
                      {current ? (
                        <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
                          {typeof ChannelPill === "function"
                            ? <ChannelPill id={current.id} size="lg"/>
                            : <span style={{
                                display: "inline-flex", alignItems: "center", justifyContent: "center",
                                width: 18, height: 18, borderRadius: 999,
                                background: current.color, color: "#fff",
                                fontWeight: 800, fontSize: 9
                              }}>{current.short}</span>}
                          <span style={{ fontWeight: 500 }}>{current.label}</span>
                        </span>
                      ) : (
                        <span style={{ color: "var(--ink-muted)", fontSize: 13 }}>—</span>
                      )}
                    </EditField>
                  </dd>
                </>
              );
            })()}

            <dt>Owners</dt>
            <dd>
              <EditField render={(close) => (
                <div style={{ maxHeight: 260, overflow: "auto" }}>
                  {assignablePeople.length === 0 && (
                    <div style={{ padding: "10px 12px", fontSize: 12, color: "var(--ink-muted)" }}>
                      This project has no members yet. Add someone from the
                      project header (<b>+ Add member</b>) and they'll show up here.
                    </div>
                  )}
                  {/* Quick-action: assign to me. Hidden if you're already
                      on the task. Stays at the top of the popover so it's
                      a single click from any state. */}
                  {currentUserId && !task.owners.includes(currentUserId) && (
                    <>
                      <div className="popover-item"
                           style={{ color: "var(--brand)", fontWeight: 600 }}
                           onClick={() => {
                             update({ owners: [...task.owners, currentUserId] },
                                    { who: currentUserId, icon: "owner", text: "assigned themselves" });
                             close();
                           }}>
                        <Icons.Plus size={13}/>
                        <span style={{ flex: 1 }}>Assign to me</span>
                      </div>
                      <div style={{ height: 1, background: "var(--border)", margin: "4px 0" }}/>
                    </>
                  )}
                  {assignablePeople.map(p => {
                    const on = task.owners.includes(p.id);
                    const stale = projectMemberIds && !projectMemberIds.has(p.id);
                    return (
                      <div key={p.id} className="popover-item" onClick={() => {
                        const next = on ? task.owners.filter(o => o !== p.id) : [...task.owners, p.id];
                        update({ owners: next });
                        // Stays open to allow multi-select
                      }}>
                        <Avatar person={p} size="sm"/>
                        <div style={{ flex: 1, minWidth: 0 }}>
                          <div>{p.name}</div>
                          {stale && (
                            <div style={{ fontSize: 10.5, color: "var(--ink-faint)", marginTop: 1 }}>
                              no longer in project
                            </div>
                          )}
                        </div>
                        {on && <Icons.Check size={13} style={{ color: "var(--brand)" }}/>}
                      </div>
                    );
                  })}
                  {task.owners.length > 0 && (
                    <>
                      <div style={{ height: 1, background: "var(--border)", margin: "4px 0" }}/>
                      <div className="popover-item" style={{ color: "var(--ink-muted)" }} onClick={() => {
                        update({ owners: [] }, { who: currentUserId || "ay", icon: "owner", text: `unassigned everyone` });
                        close();
                      }}>
                        <Icons.Close size={12}/>
                        <span style={{ flex: 1 }}>Unassign all</span>
                      </div>
                    </>
                  )}
                </div>
              )}>
                <AvatarStack ids={task.owners} max={4}/>
                <span style={{ color: "var(--ink-muted)", marginLeft: 4, fontSize: 12 }}>
                  {task.owners.length
                    ? task.owners.slice(0, 2).map(id => PEOPLE.find(p => p.id === id)?.name.split(" ")[0]).join(", ")
                      + (task.owners.length > 2 ? ` +${task.owners.length - 2}` : "")
                    : <span className="drawer-edit-empty">Unassigned</span>}
                </span>
              </EditField>
            </dd>

            <dt>Due date</dt>
            <dd>
              <EditField render={(close) => (
                // Real calendar + time picker, identical to the task
                // table's due cell. window.MiniCalendar is defined in
                // table.jsx and supports value / onChange / onClear with
                // the optional "@ HH:MM" time row.
                typeof MiniCalendar === "function" ? (
                  <MiniCalendar
                    value={task.due}
                    // Keep the popover OPEN after picking a date so the
                    // user can also enter a time without having to
                    // re-open it. MiniCalendar fires onChange for both
                    // the date click and any subsequent time edits, so
                    // we just persist each edit and let the user click
                    // outside (Popover's onClose) when they're done.
                    onChange={(d) => {
                      update({ due: d }, { who: currentUserId, kind: "due", icon: "cal", text: `set due date to ${d}` });
                    }}
                    // Clear is the only intentional "close now" path —
                    // they explicitly hit the Clear button so we both
                    // persist the empty value and dismiss the popover.
                    onClear={() => {
                      update({ due: "—" }, { who: currentUserId, kind: "due", icon: "cal", text: `cleared the due date` });
                      close();
                    }}
                  />
                ) : (
                  // Defensive fallback if MiniCalendar isn't loaded yet —
                  // keeps the drawer usable rather than crashing.
                  <div style={{ padding: 8, fontSize: 12, color: "var(--ink-muted)" }}>
                    Calendar loading…
                  </div>
                )
              )}>
                <Icons.Calendar size={13}/>
                {task.due === "—"
                  ? <span className="drawer-edit-empty">No date</span>
                  : (() => {
                      const overdue = typeof isOverdueNow === "function" && isOverdueNow(task);
                      return (
                        <span style={overdue ? { color: "var(--status-blocked)", fontWeight: 600 } : undefined}>
                          {task.due}
                          {/* Inline overdue badge — gives weight to the
                              warning right where the user is editing the
                              date, instead of hiding it up by the title. */}
                          {overdue && typeof OverdueBadge !== "undefined" && (
                            <span style={{ marginLeft: 8 }}>
                              <OverdueBadge task={task} size="tiny"/>
                            </span>
                          )}
                        </span>
                      );
                    })()
                }
              </EditField>
            </dd>

            {/* Repeat — recurring task rule. Defined inline below; uses
                window.api.recurrence under the hood. The trigger pill
                shows either "Doesn't repeat" or a humanised summary
                ("Every Mon, Wed, Fri") and opens the picker on click. */}
            <dt>Repeat</dt>
            <dd>
              {/* Pass onTaskPatch so the parent's tasks state and
                  window.ALL_TASKS reflect the new recurrence_rule_id
                  + is_recurring_template flags immediately — without
                  this, the ↻ icon in the table only shows after a
                  page refresh. */}
              <RecurrenceField taskId={task.id} task={task}
                onTaskPatch={(patch) => update(patch)}/>
            </dd>

            <dt>Sprint</dt>
            <dd>
              <EditField render={(close) => (
                <div>
                  <div style={{ fontSize: 10, color: "var(--ink-muted)", padding: "4px 10px 2px", fontWeight: 600, letterSpacing: ".06em", textTransform: "uppercase" }}>Active</div>
                  {projectSprints.filter(s => s.active).map(s => (
                    <div key={s.id} className="popover-item" onClick={() => {
                      update({ sprint: s.id }, { who: currentUserId || "ay", icon: "sprint", text: `moved to ${s.label}` });
                      close();
                    }}>
                      <Icons.Lightning size={12} style={{ color: "var(--brand)" }}/>
                      <div style={{ flex: 1 }}>
                        <div style={{ fontWeight: 600, fontSize: 13 }}>{s.label}</div>
                        <div style={{ fontSize: 11, color: "var(--ink-muted)" }}>{s.team} · {s.dates}</div>
                      </div>
                      {s.id === task.sprint && <Icons.Check size={13} style={{ color: "var(--brand)" }}/>}
                    </div>
                  ))}
                  <div style={{ fontSize: 10, color: "var(--ink-muted)", padding: "8px 10px 2px", fontWeight: 600, letterSpacing: ".06em", textTransform: "uppercase" }}>Upcoming</div>
                  {projectSprints.filter(s => !s.active && !s.completed).map(s => (
                    <div key={s.id} className="popover-item" onClick={() => {
                      update({ sprint: s.id }, { who: currentUserId || "ay", icon: "sprint", text: `moved to ${s.label}` });
                      close();
                    }}>
                      <Icons.Calendar size={12} style={{ color: "var(--ink-muted)" }}/>
                      <div style={{ flex: 1 }}>
                        <div style={{ fontSize: 13 }}>{s.label}</div>
                        <div style={{ fontSize: 11, color: "var(--ink-muted)" }}>{s.dates}</div>
                      </div>
                      {s.id === task.sprint && <Icons.Check size={13} style={{ color: "var(--brand)" }}/>}
                    </div>
                  ))}
                  <div style={{ height: 1, background: "var(--border)", margin: "4px 0" }}/>
                  <div className="popover-item" onClick={() => {
                    update({ sprint: null }, { who: currentUserId || "ay", icon: "sprint", text: `removed from sprint` });
                    close();
                  }}>
                    <Icons.Inbox size={12}/>
                    <span style={{ flex: 1 }}>No sprint</span>
                    {!task.sprint && <Icons.Check size={13} style={{ color: "var(--brand)" }}/>}
                  </div>
                </div>
              )}>
                {sprintObj
                  ? <span className="sprint-tag"><span className="dot"/>{sprintObj.label}</span>
                  : <span className="sprint-tag no-sprint">No sprint</span>}
                <span style={{ color: "var(--ink-muted)", fontSize: 11 }}>
                  {sprintObj ? sprintObj.dates : ""}
                </span>
              </EditField>
            </dd>

            <dt>Epic</dt>
            <dd>
              <EditField render={(close) => (
                <div>
                  {epicList.map(e => (
                    <div key={e.id} className="popover-item" onClick={() => {
                      update({ epicId: e.id, epicTitle: e.title, epicColor: e.color },
                             { who: currentUserId || "ay", icon: "epic", text: `moved to epic ${e.title}` });
                      close();
                    }}>
                      <span style={{ width: 10, height: 10, borderRadius: 3, background: e.color, display: "inline-block" }}/>
                      <span style={{ flex: 1 }}>{e.title}</span>
                      {e.id === task.epicId && <Icons.Check size={13} style={{ color: "var(--brand)" }}/>}
                    </div>
                  ))}
                  <div style={{ height: 1, background: "var(--border)", margin: "4px 0" }}/>
                  <div className="popover-item" onClick={() => {
                    update({ epicId: null, epicTitle: null, epicColor: null },
                           { who: currentUserId || "ay", icon: "epic", text: `removed from epic` });
                    close();
                  }}>
                    <span style={{ width: 10, height: 10, borderRadius: 3, background: "#a3a8b6", display: "inline-block" }}/>
                    <span style={{ flex: 1, fontStyle: "italic" }}>Quick tasks</span>
                    {!task.epicId && <Icons.Check size={13} style={{ color: "var(--brand)" }}/>}
                  </div>
                </div>
              )}>
                {epic
                  ? <>
                      <span style={{ width: 10, height: 10, borderRadius: 3, background: epic.color, display: "inline-block" }}/>
                      <span>{epic.title}</span>
                    </>
                  : <span className="drawer-edit-empty">Quick tasks</span>}
              </EditField>
            </dd>

            {/* User Story picker — links this task to a parent story
                so the table's Group-by Story view rolls it up correctly
                and the reviewer sign-off recompute (recomputeStoryStatus)
                fires when this task moves to / from done. Filtered to
                stories in the same project; clearing detaches without
                deleting the story. Disabled for subtasks because story
                membership inherits from the parent — changing it on
                the child silently does nothing on the server. */}
            <dt>Story</dt>
            <dd>
              {task.parentTaskId ? (
                <span className="drawer-edit-empty"
                      title="Subtasks inherit their story from the parent. Change it on the parent task instead.">
                  inherits from parent
                </span>
              ) : (
                <EditField render={(close) => {
                  const allStories = (typeof USER_STORIES !== "undefined" && Array.isArray(USER_STORIES))
                    ? USER_STORIES : [];
                  const projectStories = allStories.filter(s =>
                    !taskProjectId || s.projectId === taskProjectId
                  );
                  return (
                    <div style={{ maxHeight: 280, overflow: "auto", minWidth: 240 }}>
                      <div style={{ fontSize: 11, color: "var(--ink-muted)", fontWeight: 600,
                                    letterSpacing: ".06em", textTransform: "uppercase",
                                    padding: "6px 10px 4px" }}>
                        Link to user story
                      </div>
                      {projectStories.length === 0 && (
                        <div className="popover-item" style={{ color: "var(--ink-muted)", fontStyle: "italic" }}>
                          No stories in this project yet.
                        </div>
                      )}
                      {projectStories.map(s => {
                        const stMeta = (typeof STORY_STATUSES !== "undefined" ? STORY_STATUSES : [])
                          .find(x => x.id === s.status) || { label: "Backlog", cls: "pill-backlog" };
                        const on = s.id === task.userStoryId;
                        return (
                          <div key={s.id} className="popover-item" onClick={() => {
                            update(
                              { userStoryId: s.id, user_story_id: s.id },
                              { who: currentUserId, icon: "edit", text: `linked to story "${s.title}"` }
                            );
                            close();
                          }}>
                            <span aria-hidden="true">📖</span>
                            <span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis" }}>{s.title}</span>
                            <span className={`pill pill-sm ${stMeta.cls}`} style={{ minWidth: 70, fontSize: 10 }}>{stMeta.label}</span>
                            {on && <Icons.Check size={13} style={{ color: "var(--brand)" }}/>}
                          </div>
                        );
                      })}
                      {task.userStoryId && (
                        <>
                          <div style={{ height: 1, background: "var(--border)", margin: "4px 0" }}/>
                          <div className="popover-item" style={{ color: "var(--ink-muted)" }} onClick={() => {
                            update(
                              { userStoryId: null, user_story_id: null },
                              { who: currentUserId, icon: "edit", text: `unlinked from story` }
                            );
                            close();
                          }}>
                            <Icons.Close size={12}/>
                            <span style={{ flex: 1 }}>Unlink from story</span>
                          </div>
                        </>
                      )}
                    </div>
                  );
                }}>
                  {task.userStoryId ? (() => {
                    const all = (typeof USER_STORIES !== "undefined" && Array.isArray(USER_STORIES))
                      ? USER_STORIES : [];
                    const s = all.find(x => x.id === task.userStoryId);
                    return s
                      ? <><span aria-hidden="true">📖</span><span>{s.title}</span></>
                      : <span className="drawer-edit-empty">Unknown story</span>;
                  })() : <span className="drawer-edit-empty">Not linked</span>}
                </EditField>
              )}
            </dd>

            <dt>Estimate</dt>
            <dd>
              <EditField render={(close) => (
                <div style={{ padding: 4, display: "flex", flexWrap: "wrap", gap: 4, width: 180 }}>
                  {[1, 2, 3, 5, 8, 13, 21].map(pt => (
                    <button key={pt} className="btn" style={{
                      minWidth: 36, justifyContent: "center",
                      background: pt === task.points ? "var(--brand)" : undefined,
                      color: pt === task.points ? "white" : undefined,
                      fontWeight: 600,
                    }} onClick={() => {
                      update({ points: pt }, { who: currentUserId || "ay", icon: "est", text: `estimated ${pt} points` });
                      close();
                    }}>{pt}</button>
                  ))}
                </div>
              )}>
                <span className="points" style={{ background: "var(--brand)", color: "white" }}>{task.points}</span>
                <span style={{ color: "var(--ink-muted)" }}>story points</span>
              </EditField>
            </dd>
          </dl>

          {/* Description */}
          <div className="drawer-sec-title drawer-desc-head">
            <span>Description</span>
            <div className="drawer-desc-tools">
              <button type="button" className="drawer-desc-tool"
                      onClick={() => descFileRef.current?.click()}
                      title="Insert image">
                <Icons.Image size={13}/> Add image
              </button>
              <input ref={descFileRef} type="file" accept="image/*"
                     style={{ display: "none" }}
                     onChange={(e) => {
                       const f = e.target.files && e.target.files[0];
                       if (f) readImageFile(f);
                       e.target.value = "";
                     }}/>
            </div>
          </div>
          <div ref={descRef}
               className={`drawer-desc${description ? "" : " is-empty"}`}
               contentEditable suppressContentEditableWarning
               data-placeholder="Add a description… (paste or drop images)"
               onBlur={commitDescriptionFromDOM}
               onPaste={onPasteDesc}
               onDragOver={(e) => e.preventDefault()}
               onDrop={onDropDesc}/>

          {/* QA section */}
          {typeof QaDrawerSection !== "undefined" && (
            <QaDrawerSection
              task={task}
              project={PROJECTS.find(p => p.id === (task.projectId || "checkout"))}
              onUpdate={(next) => onUpdate && onUpdate(next)}
              onOpenQaTask={() => {}}/>
          )}

          {/* Subtasks */}
          <div className="drawer-sec-title">
            <Icons.GitBranch size={14}/> Subtasks
            <span className="drawer-sec-count">{doneCount} / {subtasks.length}</span>
            {overdueCount > 0 && (
              <span className="drawer-sec-count" style={{
                background: "rgba(226,68,92,.10)", color: "#8a1024",
                borderColor: "rgba(226,68,92,.22)",
              }}
              title={`${overdueCount} subtask${overdueCount === 1 ? "" : "s"} past due`}>
                {overdueCount} overdue
              </span>
            )}
            {subtasks.length > 0 && (
              <label className="subtask-hide-done"
                     style={{ marginLeft: "auto", display: "inline-flex", alignItems: "center", gap: 6, fontSize: 11, color: "var(--ink-muted)", cursor: "pointer" }}>
                <input type="checkbox"
                       checked={hideDoneSubs}
                       onChange={(e) => setHideDoneSubs(e.target.checked)}
                       style={{ margin: 0 }}/>
                Hide done
              </label>
            )}
          </div>
          {subtasks.length > 0 && (
            <div className="subtask-progress">
              <div className="subtask-progress-fill" style={{ width: `${subPct}%` }}/>
            </div>
          )}
          {subtasks.length === 0 && (
            <div className="subtask-empty">
              No subtasks yet — break this work down by adding one below.
            </div>
          )}
          <div className="subtask-list" onDragEnd={endStDrag}>
            {visibleSubs.map(s => {
              const dropCls = stDropTarget?.id === s.id
                ? (stDropTarget.side === "above" ? "is-drop-above" : "is-drop-below")
                : "";
              const dragCls = stDraggingId === s.id ? "is-dragging" : "";
              const justCls = stJustDroppedId === s.id ? "is-just-dropped" : "";
              const isDone = s.status === "done";
              const stMeta = (typeof STATUSES !== "undefined" ? STATUSES : []).find(x => x.id === s.status)
                          || { id: s.status || "todo", label: s.status || "To Do", cls: "pill-todo" };
              const dueDate = s.due_date ? new Date(String(s.due_date).split(" @ ")[0]) : null;
              const today = new Date(); today.setHours(0,0,0,0);
              const dueOverdue = dueDate && !isNaN(dueDate) && !isDone && dueDate < today;
              const dueLabel = dueDate && !isNaN(dueDate)
                ? dueDate.toLocaleDateString(undefined, { month: "short", day: "numeric" })
                : null;
              return (
              <div key={s.id} className={`subtask-row ${isDone ? "done" : ""} ${dropCls} ${dragCls} ${justCls}`}
                draggable
                onDragStart={(e) => {
                  e.dataTransfer.effectAllowed = "move";
                  e.dataTransfer.setData("text/plain", String(s.id));
                  stDragRef.current = s.id;
                  setStDraggingId(s.id);
                  setStDropTarget(null);
                }}
                onDragOver={(e) => {
                  e.preventDefault();
                  e.dataTransfer.dropEffect = "move";
                  if (stDraggingId === s.id) return;
                  const r = e.currentTarget.getBoundingClientRect();
                  const above = (e.clientY - r.top) < r.height / 2;
                  setStDropTarget({ id: s.id, side: above ? "above" : "below" });
                }}
                onDrop={(e) => {
                  e.preventDefault();
                  const r = e.currentTarget.getBoundingClientRect();
                  const above = (e.clientY - r.top) < r.height / 2;
                  const sourceRaw = e.dataTransfer.getData("text/plain") || stDragRef.current;
                  const sourceId = subtasks.find(x => String(x.id) === String(sourceRaw))?.id;
                  endStDrag();
                  reorderSubtasks(sourceId, s.id, above ? "above" : "below");
                  if (sourceId && sourceId !== s.id) celebrateSubtask(sourceId);
                }}>
                <span className="subtask-grip" title="Drag to reorder"><Icons.Grip size={12}/></span>
                <span className="checkbox"
                      onClick={() => toggleSubtask(s.id)}
                      title={isDone ? "Mark not done" : "Mark done"}>
                  {isDone && <Icons.Check size={12}/>}
                </span>

                {/* Status pill — click for full picker. The checkbox
                    handles the common "mark done" case; this is for
                    moving to in_progress / review / blocked / etc. */}
                <EditField render={(close) => (
                  <div style={{ minWidth: 180 }}>
                    {(typeof STATUSES !== "undefined" ? STATUSES : []).map(st => (
                      <div key={st.id} className="popover-item"
                           onClick={() => { setSubtaskStatus(s.id, st.id); close(); }}>
                        <span className={`pill pill-sm ${st.cls}`} style={{ minWidth: 80 }}>{st.label}</span>
                      </div>
                    ))}
                  </div>
                )}>
                  <span className={`pill pill-sm ${stMeta.cls} subtask-status-pill`} title={`Status · ${stMeta.label}`}>
                    {stMeta.label}
                  </span>
                </EditField>

                <span className="subtask-name" contentEditable suppressContentEditableWarning
                      onBlur={(e) => renameSubtask(s.id, e.currentTarget.textContent)}
                      onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); e.currentTarget.blur(); } }}>
                  {s.name}
                </span>

                {/* Due-date chip — clickable for inline edit. Goes
                    red when overdue (and not done) so the parent
                    task drawer surfaces "this child is slipping" at
                    a glance. */}
                <EditField render={(close) => (
                  <div style={{ padding: 8, minWidth: 220 }}>
                    <input type="date"
                           defaultValue={dueDate && !isNaN(dueDate) ? dueDate.toISOString().slice(0,10) : ""}
                           onChange={(e) => { setSubtaskDue(s.id, e.target.value || null); close(); }}
                           style={{ width: "100%", padding: 6, fontSize: 13 }}/>
                    {s.due_date && (
                      <button type="button"
                              className="popover-item"
                              style={{ marginTop: 8, color: "var(--ink-muted)", width: "100%" }}
                              onClick={() => { setSubtaskDue(s.id, null); close(); }}>
                        <Icons.Close size={12}/> Clear due date
                      </button>
                    )}
                  </div>
                )}>
                  <span className={"subtask-due-chip" + (dueOverdue ? " is-overdue" : "")}
                        title={dueLabel ? `Due ${dueLabel}` : "Set a due date"}>
                    {dueLabel ? <><Icons.Calendar size={11}/> {dueLabel}</> : <><Icons.Calendar size={11}/> Due</>}
                  </span>
                </EditField>
                <EditField render={(close) => (
                  <div style={{ maxHeight: 260, overflow: "auto", minWidth: 220 }}>
                    {/* Subtask owners follow the same project-membership
                        scope as the parent task's Owners picker. We
                        also keep currently-assigned users in the list
                        even if they're no longer members so the row
                        never silently drops them. */}
                    {currentUserId && !(s.owners || []).includes(currentUserId) && (
                      <>
                        <div className="popover-item"
                             style={{ color: "var(--brand)", fontWeight: 600 }}
                             onClick={() => { toggleSubtaskOwner(s.id, currentUserId); close(); }}>
                          <Icons.Plus size={13}/>
                          <span style={{ flex: 1 }}>Assign to me</span>
                        </div>
                        <div style={{ height: 1, background: "var(--border)", margin: "4px 0" }}/>
                      </>
                    )}
                    {assignablePeople.map(p => {
                      const on = (s.owners || []).includes(p.id);
                      return (
                        <div key={p.id} className="popover-item" onClick={() => toggleSubtaskOwner(s.id, p.id)}>
                          <Avatar person={p} size="sm"/>
                          <span style={{ flex: 1 }}>{p.name}</span>
                          {on && <Icons.Check size={13} style={{ color: "var(--brand)" }}/>}
                        </div>
                      );
                    })}
                    {(s.owners && s.owners.length > 0) && (
                      <>
                        <div style={{ height: 1, background: "var(--border)", margin: "4px 0" }}/>
                        <div className="popover-item" style={{ color: "var(--ink-muted)" }} onClick={() => {
                          const prev = subtasks;
                          setSubtasks(list => list.map(x => x.id === s.id ? { ...x, owners: [] } : x));
                          if (window.api && isPersistedSubtaskId(s.id)) {
                            api.tasks.assignees(s.id, [])
                              .catch(e => {
                                setSubtasks(prev);
                                onToast && onToast("Couldn't unassign: " + (e.message || "network error"), 4000);
                              });
                          }
                          close();
                        }}>
                          <Icons.Close size={12}/>
                          <span style={{ flex: 1 }}>Unassign all</span>
                        </div>
                      </>
                    )}
                  </div>
                )}>
                  <span className="subtask-owners" title={(s.owners || []).map(id => PEOPLE.find(p => p.id === id)?.name).filter(Boolean).join(", ") || "Assign"}>
                    {(s.owners && s.owners.length) ? (
                      <>
                        <AvatarStack ids={s.owners} max={3} size="sm"/>
                        <span className="subtask-owner-label">
                          {(() => {
                            const names = s.owners
                              .map(id => PEOPLE.find(p => p.id === id)?.name.split(" ")[0])
                              .filter(Boolean);
                            if (names.length === 1) return names[0];
                            if (names.length === 2) return `${names[0]}, ${names[1]}`;
                            return `${names[0]} +${names.length - 1}`;
                          })()}
                        </span>
                      </>
                    ) : (
                      <>
                        <span style={{
                          width: 20, height: 20, borderRadius: "50%",
                          border: "1.5px dashed var(--border-strong)",
                          color: "#a3a8b6", fontSize: 11,
                          display: "inline-flex", alignItems: "center", justifyContent: "center",
                        }}>+</span>
                        <span className="subtask-owner-label subtask-owner-label--empty">Assign</span>
                      </>
                    )}
                  </span>
                </EditField>
                {/* Open the subtask in the same drawer — lets the
                    user drill in for description / comments /
                    activity, then pop back via the parent chip. */}
                {onOpenTask && isPersistedSubtaskId(s.id) && (
                  <button className="subtask-open"
                          title="Open this subtask in the drawer"
                          onClick={() => {
                            // Hand the drawer the subtask shape it
                            // needs. We borrow most fields from the
                            // parent (project, sprint, epic) plus
                            // whatever the row has on it.
                            const all = (typeof window !== "undefined" && Array.isArray(window.ALL_TASKS))
                              ? window.ALL_TASKS : [];
                            const fromGlobal = all.find(t => t && t.id === s.id);
                            onOpenTask(fromGlobal || {
                              id: s.id, name: s.name,
                              status: s.status, prio: s.priority,
                              owners: s.owners || [],
                              due: s.due_date || "—",
                              parentTaskId: task.id,
                              projectId: task.projectId,
                              projectName: task.projectName,
                              projectColor: task.projectColor,
                              epicId: task.epicId,
                              epicTitle: task.epicTitle,
                              epicColor: task.epicColor,
                              userStoryId: task.userStoryId,
                            });
                          }}>
                    <Icons.Arrow size={12}/>
                  </button>
                )}
                <button className="subtask-delete" onClick={() => deleteSubtask(s.id)} title="Delete subtask">
                  <Icons.Close size={12}/>
                </button>
              </div>
              );
            })}
            <div className="subtask-add">
              <Icons.Plus size={14}/>
              <input value={newSubtask}
                     onChange={(e) => setNewSubtask(e.target.value)}
                     onKeyDown={(e) => { if (e.key === "Enter") addSubtask(); }}
                     placeholder="Add subtask (press Enter)…"/>
            </div>
          </div>

          </div>
          )}

          {tab === "comments" && (
            <div style={{ marginTop: 14 }}>
              <div className="comment-composer">
                {/* Composer avatar — was hardcoded to PEOPLE[0] (always
                    the first user in the workspace, regardless of who
                    was viewing). Now resolves from currentUserId so
                    each user sees their own avatar when drafting a
                    comment, matching what they see beside it after
                    posting. */}
                <Avatar person={PEOPLE.find(p => p.id === currentUserId) || PEOPLE[0]} size="sm"/>
                <div style={{ flex: 1 }}>
                  <textarea rows={2} value={commentDraft}
                            onChange={(e) => setCommentDraft(e.target.value)}
                            onKeyDown={(e) => {
                              if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) postComment();
                            }}
                            placeholder="Write a comment… (⌘+Enter to post)"/>
                  <div className="comment-composer-footer">
                    <span className="tip">Markdown supported</span>
                    <button className="btn" onClick={() => setCommentDraft("")} disabled={!commentDraft.trim()}>Cancel</button>
                    <button className="btn btn-primary" onClick={postComment} disabled={!commentDraft.trim()}>
                      Comment
                    </button>
                  </div>
                </div>
              </div>
              <div className="comment-list">
                {comments.slice().reverse().map(c => {
                  const person = PEOPLE.find(p => p.id === c.who);
                  return (
                    <div key={c.id} className="comment-item">
                      <Avatar person={person} size="sm"/>
                      <div className="comment-body">
                        <div className="comment-header">
                          <span className="comment-author">{person?.name}</span>
                          <span className="comment-when">{c.when}</span>
                        </div>
                        <div className="comment-text">{c.text}</div>
                      </div>
                    </div>
                  );
                })}
              </div>
            </div>
          )}

          {tab === "activity" && (
            <div style={{ marginTop: 14 }}>
              {/* Due-date changes are split into their own panel above the
                  general activity feed — they're the single most
                  consequential audit-log entry (a moved deadline ripples
                  through every plan that depends on it), and burying them
                  inside a long status / priority / sprint stream makes
                  them easy to miss. The general feed below excludes due
                  entries so they only appear once. */}
              {(() => {
                const dueOnly = activity.filter(a => a.kind === "due" || a.icon === "cal");
                const rest    = activity.filter(a => !(a.kind === "due" || a.icon === "cal"));
                return (
                  <>
                    {dueOnly.length > 0 && (
                      <div className="activity-due-panel">
                        <div className="activity-due-head">
                          <span className="activity-due-icon" aria-hidden="true">📅</span>
                          <span className="activity-due-title">Due date history</span>
                          <span className="activity-due-count">{dueOnly.length}</span>
                        </div>
                        <div className="activity-due-list">
                          {dueOnly.map((a, i) => {
                            const person = PEOPLE.find(p => p.id === a.who);
                            return (
                              <div key={"due-" + i} className="activity-due-row">
                                <Avatar person={person} size="sm"/>
                                <div style={{ flex: 1, minWidth: 0 }}>
                                  <div>
                                    <b style={{ color: "var(--ink-strong)" }}>{person?.name || "Someone"}</b>{" "}
                                    <span style={{ color: "var(--ink-strong)", fontWeight: 600 }}>{a.text}</span>
                                  </div>
                                  <div className="activity-when">{a.when}</div>
                                </div>
                              </div>
                            );
                          })}
                        </div>
                      </div>
                    )}
                    {rest.length > 0 && dueOnly.length > 0 && (
                      <div className="activity-section-divider">All activity</div>
                    )}
                    {rest.map((a, i) => {
                      const person = PEOPLE.find(p => p.id === a.who);
                      // Story-rejection rows get an amber background so
                      // the developer's eye lands on them when scanning
                      // the activity feed — this is "why is this task
                      // back on my plate?" answered in place.
                      const isReject = a.kind === "story_changes_requested";
                      return (
                        <div key={i}
                             className={"activity-item" + (isReject ? " activity-reject" : "")}
                             style={isReject ? {
                               background: "rgba(245, 158, 11, .08)",
                               borderLeft: "3px solid #f59e0b",
                               paddingLeft: 10,
                               borderRadius: 6,
                             } : null}>
                          <Avatar person={person} size="sm"/>
                          <div style={{ flex: 1 }}>
                            <div>
                              {isReject && <span aria-hidden="true" style={{ marginRight: 6 }}>↻</span>}
                              <b style={{ color: "var(--ink-strong)" }}>{person?.name}</b>{" "}
                              <span style={{ color: isReject ? "#92400e" : "var(--ink-muted)", fontWeight: isReject ? 500 : "inherit" }}>
                                {a.text}
                              </span>
                            </div>
                            <div className="activity-when">{a.when}</div>
                          </div>
                        </div>
                      );
                    })}
                  </>
                );
              })()}
              {!activity.length && (
                <div style={{ color: "var(--ink-muted)", fontSize: 12, padding: 20, textAlign: "center" }}>
                  No activity yet
                </div>
              )}
            </div>
          )}

          {tab === "attachments" && (
            <div className="drawer-pane-attachments" style={{ marginTop: 14 }}>
              {/* Drop zone + file picker. Accepts any file type — multer
                  is configured without a fileFilter (see middleware/upload.js)
                  so PDF, images, .docx, .xlsx, archives, audio, video, etc.
                  all flow through. Default size cap is 10 MB per file
                  via UPLOAD_MAX_BYTES on the server; the upload handler
                  surfaces a "Too large" toast if a file exceeds it. */}
              <label
                className={"att-dropzone" + (attDragActive ? " is-drag" : "")}
                onDragOver={onAttDragOver}
                onDragEnter={onAttDragOver}
                onDragLeave={onAttDragLeave}
                onDrop={onAttDrop}>
                <input
                  type="file"
                  multiple
                  style={{ display: "none" }}
                  onChange={(e) => {
                    const fs = e.target.files;
                    if (fs && fs.length) uploadAttachmentFiles(fs);
                    e.target.value = "";  // allow re-uploading the same file
                  }}/>
                <div className="att-dropzone-emoji" aria-hidden="true">📎</div>
                <div className="att-dropzone-title">
                  {attDragActive ? "Drop to upload" : "Drag a file here, or click to choose"}
                </div>
                <div className="att-dropzone-hint">
                  PDF, images, Word, Excel, video, audio, ZIP — any file type up to ~10 MB.
                </div>
              </label>

              {/* In-flight uploads — shown above the persisted list so
                  the user sees their action immediately. Each row stays
                  visible until the upload completes (then it's replaced
                  by the real row from the server) or errors out. */}
              {uploadingFiles.length > 0 && (
                <div className="att-list" style={{ marginTop: 12 }}>
                  {uploadingFiles.map(u => (
                    <div key={u.tempId} className={"att-item att-uploading" + (u.error ? " att-error" : "")}>
                      <span className="att-icon" aria-hidden="true">{_attIcon(u.type)}</span>
                      <div className="att-meta">
                        <div className="att-name" title={u.name}>{u.name}</div>
                        <div className="att-sub">
                          {u.error
                            ? <span style={{ color: "#c0223a", fontWeight: 600 }}>⚠ {u.error}</span>
                            : <span>Uploading… {_fmtAttSize(u.size)}</span>}
                        </div>
                      </div>
                      {u.error && (
                        <button type="button"
                                className="att-action"
                                title="Dismiss"
                                onClick={() => setUploadingFiles(prev => prev.filter(x => x.tempId !== u.tempId))}>
                          <Icons.Close size={13}/>
                        </button>
                      )}
                    </div>
                  ))}
                </div>
              )}

              {/* Persisted list — pulled from /api/tasks/:id/attachments.
                  Click the row (or the filename) to download. Trash icon
                  on the right deletes (with confirm). Avatar of the
                  uploader is shown so credit lands with the right person. */}
              {attachments.length > 0 ? (
                <div className="att-list" style={{ marginTop: 12 }}>
                  {attachments.map(a => {
                    const uploader = (typeof PEOPLE !== "undefined" ? PEOPLE : [])
                      .find(p => p.id === a.uploaded_by);
                    const downloadUrl = "/uploads/" + a.filename;
                    const when = typeof fmtRelative === "function" ? fmtRelative(a.created_at) : "";
                    return (
                      <div key={a.id} className="att-item">
                        <span className="att-icon" aria-hidden="true">{_attIcon(a.mime_type)}</span>
                        <a href={downloadUrl}
                           download={a.original_name || a.filename}
                           className="att-meta"
                           target="_blank"
                           rel="noopener noreferrer"
                           title={`Download ${a.original_name || a.filename}`}>
                          <div className="att-name">{a.original_name || a.filename}</div>
                          <div className="att-sub">
                            <span>{_fmtAttSize(a.size_bytes)}</span>
                            {uploader && (
                              <>
                                <span className="att-dot">·</span>
                                <span>{uploader.name}</span>
                              </>
                            )}
                            {when && (
                              <>
                                <span className="att-dot">·</span>
                                <span>{when}</span>
                              </>
                            )}
                          </div>
                        </a>
                        <a href={downloadUrl}
                           download={a.original_name || a.filename}
                           className="att-action"
                           title="Download"
                           aria-label="Download attachment">
                          <span aria-hidden="true" style={{ fontSize: 14, lineHeight: 1 }}>⤓</span>
                        </a>
                        <button type="button"
                                className="att-action att-action-del"
                                title="Delete attachment"
                                onClick={() => deleteAttachment(a)}>
                          <Icons.Close size={13}/>
                        </button>
                      </div>
                    );
                  })}
                </div>
              ) : uploadingFiles.length === 0 && (
                <div style={{ color: "var(--ink-muted)", fontSize: 12, padding: 20, textAlign: "center" }}>
                  No files attached yet.
                </div>
              )}
            </div>
          )}
        </div>
      </div>
      {typeof CompleteTaskModal !== "undefined" && (
        <CompleteTaskModal
          open={completeModalOpen}
          task={task}
          onClose={() => setCompleteModalOpen(false)}
          onConfirm={(patch) => {
            update(patch, {
              who: currentUserId || "ay", icon: "status",
              text: patch.outcome === "late"
                ? `marked as Done — ${(typeof DELAY_REASONS !== "undefined" && patch.delayReason
                     ? (DELAY_REASONS.find(r => r.id === patch.delayReason) || {}).label
                     : "late")}`
                : `marked as Done`,
            });
            setCompleteModalOpen(false);
          }}/>
      )}
    </>
  );
}
function seedSubtasks(task) {
  const count = task.subtasks || 0;
  const names = [
    "Design address form with inline validation",
    "Integrate Google Places autocomplete API",
    "Handle API rate-limit errors gracefully",
    "Add fallback to manual entry after 2 failures",
    "Unit tests for address normalization",
    "QA across Chrome/Safari/Firefox",
    "Update docs for new form component",
  ];
  // Rotate subtask owners across the parent task's owners so seeded data
  // shows realistic variation. Every 3rd subtask gets a second assignee to
  // demo the multi-owner affordance without making every row crowded.
  const pool = task.owners && task.owners.length ? task.owners : ["ay"];
  return Array.from({ length: count }, (_, i) => {
    const primary = pool[i % pool.length];
    const owners = [primary];
    if (pool.length > 1 && i % 3 === 2) {
      owners.push(pool[(i + 1) % pool.length]);
    }
    const isDone = i < Math.floor(count * 0.4);
    return {
      id: i + 1,
      name: names[i % names.length],
      // status (and derived done) — keep the field set so render
      // helpers that check s.status === 'done' work consistently
      // with API-loaded subtasks.
      status: isDone ? "done" : "todo",
      priority: "none",
      due_date: null,
      owners,
    };
  });
}

// Comments + Activity now load straight from the API. We used to seed
// with hardcoded placeholders that flashed before the network call
// returned (and stayed forever for tasks that legitimately had no
// comments / activity). Empty arrays produce a clean "No comments yet"
// / "No activity yet" empty state instead.
const SEED_COMMENTS = [];

// Map an audit-log `kind` string from /api/tasks/:id/activity to the
// icon name used by the drawer's Activity row renderer.
function _kindToIcon(kind) {
  switch (kind) {
    case 'created':    return 'plus';
    case 'commented':  return 'chat';
    case 'completed':  return 'check';
    case 'reopened':   return 'edit';
    case 'qa':         return 'check';
    // Tester / coordinator rejected the story this task belongs to.
    // Written by the server-side story request_changes handler with the
    // reviewer's note baked into `body`. Themed distinctly in the
    // Activity tab below.
    case 'story_changes_requested': return 'rotate';
    case 'status':
    case 'priority':
    case 'name':
    case 'due':
    case 'epic':
    case 'sprint':
    case 'assigned':
    case 'unassigned':
    case 'blocked':
    case 'outcome':
    case 'delay':
    default:           return 'edit';
  }
}
// Fallback label when an entry has no pre-rendered `body`.
function _kindToText(kind) {
  if (!kind) return 'updated this task';
  return 'changed ' + kind.replace(/_/g, ' ');
}

// (Removed in v1.13: the client-side `deriveActivity()` synthesizer.
//  The drawer now reads the real audit log from /api/tasks/:id/activity,
//  which is written by tasks.routes whenever a task is created, edited,
//  moved, completed, reopened, assigned, commented on, or escalated.
//  See migrations/012_task_activity.sql.)

// ── RecurrenceField ────────────────────────────────────────────
// Inline trigger pill in the drawer's "Repeat" row plus a click-to-
// open popover with preset list and a Custom panel covering every
// rule field the server's lib/recurrence.js understands. Lives in the
// drawer module so it shares the existing EditField conventions.
//
// State strategy:
//   - On mount we fetch GET /api/tasks/:id/recurrence to learn the
//     current rule (if any) + the next 5 occurrences.
//   - The popover edits a local "draft" object the user can preview
//     before saving (POST /api/tasks/:id/recurrence).
//   - Pause / Resume / Skip / Stop are immediate.
//
// Schema must stay in sync with routes/recurrence.routes.js validation
// (see validateRuleBody there).
function RecurrenceField({ taskId, task, onTaskPatch }) {
  const [open, setOpen]   = React.useState(false);
  const [data, setData]   = React.useState(null);  // { rule, next }
  const [loading, setLoading] = React.useState(true);
  const ref = React.useRef(null);

  // Load on mount + whenever the task id changes.
  React.useEffect(() => {
    let cancelled = false;
    setLoading(true);
    if (!taskId || !window.api || !api.recurrence) { setLoading(false); return; }
    api.recurrence.get(taskId)
      .then(r => { if (!cancelled) { setData(r); setLoading(false); } })
      .catch(() => { if (!cancelled) { setData({ rule: null, next: [] }); setLoading(false); } });
    return () => { cancelled = true; };
  }, [taskId]);

  // Click-outside to dismiss.
  React.useEffect(() => {
    if (!open) return;
    function onDoc(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }
    function onKey(e) { if (e.key === "Escape") setOpen(false); }
    setTimeout(() => document.addEventListener("mousedown", onDoc), 0);
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("mousedown", onDoc);
      document.removeEventListener("keydown", onKey);
    };
  }, [open]);

  async function reload() {
    if (!taskId || !window.api) return;
    try {
      const r = await api.recurrence.get(taskId);
      setData(r);
    } catch {}
  }

  async function setRule(rule) {
    try {
      const r = await api.recurrence.set(taskId, rule);
      setData(r);
      setOpen(false);
      // Fan out the new flags to the parent's tasks state +
      // window.ALL_TASKS so the ↻ icon appears in the table / Home /
      // MyTasks immediately, without waiting for a page refresh.
      if (onTaskPatch && r && r.rule) {
        onTaskPatch({
          recurrenceRuleId: r.rule.id,
          isRecurringTemplate: true,
        });
      }
      if (typeof window.fbToast === "function") window.fbToast("Recurrence saved");
    } catch (e) {
      const msg = (e && e.body && e.body.message) || (e && e.message) || "Couldn't save";
      if (typeof window.fbToast === "function") window.fbToast("Recurrence: " + msg, 4500);
    }
  }
  async function patchRule(patch) {
    try {
      const r = await api.recurrence.patch(taskId, patch);
      setData(r);
    } catch (e) {
      const msg = (e && e.body && e.body.message) || (e && e.message) || "Couldn't update";
      if (typeof window.fbToast === "function") window.fbToast("Recurrence: " + msg, 4500);
    }
  }
  async function clearRule() {
    try {
      await api.recurrence.remove(taskId);
      setData({ rule: null, next: [] });
      setOpen(false);
      // Clear the flags on the parent's task — drops the ↻ icon
      // from every visible row immediately.
      if (onTaskPatch) {
        onTaskPatch({
          recurrenceRuleId: null,
          isRecurringTemplate: false,
        });
      }
      if (typeof window.fbToast === "function") window.fbToast("Stopped recurring");
    } catch {}
  }
  async function pauseRule()  { if (data && data.rule) { await api.recurrence.pause(data.rule.id);  reload(); } }
  async function resumeRule() { if (data && data.rule) { await api.recurrence.resume(data.rule.id); reload(); } }
  async function skipNext()   { if (data && data.rule) { await api.recurrence.skipNext(data.rule.id); reload(); } }

  const rule = data && data.rule;
  const summary = rule ? _humaniseRule(rule) : "Doesn't repeat";

  return (
    <span className="drawer-recur-field" ref={ref} style={{ position: "relative", display: "inline-block" }}>
      <button type="button" className="drawer-recur-trigger"
        onClick={() => setOpen(o => !o)}
        title="Recurrence schedule"
        style={{
          display: "inline-flex", alignItems: "center", gap: 6,
          padding: "3px 9px", borderRadius: 999,
          fontSize: 12, fontWeight: 600,
          background: rule ? "rgba(162,93,220,.10)" : "transparent",
          color: rule ? "var(--brand, #a25ddc)" : "var(--ink-muted)",
          border: "1px solid " + (rule ? "rgba(162,93,220,.25)" : "var(--border, #e6e9ef)"),
          cursor: "pointer", fontFamily: "inherit",
        }}>
        <span aria-hidden="true">↻</span>
        <span>{loading ? "…" : summary}</span>
        {rule && rule.paused && (
          <span style={{
            background: "rgba(217,119,6,.16)", color: "#9c5b00",
            fontSize: 10, fontWeight: 800, padding: "1px 6px", borderRadius: 999,
            textTransform: "uppercase", letterSpacing: ".05em",
          }}>paused</span>
        )}
      </button>
      {open && (
        <RecurrencePopover
          task={task}
          rule={rule}
          nextOccurrences={(data && data.next) || []}
          onSet={setRule}
          onPatch={patchRule}
          onClear={clearRule}
          onPause={pauseRule}
          onResume={resumeRule}
          onSkip={skipNext}
          onClose={() => setOpen(false)}/>
      )}
    </span>
  );
}

// Human-readable single-line summary of a rule. e.g.
//   "Every Mon, Wed, Fri"  /  "Every 2 weeks on Tue"  /  "Monthly on the 15th"
function _humaniseRule(r) {
  if (!r) return "Doesn't repeat";
  const interval = Number(r.interval_n) || 1;
  const wd = (r.by_weekday || "").split(",").filter(Boolean);
  const wdNames = { MO:"Mon", TU:"Tue", WE:"Wed", TH:"Thu", FR:"Fri", SA:"Sat", SU:"Sun" };
  const wdList = wd.map(c => wdNames[c] || c).join(", ");
  const time = _fmt12FromTimeStr(r.time_of_day);
  const tail = time ? " at " + time : "";

  if (r.freq === "daily") {
    if (interval === 1) return "Every day" + tail;
    return "Every " + interval + " days" + tail;
  }
  if (r.freq === "weekly") {
    if (interval === 1) return wdList ? "Every " + wdList + tail : "Every week" + tail;
    return wdList ? "Every " + interval + " weeks on " + wdList + tail : "Every " + interval + " weeks" + tail;
  }
  if (r.freq === "monthly") {
    if (r.by_setpos != null && wdList) {
      const pos = { 1:"first", 2:"second", 3:"third", 4:"fourth", "-1":"last" }[String(r.by_setpos)] || (r.by_setpos + "th");
      return (interval === 1 ? "Monthly on the " : "Every " + interval + " months on the ") + pos + " " + wdList + tail;
    }
    if (r.by_monthday) {
      return (interval === 1 ? "Monthly on the " : "Every " + interval + " months on the ") + _ord(r.by_monthday) + tail;
    }
    return interval === 1 ? "Monthly" + tail : "Every " + interval + " months" + tail;
  }
  if (r.freq === "yearly") return interval === 1 ? "Yearly" + tail : "Every " + interval + " years" + tail;
  return "Recurring";
}

function _ord(n) {
  n = Number(n);
  if (n >= 11 && n <= 13) return n + "th";
  switch (n % 10) {
    case 1: return n + "st";
    case 2: return n + "nd";
    case 3: return n + "rd";
    default: return n + "th";
  }
}

function _fmt12FromTimeStr(t) {
  if (!t) return null;
  const m = /^(\d{1,2}):(\d{2})/.exec(String(t));
  if (!m) return null;
  let hh = Number(m[1]); const mm = Number(m[2]);
  const ampm = hh >= 12 ? "PM" : "AM";
  const h12 = ((hh + 11) % 12) + 1;
  return h12 + ":" + String(mm).padStart(2, "0") + " " + ampm;
}

// ── RecurrencePopover ──────────────────────────────────────────
function RecurrencePopover({ task, rule, nextOccurrences, onSet, onPatch, onClear, onPause, onResume, onSkip, onClose }) {
  // Draft state — initialised from the existing rule, or sensible
  // defaults from the task's due date.
  const today = new Date();
  const todayYmd = today.toISOString().slice(0, 10);
  const initial = rule ? {
    freq: rule.freq,
    interval_n: rule.interval_n || 1,
    by_weekday: rule.by_weekday || "",
    by_monthday: rule.by_monthday || null,
    by_setpos: rule.by_setpos || null,
    time_of_day: rule.time_of_day ? String(rule.time_of_day).slice(0, 5) : "",
    start_date: rule.start_date ? String(rule.start_date).slice(0, 10) : todayYmd,
    end_kind: rule.end_date ? "on" : (rule.count_limit ? "after" : "never"),
    end_date: rule.end_date ? String(rule.end_date).slice(0, 10) : "",
    count_limit: rule.count_limit || "",
    chain_mode: rule.chain_mode || "scheduled",
    clone_subtasks: rule.clone_subtasks !== false,
  } : {
    freq: "weekly",
    interval_n: 1,
    by_weekday: _todayWeekdayCode(),
    by_monthday: null,
    by_setpos: null,
    time_of_day: "",
    start_date: todayYmd,
    end_kind: "never",
    end_date: "",
    count_limit: "",
    chain_mode: "scheduled",
    clone_subtasks: true,
  };
  const [draft, setDraft] = React.useState(initial);
  const [preview, setPreview] = React.useState([]);
  const [loadingPreview, setLoadingPreview] = React.useState(false);

  // Live preview — debounced 250ms so typing "every 14 weeks" doesn't fire 3 round-trips.
  React.useEffect(() => {
    if (!window.api || !api.recurrence) return;
    const handle = setTimeout(() => {
      const body = _draftToRule(draft);
      if (!body) { setPreview([]); return; }
      setLoadingPreview(true);
      api.recurrence.preview(body, 6)
        .then(r => setPreview(Array.isArray(r && r.dates) ? r.dates : []))
        .catch(() => setPreview([]))
        .finally(() => setLoadingPreview(false));
    }, 250);
    return () => clearTimeout(handle);
  }, [JSON.stringify(draft)]);

  function applyPreset(preset) {
    setDraft(d => ({ ...d, ...preset }));
  }

  function save() {
    const body = _draftToRule(draft);
    if (!body) return;
    onSet(body);
  }

  return (
    <div className="drawer-recur-pop" style={{
      position: "absolute", top: "calc(100% + 6px)", left: 0, zIndex: 9000,
      background: "white",
      border: "1px solid var(--border, #e6e9ef)", borderRadius: 10,
      boxShadow: "0 12px 32px rgba(15,23,41,.16), 0 4px 10px rgba(15,23,41,.06)",
      padding: 14, width: 340,
    }}>
      <div style={{ fontSize: 13, fontWeight: 700, marginBottom: 10, color: "var(--ink-strong)" }}>
        {rule ? "Edit recurrence" : "Set up recurrence"}
      </div>

      {/* Presets */}
      <div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginBottom: 12 }}>
        <PresetChip label="Daily"    onClick={() => applyPreset({ freq: "daily", interval_n: 1, by_weekday: "", by_monthday: null, by_setpos: null })}/>
        <PresetChip label="Weekdays" onClick={() => applyPreset({ freq: "weekly", interval_n: 1, by_weekday: "MO,TU,WE,TH,FR", by_monthday: null, by_setpos: null })}/>
        <PresetChip label="Weekly"   onClick={() => applyPreset({ freq: "weekly", interval_n: 1, by_weekday: _todayWeekdayCode(), by_monthday: null, by_setpos: null })}/>
        <PresetChip label="Bi-weekly" onClick={() => applyPreset({ freq: "weekly", interval_n: 2, by_weekday: _todayWeekdayCode(), by_monthday: null, by_setpos: null })}/>
        <PresetChip label="Monthly"  onClick={() => applyPreset({ freq: "monthly", interval_n: 1, by_weekday: "", by_monthday: today.getDate(), by_setpos: null })}/>
        <PresetChip label="Quarterly" onClick={() => applyPreset({ freq: "monthly", interval_n: 3, by_weekday: "", by_monthday: today.getDate(), by_setpos: null })}/>
      </div>

      {/* Frequency */}
      <Row label="Frequency">
        <select value={draft.freq} onChange={(e) => setDraft(d => ({ ...d, freq: e.target.value }))}
          style={_fieldStyle()}>
          <option value="daily">Daily</option>
          <option value="weekly">Weekly</option>
          <option value="monthly">Monthly</option>
          <option value="yearly">Yearly</option>
        </select>
      </Row>

      <Row label="Every">
        <input type="number" min={1} max={99} value={draft.interval_n}
          onChange={(e) => setDraft(d => ({ ...d, interval_n: Math.max(1, Math.min(99, Number(e.target.value) || 1)) }))}
          style={{ ..._fieldStyle(), width: 70 }}/>
        <span style={{ fontSize: 12, color: "var(--ink-muted)" }}>
          {draft.freq === "daily" ? "day(s)" : draft.freq === "weekly" ? "week(s)" : draft.freq === "monthly" ? "month(s)" : "year(s)"}
        </span>
      </Row>

      {/* Weekly: weekday toggles */}
      {draft.freq === "weekly" && (
        <Row label="On">
          <div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
            {["MO","TU","WE","TH","FR","SA","SU"].map(c => (
              <WeekdayToggle key={c} code={c}
                on={(draft.by_weekday || "").split(",").includes(c)}
                onClick={() => {
                  const cur = (draft.by_weekday || "").split(",").filter(Boolean);
                  const next = cur.includes(c) ? cur.filter(x => x !== c) : [...cur, c];
                  setDraft(d => ({ ...d, by_weekday: next.join(",") }));
                }}/>
            ))}
          </div>
        </Row>
      )}

      {/* Monthly: by-monthday OR by-weekday+setpos */}
      {draft.freq === "monthly" && (
        <>
          <Row label="On">
            <select
              value={draft.by_setpos != null ? "setpos" : "monthday"}
              onChange={(e) => {
                if (e.target.value === "setpos") {
                  setDraft(d => ({ ...d, by_monthday: null, by_setpos: 1, by_weekday: d.by_weekday || _todayWeekdayCode() }));
                } else {
                  setDraft(d => ({ ...d, by_setpos: null, by_weekday: "", by_monthday: today.getDate() }));
                }
              }}
              style={_fieldStyle()}>
              <option value="monthday">Day of month</option>
              <option value="setpos">First/Second/Last weekday</option>
            </select>
          </Row>
          {draft.by_setpos != null ? (
            <Row label="Pick">
              <select value={String(draft.by_setpos)}
                onChange={(e) => setDraft(d => ({ ...d, by_setpos: Number(e.target.value) }))}
                style={_fieldStyle()}>
                <option value="1">First</option>
                <option value="2">Second</option>
                <option value="3">Third</option>
                <option value="4">Fourth</option>
                <option value="-1">Last</option>
              </select>
              <select value={(draft.by_weekday || "MO").split(",")[0]}
                onChange={(e) => setDraft(d => ({ ...d, by_weekday: e.target.value }))}
                style={_fieldStyle()}>
                <option value="MO">Monday</option>
                <option value="TU">Tuesday</option>
                <option value="WE">Wednesday</option>
                <option value="TH">Thursday</option>
                <option value="FR">Friday</option>
                <option value="SA">Saturday</option>
                <option value="SU">Sunday</option>
              </select>
            </Row>
          ) : (
            <Row label="Day">
              <input type="number" min={1} max={31} value={draft.by_monthday || ""}
                onChange={(e) => setDraft(d => ({ ...d, by_monthday: Math.max(1, Math.min(31, Number(e.target.value) || 1)) }))}
                style={{ ..._fieldStyle(), width: 80 }}/>
            </Row>
          )}
        </>
      )}

      {/* Time of day (optional) */}
      <Row label="Time">
        <input type="time" value={draft.time_of_day}
          onChange={(e) => setDraft(d => ({ ...d, time_of_day: e.target.value }))}
          style={{ ..._fieldStyle(), width: 130 }}/>
        <span style={{ fontSize: 11, color: "var(--ink-muted)" }}>(optional)</span>
      </Row>

      {/* Start */}
      <Row label="Starts">
        <input type="date" value={draft.start_date}
          onChange={(e) => setDraft(d => ({ ...d, start_date: e.target.value }))}
          style={{ ..._fieldStyle(), width: 150 }}/>
      </Row>

      {/* End */}
      <Row label="Ends">
        <select value={draft.end_kind}
          onChange={(e) => setDraft(d => ({ ...d, end_kind: e.target.value }))}
          style={_fieldStyle()}>
          <option value="never">Never</option>
          <option value="on">On date</option>
          <option value="after">After N occurrences</option>
        </select>
      </Row>
      {draft.end_kind === "on" && (
        <Row label=" ">
          <input type="date" value={draft.end_date}
            onChange={(e) => setDraft(d => ({ ...d, end_date: e.target.value }))}
            style={{ ..._fieldStyle(), width: 150 }}/>
        </Row>
      )}
      {draft.end_kind === "after" && (
        <Row label=" ">
          <input type="number" min={1} max={9999} value={draft.count_limit}
            placeholder="N"
            onChange={(e) => setDraft(d => ({ ...d, count_limit: Number(e.target.value) || "" }))}
            style={{ ..._fieldStyle(), width: 90 }}/>
          <span style={{ fontSize: 12, color: "var(--ink-muted)" }}>occurrences</span>
        </Row>
      )}

      {/* Advanced — chain mode + subtasks */}
      <details style={{ marginTop: 6 }}>
        <summary style={{ fontSize: 11.5, color: "var(--ink-muted)", cursor: "pointer", padding: "4px 0" }}>
          Advanced
        </summary>
        <Row label="Chain">
          <select value={draft.chain_mode}
            onChange={(e) => setDraft(d => ({ ...d, chain_mode: e.target.value }))}
            style={_fieldStyle()}>
            <option value="scheduled">On schedule</option>
            <option value="after_complete">After previous is done</option>
          </select>
        </Row>
        <Row label="Subtasks">
          <label style={{ display: "inline-flex", alignItems: "center", gap: 6, fontSize: 12, color: "var(--ink-strong)" }}>
            <input type="checkbox" checked={!!draft.clone_subtasks}
              onChange={(e) => setDraft(d => ({ ...d, clone_subtasks: e.target.checked }))}/>
            Clone subtasks each time
          </label>
        </Row>
      </details>

      {/* Preview */}
      <div style={{
        marginTop: 12, paddingTop: 10, borderTop: "1px dashed var(--border, #e6e9ef)",
        fontSize: 11.5,
      }}>
        <div style={{ color: "var(--ink-muted)", fontWeight: 600, marginBottom: 4 }}>
          {loadingPreview ? "Computing…" : "Next " + preview.length + " occurrence" + (preview.length === 1 ? "" : "s")}
        </div>
        <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
          {preview.length === 0 && !loadingPreview && (
            <span style={{ color: "var(--ink-faint)", fontStyle: "italic" }}>No upcoming occurrences</span>
          )}
          {preview.map((p, i) => (
            <span key={i} style={{
              display: "inline-block", padding: "2px 8px", borderRadius: 6,
              background: i === 0 ? "var(--brand-soft, rgba(162,93,220,.10))" : "var(--bg-subtle, #f1f4f9)",
              color: i === 0 ? "var(--brand, #a25ddc)" : "var(--ink-strong)",
              fontSize: 11, fontWeight: 600,
            }}>{p.label}</span>
          ))}
        </div>
      </div>

      {/* Actions */}
      <div style={{ display: "flex", gap: 6, marginTop: 14, flexWrap: "wrap" }}>
        <button type="button" onClick={save} className="btn-primary"
          style={{ padding: "6px 14px", fontSize: 12.5, fontWeight: 600, cursor: "pointer" }}>
          {rule ? "Update" : "Save"}
        </button>
        <button type="button" onClick={onClose}
          style={{ padding: "6px 14px", fontSize: 12.5, fontWeight: 600, cursor: "pointer",
                   background: "transparent", border: "1px solid var(--border, #e6e9ef)", borderRadius: 6,
                   color: "var(--ink-body)" }}>
          Cancel
        </button>
        {rule && (
          <>
            <span style={{ flex: 1 }}/>
            {rule.paused
              ? <button type="button" onClick={onResume} style={_secondaryBtn()}>Resume</button>
              : <button type="button" onClick={onPause}  style={_secondaryBtn()}>Pause</button>}
            <button type="button" onClick={onSkip}  style={_secondaryBtn()}>Skip next</button>
            <button type="button" onClick={onClear}
              style={{ ..._secondaryBtn(), color: "#b41f37" }}>Stop</button>
          </>
        )}
      </div>
    </div>
  );
}

function Row({ label, children }) {
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
      <span style={{ fontSize: 11, color: "var(--ink-muted)", fontWeight: 600,
                     textTransform: "uppercase", letterSpacing: ".05em",
                     width: 76, flexShrink: 0 }}>
        {label}
      </span>
      <div style={{ display: "inline-flex", alignItems: "center", gap: 6, flex: 1, minWidth: 0 }}>
        {children}
      </div>
    </div>
  );
}

function PresetChip({ label, onClick }) {
  return (
    <button type="button" onClick={onClick} style={{
      padding: "3px 9px", borderRadius: 999,
      background: "var(--bg-subtle, #f1f4f9)", border: "1px solid var(--border, #e6e9ef)",
      fontSize: 11, fontWeight: 600, color: "var(--ink-body)",
      cursor: "pointer", fontFamily: "inherit",
    }}>{label}</button>
  );
}

function WeekdayToggle({ code, on, onClick }) {
  const labels = { MO:"M", TU:"T", WE:"W", TH:"T", FR:"F", SA:"S", SU:"S" };
  return (
    <button type="button" onClick={onClick} style={{
      width: 28, height: 28, borderRadius: 8,
      background: on ? "var(--brand, #a25ddc)" : "var(--bg-subtle, #f1f4f9)",
      color: on ? "white" : "var(--ink-body)",
      border: "1px solid " + (on ? "var(--brand, #a25ddc)" : "var(--border, #e6e9ef)"),
      fontSize: 11, fontWeight: 700, cursor: "pointer",
      fontFamily: "inherit",
    }} title={code}>
      {labels[code]}
    </button>
  );
}

function _fieldStyle() {
  return {
    padding: "4px 8px", fontSize: 12.5,
    background: "white",
    border: "1px solid var(--border, #e6e9ef)", borderRadius: 6,
    fontFamily: "inherit", color: "var(--ink-strong)",
  };
}
function _secondaryBtn() {
  return {
    padding: "5px 11px", fontSize: 11.5, fontWeight: 600,
    background: "transparent", border: "1px solid var(--border, #e6e9ef)",
    borderRadius: 6, color: "var(--ink-body)", cursor: "pointer", fontFamily: "inherit",
  };
}
function _todayWeekdayCode() {
  return ["SU","MO","TU","WE","TH","FR","SA"][new Date().getDay()];
}

// Convert the popover's draft state back into the server rule shape.
// Returns null if the draft is invalid (e.g. weekly with no weekday picked).
function _draftToRule(draft) {
  if (!draft || !draft.freq) return null;
  if (draft.freq === "weekly" && !draft.by_weekday) return null;
  const out = {
    freq: draft.freq,
    interval_n: Number(draft.interval_n) || 1,
    by_weekday: draft.by_weekday || null,
    by_monthday: draft.by_monthday || null,
    by_setpos: draft.by_setpos || null,
    time_of_day: draft.time_of_day || null,
    start_date: draft.start_date,
    end_date: null,
    count_limit: null,
    chain_mode: draft.chain_mode || "scheduled",
    clone_subtasks: draft.clone_subtasks !== false,
  };
  if (draft.end_kind === "on" && draft.end_date) out.end_date = draft.end_date;
  if (draft.end_kind === "after" && draft.count_limit) out.count_limit = Number(draft.count_limit);
  return out;
}

Object.assign(window, { TaskDrawer, EditField, CopyTaskLinkButton, RecurrenceField });
