// notes.jsx — Notion-style block-based personal notes, private to the
// signed-in user. Each note is a small "page" that holds an ordered
// list of blocks (paragraph / H1-H3 / bulleted / numbered / todo /
// quote / divider / code).
//
// Privacy contract unchanged: every API call goes through api.notes.*,
// which hard-filters by req.user.id. Workspace owners cannot see
// another user's notes via the API.
//
// What's new (vs the old textarea + separate To-do tab):
//   • Block editor with type-specific affordances (heading sizes,
//     bullet glyph, todo checkbox, code mono-font, etc.)
//   • Slash menu — type "/" inside an empty block (or after some
//     filter text) to switch the block's type via keyboard.
//   • Markdown shortcuts on Enter — typing "# ", "## ", "### ",
//     "- ", "1. ", "[] ", "> ", "``` " converts the current block
//     in place. Keeps hands on the keyboard.
//   • Enter at the end of a block creates a new paragraph below.
//     Enter on an empty list/todo/heading converts back to paragraph
//     (Notion convention — feels natural after a year of using it).
//   • Backspace at the start of an empty block deletes it and moves
//     focus to the previous block.
//   • Auto-save with a 600 ms debounce — no Save button.
//
// Todos are now a block type, not a separate tab. The old dedicated
// "To-do" view is removed; existing todo notes still load (the API
// still accepts kind=todo) but new ones are encouraged to be a Todo
// block inside a regular note. Migration of existing kind="todo"
// rows happens lazily — they show up in the list with their text
// rendered as a single todo block.

const NOTE_COLORS = [
  { id: null,     swatch: "#f3f4f7", label: "Default" },
  { id: "amber",  swatch: "#fde68a", label: "Amber"   },
  { id: "blue",   swatch: "#bfdbfe", label: "Blue"    },
  { id: "green",  swatch: "#bbf7d0", label: "Green"   },
  { id: "pink",   swatch: "#fbcfe8", label: "Pink"    },
  { id: "purple", swatch: "#ddd6fe", label: "Purple"  },
];

// Block types that the Slash menu offers. Order matters — keyboard
// nav uses this order, and the most common ones go to the top.
const BLOCK_TYPES = [
  { id: "paragraph", label: "Text",            icon: "¶",  hint: "Start writing with plain text" },
  { id: "h1",        label: "Heading 1",       icon: "H₁", hint: "Big section heading" },
  { id: "h2",        label: "Heading 2",       icon: "H₂", hint: "Medium section heading" },
  { id: "h3",        label: "Heading 3",       icon: "H₃", hint: "Small section heading" },
  { id: "todo",      label: "To-do",           icon: "☑",  hint: "Checkable task with a checkbox" },
  { id: "bulleted",  label: "Bulleted list",   icon: "•",  hint: "Unordered list" },
  { id: "numbered",  label: "Numbered list",   icon: "1.", hint: "Ordered list" },
  { id: "quote",     label: "Quote",           icon: "❝",  hint: "Pull-quote / callout" },
  { id: "code",      label: "Code",            icon: "</>",hint: "Monospace code block" },
  { id: "divider",   label: "Divider",         icon: "—",  hint: "Horizontal rule between sections" },
];

// Generate a stable-ish id for new blocks. Used as the React key so
// re-typing in a block doesn't unmount/remount the contentEditable
// (which would lose caret position).
let _blkSeq = 0;
function blockId() {
  _blkSeq = (_blkSeq + 1) | 0;
  return "b_" + Date.now().toString(36) + "_" + _blkSeq.toString(36);
}
function makeBlock(type = "paragraph", text = "") {
  const b = { id: blockId(), type, text };
  if (type === "todo") b.done = false;
  return b;
}

// Best-effort upgrade: a note with a body but no blocks gets one
// paragraph block per non-empty line. Lossy by design — if it had
// real structure we'd already have stored it as blocks. Keeps old
// notes from rendering as a single mushed paragraph.
function bodyToBlocks(body) {
  if (!body) return [makeBlock("paragraph", "")];
  const lines = String(body).split(/\r?\n/);
  const out = [];
  for (const raw of lines) {
    const line = raw.trimEnd();
    if (!line && out.length === 0) continue;
    // Cheap markdown-ish recognition for the most common shapes so
    // the upgrade isn't a wall of paragraph blocks.
    const h1 = /^# (.*)$/.exec(line);
    const h2 = /^## (.*)$/.exec(line);
    const h3 = /^### (.*)$/.exec(line);
    const bul = /^[-•] (.*)$/.exec(line);
    const num = /^\d+\.\s+(.*)$/.exec(line);
    const todoChk  = /^\[([ xX])\]\s+(.*)$/.exec(line);
    const quote    = /^>\s+(.*)$/.exec(line);
    if      (h1)      out.push(makeBlock("h1", h1[1]));
    else if (h2)      out.push(makeBlock("h2", h2[1]));
    else if (h3)      out.push(makeBlock("h3", h3[1]));
    else if (todoChk) {
      const b = makeBlock("todo", todoChk[2]);
      b.done = todoChk[1].toLowerCase() === "x";
      out.push(b);
    }
    else if (bul)     out.push(makeBlock("bulleted", bul[1]));
    else if (num)     out.push(makeBlock("numbered", num[1]));
    else if (quote)   out.push(makeBlock("quote", quote[1]));
    else if (line === "---" || line === "***") out.push(makeBlock("divider", ""));
    else              out.push(makeBlock("paragraph", line));
  }
  if (!out.length) out.push(makeBlock("paragraph", ""));
  return out;
}

// Convert a saved blocks array into the small preview shown on
// collapsed note cards. We bail after a few blocks worth of content
// so the card doesn't grow with the document.
function blocksPreview(blocks, body) {
  const arr = Array.isArray(blocks) && blocks.length ? blocks : bodyToBlocks(body);
  const lines = [];
  for (const b of arr) {
    if (b.type === "divider") { lines.push("———"); continue; }
    const t = (b.text || "").trim();
    if (!t && b.type !== "todo") continue;
    if (b.type === "h1")      lines.push(t);
    else if (b.type === "h2") lines.push(t);
    else if (b.type === "h3") lines.push(t);
    else if (b.type === "bulleted") lines.push("• " + t);
    else if (b.type === "numbered") lines.push("1. " + t);
    else if (b.type === "todo")     lines.push((b.done ? "☑ " : "☐ ") + t);
    else if (b.type === "quote")    lines.push("❝ " + t);
    else if (b.type === "code")     lines.push(t);
    else                            lines.push(t);
    if (lines.length >= 6) { lines.push("…"); break; }
  }
  return lines.join("\n").slice(0, 320);
}

// ── NotesView ──────────────────────────────────────────────────────
// Personal = two sections side by side:
//   1. "To-do" — quick-capture checklist (kind='todo' rows). Designed
//      for typing tasks fast, ticking them off, optional due dates.
//   2. "Notes" — the Notion-style block editor (kind='note' rows).
//      Long-form thinking with headings, lists, todos-inside-notes.
// On wide screens they sit side by side; on narrow screens they
// stack with the To-do list first (mobile users tend to need quick
// checklist capture more than long notes).
function NotesView() {
  const [todos, setTodos] = React.useState([]);
  const [notes, setNotes] = React.useState([]);
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState("");
  // Tab state — Personal switches between two focused views instead
  // of cramming both into a side-by-side body. Persisted in
  // localStorage so the user lands back on whichever tab they last
  // used (typically To-do for quick capture).
  const [activeTab, setActiveTab] = React.useState(() => {
    try {
      const v = window.localStorage.getItem("fb.personal.activeTab");
      if (v === "todo" || v === "notes" || v === "coach") return v;
    } catch {}
    return "todo";
  });
  React.useEffect(() => {
    try { window.localStorage.setItem("fb.personal.activeTab", activeTab); } catch {}
  }, [activeTab]);

  async function load() {
    setLoading(true); setError("");
    try {
      // Two parallel calls — splitting by kind keeps the section UIs
      // simple and the Todo composer doesn't need to filter the full
      // mixed list on every render.
      const [t, n] = await Promise.all([
        api.notes.list("todo"),
        api.notes.list("note"),
      ]);
      setTodos(Array.isArray(t) ? t : []);
      setNotes(Array.isArray(n) ? n : []);
    } catch (e) {
      setError((e && e.message) || "Couldn't load your notes.");
    } finally {
      setLoading(false);
    }
  }
  React.useEffect(() => { load(); }, []);

  // Re-uses the Home page's CSS shell (.home-root, .home-hero,
  // .home-body, .home-card, .home-card-head) so the Personal page
  // visually matches the rest of the app instead of feeling like an
  // island. Section-specific styling (todo rows, notes grid) lives
  // in the .personal-* classes injected below.
  // Status semantics: prefer task_status when present; fall back to
  // the legacy `done` boolean for older rows.
  const _isDone = (t) => (t.task_status || (t.done ? "done" : "todo")) === "done";
  const openCount = todos.filter(t => !_isDone(t)).length;
  const doneCount = todos.filter(t =>  _isDone(t)).length;
  // Compute open-task tone counts so the hero stats can flag overdue
  // / due-today work. Mirrors the bucket logic used inside the table
  // below; we keep it simple here (date-only, no time-of-day parsing)
  // since we just need a coarse banner-level count.
  function _ord(t) {
    const raw = t && t.due_at;
    if (!raw) return null;
    const head = String(raw).split(" @ ")[0].trim();
    const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(head);
    if (!m) return null;
    return Number(m[1]) * 10000 + Number(m[2]) * 100 + Number(m[3]);
  }
  const _today = new Date();
  const _todayOrd = _today.getFullYear() * 10000 + (_today.getMonth() + 1) * 100 + _today.getDate();
  const overdueCount = todos.filter(t => !_isDone(t) && _ord(t) != null && _ord(t) <  _todayOrd).length;
  const todayCount   = todos.filter(t => !_isDone(t) && _ord(t) === _todayOrd).length;

  return (
    <div className="home-root personal-root">
      {/* Hero — Personal gets a warm peach-to-coral gradient instead of
          the cool blue/purple Home uses. Same shell + dimensions, but
          the colour treatment makes "this is your private space"
          obvious at a glance. */}
      <div className="home-hero personal-hero">
        <div className="personal-hero-text">
          <div className="home-greet personal-greet">
            <span className="personal-greet-glyph" aria-hidden="true">✦</span>
            Personal
          </div>
          <div className="home-sub personal-sub">
            Your private workspace — only you can see this. <b>To-do</b> for quick task tracking, <b>Notes</b> for longer thinking. Type <kbd>/</kbd> inside a note for the block menu.
          </div>
        </div>
        {/* Right-side stat chips mirror the Home pattern. Open / Overdue
            / Due-today / Notes — all read-only summaries; clicking the
            table below is the actual interaction. */}
        <div className="home-stats personal-stats">
          <div className="home-stat personal-stat" style={{ cursor: "default" }}>
            <span className="home-stat-num personal-stat-num">{openCount}</span>
            <span className="home-stat-label">Open</span>
          </div>
          <div className={"home-stat personal-stat" + (overdueCount > 0 ? " is-danger" : "")} style={{ cursor: "default" }}>
            <span className="home-stat-num personal-stat-num">{overdueCount}</span>
            <span className="home-stat-label">Overdue</span>
          </div>
          <div className={"home-stat personal-stat" + (todayCount > 0 ? " is-warn" : "")} style={{ cursor: "default" }}>
            <span className="home-stat-num personal-stat-num">{todayCount}</span>
            <span className="home-stat-label">Today</span>
          </div>
          <div className="home-stat personal-stat" style={{ cursor: "default" }}>
            <span className="home-stat-num personal-stat-num">{notes.length}</span>
            <span className="home-stat-label">Notes</span>
          </div>
        </div>
      </div>

      {error && <div className="notes-error">{error}</div>}

      {/* Tab strip — sits between the hero and the active panel.
          Underline-style tabs in the Personal warm palette, with a
          live subtitle on each tab so the user sees counts at a
          glance without clicking. */}
      <nav className="personal-tabs" role="tablist" aria-label="Personal sections">
        <button
          type="button"
          role="tab"
          aria-selected={activeTab === "todo"}
          className={"personal-tab" + (activeTab === "todo" ? " is-active" : "")}
          onClick={() => setActiveTab("todo")}>
          <span className="personal-tab-icon personal-pip personal-pip--todo">✓</span>
          <span className="personal-tab-label">To-do</span>
          <span className="personal-tab-sub">
            {openCount} open
            {doneCount > 0 && <span className="personal-section-count-muted">&nbsp;·&nbsp;{doneCount} done</span>}
          </span>
        </button>
        <button
          type="button"
          role="tab"
          aria-selected={activeTab === "notes"}
          className={"personal-tab" + (activeTab === "notes" ? " is-active" : "")}
          onClick={() => setActiveTab("notes")}>
          <span className="personal-tab-icon personal-pip personal-pip--notes">📝</span>
          <span className="personal-tab-label">Notes</span>
          <span className="personal-tab-sub">
            {notes.length} note{notes.length === 1 ? "" : "s"}
          </span>
        </button>
        {/* Coach — accountability tracker (habits, goals, streaks).
            Local-only, private to this device. Hidden if coach.jsx
            failed to load so the tab never points at nothing. */}
        {typeof window.CoachView !== "undefined" && (
          <button
            type="button"
            role="tab"
            aria-selected={activeTab === "coach"}
            className={"personal-tab" + (activeTab === "coach" ? " is-active" : "")}
            onClick={() => setActiveTab("coach")}>
            <span className="personal-tab-icon personal-pip personal-pip--coach">◎</span>
            <span className="personal-tab-label">Coach</span>
            <span className="personal-tab-sub">discipline</span>
          </button>
        )}
      </nav>

      {loading ? (
        <div className="home-card">
          <div className="home-empty">Loading…</div>
        </div>
      ) : (
        <div className="personal-tab-panel">
          {activeTab === "todo" && (
            <section className="home-card">
              <header className="home-card-head">
                <h3><span className="personal-pip personal-pip--todo">✓</span> To-do</h3>
                <span className="personal-section-count">
                  {openCount} open
                  {doneCount > 0 && (
                    <span className="personal-section-count-muted">&nbsp;·&nbsp;{doneCount} done</span>
                  )}
                </span>
              </header>
              <TodoSection todos={todos} setTodos={setTodos}/>
            </section>
          )}
          {activeTab === "notes" && (
            <section className="home-card">
              <header className="home-card-head">
                <h3><span className="personal-pip personal-pip--notes">📝</span> Notes</h3>
                <span className="personal-section-count">{notes.length} note{notes.length === 1 ? "" : "s"}</span>
              </header>
              <NotesGrid notes={notes} setNotes={setNotes}/>
            </section>
          )}
          {activeTab === "coach" && typeof window.CoachView !== "undefined" && (
            React.createElement(window.CoachView)
          )}
        </div>
      )}
    </div>
  );
}

// ── TodoSection — Personal task table ────────────────────────────
// Each personal todo is now a full task: name + status + priority +
// due date. Status / priority pills reuse the same enum (and CSS
// classes) as project tasks (STATUSES, PRIORITIES from data.jsx) so
// the visual language stays consistent. Composer at the top still
// supports rapid-fire Enter capture; the new fields are filled in
// inline by clicking the pills.
function TodoSection({ todos, setTodos }) {
  const [text, setText] = React.useState("");
  const [busy, setBusy] = React.useState(false);
  const [hideDone, setHideDone] = React.useState(() => {
    try { return localStorage.getItem("fb.personal.todos.hideDone") === "1"; }
    catch { return false; }
  });
  React.useEffect(() => {
    try { localStorage.setItem("fb.personal.todos.hideDone", hideDone ? "1" : "0"); } catch {}
  }, [hideDone]);

  // Filters — match the project task table's BoardToolbar set:
  //   · Search    — substring match on title
  //   · Status    — single-pick from STATUSES
  //   · Priority  — single-pick from PRIORITIES
  //   · Sort      — default | priority | due | name | created
  // Each one persists in localStorage so power users land on their
  // last view. "all" / "default" / "" disable the filter.
  const [searchQ, setSearchQ] = React.useState(() => {
    try { return localStorage.getItem("fb.personal.todos.q") || ""; }
    catch { return ""; }
  });
  const [statusFilter, setStatusFilter] = React.useState(() => {
    try { return localStorage.getItem("fb.personal.todos.statusFilter") || "all"; }
    catch { return "all"; }
  });
  const [priorityFilter, setPriorityFilter] = React.useState(() => {
    try { return localStorage.getItem("fb.personal.todos.priorityFilter") || "all"; }
    catch { return "all"; }
  });
  const [sortBy, setSortBy] = React.useState(() => {
    try { return localStorage.getItem("fb.personal.todos.sort") || "default"; }
    catch { return "default"; }
  });
  React.useEffect(() => { try { localStorage.setItem("fb.personal.todos.q", searchQ); } catch {} }, [searchQ]);
  React.useEffect(() => { try { localStorage.setItem("fb.personal.todos.statusFilter", statusFilter); } catch {} }, [statusFilter]);
  React.useEffect(() => { try { localStorage.setItem("fb.personal.todos.priorityFilter", priorityFilter); } catch {} }, [priorityFilter]);
  React.useEffect(() => { try { localStorage.setItem("fb.personal.todos.sort", sortBy); } catch {} }, [sortBy]);

  async function add() {
    const t = text.trim();
    if (!t || busy) return;
    setBusy(true);
    const tempId = "tmp_" + Date.now();
    const optimistic = {
      id: tempId, kind: "todo",
      title: t, body: "",
      done: false, task_status: "todo", task_priority: "none",
      color: null, pinned: false, due_at: null, position: 9999,
    };
    setTodos(ts => [...ts, optimistic]);
    setText("");
    try {
      const r = await api.notes.create({ kind: "todo", title: t });
      setTodos(ts => ts.map(x => x.id === tempId ? r : x));
    } catch (e) {
      setTodos(ts => ts.filter(x => x.id !== tempId));
      alert("Couldn't add: " + ((e && e.message) || "network error"));
    } finally {
      setBusy(false);
    }
  }
  // Generic patch helper — applies optimistic UI then syncs server.
  async function patchTodo(id, patch) {
    setTodos(ts => ts.map(x => x.id === id ? { ...x, ...patch } : x));
    try { await api.notes.patch(id, patch); } catch {}
  }
  async function setStatus(t, status) {
    // Setting status='done' also sets the legacy `done` boolean for
    // the count badge in the header. The server already aligns the
    // two — we just mirror it locally for instant UI.
    patchTodo(t.id, { task_status: status, done: status === "done" });
  }
  async function setPriority(t, priority) { patchTodo(t.id, { task_priority: priority }); }
  async function setDue(t, due_at) { patchTodo(t.id, { due_at }); }
  async function rename(t, newTitle) {
    const v = (newTitle || "").trim();
    if (!v || v === t.title) return;
    patchTodo(t.id, { title: v });
  }
  async function remove(t) {
    setTodos(ts => ts.filter(x => x.id !== t.id));
    try { await api.notes.remove(t.id); } catch {}
  }

  // Status helper used by the checkbox: one-click toggle between
  // todo and done. Other statuses still reachable via the pill.
  function quickToggle(t) {
    const isDone = (t.task_status || (t.done ? "done" : "todo")) === "done";
    setStatus(t, isDone ? "todo" : "done");
  }

  // Sort + bucket. Open tasks first (overdue → due-today → no-date),
  // done at the bottom (newest-first). Priority breaks ties so a
  // critical-priority "no due date" still floats above a low-priority one.
  const PRIO_ORDER = { critical: 0, high: 1, medium: 2, low: 3, none: 4 };
  function dueOrdinal(t) {
    const raw = t && t.due_at;
    if (!raw) return null;
    const head = String(raw).split(" @ ")[0].trim();
    const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(head);
    if (iso) return Number(iso[1]) * 10000 + Number(iso[2]) * 100 + Number(iso[3]);
    const m = /^([A-Za-z]{3})\s+(\d{1,2})$/.exec(head);
    if (m) {
      const moMap = { Jan:1,Feb:2,Mar:3,Apr:4,May:5,Jun:6,Jul:7,Aug:8,Sep:9,Oct:10,Nov:11,Dec:12 };
      const year = new Date().getFullYear();
      return year * 10000 + (moMap[m[1]] || 0) * 100 + Number(m[2]);
    }
    return null;
  }
  const todayOrd = (() => {
    const t = new Date();
    return t.getFullYear() * 10000 + (t.getMonth() + 1) * 100 + t.getDate();
  })();

  // Apply search + status + priority filters before bucketing into
  // open/done. The Open / Done bucket counts reflect the filtered
  // subset so the table header stays consistent with what's rendered.
  const _q = searchQ.trim().toLowerCase();
  function _matchesFilters(t) {
    if (_q) {
      const title = String(t.title || "").toLowerCase();
      if (!title.includes(_q)) return false;
    }
    if (statusFilter !== "all") {
      const st = t.task_status || (t.done ? "done" : "todo");
      if (st !== statusFilter) return false;
    }
    if (priorityFilter !== "all") {
      const pr = t.task_priority || "none";
      if (pr !== priorityFilter) return false;
    }
    return true;
  }
  function _sortFn(a, b) {
    if (sortBy === "priority") {
      const ap = PRIO_ORDER[a.task_priority || "none"] ?? 4;
      const bp = PRIO_ORDER[b.task_priority || "none"] ?? 4;
      if (ap !== bp) return ap - bp;
    }
    if (sortBy === "due") {
      const ao = dueOrdinal(a), bo = dueOrdinal(b);
      if ((ao == null) !== (bo == null)) return ao == null ? 1 : -1;
      if (ao != null && bo != null && ao !== bo) return ao - bo;
    }
    if (sortBy === "name") {
      const an = String(a.title || "").toLowerCase();
      const bn = String(b.title || "").toLowerCase();
      const c = an.localeCompare(bn);
      if (c !== 0) return c;
    }
    if (sortBy === "created") {
      return new Date(b.created_at || 0) - new Date(a.created_at || 0);
    }
    // "default" — due asc → priority → created asc (the original ordering)
    const ao = dueOrdinal(a), bo = dueOrdinal(b);
    if ((ao == null) !== (bo == null)) return ao == null ? 1 : -1;
    if (ao != null && bo != null && ao !== bo) return ao - bo;
    const ap = PRIO_ORDER[a.task_priority || "none"] ?? 4;
    const bp = PRIO_ORDER[b.task_priority || "none"] ?? 4;
    if (ap !== bp) return ap - bp;
    return new Date(a.created_at || 0) - new Date(b.created_at || 0);
  }
  const filtered = todos.filter(_matchesFilters);
  const open = filtered
    .filter(t => (t.task_status || (t.done ? "done" : "todo")) !== "done")
    .sort(_sortFn);
  const done = filtered
    .filter(t => (t.task_status || (t.done ? "done" : "todo")) === "done")
    .sort((a, b) => new Date(b.updated_at || 0) - new Date(a.updated_at || 0));
  const filterActive = !!_q || statusFilter !== "all" || priorityFilter !== "all" || sortBy !== "default";

  return (
    <div className="todo-pane">
      <div className="todo-composer">
        <input
          type="text"
          value={text}
          onChange={(e) => setText(e.target.value)}
          onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
          placeholder="Add a task — press Enter, then click pills to set status / priority / due"
        />
        {done.length > 0 && (
          <button type="button"
                  className={"todo-hide-done" + (hideDone ? " is-on" : "")}
                  onClick={() => setHideDone(h => !h)}
                  title={hideDone ? "Show completed tasks" : "Hide completed tasks"}>
            {hideDone ? "Show done" : "Hide done"}
          </button>
        )}
      </div>
      {/* Filter toolbar — Search · Status · Priority · Sort · Clear ·
          Count. Mirrors the project task table's BoardToolbar set so
          the personal todo flow has the same filtering power as a
          project view. All five controls persist in localStorage. */}
      <div className="todo-filter-row">
        <label className="todo-filter todo-filter--grow">
          <span className="todo-filter-label">Search</span>
          <input type="search"
                 className="todo-filter-input"
                 placeholder="Find by title…"
                 value={searchQ}
                 onChange={(e) => setSearchQ(e.target.value)}/>
        </label>
        <label className="todo-filter">
          <span className="todo-filter-label">Status</span>
          <select className="todo-filter-select"
                  value={statusFilter}
                  onChange={(e) => setStatusFilter(e.target.value)}>
            <option value="all">All</option>
            {(typeof STATUSES !== "undefined" ? STATUSES : []).map(s => (
              <option key={s.id} value={s.id}>{s.label}</option>
            ))}
          </select>
        </label>
        <label className="todo-filter">
          <span className="todo-filter-label">Priority</span>
          <select className="todo-filter-select"
                  value={priorityFilter}
                  onChange={(e) => setPriorityFilter(e.target.value)}>
            <option value="all">All</option>
            {(typeof PRIORITIES !== "undefined" ? PRIORITIES : []).map(p => (
              <option key={p.id} value={p.id}>{p.label}</option>
            ))}
          </select>
        </label>
        <label className="todo-filter">
          <span className="todo-filter-label">Sort</span>
          <select className="todo-filter-select"
                  value={sortBy}
                  onChange={(e) => setSortBy(e.target.value)}>
            <option value="default">Smart (due · priority)</option>
            <option value="priority">Priority (high → low)</option>
            <option value="due">Due date (soonest)</option>
            <option value="name">Name (A → Z)</option>
            <option value="created">Recently created</option>
          </select>
        </label>
        {filterActive && (
          <button type="button" className="todo-filter-clear"
                  onClick={() => {
                    setSearchQ(""); setStatusFilter("all");
                    setPriorityFilter("all"); setSortBy("default");
                  }}
                  title="Clear filters">
            Clear
          </button>
        )}
        <span className="todo-filter-count">
          {filtered.length === todos.length
            ? `${todos.length} task${todos.length === 1 ? "" : "s"}`
            : `${filtered.length} of ${todos.length}`}
        </span>
      </div>
      {open.length === 0 && done.length === 0 && (
        <div className="todo-empty">
          No tasks yet. Type something above and hit Enter.
        </div>
      )}
      {/* Project-style task <table.t> — same HTML structure as the
          workspace TableView in table.jsx so the visual language
          matches. Real thead with column labels, tbody with cell-
          name + cell-center cells. The Open/Done buckets are header
          rows (full-width <td colspan>) so the table layout flows
          continuously. */}
      {(open.length > 0 || (done.length > 0 && !hideDone)) && (
        // NOTE: deliberately NOT using `.table-wrap` from table.css —
        // that class sets `overflow: hidden` to support the project
        // view's epic-collapse animation, which the personal page
        // doesn't have. Inheriting it clipped every popover (status,
        // priority, date picker) so they rendered but were invisible.
        <div className="personal-table-wrap">
          <table className="t personal-todo-t">
            <colgroup>
              <col style={{ width: 36 }}/>
              <col/>
              <col style={{ width: 130 }}/>
              <col style={{ width: 110 }}/>
              <col style={{ width: 130 }}/>
              <col style={{ width: 36 }}/>
            </colgroup>
            <thead>
              <tr>
                <th aria-hidden="true"/>
                <th>Task</th>
                <th className="th-center">Status</th>
                <th className="th-center">Priority</th>
                <th className="th-center">Due</th>
                <th aria-hidden="true"/>
              </tr>
            </thead>
            <tbody>
              {open.length > 0 && (
                <tr className="personal-tbl-grouprow">
                  <td colSpan={6}>
                    <span className="personal-tbl-groupdot personal-tbl-groupdot--open"/>
                    <span className="personal-tbl-grouplabel">Open</span>
                    <span className="personal-tbl-groupcount">{open.length}</span>
                  </td>
                </tr>
              )}
              {open.map((t) => (
                <TodoRow key={t.id} todo={t} todayOrd={todayOrd}
                         onQuickToggle={() => quickToggle(t)}
                         onSetStatus={(s) => setStatus(t, s)}
                         onSetPriority={(p) => setPriority(t, p)}
                         onSetDue={(d) => setDue(t, d)}
                         onRename={(v) => rename(t, v)}
                         onDelete={() => remove(t)}/>
              ))}
              {done.length > 0 && !hideDone && (
                <tr className="personal-tbl-grouprow personal-tbl-grouprow--done">
                  <td colSpan={6}>
                    <span className="personal-tbl-groupdot personal-tbl-groupdot--done"/>
                    <span className="personal-tbl-grouplabel">Done</span>
                    <span className="personal-tbl-groupcount">{done.length}</span>
                  </td>
                </tr>
              )}
              {done.length > 0 && !hideDone && done.map((t) => (
                <TodoRow key={t.id} todo={t} todayOrd={todayOrd}
                         onQuickToggle={() => quickToggle(t)}
                         onSetStatus={(s) => setStatus(t, s)}
                         onSetPriority={(p) => setPriority(t, p)}
                         onSetDue={(d) => setDue(t, d)}
                         onRename={(v) => rename(t, v)}
                         onDelete={() => remove(t)}/>
              ))}
            </tbody>
          </table>
        </div>
      )}
    </div>
  );
}

function TodoRow({ todo, todayOrd, onQuickToggle, onSetStatus, onSetPriority, onSetDue, onRename, onDelete }) {
  const ref = React.useRef(null);
  const [original, setOriginal] = React.useState(todo.title || "");
  React.useEffect(() => {
    if (ref.current && ref.current.textContent !== (todo.title || "")) {
      ref.current.textContent = todo.title || "";
      setOriginal(todo.title || "");
    }
  }, [todo.id, todo.title]);

  const status = todo.task_status || (todo.done ? "done" : "todo");
  const priority = todo.task_priority || "none";
  const isDone = status === "done";
  const stMeta = (typeof STATUSES !== "undefined" ? STATUSES : []).find(x => x.id === status)
              || { id: status, label: status, cls: "pill-todo" };
  const prMeta = (typeof PRIORITIES !== "undefined" ? PRIORITIES : []).find(x => x.id === priority)
              || { id: priority, label: priority, cls: "pill-prio-none" };

  // Due chip — green-ish neutral, amber if today, red if overdue.
  const ord = (() => {
    const raw = todo.due_at;
    if (!raw) return null;
    const head = String(raw).split(" @ ")[0].trim();
    const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(head);
    if (iso) return Number(iso[1]) * 10000 + Number(iso[2]) * 100 + Number(iso[3]);
    const m = /^([A-Za-z]{3})\s+(\d{1,2})$/.exec(head);
    if (m) {
      const moMap = { Jan:1,Feb:2,Mar:3,Apr:4,May:5,Jun:6,Jul:7,Aug:8,Sep:9,Oct:10,Nov:11,Dec:12 };
      return new Date().getFullYear() * 10000 + (moMap[m[1]] || 0) * 100 + Number(m[2]);
    }
    return null;
  })();
  const overdue = ord != null && !isDone && ord < todayOrd;
  const dueToday = ord != null && !isDone && ord === todayOrd;
  const dueLabel = (() => {
    if (!todo.due_at) return null;
    const head = String(todo.due_at).split(" @ ")[0].trim();
    const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(head);
    if (iso) {
      const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
      return months[Number(iso[2]) - 1] + " " + Number(iso[3]);
    }
    return head;
  })();
  const dueIsoForInput = (() => {
    if (!todo.due_at) return "";
    const head = String(todo.due_at).split(" @ ")[0].trim();
    const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(head);
    if (iso) return head;
    return "";
  })();

  // Render as a project-style table row. Same .cell-name + .cell-
  // center class semantics as TableView so styling matches without a
  // bespoke ruleset. The coloured left rail comes from the .t
  // tbody td.cell-name border-left rule, scoped here via
  // --epic-color to the personal coral.
  return (
    <tr className={"personal-tbl-row" + (isDone ? " is-done" : "")}>
      <td className="row-check cell-center">
        <button type="button" className="todo-checkbox" onClick={onQuickToggle}
                title={isDone ? "Mark as not done" : "Mark as done"}>
          {isDone ? "✓" : ""}
        </button>
      </td>
      <td className="cell-name personal-tbl-name">
        <span className="name-wrap">
          <span className="name-text" contentEditable suppressContentEditableWarning
                ref={ref}
                onBlur={(e) => onRename(e.currentTarget.textContent || "")}
                onKeyDown={(e) => {
                  if (e.key === "Enter") { e.preventDefault(); e.currentTarget.blur(); }
                  if (e.key === "Escape") {
                    e.preventDefault();
                    if (ref.current) ref.current.textContent = original;
                    e.currentTarget.blur();
                  }
                }}/>
        </span>
      </td>
      {/* Status + Priority — use the same `pill pill-cell` shape as
          the project task table so the personal row visually matches
          the project rows pixel-for-pixel. The PillPicker handles its
          own click-to-edit popover. */}
      <td className="cell-center cell-pill-fill">
        <PillPicker
          className={"pill pill-cell " + stMeta.cls + " todo-pill"}
          label={stMeta.label}
          options={(typeof STATUSES !== "undefined" ? STATUSES : []).map(s => ({
            id: s.id, label: s.label, cls: s.cls,
          }))}
          value={status}
          onPick={onSetStatus}/>
      </td>
      <td className="cell-center cell-pill-fill">
        <PillPicker
          className={"pill pill-cell " + prMeta.cls + " todo-pill"}
          label={prMeta.label}
          options={(typeof PRIORITIES !== "undefined" ? PRIORITIES : []).map(p => ({
            id: p.id, label: p.label, cls: p.cls,
          }))}
          value={priority}
          onPick={onSetPriority}/>
      </td>
      <td className="cell-center">
        <DueChip
          rawValue={todo.due_at || ""}
          displayLabel={dueLabel}
          overdue={overdue}
          dueToday={dueToday}
          onChange={(v) => onSetDue(v || null)}/>
      </td>
      <td className="cell-center">
        <button type="button" className="todo-delete" onClick={onDelete} title="Delete task">×</button>
      </td>
    </tr>
  );
}

// Pill picker — click the pill → options open below it, click one →
// applied. Uses the workspace's portal-based <Popover> from ui.jsx so
// it renders at document.body level and never gets clipped or
// z-index-trapped by table / wrapper overflow. Same component the
// project task table uses for its status/priority pickers.
function PillPicker({ className, label, options, value, onPick }) {
  const [anchor, setAnchor] = React.useState(null);
  return (
    <span className="todo-pill-wrap">
      <button type="button" className={className}
              onClick={(e) => setAnchor(anchor ? null : e.currentTarget)}>
        {label}
      </button>
      {anchor && typeof Popover !== "undefined" && (
        <Popover anchor={anchor} onClose={() => setAnchor(null)}>
          {options.map(o => (
            <div key={o.id} className="popover-item"
                 onClick={(e) => { e.stopPropagation(); onPick(o.id); setAnchor(null); }}>
              <span className={"pill pill-sm " + (o.cls || "") + (o.id === value ? " is-on" : "")}
                    style={{ minWidth: 80 }}>{o.label}</span>
            </div>
          ))}
        </Popover>
      )}
    </span>
  );
}

// Date + time picker — uses the project's MiniCalendar component
// (defined in table.jsx, exposed as window.MiniCalendar) so the
// Personal page gets the same calendar grid + optional time picker
// as the project task table. The chip itself is the trigger; the
// calendar opens in a popover anchored below it.
//
// MiniCalendar's value/onChange contract uses the human-readable
// format "MMM D" or "MMM D @ h:mm AM/PM" (parseDateLoose accepts
// ISO too, so legacy YYYY-MM-DD rows in user_notes still parse).
// We pass the raw stored value through; the server stores whatever
// string the picker emits.
function DueChip({ rawValue, displayLabel, overdue, dueToday, onChange }) {
  const [anchor, setAnchor] = React.useState(null);
  const Mini = (typeof window !== "undefined" && window.MiniCalendar) ? window.MiniCalendar : null;
  const className = "todo-due-chip"
    + (overdue ? " is-overdue" : dueToday ? " is-today" : "")
    + (displayLabel ? "" : " is-empty");
  return (
    <span className="todo-pill-wrap">
      <button type="button" className={className}
              onClick={(e) => setAnchor(anchor ? null : e.currentTarget)}
              title={displayLabel || "Pick a due date"}>
        {displayLabel || "Due"}
      </button>
      {anchor && typeof Popover !== "undefined" && (
        <Popover anchor={anchor} onClose={() => setAnchor(null)}>
          {Mini ? (
            <Mini
              value={rawValue || ""}
              onChange={(v) => { onChange(v || null); setAnchor(null); }}
              onClear={() => { onChange(null); setAnchor(null); }}/>
          ) : (
            // Fallback if table.jsx hasn't loaded yet — keep the
            // native input so the user can still set a due date.
            <div style={{ padding: 10, minWidth: 220 }}>
              <input type="date"
                     defaultValue={rawValue || ""}
                     autoFocus
                     onChange={(e) => { onChange(e.target.value); setAnchor(null); }}
                     style={{ width: "100%", padding: 6, fontSize: 13,
                              border: "1px solid var(--border)", borderRadius: 4 }}/>
            </div>
          )}
        </Popover>
      )}
    </span>
  );
}

// ── Grid of note cards ─────────────────────────────────────────────
function NotesGrid({ notes, setNotes }) {
  const [composing, setComposing] = React.useState(false);
  const [openId, setOpenId] = React.useState(null);

  async function create(payload) {
    try {
      const created = await api.notes.create({ kind: "note", ...payload });
      setNotes(ns => [created, ...ns]);
      setComposing(false);
      // Open the freshly-created note straight into edit mode so the
      // user can keep typing instead of clicking again.
      setOpenId(created.id);
    } catch (e) {
      alert("Couldn't save note: " + ((e && e.message) || "network error"));
    }
  }
  async function patch(id, body) {
    setNotes(ns => ns.map(n => n.id === id ? { ...n, ...body } : n));
    try { await api.notes.patch(id, body); }
    catch (e) { /* leave optimistic state */ }
  }
  async function remove(id) {
    if (!confirm("Delete this note? This can't be undone.")) return;
    setNotes(ns => ns.filter(n => n.id !== id));
    if (openId === id) setOpenId(null);
    try { await api.notes.remove(id); } catch (e) {}
  }

  // Sort: pinned first, then by updated_at desc.
  const ordered = [...notes].sort((a, b) => {
    if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
    return new Date(b.updated_at) - new Date(a.updated_at);
  });

  return (
    <div className="notes-grid-wrap">
      {!composing && (
        <button className="notes-add-card" onClick={() => setComposing(true)}>
          <span className="notes-add-icon">+</span>
          <span>Write a note</span>
        </button>
      )}
      {composing && (
        <NoteEditor isNew
                    note={{ title: "", body: "", blocks: null, color: null, pinned: false }}
                    onSave={create}
                    onCancel={() => setComposing(false)}/>
      )}
      {ordered.length === 0 && !composing && (
        <div className="notes-empty">
          No notes yet. Click <b>Write a note</b> to start your first page.
        </div>
      )}
      <div className="notes-grid">
        {ordered.map(n => openId === n.id ? (
          <NoteEditor key={n.id} note={n}
                      onSave={(body) => patch(n.id, body)}
                      onClose={() => setOpenId(null)}
                      onDelete={() => remove(n.id)}/>
        ) : (
          <NoteCardCollapsed key={n.id} note={n}
                             onOpen={() => setOpenId(n.id)}
                             onTogglePin={() => patch(n.id, { pinned: !n.pinned })}
                             onDelete={() => remove(n.id)}/>
        ))}
      </div>
    </div>
  );
}

// ── Collapsed note card (read-only preview) ────────────────────────
function NoteCardCollapsed({ note, onOpen, onTogglePin, onDelete }) {
  const swatch = NOTE_COLORS.find(c => c.id === (note.color || null)) || NOTE_COLORS[0];
  const cardStyle = note.color ? { background: swatch.swatch } : null;
  const preview = blocksPreview(note.blocks, note.body);
  return (
    <div className={`note-card ${note.pinned ? "is-pinned" : ""}`} style={cardStyle} onClick={onOpen}>
      <div className="note-card-head">
        {note.pinned && <span className="note-pin-mark">📌</span>}
        <div className="note-card-title">
          {note.title || <em className="note-card-untitled">Untitled</em>}
        </div>
      </div>
      {preview && <div className="note-card-body" style={{ whiteSpace: "pre-wrap" }}>{preview}</div>}
      <div className="note-card-foot">
        <button type="button" className="note-card-action"
                onClick={(e) => { e.stopPropagation(); onTogglePin && onTogglePin(); }}
                title={note.pinned ? "Unpin" : "Pin to top"}>
          {note.pinned ? "📌 Pinned" : "📌 Pin"}
        </button>
        <button type="button" className="note-card-action danger"
                onClick={(e) => { e.stopPropagation(); onDelete && onDelete(); }}>
          Delete
        </button>
      </div>
    </div>
  );
}

// ── NoteEditor — the full Notion-style editing surface ─────────────
function NoteEditor({ note, isNew, onSave, onCancel, onClose, onDelete }) {
  // Title is its own input (not a block). Cover image / icon could
  // live up here later — keeping it simple for now.
  const [title, setTitle]   = React.useState(note.title || "");
  const [color, setColor]   = React.useState(note.color || null);
  const [pinned, setPinned] = React.useState(!!note.pinned);
  const [colorOpen, setColorOpen] = React.useState(false);
  const colorAnchorRef = React.useRef(null);

  // Block document. Always at least one block so the user has
  // something to click into. On mount we hydrate from `note.blocks`
  // when available, otherwise upgrade the legacy `body` string.
  const [blocks, setBlocks] = React.useState(() => {
    if (Array.isArray(note.blocks) && note.blocks.length) {
      // Re-key in case the server returned blocks without ids.
      return note.blocks.map(b => ({ ...b, id: b.id || blockId() }));
    }
    return bodyToBlocks(note.body || "");
  });

  // Caret routing — when an action (Enter creates new, slash menu
  // inserts, backspace merges, etc.) needs the next/previous block
  // to take focus, we set this to the target id and a useEffect on
  // the block component picks it up.
  const [focusReq, setFocusReq] = React.useState(null); // { id, at: "start"|"end" }

  // Slash menu state — anchored to the block currently being typed
  // into. `q` is the filter text after the slash; null = closed.
  const [slash, setSlash] = React.useState(null); // { blockId, q, anchorRect }

  // Auto-save: debounce 600 ms after the last edit. This is the
  // single source of truth for persistence — the only Save buttons
  // are in the new-note composer (because we don't want to POST an
  // empty stub on every keystroke).
  const dirtyRef = React.useRef(false);
  React.useEffect(() => {
    if (isNew) return;
    if (!dirtyRef.current) return;
    const t = setTimeout(() => {
      dirtyRef.current = false;
      onSave && onSave({
        title: title.trim().slice(0, 255),
        blocks,
        color: color || null,
        pinned: !!pinned,
      });
    }, 600);
    return () => clearTimeout(t);
  }, [title, blocks, color, pinned]);
  // Mark dirty whenever any of those change so the debounce fires.
  React.useEffect(() => { dirtyRef.current = true; }, [title, blocks, color, pinned]);

  function saveNew() {
    const hasContent = title.trim() || blocks.some(b => (b.text || "").trim() || b.type === "divider" || b.type === "todo");
    if (!hasContent) {
      onCancel && onCancel();
      return;
    }
    onSave && onSave({
      title: title.trim().slice(0, 255),
      blocks,
      color: color || null,
      pinned: !!pinned,
    });
  }

  // ── Block-level mutations ───────────────────────────────────────
  function patchBlock(id, patch) {
    setBlocks(bs => bs.map(b => b.id === id ? { ...b, ...patch } : b));
  }
  function changeType(id, type) {
    setBlocks(bs => bs.map(b => {
      if (b.id !== id) return b;
      const next = { ...b, type };
      if (type === "todo" && !("done" in next)) next.done = false;
      if (type !== "todo") delete next.done;
      if (type === "divider") next.text = "";
      return next;
    }));
  }
  function insertAfter(id, type = "paragraph", text = "") {
    const newBlock = makeBlock(type, text);
    setBlocks(bs => {
      const i = bs.findIndex(b => b.id === id);
      if (i === -1) return [...bs, newBlock];
      const next = bs.slice();
      next.splice(i + 1, 0, newBlock);
      return next;
    });
    setFocusReq({ id: newBlock.id, at: "start" });
    return newBlock.id;
  }
  function deleteBlock(id) {
    setBlocks(bs => {
      if (bs.length <= 1) return [makeBlock("paragraph", "")];
      const i = bs.findIndex(b => b.id === id);
      if (i === -1) return bs;
      const next = bs.slice();
      next.splice(i, 1);
      // Move caret to the previous block (or first if removing top).
      const prev = next[Math.max(0, i - 1)];
      if (prev) setFocusReq({ id: prev.id, at: "end" });
      return next;
    });
  }
  function mergeWithPrevious(id) {
    setBlocks(bs => {
      const i = bs.findIndex(b => b.id === id);
      if (i <= 0) return bs;
      const prev = bs[i - 1];
      const cur = bs[i];
      // Divider-prev + empty-cur → drop the divider, keep cur.
      if (prev.type === "divider" && (!cur.text || cur.text === "")) {
        const next = bs.slice();
        next.splice(i - 1, 1);
        setFocusReq({ id: cur.id, at: "start" });
        return next;
      }
      // Otherwise concatenate cur.text onto prev.text.
      const merged = (prev.text || "") + (cur.text || "");
      const next = bs.slice();
      next[i - 1] = { ...prev, text: merged };
      next.splice(i, 1);
      // Caret goes where the merge happened.
      setFocusReq({ id: prev.id, at: "end" });
      return next;
    });
  }

  // ── Slash menu actions ──────────────────────────────────────────
  function openSlashMenu(blockId, q, anchorRect) {
    setSlash({ blockId, q, anchorRect });
  }
  function closeSlashMenu() { setSlash(null); }
  function pickSlashType(type) {
    if (!slash) return;
    const id = slash.blockId;
    // Strip the leading "/<query>" the user typed when they opened
    // the menu — that text was the trigger, not content.
    setBlocks(bs => bs.map(b => {
      if (b.id !== id) return b;
      const next = { ...b, type, text: "" };
      if (type === "todo" && !("done" in next)) next.done = false;
      if (type !== "todo") delete next.done;
      return next;
    }));
    setSlash(null);
    setFocusReq({ id, at: "end" });
  }

  // ── Render ──────────────────────────────────────────────────────
  const swatch = NOTE_COLORS.find(c => c.id === (color || null)) || NOTE_COLORS[0];
  const cardStyle = color ? { background: swatch.swatch } : null;

  return (
    <div className="note-card is-editing note-editor" style={cardStyle}>
      <input type="text" className="note-edit-title"
             autoFocus={isNew}
             value={title}
             placeholder="Untitled"
             onChange={(e) => setTitle(e.target.value)}/>

      <div className="note-blocks">
        {blocks.map((b, idx) => (
          <BlockRow
            key={b.id}
            block={b}
            isFirst={idx === 0}
            focusReq={focusReq && focusReq.id === b.id ? focusReq : null}
            onFocused={() => setFocusReq(null)}
            onTextChange={(text) => patchBlock(b.id, { text })}
            onToggleTodo={() => patchBlock(b.id, { done: !b.done })}
            onChangeType={(type) => changeType(b.id, type)}
            onEnter={(after) => {
              // Empty list/heading/todo on Enter → convert to paragraph
              // (Notion convention).
              if (!after && (b.type === "bulleted" || b.type === "numbered" || b.type === "todo" || b.type === "h1" || b.type === "h2" || b.type === "h3" || b.type === "quote")) {
                changeType(b.id, "paragraph");
                return;
              }
              insertAfter(b.id, b.type === "h1" || b.type === "h2" || b.type === "h3" || b.type === "code" ? "paragraph" : (b.type === "divider" ? "paragraph" : b.type), "");
            }}
            onBackspaceEmpty={() => {
              if (b.type !== "paragraph" && (b.text || "") === "") {
                changeType(b.id, "paragraph");
                return;
              }
              mergeWithPrevious(b.id);
            }}
            onSlash={(q, rect) => openSlashMenu(b.id, q, rect)}
            onSlashUpdate={(q) => setSlash(s => s && s.blockId === b.id ? { ...s, q } : s)}
            onSlashClose={() => closeSlashMenu()}
            onMarkdownTransform={(type, text) => {
              // Markdown shortcut applied — flip type, drop the prefix
              // we matched, and keep caret at the start of the cleaned
              // text.
              setBlocks(bs => bs.map(bl =>
                bl.id === b.id ? { ...bl, type, text: text || "", ...(type === "todo" ? { done: false } : {}) } : bl
              ));
              setFocusReq({ id: b.id, at: "start" });
            }}
            onDelete={() => deleteBlock(b.id)}
          />
        ))}
      </div>

      <div className="note-edit-foot">
        <button ref={colorAnchorRef}
                type="button"
                className="note-color-btn"
                onClick={() => setColorOpen(o => !o)}
                title="Background colour">
          <span className="note-color-swatch" style={{ background: swatch.swatch }}/>
        </button>
        {colorOpen && (
          <Popover anchor={colorAnchorRef.current} onClose={() => setColorOpen(false)}>
            <div style={{ display: "flex", gap: 6, padding: 6 }}>
              {NOTE_COLORS.map(c => (
                <button key={c.id || "none"}
                        type="button"
                        className="note-color-swatch-btn"
                        title={c.label}
                        onClick={() => { setColor(c.id); setColorOpen(false); }}>
                  <span className="note-color-swatch" style={{ background: c.swatch }}/>
                </button>
              ))}
            </div>
          </Popover>
        )}

        <button type="button"
                className={`note-pin-btn ${pinned ? "is-on" : ""}`}
                onClick={() => setPinned(p => !p)}
                title={pinned ? "Unpin" : "Pin to top"}>
          📌
        </button>

        {/* Auto-save indicator (existing notes only) */}
        {!isNew && (
          <span style={{
            fontSize: 11, color: "var(--ink-muted)",
            marginLeft: 8, display: "inline-flex", alignItems: "center", gap: 4,
          }}>
            <span style={{ width: 6, height: 6, borderRadius: 999, background: "#22c55e" }}/>
            Auto-saved
          </span>
        )}

        <div style={{ flex: 1 }}/>

        {!isNew && onDelete && (
          <button type="button" className="btn"
                  style={{ color: "#c0223a" }}
                  onClick={onDelete}>
            Delete
          </button>
        )}
        {isNew && (
          <button type="button" className="btn" onClick={onCancel}>Cancel</button>
        )}
        {isNew ? (
          <button type="button" className="btn btn-primary" onClick={saveNew}>
            Save
          </button>
        ) : (
          <button type="button" className="btn" onClick={onClose}>Done</button>
        )}
      </div>

      {slash && (
        <SlashMenu slash={slash}
                   onPick={pickSlashType}
                   onClose={closeSlashMenu}/>
      )}
    </div>
  );
}

// ── A single block row ─────────────────────────────────────────────
// Each block is rendered as a contentEditable <div> typed by the block
// type (heading sizes, monospace for code, etc.). We use one element
// per block instead of ProseMirror / Slate to keep the bundle tiny —
// the trade-off is that we hand-roll caret routing (focusReq) and the
// markdown-shortcut transforms.
function BlockRow({
  block, isFirst, focusReq, onFocused,
  onTextChange, onToggleTodo, onChangeType,
  onEnter, onBackspaceEmpty,
  onSlash, onSlashUpdate, onSlashClose,
  onMarkdownTransform, onDelete,
}) {
  const ref = React.useRef(null);

  // Sync DOM textContent from React state ONCE per block id — never
  // on every text change, because that would fight contentEditable
  // and trash the caret. The component effectively treats the DOM
  // as the source of truth between mount and unmount.
  React.useEffect(() => {
    if (!ref.current) return;
    const dom = ref.current;
    const cur = dom.textContent || "";
    if (cur !== (block.text || "")) {
      dom.textContent = block.text || "";
    }
  }, [block.id]);

  // Focus router — placeCaret to start/end after a state-driven
  // change (insertAfter, merge, type-change).
  React.useEffect(() => {
    if (!focusReq || !ref.current) return;
    const el = ref.current;
    el.focus();
    try {
      const sel = window.getSelection();
      const range = document.createRange();
      if (focusReq.at === "end") {
        range.selectNodeContents(el);
        range.collapse(false);
      } else {
        range.setStart(el, 0);
        range.collapse(true);
      }
      sel.removeAllRanges();
      sel.addRange(range);
    } catch {}
    onFocused && onFocused();
  }, [focusReq]);

  // Helpers to read DOM caret state — used by Enter / Backspace
  // handlers to decide between split, merge, and convert.
  function getSelectionInfo() {
    const sel = window.getSelection();
    if (!sel || !sel.rangeCount || !ref.current) return { atStart: false, atEnd: false, after: "", before: "" };
    const range = sel.getRangeAt(0);
    if (!ref.current.contains(range.startContainer)) return { atStart: false, atEnd: false, after: "", before: "" };
    const text = ref.current.textContent || "";
    // Compute caret offset within the text content. Iterating
    // child nodes covers the case where the contentEditable has
    // text nodes interleaved with formatting elements.
    let offset = 0;
    const walk = (node) => {
      if (node === range.startContainer) {
        offset += range.startOffset;
        return true;
      }
      if (node.nodeType === Node.TEXT_NODE) {
        offset += node.length;
        return false;
      }
      for (const child of node.childNodes) if (walk(child)) return true;
      return false;
    };
    walk(ref.current);
    return {
      atStart: offset === 0,
      atEnd: offset === text.length,
      after: text.slice(offset),
      before: text.slice(0, offset),
    };
  }

  // Markdown shortcut detection on space — fires when the user types
  // certain prefixes followed by space. Mirrors Notion / Linear.
  function tryMarkdownTransformOnSpace() {
    const text = (ref.current?.textContent || "");
    let m;
    if ((m = /^# $/.exec(text)))      { onMarkdownTransform("h1", ""); return true; }
    if ((m = /^## $/.exec(text)))     { onMarkdownTransform("h2", ""); return true; }
    if ((m = /^### $/.exec(text)))    { onMarkdownTransform("h3", ""); return true; }
    if ((m = /^[-*•] $/.exec(text)))  { onMarkdownTransform("bulleted", ""); return true; }
    if ((m = /^1\. $/.exec(text)))    { onMarkdownTransform("numbered", ""); return true; }
    if ((m = /^\[\] $/.exec(text)))   { onMarkdownTransform("todo", ""); return true; }
    if ((m = /^\[x\] $/i.exec(text))) { onMarkdownTransform("todo", ""); return true; }
    if ((m = /^> $/.exec(text)))      { onMarkdownTransform("quote", ""); return true; }
    return false;
  }

  function handleKeyDown(e) {
    // Slash menu — open when "/" is typed at start of an empty block,
    // navigation handled by the SlashMenu itself (it captures arrow
    // keys / Enter while open).
    if (e.key === "/" && (block.text || "") === "") {
      // Defer the open until after the "/" lands so the menu's q is "".
      setTimeout(() => {
        const r = ref.current?.getBoundingClientRect();
        onSlash && onSlash("", r ? { left: r.left, top: r.bottom } : { left: 100, top: 100 });
      }, 0);
      // Don't preventDefault — let the "/" land so the user sees they
      // can type to filter. We strip it on pick.
      return;
    }
    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault();
      const info = getSelectionInfo();
      // Empty list/todo/heading/quote on Enter → convert to paragraph
      // ("escape" the format so the user can write a normal line).
      if ((block.text || "") === "") {
        onEnter(false);
        return;
      }
      onEnter(true);
      return;
    }
    if (e.key === "Backspace") {
      const info = getSelectionInfo();
      if (info.atStart && (block.text || "") === "") {
        e.preventDefault();
        onBackspaceEmpty && onBackspaceEmpty();
        return;
      }
      // Backspace at start of a non-empty paragraph after a previous
      // block → merge the lines (Notion does this; nice).
      if (info.atStart && !isFirst) {
        e.preventDefault();
        onBackspaceEmpty && onBackspaceEmpty();
        return;
      }
    }
    if (e.key === "Escape") {
      // If slash menu is open, the menu closes itself — otherwise blur
      // out of the editable so the user can click elsewhere cleanly.
      if (ref.current) ref.current.blur();
    }
  }

  function handleInput(e) {
    const text = ref.current?.textContent || "";
    // Maintain slash menu's filter as the user types.
    if (text.startsWith("/")) {
      const q = text.slice(1);
      onSlashUpdate && onSlashUpdate(q);
    } else {
      // User backed out — close the menu.
      onSlashClose && onSlashClose();
    }
    onTextChange(text);
  }
  function handleKeyUp(e) {
    if (e.key === " ") {
      // Markdown shortcut: convert prefix → block type.
      tryMarkdownTransformOnSpace();
    }
  }

  // Render variants per block type. Heading/code/quote get a typed
  // wrapper element so default browser styles + our CSS hit them.
  const editableProps = {
    ref,
    contentEditable: true,
    suppressContentEditableWarning: true,
    spellCheck: true,
    className: "note-block-edit",
    onInput: handleInput,
    onKeyDown: handleKeyDown,
    onKeyUp: handleKeyUp,
    "data-placeholder": placeholderFor(block.type),
  };

  if (block.type === "divider") {
    return (
      <div className="note-block note-block-divider" onClick={onDelete} title="Click to remove divider">
        <hr/>
      </div>
    );
  }

  if (block.type === "todo") {
    return (
      <div className={`note-block note-block-todo ${block.done ? "is-done" : ""}`}>
        <input type="checkbox" checked={!!block.done} onChange={onToggleTodo}/>
        <div {...editableProps}/>
      </div>
    );
  }
  if (block.type === "h1" || block.type === "h2" || block.type === "h3") {
    return (
      <div className={`note-block note-block-${block.type}`}>
        <div {...editableProps}/>
      </div>
    );
  }
  if (block.type === "bulleted") {
    return (
      <div className="note-block note-block-bulleted">
        <span className="note-block-marker">•</span>
        <div {...editableProps}/>
      </div>
    );
  }
  if (block.type === "numbered") {
    return (
      <div className="note-block note-block-numbered">
        <span className="note-block-marker">1.</span>
        <div {...editableProps}/>
      </div>
    );
  }
  if (block.type === "quote") {
    return (
      <div className="note-block note-block-quote">
        <div {...editableProps}/>
      </div>
    );
  }
  if (block.type === "code") {
    return (
      <div className="note-block note-block-code">
        <div {...editableProps}/>
      </div>
    );
  }
  // paragraph (default)
  return (
    <div className="note-block note-block-paragraph">
      <div {...editableProps}/>
    </div>
  );
}
function placeholderFor(type) {
  if (type === "h1") return "Heading 1";
  if (type === "h2") return "Heading 2";
  if (type === "h3") return "Heading 3";
  if (type === "todo") return "To-do";
  if (type === "bulleted" || type === "numbered") return "List item";
  if (type === "quote") return "Quote";
  if (type === "code") return "Code";
  return "Type '/' for commands";
}

// ── Slash menu ─────────────────────────────────────────────────────
// Renders a small popup anchored to the block currently being typed
// in. Keyboard nav: ↑ ↓ to highlight, Enter to pick, Esc to close.
function SlashMenu({ slash, onPick, onClose }) {
  const filtered = React.useMemo(() => {
    const q = (slash.q || "").toLowerCase().trim();
    if (!q) return BLOCK_TYPES;
    return BLOCK_TYPES.filter(t =>
      t.id.includes(q) || t.label.toLowerCase().includes(q) || (t.hint || "").toLowerCase().includes(q)
    );
  }, [slash.q]);
  const [active, setActive] = React.useState(0);
  React.useEffect(() => { setActive(0); }, [slash.q]);

  // Capture arrow keys / Enter at the document level while the menu
  // is open. We don't put a child input here — the user is still
  // typing into the contentEditable, we just steer the highlight.
  // stopPropagation() is critical: without it the Enter that picks
  // a slash menu item ALSO bubbles down to the BlockRow's keydown
  // and creates a new paragraph block, leaving an empty row above
  // the converted block.
  React.useEffect(() => {
    function onKey(e) {
      if (e.key === "ArrowDown") {
        e.preventDefault(); e.stopPropagation();
        setActive(a => Math.min(a + 1, filtered.length - 1));
      }
      else if (e.key === "ArrowUp") {
        e.preventDefault(); e.stopPropagation();
        setActive(a => Math.max(a - 1, 0));
      }
      else if (e.key === "Enter") {
        if (filtered[active]) {
          e.preventDefault(); e.stopPropagation();
          onPick(filtered[active].id);
        }
      }
      else if (e.key === "Escape") {
        e.preventDefault(); e.stopPropagation();
        onClose();
      }
    }
    document.addEventListener("keydown", onKey, true);
    return () => document.removeEventListener("keydown", onKey, true);
  }, [filtered, active]);

  if (!filtered.length) return null;
  const top = (slash.anchorRect && slash.anchorRect.top) || 100;
  const left = (slash.anchorRect && slash.anchorRect.left) || 100;
  return ReactDOM.createPortal(
    <div className="slash-menu"
         style={{ position: "fixed", top, left }}
         onMouseDown={(e) => e.preventDefault()}>
      <div className="slash-menu-hint">Block types</div>
      {filtered.map((t, i) => (
        <div key={t.id}
             className={`slash-menu-item ${i === active ? "is-active" : ""}`}
             onMouseEnter={() => setActive(i)}
             onClick={() => onPick(t.id)}>
          <span className="slash-menu-icon">{t.icon}</span>
          <div className="slash-menu-text">
            <div className="slash-menu-label">{t.label}</div>
            <div className="slash-menu-hint-row">{t.hint}</div>
          </div>
        </div>
      ))}
    </div>,
    document.body
  );
}

// ── CSS injected once per session ──────────────────────────────────
if (typeof document !== "undefined" && !document.getElementById("notes-block-css")) {
  const s = document.createElement("style");
  s.id = "notes-block-css";
  s.textContent = `
    .note-editor { grid-column: 1 / -1; }
    .note-blocks {
      display: flex; flex-direction: column; gap: 2px;
      padding: 4px 0 8px;
    }
    .note-block {
      display: flex; align-items: flex-start; gap: 6px;
      padding: 2px 4px; border-radius: 4px;
      transition: background .12s;
    }
    .note-block:hover { background: rgba(0,0,0,.025); }
    .note-block-edit {
      flex: 1; min-width: 0;
      outline: none; border: none; background: transparent;
      font: inherit; color: inherit;
      line-height: 1.55;
      white-space: pre-wrap;
      overflow-wrap: anywhere;
      padding: 2px 4px;
      border-radius: 3px;
      caret-color: var(--ink-strong);
    }
    .note-block-edit:empty::before {
      content: attr(data-placeholder);
      color: #a3a8b6; pointer-events: none;
    }
    .note-block-h1 .note-block-edit { font-size: 26px; font-weight: 700; line-height: 1.2; }
    .note-block-h2 .note-block-edit { font-size: 21px; font-weight: 700; line-height: 1.25; }
    .note-block-h3 .note-block-edit { font-size: 17px; font-weight: 700; line-height: 1.3; }
    .note-block-bulleted .note-block-marker,
    .note-block-numbered .note-block-marker {
      color: var(--ink-muted); font-weight: 700;
      width: 16px; flex: none; text-align: center;
      padding-top: 3px;
    }
    .note-block-quote { border-left: 3px solid var(--ink-muted); padding-left: 12px; }
    .note-block-quote .note-block-edit { font-style: italic; color: var(--ink-body); }
    .note-block-code {
      background: rgba(0,0,0,.04);
      border-radius: 6px;
      padding: 6px 10px;
    }
    .note-block-code .note-block-edit {
      font-family: ui-monospace, "SF Mono", Menlo, monospace;
      font-size: 12.5px;
    }
    .note-block-divider { padding: 6px 0; cursor: pointer; }
    .note-block-divider hr { border: 0; border-top: 1px solid var(--border-strong); margin: 0; }
    .note-block-todo { align-items: flex-start; }
    .note-block-todo input[type="checkbox"] {
      margin: 6px 4px 0 0; flex: none;
      width: 14px; height: 14px;
      accent-color: var(--brand);
    }
    .note-block-todo.is-done .note-block-edit {
      color: var(--ink-muted); text-decoration: line-through;
    }

    /* Slash menu */
    .slash-menu {
      z-index: 10000;
      min-width: 280px; max-width: 320px;
      max-height: 360px; overflow-y: auto;
      background: white;
      border: 1px solid var(--border);
      border-radius: 8px;
      box-shadow: 0 10px 30px rgba(15,23,41,.18);
      padding: 4px;
    }
    .slash-menu-hint {
      font-size: 10.5px; font-weight: 700; letter-spacing: .06em;
      text-transform: uppercase; color: var(--ink-muted);
      padding: 6px 8px 4px;
    }
    .slash-menu-item {
      display: flex; align-items: center; gap: 10px;
      padding: 8px 8px; border-radius: 6px;
      cursor: pointer;
    }
    .slash-menu-item.is-active { background: rgba(162,93,220,.08); }
    .slash-menu-item:hover { background: rgba(162,93,220,.06); }
    .slash-menu-icon {
      width: 28px; height: 28px;
      display: inline-flex; align-items: center; justify-content: center;
      flex: none;
      background: rgba(0,0,0,.04);
      border-radius: 6px;
      font-weight: 700; font-size: 13px;
      font-variant-numeric: tabular-nums;
      color: var(--ink-strong);
    }
    .slash-menu-text { flex: 1; min-width: 0; }
    .slash-menu-label { font-size: 13px; font-weight: 600; color: var(--ink-strong); }
    .slash-menu-hint-row { font-size: 11px; color: var(--ink-muted); margin-top: 1px; }

    /* Inline kbd in the header tagline */
    .notes-meta kbd {
      display: inline-block;
      padding: 0 5px; margin: 0 1px;
      background: rgba(0,0,0,.06);
      border: 1px solid rgba(0,0,0,.10);
      border-radius: 3px;
      font: 11px ui-monospace, "SF Mono", Menlo, monospace;
      color: var(--ink-strong);
    }

    /* ── Personal page (project-toned) ──────────────────────────
       Visually identical to the rest of the workspace — same cool
       Home gradient on the hero, same brand-purple accents, same
       grey/white surface treatment. The "Personal" name in the
       greeting + the small ✦ glyph are the only cues; everything
       else inherits from .home-* and .t styles so Personal reads
       as another project page rather than a separate visual island. */
    .personal-root .home-hero { padding-top: 24px; padding-bottom: 24px; }
    .personal-root .home-card-head h3 {
      display: flex; align-items: center; gap: 8px;
    }
    /* Inherit the default home-hero gradient — no peach overrides. */
    .personal-greet {
      display: inline-flex; align-items: center; gap: 10px;
    }
    .personal-greet-glyph {
      display: inline-flex; align-items: center; justify-content: center;
      width: 28px; height: 28px; border-radius: 7px;
      background: var(--brand-soft, rgba(162, 93, 220, .12));
      color: var(--brand, #a25ddc);
      font-size: 15px;
    }
    .personal-sub kbd {
      background: var(--bg-subtle, #f1f4f9);
      border: 1px solid var(--border, #e6e9ef);
      border-radius: 4px;
      padding: 1px 6px;
      font-size: 11.5px;
      font-family: ui-monospace, SF Mono, Menlo, monospace;
      color: var(--ink-strong, #0f1729);
    }
    /* Personal-stat tone overrides — same accent system the Home
       hero uses (slate / danger / warn / brand). */
    .personal-stat.is-danger .home-stat-num { color: #b41f37; }
    .personal-stat.is-warn   .home-stat-num { color: #b66f00; }

    /* ── Section pips — same brand palette as project headers ─ */
    .personal-pip {
      display: inline-flex; align-items: center; justify-content: center;
      width: 22px; height: 22px; border-radius: 6px;
      font-size: 12px; font-weight: 700; line-height: 1;
      background: var(--brand-soft, rgba(162, 93, 220, .12));
      color: var(--brand, #a25ddc);
    }

    .personal-section-count {
      font-size: 11.5px; color: var(--ink-muted); font-weight: 600;
    }
    .personal-section-count-muted {
      color: var(--ink-faint, #a3a8b6); font-weight: 400;
    }

    /* ── Personal tabs ─────────────────────────────────────────
       Underline-style tab strip — matches the project view's tab
       chrome (Backlog / Tasks / Kanban / Calendar / Timeline) so
       Personal feels like another project page. Brand-purple
       underline on active, muted grey labels otherwise, count
       chip in a small grey pill next to the label. */
    .personal-tabs {
      display: flex; gap: 4px;
      margin: 0;
      border-bottom: 1px solid var(--border, #e6e9ef);
      position: relative;
      overflow-x: auto;
      scrollbar-width: none;
    }
    .personal-tabs::-webkit-scrollbar { display: none; }
    .personal-tab {
      appearance: none; background: transparent; border: none;
      font: inherit; cursor: pointer;
      padding: 12px 18px 14px; margin: 0;
      display: inline-flex; align-items: center; gap: 8px;
      white-space: nowrap;
      color: var(--ink-muted, #676879);
      position: relative;
      border-radius: 8px 8px 0 0;
      transition: color .14s ease, background .14s ease;
    }
    .personal-tab:hover {
      color: var(--ink-strong, #0f1729);
      background: rgba(15, 23, 41, .025);
    }
    .personal-tab-icon { flex-shrink: 0; }
    .personal-tab-label {
      font-size: 13.5px; font-weight: 600; letter-spacing: 0;
      line-height: 1.2;
    }
    .personal-tab-sub {
      display: inline-flex; align-items: center; justify-content: center;
      min-width: 20px; height: 20px;
      padding: 0 7px; border-radius: 999px;
      background: var(--bg-subtle, #f1f4f9);
      font-size: 10.5px; font-weight: 800; letter-spacing: 0;
      color: var(--ink-muted, #676879);
    }
    .personal-tab.is-active {
      color: var(--brand, #a25ddc);
    }
    .personal-tab.is-active .personal-tab-sub {
      background: var(--brand-soft, rgba(162, 93, 220, .12));
      color: var(--brand, #a25ddc);
    }
    .personal-tab.is-active::after {
      content: "";
      position: absolute; left: 0; right: 0; bottom: -1px;
      height: 2px;
      background: var(--brand, #a25ddc);
      animation: personalTabUnderline .18s cubic-bezier(.2,.9,.25,1.15);
    }
    @keyframes personalTabUnderline {
      from { transform: scaleX(.4); opacity: 0; }
      to   { transform: scaleX(1);  opacity: 1; }
    }
    .personal-tab:focus-visible {
      outline: 2px solid var(--brand, #a25ddc);
      outline-offset: -3px;
    }

    /* The single active panel — fills the home-body slot the
       previous side-by-side layout had. Card width unconstrained so
       the to-do table or notes grid can stretch to the page edge. */
    .personal-tab-panel {
      display: block;
      min-width: 0;
    }
    .personal-tab-panel .home-card { margin-bottom: 0; }

    @media (max-width: 760px) {
      .personal-tabs { gap: 0; }
      .personal-tab { padding: 10px 12px 12px; gap: 8px; }
      .personal-tab-label { font-size: 13px; }
      .personal-tab-sub {
        font-size: 11px;
        padding-left: 6px;
      }
    }

    /* ── Project-style task <table.t> inside Personal ─────────
       The HTML structure + styling are intentionally IDENTICAL to
       the workspace TableView (table.jsx). We don't override any
       of the .t / .cell-name / .cell-center rules — header
       background, hover, left-rail brand colour, font sizes all
       inherit. The only Personal-specific bits are:
         · the bucket-header rows (Open / Done) styled as muted
           grey dividers (mirrors the "No story" group header on
           the project table)
         · the editable name + fade-in delete affordances
         · done-row strike + dim semantics

       Everything else MUST stay defaulted so Personal looks like
       just another project task table to the user. */
    .personal-table-wrap {
      margin: 0; padding: 0 8px 4px;
      /* explicitly override .table-wrap's overflow:hidden (which we
         inherit by class name lookup paths from table.css) so the
         status / priority / date popovers can render outside the
         row without being clipped. */
      overflow: visible;
    }
    .personal-todo-t { table-layout: fixed; width: 100%; }

    /* Bucket header rows ("Open" / "Done"). Same muted-grey style
       as the project table's epic / story group dividers — light
       surface background, italic-feeling label, count pill on the
       right. Spans all six columns. */
    .personal-tbl-grouprow > td {
      background: var(--bg-subtle, #f5f6f8) !important;
      border-bottom: 1px solid var(--border, #e6e9ef);
      border-left: 0 !important;
      padding: 8px 14px !important;
      height: auto !important;
      color: var(--ink-muted, #676879);
    }
    .personal-tbl-grouprow > td > * { vertical-align: middle; }
    .personal-tbl-groupdot {
      display: inline-block;
      width: 7px; height: 7px; border-radius: 50%;
      margin-right: 8px;
      background: var(--ink-muted, #9aa0a6);
    }
    .personal-tbl-groupdot--open { background: var(--brand, #a25ddc); }
    .personal-tbl-groupdot--done { background: var(--ink-faint, #a3a8b6); }
    .personal-tbl-grouplabel {
      font-size: 12.5px; font-weight: 600;
      color: var(--ink-strong, #0f1729);
      font-style: italic;
    }
    .personal-tbl-grouprow--done .personal-tbl-grouplabel {
      color: var(--ink-muted, #676879);
    }
    .personal-tbl-groupcount {
      display: inline-block;
      margin-left: 8px;
      color: var(--ink-muted, #676879);
      font-size: 12px; font-weight: 500;
    }

    /* Done-row treatment — strike + dim, mirrors the project table
       semantics for completed rows. */
    .personal-tbl-row.is-done .name-text {
      color: var(--ink-faint, #a3a8b6);
      text-decoration: line-through;
    }
    .personal-tbl-row.is-done .todo-pill { opacity: 0.7; }

    /* Editable name field — keep it as a single line with ellipsis
       like the project table, but allow inline editing. Brand focus
       ring matches the workspace input convention. */
    .personal-tbl-name .name-wrap {
      display: flex; align-items: center; gap: 8px; min-width: 0;
    }
    .personal-tbl-name .name-text {
      flex: 1; min-width: 0;
      outline: none;
      padding: 2px 4px;
      border-radius: 4px;
      transition: background .12s, box-shadow .12s;
    }
    .personal-tbl-name .name-text:focus {
      background: #fff;
      box-shadow: 0 0 0 2px rgba(162, 93, 220, .22);
    }

    /* Action button (delete) — fade in on hover only. */
    .personal-tbl-row .todo-delete {
      opacity: 0;
      transition: opacity .12s;
    }
    .personal-tbl-row:hover .todo-delete { opacity: 1; }

    /* ── To-do pane (lives inside a .home-card body) ────────── */
    .todo-pane {
      display: flex; flex-direction: column;
      padding: 12px 16px 14px;
    }
    .todo-composer {
      margin-bottom: 8px;
    }
    .todo-composer input {
      width: 100%;
      padding: 10px 12px;
      border: 1px solid var(--border);
      border-radius: var(--r-sm);
      font: inherit; font-size: 13px;
      background: #fbfcfe;
      transition: border-color .12s, background .12s, box-shadow .12s;
    }
    .todo-composer input::placeholder {
      color: var(--ink-faint, #a3a8b6);
    }
    .todo-composer input:focus {
      outline: none;
      background: white;
      border-color: var(--brand);
      box-shadow: 0 0 0 3px rgba(162,93,220,.12);
    }

    .todo-empty {
      padding: 14px 6px;
      color: var(--ink-muted);
      font-size: 12.5px;
      text-align: center;
      font-style: italic;
    }
    .todo-list {
      list-style: none; margin: 0; padding: 0;
      display: flex; flex-direction: column; gap: 0;
    }
    .todo-list--done { margin-top: 4px; }
    /* On narrow viewports the project-style table allows horizontal
       scroll inside .table-wrap (same pattern as the workspace
       TableView). The .personal-table-wrap simply inherits that. */
    @media (max-width: 760px) {
      .personal-table-wrap { padding: 0 4px 4px; }
      .personal-todo-t thead th { font-size: 10px !important; }
    }
    /* Hide-done toggle in the composer row */
    .todo-composer { display: flex; align-items: center; gap: 8px; }
    .todo-composer input { flex: 1; }
    .todo-hide-done {
      flex: none;
      padding: 7px 12px;
      font-size: 12px; font-weight: 600;
      color: var(--ink-muted);
      background: white;
      border: 1px solid var(--border);
      border-radius: var(--r-sm);
      cursor: pointer;
      transition: background .12s, color .12s, border-color .12s;
    }
    .todo-hide-done:hover { color: var(--ink-strong); border-color: var(--border-strong); }
    .todo-hide-done.is-on {
      background: var(--brand-soft);
      border-color: var(--brand);
      color: var(--brand);
    }
    .todo-checkbox {
      flex: none;
      width: 18px; height: 18px;
      border: 1.6px solid var(--border-strong, #d1d5e0);
      border-radius: 4px;
      background: white;
      cursor: pointer;
      display: inline-flex; align-items: center; justify-content: center;
      font-size: 12px; font-weight: 700; line-height: 1;
      color: white;
      transition: background .12s, border-color .12s;
      padding: 0;
    }
    .todo-checkbox:hover { border-color: var(--brand); }
    .todo-row.is-done .todo-checkbox {
      background: #22c55e;
      border-color: #22c55e;
    }
    .todo-text {
      flex: 1; min-width: 0;
      font-size: 13px; line-height: 1.4;
      color: var(--ink-strong);
      cursor: text;
      padding: 2px 4px; margin: -2px -4px;
      border-radius: 3px;
      outline: none;
      overflow-wrap: anywhere; word-break: break-word;
    }
    .todo-text:hover { background: #f8f9fb; }
    .todo-text:focus { background: white; outline: 1px solid var(--brand); }
    .todo-delete {
      flex: none;
      width: 22px; height: 22px;
      border: 0; background: transparent;
      color: var(--ink-faint, #a3a8b6);
      font-size: 18px; line-height: 1;
      cursor: pointer; opacity: 0;
      border-radius: 4px;
      transition: opacity .12s, background .12s, color .12s;
    }
    .todo-delete:hover {
      background: rgba(226,68,92,.10);
      color: #c0223a;
    }

    /* Done-group collapsible. Same disclosure-triangle treatment as
       Home's "more activity" patterns — small, low-key. */
    .todo-done-group {
      margin-top: 8px; padding-top: 8px;
      border-top: 1px dashed var(--border);
    }
    .todo-done-group > summary {
      list-style: none;
      cursor: pointer;
      font-size: 11.5px; font-weight: 600;
      color: var(--ink-muted);
      padding: 4px 6px;
      border-radius: 4px;
      user-select: none;
    }
    .todo-done-group > summary::-webkit-details-marker { display: none; }
    .todo-done-group > summary:hover { background: rgba(0,0,0,.025); }
    .todo-done-group > summary::before {
      content: "▸";
      display: inline-block;
      margin-right: 6px;
      font-size: 10px;
      transition: transform .12s;
    }
    .todo-done-group[open] > summary::before { transform: rotate(90deg); }

    /* ── Notes pane (lives inside a .home-card body) ──────────── */
    /* The NotesGrid is reused from earlier; here we make it sit
       cleanly inside a home-card and tighten the columns to match
       the column-width pressure from the new two-pane layout. */
    .personal-root .notes-grid-wrap {
      padding: 12px 16px 14px;
    }
    .personal-root .notes-grid {
      grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
      gap: 12px;
    }
    .personal-root .notes-add-card {
      padding: 12px;
      border: 1px dashed var(--border-strong, #d1d5e0);
      border-radius: var(--r-sm);
      background: #fbfcfe;
      cursor: pointer;
      display: inline-flex; align-items: center; gap: 8px;
      font-size: 12.5px; font-weight: 600;
      color: var(--ink-muted);
      transition: border-color .12s, background .12s, color .12s;
      margin-bottom: 12px;
    }
    .personal-root .notes-add-card:hover {
      border-color: var(--brand);
      background: white;
      color: var(--brand);
    }

    /* ── Personal todo filter row ─────────────────────────────
       Sits between the composer and the table. Same idea as the
       project task table's BoardToolbar — labelled selects for
       status + priority, a Clear button when filters are active,
       and a live count chip on the right. */
    .todo-filter-row {
      display: flex; align-items: center; gap: 12px;
      padding: 8px 12px;
      margin: 0 8px 6px;
      background: var(--bg-subtle, #f5f6f8);
      border: 1px solid var(--border, #e6e9ef);
      border-radius: 8px;
      flex-wrap: wrap;
    }
    .todo-filter {
      display: inline-flex; align-items: center; gap: 6px;
    }
    .todo-filter--grow { flex: 1 1 200px; min-width: 160px; }
    .todo-filter-label {
      font-size: 10.5px; font-weight: 700;
      text-transform: uppercase; letter-spacing: .06em;
      color: var(--ink-muted, #676879);
    }
    .todo-filter-input {
      appearance: none; -webkit-appearance: none;
      flex: 1; height: 28px; padding: 0 10px;
      border: 1px solid var(--border, #e6e9ef);
      background: #fff;
      border-radius: 6px;
      font: inherit; font-size: 12.5px;
      color: var(--ink-strong, #0f1729);
      transition: border-color .12s, box-shadow .12s;
    }
    .todo-filter-input::placeholder { color: var(--ink-faint, #a3a8b6); }
    .todo-filter-input:focus {
      outline: none;
      border-color: var(--brand, #a25ddc);
      box-shadow: 0 0 0 3px rgba(162,93,220,.15);
    }
    .todo-filter-input::-webkit-search-cancel-button { display: none; }
    .todo-filter-input::-webkit-search-decoration  { display: none; }
    .todo-filter-select {
      appearance: none; -webkit-appearance: none;
      height: 28px; padding: 0 26px 0 10px;
      border: 1px solid var(--border, #e6e9ef);
      background: #fff;
      border-radius: 6px;
      font: inherit; font-size: 12.5px; font-weight: 500;
      color: var(--ink-strong, #0f1729);
      cursor: pointer;
      background-image:
        url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23676879' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>");
      background-repeat: no-repeat;
      background-position: right 7px center;
      background-size: 12px;
      transition: border-color .12s, box-shadow .12s;
    }
    .todo-filter-select:focus {
      outline: none;
      border-color: var(--brand, #a25ddc);
      box-shadow: 0 0 0 3px rgba(162,93,220,.15);
    }
    .todo-filter-clear {
      height: 28px; padding: 0 12px;
      background: transparent;
      border: 1px dashed rgba(162,93,220,.4);
      border-radius: 6px;
      font: inherit; font-size: 12px; font-weight: 600;
      color: var(--brand, #a25ddc); cursor: pointer;
      transition: background .12s, border-style .12s;
    }
    .todo-filter-clear:hover {
      background: rgba(162,93,220,.08);
      border-style: solid;
    }
    .todo-filter-count {
      margin-left: auto;
      font-size: 11.5px; font-weight: 600;
      color: var(--ink-muted, #676879);
    }
    @media (max-width: 760px) {
      .todo-filter-row { gap: 8px; padding: 6px 10px; }
      .todo-filter-count { margin-left: 0; flex: 1 1 100%; text-align: right; }
    }

    /* ── Personal task pills (status / priority / due) ───────── */
    /* Wrapping span gives the popover a stable anchor. In the
       Status / Priority cells we want the pill to FILL the cell —
       same pixel rhythm as the project task table — so the wrapper
       needs width:100%; height:100% there. The .cell-pill-fill class
       on the parent TD targets the wrapper without affecting other
       PillPicker uses elsewhere on the page. */
    .todo-pill-wrap { position: relative; display: inline-flex; }
    .personal-todo-t td.cell-pill-fill { padding: 0; }
    .personal-todo-t td.cell-pill-fill .todo-pill-wrap {
      display: flex; width: 100%; height: 100%;
    }
    /* Default todo-pill spec — only kicks in if the consumer DOESN'T
       use .pill-cell. With .pill-cell the project table's CSS owns
       the layout (full-cell, no rounding) so the row matches a
       project task row pixel-for-pixel. */
    .todo-pill {
      cursor: pointer;
      border: 0;
      font-size: 11px; font-weight: 700;
      letter-spacing: .02em;
      transition: filter .12s, transform .08s;
    }
    .todo-pill:not(.pill-cell) { padding: 2px 9px; }
    .todo-pill:hover  { filter: brightness(0.96); }
    .todo-pill:focus  { outline: none; box-shadow: 0 0 0 2px rgba(162,93,220,.30); }
    /* Project table's .pill-cell already drives width/height/padding;
       just override transform-on-hover so the cell doesn't lift out
       of the row (we want a uniform table look). */
    .todo-pill.pill-cell {
      width: 100%; height: 100%;
      min-width: 0; padding: 6px 10px; border-radius: 0;
      display: flex; align-items: center; justify-content: center;
      transform: none !important;
    }
    .todo-pill-popover {
      position: absolute; top: calc(100% + 6px); left: 0;
      z-index: 50;
      background: white;
      border: 1px solid var(--border);
      border-radius: 6px;
      box-shadow: 0 10px 24px rgba(15,23,41,.14);
      padding: 4px;
      display: flex; flex-direction: column; gap: 2px;
      min-width: 140px;
    }
    /* Due-date popover hosts the project's MiniCalendar (~240px
       wide, brings its own padding). Right-anchor so it can't run
       off the page edge from the rightmost column, and reset the
       parent's flex/padding so MiniCalendar fills cleanly. */
    .todo-due-popover {
      padding: 0;
      display: block;
      min-width: 240px;
      left: auto; right: 0;
    }
    .todo-due-popover .mini-cal {
      width: 240px;
    }
    .todo-pill-option {
      display: flex; align-items: center;
      padding: 5px 8px;
      border: 0; background: transparent;
      cursor: pointer;
      border-radius: 4px;
      text-align: left;
    }
    .todo-pill-option:hover { background: #f4f5f8; }
    .todo-pill-option.is-on { background: var(--brand-soft); }
    .todo-pill-option .pill { pointer-events: none; }

    /* Due chip — neutral grey, amber when due-today, red when overdue.
       Matches the visual language used by the project task table. */
    .todo-due-chip {
      cursor: pointer;
      border: 1px solid var(--border);
      background: white;
      padding: 3px 9px;
      border-radius: 999px;
      font-size: 11.5px; font-weight: 600;
      color: var(--ink-body);
      white-space: nowrap;
      transition: border-color .12s, background .12s, color .12s;
    }
    .todo-due-chip:hover { border-color: var(--border-strong); background: #fbfcfe; }
    .todo-due-chip.is-empty { color: var(--ink-faint, #a3a8b6); border-style: dashed; }
    .todo-due-chip.is-today {
      background: rgba(245,158,11,.14);
      border-color: rgba(245,158,11,.32);
      color: #7a4205;
    }
    .todo-due-chip.is-overdue {
      background: rgba(226,68,92,.12);
      border-color: rgba(226,68,92,.30);
      color: #8a1024;
    }

    /* Hero kbd hint matches the rest of the app's keyboard pills. */
    .personal-root .home-sub kbd {
      display: inline-block;
      padding: 0 5px; margin: 0 1px;
      background: rgba(0,0,0,.06);
      border: 1px solid rgba(0,0,0,.10);
      border-radius: 3px;
      font: 11px ui-monospace, "SF Mono", Menlo, monospace;
      color: var(--ink-strong);
    }
  `;
  document.head.appendChild(s);
}

Object.assign(window, { NotesView });
