AkurAI Build
Menu

Public repository / 4fac5c7826068a0d615a09851633816b41204468

AkurAI-Notes / site/frontend/notes-app.js

Latest change 3452d8a620647be68842e0998dd1887d66a207a4 - AKURAI-NOTES-9: add aria-label to dynamic rows for agent browser tool compatibility by Ólafur Búi Ólafsson

// AkurAI-Notes — Notion-like workspace. Native ESM, zero dependencies.
// Full 1:1 client for the REST API: notebooks (nested), notes, tags, search.
(function () {
  "use strict";

  // ── DOM helpers ────────────────────────────────────────────────────────────
  function h(tag, attrs, children) {
    const e = document.createElement(tag);
    if (attrs) {
      for (const k in attrs) {
        const v = attrs[k];
        if (v == null || v === false) continue;
        if (k === "class") e.className = v;
        else if (k === "style") e.setAttribute("style", v);
        else if (k === "html") e.innerHTML = v;
        else if (k.slice(0, 2) === "on" && typeof v === "function") e.addEventListener(k.slice(2), v);
        else if (k === "dataset") for (const d in v) e.dataset[d] = v[d];
        else e.setAttribute(k, v === true ? "" : v);
      }
    }
    if (children != null) append(e, children);
    return e;
  }
  function append(parent, child) {
    if (child == null) return;
    if (Array.isArray(child)) child.forEach((c) => append(parent, c));
    else if (child instanceof Node) parent.appendChild(child);
    else parent.appendChild(document.createTextNode(String(child)));
  }
  function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); }
  function $(id) { return document.getElementById(id); }

  // SVG icons (stroke-based, 24-grid). Returns an <svg> element.
  const ICON = {
    doc: "M8 3h6l4 4v14H6V3z M14 3v4h4",
    docLine: "M8 3h6l4 4v14H6V3z M14 3v4h4 M9 12h6 M9 16h4",
    book: "M4 5a2 2 0 0 1 2-2h12v18H6a2 2 0 0 1-2-2z M8 3v18",
    chevron: "m9 6 6 6-6 6",
    plus: "M12 5v14M5 12h14",
    search: "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14z M21 21l-4.3-4.3",
    layers: "M12 3 2 8l10 5 10-5-10-5z M2 13l10 5 10-5 M2 18l10 5 10-5",
    clock: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z M12 7v5l3 2",
    archive: "M3 5h18v4H3z M5 9v11h14V9 M9 13h6",
    tag: "M20 12 12 20l-8-8V4h8z M8 8h.01",
    trash: "M4 7h16 M9 7V5h6v2 M6 7l1 13h10l1-13",
    edit: "M4 20h4L20 8l-4-4L4 16z",
    move: "M5 9 2 12l3 3 M9 5l3-3 3 3 M15 19l-3 3-3-3 M19 9l3 3-3 3 M2 12h20 M12 2v20",
    x: "M6 6l12 12M18 6 6 18",
    check: "m5 12 5 5 9-11",
    settings: "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M19 12a7 7 0 0 0-.1-1l2-1.5-2-3.5-2.4 1a7 7 0 0 0-1.7-1L14.5 3h-5l-.3 2.5a7 7 0 0 0-1.7 1l-2.4-1-2 3.5L2.6 11a7 7 0 0 0 0 2l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 1.7 1l.3 2.5h5l.3-2.5a7 7 0 0 0 1.7-1l2.4 1 2-3.5-2-1.5c.1-.3.1-.7.1-1z",
    logout: "M15 4h4v16h-4 M10 8l-4 4 4 4 M6 12h9",
    palette: "M12 3a9 9 0 1 0 0 18c1 0 1.5-.8 1.5-1.5 0-1 .7-1.5 1.5-1.5H18a3 3 0 0 0 3-3 8 8 0 0 0-9-9z",
    dots: "M12 6h.01M12 12h.01M12 18h.01",
    eye: "M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z",
    pen: "M4 20h4L20 8l-4-4L4 16z M14 6l4 4",
    box: "M4 5h16v4H4z M6 9v11h12V9",
    info: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z M12 8h.01 M11 12h1v4h1",
    warn: "M12 3 2 20h20z M12 9v5 M12 17h.01",
    grip: "M9 5h.01M15 5h.01M9 12h.01M15 12h.01M9 19h.01M15 19h.01",
  };
  function icon(name, cls) {
    const ns = "http://www.w3.org/2000/svg";
    const svg = document.createElementNS(ns, "svg");
    svg.setAttribute("viewBox", "0 0 24 24");
    svg.setAttribute("fill", "none");
    svg.setAttribute("stroke", "currentColor");
    svg.setAttribute("stroke-width", "1.9");
    svg.setAttribute("stroke-linecap", "round");
    svg.setAttribute("stroke-linejoin", "round");
    svg.setAttribute("aria-hidden", "true");
    if (cls) svg.setAttribute("class", cls);
    const p = document.createElementNS(ns, "path");
    p.setAttribute("d", ICON[name] || "");
    svg.appendChild(p);
    return svg;
  }

  const COLORS = ["#5b8cff", "#7af0d0", "#22c55e", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899", "#3b82f6", "#14b8a6", "#64748b"];

  // ── API ─────────────────────────────────────────────────────────────────────
  async function api(method, path, body) {
    const opts = { method, credentials: "same-origin", headers: { accept: "application/json" } };
    if (body !== undefined) {
      opts.headers["content-type"] = "application/json";
      opts.body = JSON.stringify(body);
    }
    const res = await fetch(path, opts);
    const text = await res.text();
    let data = null;
    if (text) { try { data = JSON.parse(text); } catch (_) { data = null; } }
    if (data && data.error === "unauthorized") {
      window.location.assign("/auth/login");
      throw new Error("unauthorized");
    }
    if (!res.ok) throw new Error((data && data.error) || `HTTP ${res.status}`);
    return data;
  }

  // ── Markdown (small, safe subset) ────────────────────────────────────────────
  function escapeHtml(s) {
    return String(s == null ? "" : s).replace(/[&<>"]/g, (m) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[m]));
  }
  function inlineMd(s) {
    let t = escapeHtml(s);
    t = t.replace(/`([^`]+)`/g, "<code>$1</code>");
    t = t.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
    t = t.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
    t = t.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
    return t;
  }
  function renderMarkdown(src) {
    const lines = String(src || "").split("\n");
    let html = "";
    let i = 0;
    let list = null; // 'ul' | 'ol'
    const closeList = () => { if (list) { html += `</${list}>`; list = null; } };
    while (i < lines.length) {
      let line = lines[i];
      if (/^```/.test(line)) {
        closeList();
        i++;
        let code = "";
        while (i < lines.length && !/^```/.test(lines[i])) { code += lines[i] + "\n"; i++; }
        i++;
        html += `<pre><code>${escapeHtml(code.replace(/\n$/, ""))}</code></pre>`;
        continue;
      }
      let m;
      if ((m = line.match(/^(#{1,3})\s+(.*)$/))) { closeList(); html += `<h${m[1].length}>${inlineMd(m[2])}</h${m[1].length}>`; i++; continue; }
      if (/^\s*>\s?/.test(line)) { closeList(); html += `<blockquote>${inlineMd(line.replace(/^\s*>\s?/, ""))}</blockquote>`; i++; continue; }
      if (/^\s*([-*+])\s+/.test(line)) { if (list !== "ul") { closeList(); html += "<ul>"; list = "ul"; } html += `<li>${inlineMd(line.replace(/^\s*[-*+]\s+/, ""))}</li>`; i++; continue; }
      if (/^\s*\d+\.\s+/.test(line)) { if (list !== "ol") { closeList(); html += "<ol>"; list = "ol"; } html += `<li>${inlineMd(line.replace(/^\s*\d+\.\s+/, ""))}</li>`; i++; continue; }
      if (/^\s*(---|\*\*\*|___)\s*$/.test(line)) { closeList(); html += "<hr>"; i++; continue; }
      if (line.trim() === "") { closeList(); i++; continue; }
      closeList();
      let para = inlineMd(line);
      i++;
      while (i < lines.length && lines[i].trim() !== "" && !/^(#{1,3}\s|\s*[-*+]\s|\s*\d+\.\s|\s*>|```|---)/.test(lines[i])) {
        para += "<br>" + inlineMd(lines[i]); i++;
      }
      html += `<p>${para}</p>`;
    }
    closeList();
    return html;
  }

  function fmtDate(sec) {
    if (!sec) return "";
    const d = new Date(sec * 1000);
    const now = Date.now();
    const diff = (now - d.getTime()) / 1000;
    if (diff < 60) return "just now";
    if (diff < 3600) return Math.floor(diff / 60) + "m ago";
    if (diff < 86400) return Math.floor(diff / 3600) + "h ago";
    if (diff < 604800) return Math.floor(diff / 86400) + "d ago";
    return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: d.getFullYear() === new Date().getFullYear() ? undefined : "numeric" });
  }
  function wordCount(s) { const t = String(s || "").trim(); return t ? t.split(/\s+/).length : 0; }

  // ── State ─────────────────────────────────────────────────────────────────
  const S = {
    me: null,
    notebooks: [],
    notes: [],
    tags: [],
    view: { type: "all" }, // all | recent | archived | notebook | tag | note | search
    expanded: new Set(),
    current: null, // active note object being edited
    dirty: false,
    saveTimer: null,
  };

  const nbById = (id) => S.notebooks.find((n) => Number(n.id) === Number(id));
  const tagById = (id) => S.tags.find((t) => Number(t.id) === Number(id));
  const parentOf = (nb) => (nb.parentId != null && Number(nb.parentId) > 0 ? Number(nb.parentId) : 0);
  function childNotebooks(pid) {
    return S.notebooks
      .filter((n) => parentOf(n) === Number(pid))
      .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || String(a.name).localeCompare(String(b.name)));
  }
  function notesIn(nbId, includeArchived) {
    return S.notes
      .filter((n) => Number(n.notebookId) === Number(nbId) && (includeArchived || !n.archived))
      .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
  }
  function noteTagIds(n) {
    return String(n.tagIds || "").split(",").map((s) => s.trim()).filter(Boolean).map(Number);
  }

  // ── Toasts ──────────────────────────────────────────────────────────────────
  function toast(msg, kind) {
    const wrap = $("ws-toasts");
    if (!wrap) return;
    const t = h("div", { class: "ws-toast " + (kind || "info"), role: "status" }, [
      h("span", { class: "tico" }, icon(kind === "err" ? "warn" : kind === "ok" ? "check" : "info")),
      h("span", { class: "tmsg" }, msg),
      h("button", { class: "tclose", "aria-label": "Dismiss", onclick: () => dismiss() }, icon("x")),
    ]);
    function dismiss() { t.classList.add("leaving"); setTimeout(() => t.remove(), 180); }
    wrap.appendChild(t);
    setTimeout(dismiss, kind === "err" ? 5200 : 3200);
  }

  // ── Popover menu ─────────────────────────────────────────────────────────────
  let openMenu = null;
  function closeMenu() { if (openMenu) { openMenu.remove(); openMenu = null; document.removeEventListener("mousedown", onDocDown, true); } }
  function onDocDown(e) { if (openMenu && !openMenu.contains(e.target)) closeMenu(); }
  function menu(anchorRect, items) {
    closeMenu();
    const m = h("div", { class: "ws-menu", role: "menu" });
    items.forEach((it) => {
      if (it.type === "sep") { m.appendChild(h("div", { class: "sep" })); return; }
      if (it.type === "label") { m.appendChild(h("div", { class: "mlabel" }, it.text)); return; }
      if (it.type === "swatches") {
        const row = h("div", { class: "swatch-row" });
        COLORS.forEach((c) => row.appendChild(h("button", {
          class: "ws-swatch" + (it.value === c ? " sel" : ""), style: `background:${c}`, title: c,
          "aria-label": "Color " + c, onclick: () => { closeMenu(); it.onpick(c); },
        })));
        m.appendChild(row); return;
      }
      m.appendChild(h("button", {
        class: it.danger ? "danger" : "", role: "menuitem",
        onclick: () => { closeMenu(); it.onclick(); },
      }, [it.icon ? icon(it.icon) : null, h("span", null, it.label), it.checked ? icon("check", "menu-check") : null]));
    });
    document.body.appendChild(m);
    // position
    const mw = m.offsetWidth, mh = m.offsetHeight;
    let left = anchorRect.left, top = anchorRect.bottom + 4;
    if (left + mw > window.innerWidth - 8) left = window.innerWidth - mw - 8;
    if (top + mh > window.innerHeight - 8) top = anchorRect.top - mh - 4;
    m.style.left = Math.max(8, left) + "px";
    m.style.top = Math.max(8, top) + "px";
    openMenu = m;
    setTimeout(() => document.addEventListener("mousedown", onDocDown, true), 0);
  }

  // ── Dialogs ─────────────────────────────────────────────────────────────────
  function closeOverlay(ov) { ov.classList.add("leaving"); ov.remove(); document.removeEventListener("keydown", ov._esc, true); }
  function overlay(cls, build) {
    const ov = h("div", { class: "ws-overlay " + (cls || ""), role: "dialog", "aria-modal": "true" });
    ov._esc = (e) => { if (e.key === "Escape") { e.stopPropagation(); closeOverlay(ov); } };
    ov.addEventListener("mousedown", (e) => { if (e.target === ov) closeOverlay(ov); });
    document.addEventListener("keydown", ov._esc, true);
    document.body.appendChild(ov);
    build(ov, () => closeOverlay(ov));
    return ov;
  }

  function confirmDialog({ title, body, confirmLabel, danger }) {
    return new Promise((resolve) => {
      overlay("", (ov, close) => {
        const dlg = h("div", { class: "ws-dialog" }, [
          h("h2", null, title),
          body ? h("p", { class: "sub" }, body) : null,
          h("div", { class: "ws-dialog-actions" }, [
            h("button", { class: "ws-btn", onclick: () => { close(); resolve(false); } }, "Cancel"),
            h("button", { class: "ws-btn " + (danger ? "danger" : "primary"), onclick: () => { close(); resolve(true); } }, confirmLabel || "Confirm"),
          ]),
        ]);
        ov.appendChild(dlg);
        dlg.querySelector(".ws-dialog-actions .ws-btn:last-child").focus();
      });
    });
  }

  function promptDialog({ title, label, value, placeholder, multiline, confirmLabel }) {
    return new Promise((resolve) => {
      overlay("", (ov, close) => {
        const input = multiline
          ? h("textarea", { class: "ws-textarea", placeholder: placeholder || "" })
          : h("input", { class: "ws-input", type: "text", placeholder: placeholder || "" });
        input.value = value || "";
        const submit = () => { const v = input.value.trim(); close(); resolve(v); };
        input.addEventListener("keydown", (e) => { if (!multiline && e.key === "Enter") { e.preventDefault(); submit(); } });
        const dlg = h("div", { class: "ws-dialog" }, [
          h("h2", null, title),
          h("div", { class: "ws-field" }, [label ? h("label", null, label) : null, input]),
          h("div", { class: "ws-dialog-actions" }, [
            h("button", { class: "ws-btn", onclick: () => { close(); resolve(null); } }, "Cancel"),
            h("button", { class: "ws-btn primary", onclick: submit }, confirmLabel || "Save"),
          ]),
        ]);
        ov.appendChild(dlg);
        input.focus(); input.select();
      });
    });
  }

  // Pick a notebook (styled list, no native select)
  function pickNotebook({ title, excludeId, excludeSubtree, allowRoot }) {
    const blocked = new Set();
    if (excludeSubtree != null) {
      const walk = (id) => { blocked.add(Number(id)); childNotebooks(id).forEach((c) => walk(c.id)); };
      walk(excludeSubtree);
    } else if (excludeId != null) {
      blocked.add(Number(excludeId));
    }
    return new Promise((resolve) => {
      overlay("", (ov, close) => {
        const list = h("div", { class: "ws-menu", style: "position:static;box-shadow:none;border:1px solid var(--border);max-height:46vh;overflow:auto;min-width:0;" });
        if (allowRoot) list.appendChild(h("button", { onclick: () => { close(); resolve(0); } }, [icon("layers"), h("span", null, "Top level (no parent)")]));
        const add = (nb, depth) => {
          if (blocked.has(Number(nb.id))) return; // skip self + descendants to prevent cycles
          list.appendChild(h("button", { style: `padding-left:${9 + depth * 16}px`, onclick: () => { close(); resolve(Number(nb.id)); } }, [
            h("span", { class: "ws-dot", style: `background:${nb.color || "#5b8cff"}` }), h("span", null, nb.name),
          ]));
          childNotebooks(nb.id).forEach((c) => add(c, depth + 1));
        };
        childNotebooks(0).forEach((nb) => add(nb, 0));
        const dlg = h("div", { class: "ws-dialog" }, [
          h("h2", null, title),
          list,
          h("div", { class: "ws-dialog-actions" }, [h("button", { class: "ws-btn", onclick: () => { close(); resolve(null); } }, "Cancel")]),
        ]);
        ov.appendChild(dlg);
      });
    });
  }

  // ── Data loading ────────────────────────────────────────────────────────────
  async function loadAll() {
    const [me, notebooks, notes, tags] = await Promise.all([
      api("GET", "/api/me").catch(() => ({ authenticated: false })),
      api("GET", "/api/notebooks").catch(() => []),
      api("GET", "/api/notes").catch(() => []),
      api("GET", "/api/tags").catch(() => []),
    ]);
    S.me = me;
    S.notebooks = Array.isArray(notebooks) ? notebooks : [];
    S.notes = Array.isArray(notes) ? notes : [];
    S.tags = Array.isArray(tags) ? tags : [];
  }

  // ── Sidebar render ───────────────────────────────────────────────────────────
  function sideItem({ ico, label, count, active, onclick, depth }) {
    return h("button", { class: "ws-item" + (active ? " active" : ""), style: depth ? `padding-left:${8 + depth * 15}px` : null, onclick }, [
      h("span", { class: "ico" }, icon(ico)),
      h("span", { class: "label" }, label),
      count != null ? h("span", { class: "count" }, String(count)) : null,
    ]);
  }

  function renderSidebar() {
    // user
    const email = (S.me && S.me.email) || "";
    $("ws-user-name").textContent = email ? email.split("@")[0] : "Workspace";
    $("ws-user-sub").textContent = email || "Not signed in";
    $("ws-avatar").textContent = (email ? email[0] : "A").toUpperCase();

    // quick views
    const views = $("ws-views");
    clear(views);
    const activeNotes = S.notes.filter((n) => !n.archived);
    views.appendChild(sideItem({ ico: "layers", label: "All notes", count: activeNotes.length, active: S.view.type === "all", onclick: () => setView({ type: "all" }) }));
    views.appendChild(sideItem({ ico: "clock", label: "Recent", active: S.view.type === "recent", onclick: () => setView({ type: "recent" }) }));
    views.appendChild(sideItem({ ico: "archive", label: "Archived", count: S.notes.filter((n) => n.archived).length, active: S.view.type === "archived", onclick: () => setView({ type: "archived" }) }));

    // notebook tree
    const tree = $("ws-tree");
    clear(tree);
    const roots = childNotebooks(0);
    if (!roots.length) tree.appendChild(h("div", { class: "ws-item", style: "color:var(--dim);cursor:default" }, "No notebooks yet"));
    roots.forEach((nb) => renderNbNode(tree, nb, 0));

    // tags
    const tagsEl = $("ws-tags");
    clear(tagsEl);
    if (!S.tags.length) tagsEl.appendChild(h("div", { class: "ws-item", style: "color:var(--dim);cursor:default" }, "No tags yet"));
    S.tags.slice().sort((a, b) => String(a.name).localeCompare(String(b.name))).forEach((tg) => {
      const cnt = S.notes.filter((n) => !n.archived && noteTagIds(n).includes(Number(tg.id))).length;
      const row = h("div", { class: "ws-item ws-row" + (S.view.type === "tag" && Number(S.view.id) === Number(tg.id) ? " active" : ""), style: "--depth:0", "aria-label": "Tag: " + tg.name, onclick: () => setView({ type: "tag", id: tg.id }) }, [
        h("span", { class: "ws-dot", style: `background:${tg.color || "#7af0d0"};border-radius:50%` }),
        h("span", { class: "label" }, tg.name),
        h("span", { class: "count" }, String(cnt)),
        h("span", { class: "row-actions" }, [
          menuBtn((r) => tagMenu(r, tg)),
        ]),
      ]);
      tagsEl.appendChild(row);
    });

    // footer
    const foot = $("ws-foot");
    clear(foot);
    foot.appendChild(sideItem({ ico: "settings", label: "Settings", onclick: openSettings }));
    foot.appendChild(h("a", { class: "ws-item", href: "/auth/logout" }, [h("span", { class: "ico" }, icon("logout")), h("span", { class: "label" }, "Sign out")]));
  }

  function menuBtn(onOpen) {
    return h("button", { class: "ws-icon-btn", "aria-label": "More actions", onclick: (e) => { e.stopPropagation(); onOpen(e.currentTarget.getBoundingClientRect()); } }, icon("dots"));
  }

  function renderNbNode(container, nb, depth) {
    if (depth > 20) return; // defensive: never infinite-recurse on cyclic data
    const kids = childNotebooks(nb.id);
    const nbNotes = notesIn(nb.id, false);
    const hasChildren = kids.length > 0 || nbNotes.length > 0;
    const isOpen = S.expanded.has(Number(nb.id));
    const twist = h("span", { class: "ws-twist" + (isOpen ? " open" : "") + (hasChildren ? "" : " leaf"), onclick: (e) => { e.stopPropagation(); toggleExpand(nb.id); } }, icon("chevron"));
    const row = h("div", {
      class: "ws-item ws-row" + (S.view.type === "notebook" && Number(S.view.id) === Number(nb.id) ? " active" : ""),
      style: `--depth:${depth}`, "aria-label": "Open notebook: " + nb.name, onclick: () => setView({ type: "notebook", id: nb.id }),
    }, [
      twist,
      h("span", { class: "ws-dot", style: `background:${nb.color || "#5b8cff"}` }),
      h("span", { class: "label" }, nb.name),
      h("span", { class: "row-actions" }, [
        h("button", { class: "ws-icon-btn", "aria-label": "New note", title: "New note", onclick: (e) => { e.stopPropagation(); createNote(nb.id); } }, icon("plus")),
        menuBtn((r) => notebookMenu(r, nb)),
      ]),
      h("span", { class: "count" }, nbNotes.length ? String(nbNotes.length) : ""),
    ]);
    container.appendChild(row);
    if (isOpen) {
      kids.forEach((c) => renderNbNode(container, c, depth + 1));
      nbNotes.forEach((note) => {
        const leaf = h("div", {
          class: "ws-item ws-row" + (S.view.type === "note" && S.current && Number(S.current.id) === Number(note.id) ? " active" : ""),
          style: `--depth:${depth + 1}`, "aria-label": "Open note: " + (note.title || "Untitled"), onclick: () => openNote(note.id),
        }, [
          h("span", { class: "ws-twist leaf" }),
          h("span", { class: "ico" }, icon("doc")),
          h("span", { class: "label" }, note.title || "Untitled"),
          h("span", { class: "row-actions" }, [menuBtn((r) => noteMenu(r, note))]),
        ]);
        container.appendChild(leaf);
      });
    }
  }

  function toggleExpand(id) { id = Number(id); if (S.expanded.has(id)) S.expanded.delete(id); else S.expanded.add(id); renderSidebar(); }

  // ── Views ────────────────────────────────────────────────────────────────────
  function setView(v) {
    if (S.dirty) flushSave();
    S.view = v;
    S.current = null;
    closeDrawer();
    render();
    location.hash = viewHash(v);
  }
  function viewHash(v) {
    if (v.type === "note") return "#note/" + v.id;
    if (v.type === "notebook") return "#notebook/" + v.id;
    if (v.type === "tag") return "#tag/" + v.id;
    return "#" + v.type;
  }

  function render() {
    renderSidebar();
    const body = $("ws-body");
    clear(body);
    const crumbs = $("ws-crumbs");
    clear(crumbs);
    const actions = $("ws-topbar-actions");
    clear(actions);

    if (S.view.type === "note" && S.current) return renderEditor(body, crumbs, actions);
    if (S.view.type === "notebook") return renderNotebookView(body, crumbs, actions);
    if (S.view.type === "tag") return renderTagView(body, crumbs, actions);
    return renderListView(body, crumbs, actions);
  }

  function crumb(label, onclick) { return h("span", { class: "crumb", onclick, role: onclick ? "button" : null, tabindex: onclick ? "0" : null }, label); }
  function nbPath(nb) {
    const chain = [];
    let cur = nb;
    const seen = new Set();
    while (cur && !seen.has(Number(cur.id))) { seen.add(Number(cur.id)); chain.unshift(cur); cur = nbById(parentOf(cur)); }
    return chain;
  }

  function noteRow(note, opts) {
    opts = opts || {};
    const tagDots = noteTagIds(note).map((id) => tagById(id)).filter(Boolean).slice(0, 4)
      .map((t) => h("span", { class: "nr-tag", style: `background:${t.color || "#7af0d0"}`, title: t.name }));
    return h("div", { class: "note-row", role: "button", tabindex: "0", "aria-label": "Open note: " + (note.title || "Untitled"), onclick: () => openNote(note.id), onkeydown: (e) => { if (e.key === "Enter") openNote(note.id); } }, [
      h("span", { class: "nr-ico" }, icon("docLine")),
      h("span", { class: "nr-main" }, [
        h("span", { class: "nr-title" }, [note.title || "Untitled", note.archived ? h("span", { class: "archived-badge" }, "Archived") : null]),
        h("span", { class: "nr-snip" }, (note.body || "").replace(/[#*`>\-]/g, "").slice(0, 140) || "Empty note"),
      ]),
      h("span", { class: "nr-side" }, [
        opts.showNotebook && nbById(note.notebookId) ? h("span", null, nbById(note.notebookId).name) : null,
        tagDots.length ? h("span", { class: "nr-tags" }, tagDots) : null,
        opts.score != null ? h("span", { class: "score-pill" }, "score " + opts.score) : h("span", { class: "nr-date" }, fmtDate(note.updatedAt)),
      ]),
    ]);
  }
  function emptyState(ico, title, msg, actionEl) {
    return h("div", { class: "ws-empty" }, [h("div", { class: "ei" }, icon(ico)), h("h2", null, title), msg ? h("p", null, msg) : null, actionEl || null]);
  }
  function renderCollectionView({ title, description, dot, notes, noteOptions, subContent, empty }) {
    const canvas = h("div", { class: "ws-canvas" });
    const heading = [];
    if (dot) heading.push(h("span", { class: "lh-dot", style: dot }));
    heading.push(h("div", null, [h("h1", null, title), description ? h("p", { class: "lh-desc" }, description) : null]));
    canvas.appendChild(h("div", { class: "ws-listhead" }, heading));
    if (subContent) subContent.forEach((el) => canvas.appendChild(el));
    canvas.appendChild(h("div", { class: "ws-listmeta" }, [String(notes.length) + " note" + (notes.length === 1 ? "" : "s")]));
    if (!notes.length) {
      if (empty && !subContent) canvas.appendChild(empty);
    } else {
      canvas.appendChild(h("div", { class: "ws-notelist" }, notes.map((n) => noteRow(n, noteOptions))));
    }
    return canvas;
  }


  function renderListView(body, crumbs, actions) {
    let notes, title, desc;
    if (S.view.type === "recent") {
      notes = S.notes.filter((n) => !n.archived).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)).slice(0, 50);
      title = "Recent"; desc = "Your 50 most recently edited notes.";
    } else if (S.view.type === "archived") {
      notes = S.notes.filter((n) => n.archived).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
      title = "Archived"; desc = "Notes you've archived. Restore them anytime.";
    } else {
      notes = S.notes.filter((n) => !n.archived).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
      title = "All notes"; desc = "Every note across your notebooks.";
    }
    crumbs.appendChild(crumb(title));
    if (S.view.type !== "archived") actions.appendChild(h("button", { class: "ws-btn primary", onclick: () => createNote() }, [icon("plus"), "New note"]));

    body.appendChild(renderCollectionView({
      title,
      description: desc,
      notes,
      noteOptions: { showNotebook: true },
      empty: emptyState("docLine", "Nothing here yet", S.view.type === "archived" ? "Archived notes will appear here." : "Create your first note to get started.", S.view.type !== "archived" ? h("button", { class: "ws-btn primary", onclick: () => createNote() }, [icon("plus"), "New note"]) : null),
    }));
  }

  function renderNotebookView(body, crumbs, actions) {
    const nb = nbById(S.view.id);
    if (!nb) return renderListView(body, crumbs, actions);
    nbPath(nb).forEach((p, i, arr) => {
      crumbs.appendChild(crumb(p.name, () => setView({ type: "notebook", id: p.id })));
      if (i < arr.length - 1) crumbs.appendChild(h("span", { class: "sep" }, "/"));
    });
    actions.appendChild(h("button", { class: "ws-btn", onclick: (e) => notebookMenu(e.currentTarget.getBoundingClientRect(), nb) }, [icon("settings"), "Manage"]));
    actions.appendChild(h("button", { class: "ws-btn primary", onclick: () => createNote(nb.id) }, [icon("plus"), "New note"]));

    const notes = notesIn(nb.id, true);
    const subs = childNotebooks(nb.id);
    const subContent = subs.length ? [
      h("div", { class: "ws-listmeta" }, "Sub-notebooks"),
      h("div", { class: "ws-notelist" }, subs.map((s) => h("div", { class: "note-row", role: "button", tabindex: "0", "aria-label": "Open notebook: " + s.name, onclick: () => setView({ type: "notebook", id: s.id }) }, [
        h("span", { class: "nr-ico" }, [h("span", { class: "ws-dot", style: `background:${s.color || "#5b8cff"}` })]),
        h("span", { class: "nr-main" }, [h("span", { class: "nr-title" }, s.name), h("span", { class: "nr-snip" }, (s.description || "") + " · " + notesIn(s.id, false).length + " notes")]),
        h("span", { class: "nr-side" }, [icon("chevron")]),
      ]))),
    ] : null;
    body.appendChild(renderCollectionView({
      title: nb.name,
      description: nb.description,
      dot: `background:${nb.color || "#5b8cff"}`,
      notes,
      subContent,
      empty: emptyState("docLine", "Empty notebook", "Add a note or a sub-notebook to fill it up.", h("button", { class: "ws-btn primary", onclick: () => createNote(nb.id) }, [icon("plus"), "New note"])),
    }));
  }

  function renderTagView(body, crumbs, actions) {
    const tg = tagById(S.view.id);
    if (!tg) return renderListView(body, crumbs, actions);
    crumbs.appendChild(crumb("Tags"));
    crumbs.appendChild(h("span", { class: "sep" }, "/"));
    crumbs.appendChild(crumb(tg.name));
    const notes = S.notes.filter((n) => noteTagIds(n).includes(Number(tg.id))).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
    body.appendChild(renderCollectionView({
      title: "#" + tg.name,
      description: "Notes tagged " + tg.name,
      dot: `background:${tg.color || "#7af0d0"};border-radius:50%`,
      notes,
      noteOptions: { showNotebook: true },
      empty: emptyState("tag", "No notes with this tag", "Tag a note to see it here."),
    }));
  }

  // ── Editor ────────────────────────────────────────────────────────────────────
  function autoGrow(el) { el.style.height = "auto"; el.style.height = el.scrollHeight + "px"; }

  function renderEditor(body, crumbs, actions) {
    const note = S.current;
    const nb = nbById(note.notebookId);
    nbPath(nb || {}).forEach((p) => { crumbs.appendChild(crumb(p.name, () => setView({ type: "notebook", id: p.id }))); crumbs.appendChild(h("span", { class: "sep" }, "/")); });
    crumbs.appendChild(crumb(note.title || "Untitled"));

    const saveStatus = h("span", { class: "save-status saved", id: "ed-save" }, "Saved");
    actions.appendChild(saveStatus);
    actions.appendChild(h("button", { class: "ws-btn", "aria-label": "Note options", onclick: (e) => noteMenu(e.currentTarget.getBoundingClientRect(), note) }, [icon("dots"), "Options"]));

    const canvas = h("div", { class: "ws-canvas" });

    const titleEl = h("textarea", { class: "ed-title", rows: "1", placeholder: "Untitled", spellcheck: "false" });
    titleEl.value = note.title || "";
    titleEl.addEventListener("input", () => { note.title = titleEl.value; autoGrow(titleEl); markDirty(); renderSidebarSoon(); });
    titleEl.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); bodyEl.focus(); } });

    // meta bar
    const meta = h("div", { class: "ed-meta" });
    const tagsWrap = h("span", { class: "ed-tags" });
    function renderTags() {
      clear(tagsWrap);
      noteTagIds(note).map((id) => tagById(id)).filter(Boolean).forEach((t) => {
        tagsWrap.appendChild(h("span", { class: "tag-chip", style: `--chip:${t.color || "#7af0d0"}` }, [
          h("span", { class: "dot" }), t.name,
          h("button", { "aria-label": "Remove tag " + t.name, onclick: () => toggleNoteTag(note, t.id, false).then(renderTags) }, icon("x")),
        ]));
      });
      tagsWrap.appendChild(h("button", { class: "ed-metabtn", onclick: (e) => tagPickerMenu(e.currentTarget.getBoundingClientRect(), note, renderTags) }, [icon("tag"), "Tag"]));
    }
    renderTags();

    const nbBtn = h("button", { class: "ed-metabtn", onclick: () => moveNote(note) }, [icon("book"), nb ? nb.name : "No notebook"]);
    const archBtn = h("button", { class: "ed-metabtn" + (note.archived ? " on" : ""), onclick: () => toggleArchive(note) }, [icon("archive"), note.archived ? "Archived" : "Archive"]);
    const fmtBtn = h("button", { class: "ed-metabtn", onclick: () => toggleFormat(note) }, [icon("box"), note.format === "plain" ? "Plain text" : "Markdown"]);
    const previewBtn = h("button", { class: "ed-metabtn", onclick: () => togglePreview() }, [icon("eye"), "Preview"]);

    append(meta, [tagsWrap, nbBtn, fmtBtn, note.format !== "plain" ? previewBtn : null, archBtn]);

    const bodyEl = h("textarea", { class: "ed-body" + (note.format === "plain" ? " mono" : ""), placeholder: "Start writing… Markdown supported.", spellcheck: "true" });
    bodyEl.value = note.body || "";
    bodyEl.addEventListener("input", () => { note.body = bodyEl.value; autoGrow(bodyEl); markDirty(); });
    const preview = h("div", { class: "md", style: "display:none" });

    let previewing = false;
    function togglePreview() {
      previewing = !previewing;
      previewBtn.classList.toggle("on", previewing);
      if (previewing) { preview.innerHTML = renderMarkdown(note.body || "") || '<p style="color:var(--dim)">Nothing to preview.</p>'; preview.style.display = ""; bodyEl.style.display = "none"; }
      else { preview.style.display = "none"; bodyEl.style.display = ""; }
    }

    const footMeta = h("div", { class: "ws-listmeta", style: "margin-top:26px;border-top:1px solid var(--border);padding-top:14px" }, [
      h("span", null, wordCount(note.body) + " words"),
      h("span", null, "Updated " + fmtDate(note.updatedAt)),
      h("span", null, "Created " + fmtDate(note.createdAt)),
    ]);

    append(canvas, [titleEl, meta, bodyEl, preview, footMeta]);
    body.appendChild(canvas);
    requestAnimationFrame(() => { autoGrow(titleEl); autoGrow(bodyEl); if (!note.title) titleEl.focus(); });

    // expose for external updates
    S._ed = { renderTags, refreshMeta: () => {
      nbBtn.lastChild.textContent = (nbById(note.notebookId) || {}).name || "No notebook";
      archBtn.classList.toggle("on", !!note.archived); archBtn.lastChild.textContent = note.archived ? "Archived" : "Archive";
      fmtBtn.lastChild.textContent = note.format === "plain" ? "Plain text" : "Markdown";
      bodyEl.classList.toggle("mono", note.format === "plain");
    } };
  }

  let sidebarTimer = null;
  function renderSidebarSoon() { clearTimeout(sidebarTimer); sidebarTimer = setTimeout(renderSidebar, 400); }

  function markDirty() {
    S.dirty = true;
    const st = $("ed-save");
    if (st) { st.className = "save-status saving"; st.textContent = "Saving…"; }
    clearTimeout(S.saveTimer);
    S.saveTimer = setTimeout(flushSave, 800);
  }
  async function flushSave() {
    clearTimeout(S.saveTimer);
    if (!S.dirty || !S.current) return;
    const note = S.current;
    S.dirty = false;
    try {
      const updated = await api("PUT", "/api/notes/" + note.id, { title: note.title, body: note.body, format: note.format });
      Object.assign(note, updated);
      const idx = S.notes.findIndex((n) => Number(n.id) === Number(note.id));
      if (idx >= 0) Object.assign(S.notes[idx], updated);
      const st = $("ed-save"); if (st && !S.dirty) { st.className = "save-status saved"; st.textContent = "Saved"; }
    } catch (e) {
      S.dirty = true;
      const st = $("ed-save"); if (st) { st.className = "save-status"; st.textContent = "Save failed"; }
      toast("Couldn't save note: " + e.message, "err");
    }
  }

  // ── Mutations ────────────────────────────────────────────────────────────────
  async function createNote(notebookId) {
    if (notebookId == null) {
      if (S.view.type === "notebook") notebookId = S.view.id;
      else notebookId = (childNotebooks(0)[0] || {}).id;
    }
    if (notebookId == null) { toast("Create a notebook first.", "info"); return newNotebook(0); }
    try {
      const note = await api("POST", "/api/notes", { title: "", body: "", format: "markdown", notebookId: Number(notebookId), tagIds: "", archived: false });
      S.notes.push(note);
      S.expanded.add(Number(notebookId));
      openNote(note.id);
      toast("Note created", "ok");
    } catch (e) { toast("Couldn't create note: " + e.message, "err"); }
  }

  function openNote(id) {
    if (S.dirty) flushSave();
    const note = S.notes.find((n) => Number(n.id) === Number(id));
    if (!note) { toast("Note not found", "err"); return; }
    S.current = note;
    S.view = { type: "note", id: Number(id) };
    closeDrawer();
    render();
    location.hash = "#note/" + id;
  }

  async function toggleArchive(note) {
    try {
      const updated = await api("PUT", "/api/notes/" + note.id, { archived: !note.archived });
      Object.assign(note, updated);
      if (S._ed) S._ed.refreshMeta();
      renderSidebar();
      toast(note.archived ? "Note archived" : "Note restored", "ok");
    } catch (e) { toast("Couldn't update note: " + e.message, "err"); }
  }
  async function toggleFormat(note) {
    note.format = note.format === "plain" ? "markdown" : "plain";
    if (S._ed) S._ed.refreshMeta();
    markDirty();
  }
  async function moveNote(note) {
    const dest = await pickNotebook({ title: "Move note to…", excludeId: null });
    if (dest == null) return;
    try {
      const updated = await api("PUT", "/api/notes/" + note.id, { notebookId: Number(dest) });
      Object.assign(note, updated);
      if (S._ed) S._ed.refreshMeta();
      renderSidebar();
      toast("Moved to " + (nbById(dest) || {}).name, "ok");
    } catch (e) { toast("Couldn't move note: " + e.message, "err"); }
  }
  async function deleteNote(note) {
    const ok = await confirmDialog({ title: "Delete note?", body: `“${note.title || "Untitled"}” will be permanently deleted. This cannot be undone.`, confirmLabel: "Delete note", danger: true });
    if (!ok) return;
    try {
      await api("DELETE", "/api/notes/" + note.id);
      S.notes = S.notes.filter((n) => Number(n.id) !== Number(note.id));
      if (S.current && Number(S.current.id) === Number(note.id)) { S.current = null; setView({ type: "notebook", id: note.notebookId }); }
      else render();
      toast("Note deleted", "ok");
    } catch (e) { toast("Couldn't delete note: " + e.message, "err"); }
  }

  async function toggleNoteTag(note, tagId, add) {
    const ids = new Set(noteTagIds(note));
    if (add) ids.add(Number(tagId)); else ids.delete(Number(tagId));
    const tagIds = Array.from(ids).join(",");
    try {
      const updated = await api("PUT", "/api/notes/" + note.id, { tagIds });
      Object.assign(note, updated);
      const idx = S.notes.findIndex((n) => Number(n.id) === Number(note.id));
      if (idx >= 0) S.notes[idx].tagIds = tagIds;
      renderSidebarSoon();
    } catch (e) { toast("Couldn't update tags: " + e.message, "err"); }
  }

  function noteMenu(rect, note) {
    menu(rect, [
      { label: "Open", icon: "docLine", onclick: () => openNote(note.id) },
      { label: "Move to notebook…", icon: "move", onclick: () => moveNote(note) },
      { label: note.archived ? "Restore from archive" : "Archive", icon: "archive", onclick: () => toggleArchive(note) },
      { type: "sep" },
      { label: "Delete", icon: "trash", danger: true, onclick: () => deleteNote(note) },
    ]);
  }

  function tagPickerMenu(rect, note, after) {
    const has = new Set(noteTagIds(note));
    const items = [{ type: "label", text: "Toggle tags" }];
    S.tags.slice().sort((a, b) => String(a.name).localeCompare(String(b.name))).forEach((t) => {
      items.push({ label: t.name, icon: "tag", checked: has.has(Number(t.id)), onclick: () => toggleNoteTag(note, t.id, !has.has(Number(t.id))).then(after) });
    });
    items.push({ type: "sep" });
    items.push({ label: "Create new tag…", icon: "plus", onclick: () => newTag().then((t) => { if (t) toggleNoteTag(note, t.id, true).then(after); }) });
    menu(rect, items);
  }

  // Notebooks
  async function newNotebook(parentId) {
    const name = await promptDialog({ title: parentId ? "New sub-notebook" : "New notebook", label: "Name", placeholder: "e.g. Projects" });
    if (!name) return null;
    try {
      const body = { name, description: "", color: COLORS[S.notebooks.length % COLORS.length] };
      if (parentId) body.parentId = Number(parentId);
      const nb = await api("POST", "/api/notebooks", body);
      S.notebooks.push(nb);
      if (parentId) S.expanded.add(Number(parentId));
      setView({ type: "notebook", id: nb.id });
      toast("Notebook created", "ok");
      return nb;
    } catch (e) { toast("Couldn't create notebook: " + e.message, "err"); return null; }
  }
  async function renameNotebook(nb) {
    const name = await promptDialog({ title: "Rename notebook", label: "Name", value: nb.name });
    if (!name || name === nb.name) return;
    await patchNotebook(nb, { name });
  }
  async function editNotebookDesc(nb) {
    const description = await promptDialog({ title: "Notebook description", label: "Description", value: nb.description, placeholder: "What's this notebook for?", multiline: true });
    if (description == null) return;
    await patchNotebook(nb, { description });
  }
  async function patchNotebook(nb, fields) {
    try { const u = await api("PUT", "/api/notebooks/" + nb.id, fields); Object.assign(nb, u); render(); toast("Notebook updated", "ok"); }
    catch (e) { toast("Couldn't update notebook: " + e.message, "err"); }
  }
  async function moveNotebook(nb) {
    const dest = await pickNotebook({ title: "Move notebook under…", excludeSubtree: nb.id, allowRoot: true });
    if (dest == null) return;
    await patchNotebook(nb, { parentId: Number(dest) });
    if (dest) S.expanded.add(Number(dest));
  }
  async function deleteNotebook(nb) {
    const n = notesIn(nb.id, true).length, subs = childNotebooks(nb.id).length;
    const warn = (n || subs) ? ` It still contains ${n} note(s)${subs ? ` and ${subs} sub-notebook(s)` : ""}, which will be orphaned.` : "";
    const ok = await confirmDialog({ title: "Delete notebook?", body: `“${nb.name}” will be deleted.${warn} This cannot be undone.`, confirmLabel: "Delete notebook", danger: true });
    if (!ok) return;
    try {
      await api("DELETE", "/api/notebooks/" + nb.id);
      S.notebooks = S.notebooks.filter((x) => Number(x.id) !== Number(nb.id));
      if (S.view.type === "notebook" && Number(S.view.id) === Number(nb.id)) setView({ type: "all" });
      else render();
      toast("Notebook deleted", "ok");
    } catch (e) { toast("Couldn't delete notebook: " + e.message, "err"); }
  }
  function notebookMenu(rect, nb) {
    menu(rect, [
      { label: "New note", icon: "plus", onclick: () => createNote(nb.id) },
      { label: "New sub-notebook", icon: "book", onclick: () => newNotebook(nb.id) },
      { type: "sep" },
      { label: "Rename", icon: "edit", onclick: () => renameNotebook(nb) },
      { label: "Edit description", icon: "pen", onclick: () => editNotebookDesc(nb) },
      { label: "Move…", icon: "move", onclick: () => moveNotebook(nb) },
      { type: "label", text: "Color" },
      { type: "swatches", value: nb.color, onpick: (c) => patchNotebook(nb, { color: c }) },
      { type: "sep" },
      { label: "Delete", icon: "trash", danger: true, onclick: () => deleteNotebook(nb) },
    ]);
  }

  // Tags
  async function newTag() {
    const name = await promptDialog({ title: "New tag", label: "Name", placeholder: "e.g. important" });
    if (!name) return null;
    try { const t = await api("POST", "/api/tags", { name, color: COLORS[S.tags.length % COLORS.length] }); S.tags.push(t); renderSidebar(); toast("Tag created", "ok"); return t; }
    catch (e) { toast("Couldn't create tag: " + e.message, "err"); return null; }
  }
  async function renameTag(tg) {
    const name = await promptDialog({ title: "Rename tag", label: "Name", value: tg.name });
    if (!name || name === tg.name) return;
    try { const u = await api("PUT", "/api/tags/" + tg.id, { name }); Object.assign(tg, u); render(); toast("Tag renamed", "ok"); }
    catch (e) { toast("Couldn't rename tag: " + e.message, "err"); }
  }
  async function recolorTag(tg, c) {
    try { const u = await api("PUT", "/api/tags/" + tg.id, { color: c }); Object.assign(tg, u); render(); }
    catch (e) { toast("Couldn't update tag: " + e.message, "err"); }
  }
  async function deleteTag(tg) {
    const ok = await confirmDialog({ title: "Delete tag?", body: `“${tg.name}” will be removed. Notes keep their text but lose this tag.`, confirmLabel: "Delete tag", danger: true });
    if (!ok) return;
    try {
      await api("DELETE", "/api/tags/" + tg.id);
      S.tags = S.tags.filter((x) => Number(x.id) !== Number(tg.id));
      if (S.view.type === "tag" && Number(S.view.id) === Number(tg.id)) setView({ type: "all" });
      else render();
      toast("Tag deleted", "ok");
    } catch (e) { toast("Couldn't delete tag: " + e.message, "err"); }
  }
  function tagMenu(rect, tg) {
    menu(rect, [
      { label: "View tagged notes", icon: "tag", onclick: () => setView({ type: "tag", id: tg.id }) },
      { label: "Rename", icon: "edit", onclick: () => renameTag(tg) },
      { type: "label", text: "Color" },
      { type: "swatches", value: tg.color, onpick: (c) => recolorTag(tg, c) },
      { type: "sep" },
      { label: "Delete", icon: "trash", danger: true, onclick: () => deleteTag(tg) },
    ]);
  }

  // ── Command palette / search ──────────────────────────────────────────────────
  function openPalette() {
    let sel = 0, results = [], timer = null;
    overlay("ws-palette", (ov, close) => {
      const input = h("input", { type: "text", placeholder: "Search notes by title and text…", autocomplete: "off", spellcheck: "false", "aria-label": "Search notes" });
      const mode = h("span", { class: "ws-palmode" }, "");
      const list = h("div", { class: "ws-palresults" });
      const box = h("div", { class: "ws-palbox" }, [
        h("div", { class: "ws-palhead" }, [icon("search"), input, mode]),
        list,
      ]);
      ov.appendChild(box);
      input.focus();

      function renderResults() {
        clear(list);
        if (!results.length) { list.appendChild(h("div", { class: "ws-empty", style: "padding:36px 20px" }, [h("p", null, input.value.trim() ? "No matches." : "Type to search your notes.")])); return; }
        results.forEach((r, i) => {
          list.appendChild(h("div", { class: "pal-item" + (i === sel ? " sel" : ""), onmouseenter: () => { sel = i; renderResults(); }, onclick: () => choose(i) }, [
            h("span", { class: "pi-ico" }, icon("docLine")),
            h("span", { style: "min-width:0" }, [h("div", { class: "pi-title" }, r.title || "Untitled"), h("div", { class: "pi-snip" }, r.snippet || "")]),
            r.score != null ? h("span", { class: "score-pill" }, String(r.score)) : null,
          ]));
        });
      }
      function choose(i) { const r = results[i]; if (!r) return; close(); openNote(r.id); }
      async function run() {
        const q = input.value.trim();
        if (!q) { results = []; mode.textContent = ""; renderResults(); return; }
        try {
          const data = await api("GET", "/api/search?q=" + encodeURIComponent(q) + "&k=25");
          results = (data && data.results) || [];
          mode.textContent = (data && data.mode ? data.mode : "") + (results.length ? " · " + results.length : "");
          sel = 0; renderResults();
        } catch (e) { results = []; mode.textContent = "error"; renderResults(); }
      }
      input.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(run, 180); });
      input.addEventListener("keydown", (e) => {
        if (e.key === "ArrowDown") { e.preventDefault(); sel = Math.min(sel + 1, results.length - 1); renderResults(); }
        else if (e.key === "ArrowUp") { e.preventDefault(); sel = Math.max(sel - 1, 0); renderResults(); }
        else if (e.key === "Enter") { e.preventDefault(); choose(sel); }
      });
      renderResults();
    });
  }

  // ── Top bar search dropdown ─────────────────────────────────────────────────
  function initTopSearch() {
    const btn = $("ws-top-search");
    const drop = $("ws-search-drop");
    const input = $("ws-search-input");
    const results = $("ws-search-results");
    let sel = 0, items = [], timer = null;
    let open = false;

    function closeDrop() { open = false; drop.hidden = true; }
    function openDrop() { open = true; drop.hidden = false; input.focus(); input.select(); }
    function toggleDrop() { if (open) closeDrop(); else openDrop(); }

    function render() {
      clear(results);
      if (!items.length) { results.appendChild(h("div", { class: "ws-empty" }, [h("p", null, input.value.trim() ? "No matches." : "Type to search your notes.")])); return; }
      items.forEach((r, i) => {
        results.appendChild(h("div", { class: "sr-item" + (i === sel ? " sel" : ""), onmouseenter: () => { sel = i; render(); }, onclick: () => choose(i) }, [
          h("span", { class: "sr-ico" }, icon("docLine")),
          h("span", { style: "min-width:0" }, [h("div", { class: "sr-title" }, r.title || "Untitled"), h("div", { class: "sr-snip" }, r.snippet || "")]),
          r.score != null ? h("span", { class: "sr-score" }, String(r.score)) : null,
        ]));
      });
    }
    function choose(i) { const r = items[i]; if (!r) return; closeDrop(); openNote(r.id); }
    async function run() {
      const q = input.value.trim();
      if (!q) { items = []; render(); return; }
      try {
        const data = await api("GET", "/api/search?q=" + encodeURIComponent(q) + "&k=25");
        items = (data && data.results) || [];
        sel = 0; render();
      } catch (e) { items = []; render(); }
    }

    btn.addEventListener("click", toggleDrop);
    input.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(run, 180); });
    input.addEventListener("keydown", (e) => {
      if (e.key === "ArrowDown") { e.preventDefault(); sel = Math.min(sel + 1, items.length - 1); render(); }
      else if (e.key === "ArrowUp") { e.preventDefault(); sel = Math.max(sel - 1, 0); render(); }
      else if (e.key === "Enter") { e.preventDefault(); choose(sel); }
      else if (e.key === "Escape") { e.preventDefault(); closeDrop(); btn.focus(); }
    });
    document.addEventListener("mousedown", (e) => { if (open && !drop.contains(e.target) && e.target !== btn) closeDrop(); });
  }

  // ── Settings ──────────────────────────────────────────────────────────────────
  function openSettings() {
    overlay("", (ov, close) => {
      const row = (label, val) => h("div", { class: "setting-row", style: "border:1px solid var(--border);border-radius:9px;padding:12px 14px;display:flex;justify-content:space-between;gap:16px;margin-bottom:8px" }, [h("span", { class: "muted" }, label), h("strong", { style: "font-size:13.5px;text-align:right" }, val)]);
      const dlg = h("div", { class: "ws-dialog", style: "width:min(520px,100%)" }, [
        h("h2", null, "Settings"),
        h("p", { class: "sub" }, "Account and service details."),
        row("Signed in as", (S.me && S.me.email) || "Not signed in"),
        row("Notebooks", String(S.notebooks.length)),
        row("Notes", String(S.notes.length)),
        row("Tags", String(S.tags.length)),
        row("Storage", "B+tree notes database"),
        row("Search", "Semantic with lexical fallback"),
        row("MCP endpoint", "/mcp"),
        h("div", { style: "display:flex;gap:8px;margin-top:6px" }, [
          h("a", { class: "ws-btn", href: "/", style: "flex:1;justify-content:center" }, [icon("info"), "About"]),
          h("a", { class: "ws-btn danger", href: "/auth/logout", style: "flex:1;justify-content:center" }, [icon("logout"), "Sign out"]),
        ]),
        h("div", { class: "ws-dialog-actions" }, [h("button", { class: "ws-btn primary", onclick: close }, "Done")]),
      ]);
      ov.appendChild(dlg);
    });
  }

  // ── Drawer (mobile) ────────────────────────────────────────────────────────────
  function openDrawer() { document.body.classList.add("drawer-open"); }
  function closeDrawer() { document.body.classList.remove("drawer-open"); }

  // ── Hash routing ────────────────────────────────────────────────────────────────
  function applyHash() {
    const hash = location.hash.replace(/^#/, "");
    if (!hash || hash === "all") { S.view = { type: "all" }; S.current = null; return render(); }
    const [kind, id] = hash.split("/");
    if (kind === "note" && id) { const n = S.notes.find((x) => Number(x.id) === Number(id)); if (n) { S.current = n; S.view = { type: "note", id: Number(id) }; return render(); } }
    if (kind === "notebook" && id && nbById(id)) { S.view = { type: "notebook", id: Number(id) }; S.current = null; return render(); }
    if (kind === "tag" && id && tagById(id)) { S.view = { type: "tag", id: Number(id) }; S.current = null; return render(); }
    if (kind === "recent" || kind === "archived") { S.view = { type: kind }; S.current = null; return render(); }
    S.view = { type: "all" }; S.current = null; render();
  }

  // ── Init ──────────────────────────────────────────────────────────────────────
  async function initWorkspace() {
    const ws = $("workspace");
    // skeleton
    $("ws-body").appendChild(h("div", { class: "ws-canvas" }, h("div", { class: "ws-skeleton" }, [h("div", { class: "sk" }), h("div", { class: "sk" }), h("div", { class: "sk" })])));

    // global wiring
    $("ws-open-search").addEventListener("click", openPalette);
    $("ws-add-notebook").addEventListener("click", () => newNotebook(0));
    $("ws-add-tag").addEventListener("click", () => newTag());
    $("ws-drawer").addEventListener("click", openDrawer);
    $("ws-scrim").addEventListener("click", closeDrawer);
    window.addEventListener("hashchange", applyHash);
    window.addEventListener("beforeunload", () => { if (S.dirty) flushSave(); });
    document.addEventListener("keydown", (e) => {
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); openPalette(); }
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") { e.preventDefault(); if (S.dirty) flushSave(); }
    });
    initTopSearch();

    window.addEventListener("resize", closeMenu);

    try {
      await loadAll();
    } catch (e) {
      clear($("ws-body"));
      $("ws-body").appendChild(emptyState("warn", "Couldn't load your workspace", e.message));
      ws.setAttribute("aria-busy", "false");
      return;
    }
    if (S.me && S.me.authenticated === false) { window.location.assign("/auth/login"); return; }

    // expand notebooks that contain the current hash target, else first root
    const roots = childNotebooks(0);
    if (roots[0]) S.expanded.add(Number(roots[0].id));
    ws.setAttribute("aria-busy", "false");
    applyHash();
  }

  // Legacy fallback pages (search.html / settings.html) — send users to the app.
  function initSearchPage() { window.location.replace("/app#all"); }
  function initSettingsPage() { window.location.replace("/app#all"); }

  window.NotesApp = { initWorkspace, initSearchPage, initSettingsPage };
})();