// Tree-aware list detail (v2) — the new lists rebuild.
//
// Renders a single root Node + its subtree recursively. Anything in the tree
// is one of three node types:
//   • 'root'  — the list itself (only at depth 0)
//   • 'slot'  — a categorical need ("5 trousers") or a sub-list header ("Sleep")
//   • 'item'  — a leaf carrying either a product_id or a custom blob
//
// A list owner can add items, slots, or sub-lists at any depth. Items under
// a slot have a `picked` toggle — when picked.count >= slot.quantityNeeded
// the slot is "fully picked". Voting is available at every node level so
// partners can agree (or disagree) per item AND per slot.
//
// Talks to window.MR.nodes for all persistence.

const { useState: _v2s, useEffect: _v2e, useRef: _v2r, useMemo: _v2m, useCallback: _v2cb } = React;

// Walks the DOM tree from `el` up to the body, returning true if any
// ancestor is an interactive element (button, link, input, etc.) or
// carries role="button". Used to suppress whole-card drag when the
// user mousedown'd on an inner control like Pick/Status/Vote/Delete.
// Tiny confetti burst — no libraries. Spawns N coloured divs at the cursor
// (or screen-centre fallback) and animates them down + out with a small
// gravity feel. Auto-cleans after 1.5s. Safe to call multiple times.
function fireConfetti(originX, originY) {
  if (typeof document === 'undefined') return;
  const cx = originX != null ? originX : window.innerWidth / 2;
  const cy = originY != null ? originY : Math.min(window.innerHeight / 2, 280);
  const COUNT = 36;
  const colors = ['#cf5d4a', '#e0a94c', '#3f6b54', '#1c1a14', '#fffaf0', '#e07059', '#4a8265', '#8a6320'];
  const wrap = document.createElement('div');
  wrap.style.cssText = `position: fixed; inset: 0; pointer-events: none; z-index: 99998;`;
  for (let i = 0; i < COUNT; i++) {
    const piece = document.createElement('span');
    const dx = (Math.random() - 0.5) * 360;
    const dy = -120 - Math.random() * 240;
    const rot = (Math.random() - 0.5) * 720;
    const size = 6 + Math.random() * 6;
    const color = colors[i % colors.length];
    const dur = 1100 + Math.random() * 500;
    piece.style.cssText = `
      position: absolute;
      left: ${cx}px;
      top: ${cy}px;
      width: ${size}px;
      height: ${size * 0.6}px;
      background: ${color};
      border-radius: 1px;
      transform: translate(-50%, -50%);
      animation: mr-confetti-fly ${dur}ms cubic-bezier(.22,.7,.4,1) forwards;
      --dx: ${dx}px;
      --dy: ${dy}px;
      --rot: ${rot}deg;
    `;
    wrap.appendChild(piece);
  }
  document.body.appendChild(wrap);
  setTimeout(() => { try { wrap.remove(); } catch {} }, 1700);
}

// ──────────────────────────────────────────────────────────────────────
// Magical-moment helper. The point: when a user makes a decision, the
// app should *feel* like it noticed. fireConfetti() above is great for
// big milestones (100% complete, finished a shortlist) but too loud for
// every Pick toggle. This helper picks the right variant for the moment
// and animates around a focal element.
//
// Variants:
//   • 'pick'     — sparkle ring + small star burst (per-item)
//   • 'decide'   — confetti rain (shortlist fully decided)
//   • 'milestone'— bigger confetti (50/75/100% list complete)
//   • 'add'      — quick pulse ring (item landed)
// ──────────────────────────────────────────────────────────────────────
function mrCelebrate(targetEl, opts = {}) {
  if (typeof document === 'undefined') return;
  const variant = (opts && opts.variant) || 'pick';

  // Find a sensible origin from the target element.
  let cx = window.innerWidth / 2;
  let cy = Math.min(window.innerHeight / 2, 200);
  if (targetEl && typeof targetEl.getBoundingClientRect === 'function') {
    const r = targetEl.getBoundingClientRect();
    cx = r.left + r.width / 2;
    cy = r.top + r.height / 2;
  }

  // Respect motion preferences. We still want to confirm the action; a
  // simple toast can stand in for the animation when the user has asked
  // the system to reduce motion.
  const reduceMotion = typeof window.matchMedia === 'function'
    && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  if (reduceMotion) return;

  if (variant === 'decide' || variant === 'milestone') {
    fireConfetti(cx, cy);
    return;
  }

  // 'pick' / 'add' — a sparkle burst + an expanding ring. Cheaper than
  // confetti and feels per-element rather than per-page.
  const wrap = document.createElement('div');
  wrap.style.cssText = `position: fixed; left: ${cx}px; top: ${cy}px; width: 0; height: 0; pointer-events: none; z-index: 99999;`;
  document.body.appendChild(wrap);

  // Expanding ring.
  const ring = document.createElement('span');
  ring.style.cssText = `
    position: absolute;
    left: -28px; top: -28px;
    width: 56px; height: 56px;
    border-radius: 999px;
    border: 2px solid #ffaf3a;
    opacity: 0.9;
    animation: mr-celeb-ring 600ms cubic-bezier(.2,.7,.4,1) forwards;
  `;
  wrap.appendChild(ring);

  // Star sparkles.
  const N = variant === 'add' ? 4 : 7;
  const palette = ['#ffaf3a', '#ff7fa8', '#6ea4ff', '#5fc59a', '#ffd35a'];
  const glyphs = ['✦', '✧', '★', '✺'];
  for (let i = 0; i < N; i++) {
    const angle = (i / N) * Math.PI * 2 + (Math.random() - 0.5) * 0.4;
    const dist = 26 + Math.random() * 22;
    const dx = Math.cos(angle) * dist;
    const dy = Math.sin(angle) * dist - 6;  // slight upward bias
    const dur = 700 + Math.random() * 400;
    const s = document.createElement('span');
    s.textContent = glyphs[i % glyphs.length];
    s.style.cssText = `
      position: absolute;
      left: 0; top: 0;
      transform: translate(-50%, -50%);
      color: ${palette[i % palette.length]};
      font-size: ${12 + Math.random() * 6}px;
      text-shadow: 0 0 6px ${palette[i % palette.length]}66;
      animation: mr-celeb-spark ${dur}ms ease-out forwards;
      --dx: ${dx}px;
      --dy: ${dy}px;
    `;
    wrap.appendChild(s);
  }

  setTimeout(() => { try { wrap.remove(); } catch {} }, 1100);
}

// Expose globally so any component (not just NodeChildren) can fire
// celebrations without prop-drilling. Idempotent: assignment overwrites
// if the file hot-reloads, but the API surface is stable.
if (typeof window !== 'undefined') {
  window.__mr_celebrate = mrCelebrate;
  window.__mr_fireConfetti = fireConfetti;
}

// Inline form used by both the root toolbar and the slot mini-toolbar
// when the user picks "+ Placeholder". Captures a name + optional price
// for an item the user knows they need but hasn't found the product for
// yet. Submitting emits the spec; the parent wraps it in addItem with a
// `placeholder: true` flag in the custom blob.
function PlaceholderForm({ onSubmit, onCancel }) {
  const [name, setName] = _v2s('');
  const [price, setPrice] = _v2s('');
  const submit = (e) => {
    if (e) e.preventDefault();
    const n = name.trim();
    if (!n) return;
    const p = price.trim() ? Number(price) : null;
    onSubmit({ name: n, price: Number.isFinite(p) ? p : null });
    setName(''); setPrice('');
  };
  return (
    <form className="list2-toolbar-inline list2-toolbar-inline--placeholder" onSubmit={submit}>
      <input
        type="text"
        autoFocus
        placeholder="What do you need? e.g. 'A baby monitor', 'Newborn nappies'"
        value={name}
        onChange={(e) => setName(e.target.value)}
        className="list2-toolbar-inline-name"
      />
      <label className="list2-toolbar-inline-qty">
        <span>A$</span>
        <input
          type="number"
          min="0"
          step="1"
          placeholder="—"
          value={price}
          onChange={(e) => setPrice(e.target.value)}
          title="Optional budget for this placeholder"
        />
      </label>
      <button type="submit" className="btn" disabled={!name.trim()} style={{ width: 'auto', padding: '8px 14px' }}>
        <span>Add placeholder</span><span className="arrow">→</span>
      </button>
      <button type="button" className="btn btn-ghost" onClick={onCancel} style={{ width: 'auto', padding: '8px 12px' }}>Cancel</button>
    </form>
  );
}

// PickOfToggle — the single control on every container that says either
// "Pick [N] of…" (when on) or "+ Pick one of…" (when off). Replaces the
// old kind-badge + Make-shortlist button combo. Toggling on flips the
// container from group → shortlist with N=1. The N is inline-editable
// once on, so changing "Pick 1 of" → "Pick 2 of" is a single tap.
function PickOfToggle({ isOn, count, onChange }) {
  const [editing, setEditing] = _v2s(false);
  const [draft, setDraft] = _v2s(String(count || 1));
  _v2e(() => { setDraft(String(count || 1)); }, [count]);
  const commit = () => {
    const n = Math.max(1, Math.min(99, parseInt(draft, 10) || 1));
    setEditing(false);
    if (n !== (count || 1)) onChange({ slotKind: 'shortlist', quantityNeeded: n });
  };
  const toggle = (e) => {
    e.stopPropagation();
    console.log('[v2] PickOfToggle clicked, isOn=', isOn, 'editing=', editing);
    if (editing) return;
    if (isOn) {
      onChange({ slotKind: 'group', quantityNeeded: 0 });
    } else {
      onChange({ slotKind: 'shortlist', quantityNeeded: 1 });
    }
  };
  return (
    <button
      type="button"
      className={`pickof${isOn ? ' is-on' : ''}`}
      onClick={toggle}
      role="switch"
      aria-checked={isOn}
      title={isOn ? 'Currently a "Pick of" decision — tap to make this a plain group' : 'Tap to turn this into a "Pick one of" decision'}
    >
      <span className="pickof-switch" aria-hidden="true">
        <span className="pickof-knob" />
      </span>
      <span className="pickof-label">
        {isOn ? (
          <>
            Pick&nbsp;
            {editing ? (
              <input
                type="number"
                min="1"
                max="99"
                value={draft}
                autoFocus
                onClick={(e) => e.stopPropagation()}
                onChange={(e) => setDraft(e.target.value)}
                onBlur={commit}
                onKeyDown={(e) => {
                  if (e.key === 'Enter') { e.preventDefault(); commit(); }
                  if (e.key === 'Escape') { setDraft(String(count || 1)); setEditing(false); }
                }}
                className="pickof-input"
              />
            ) : (
              <span
                className="pickof-n"
                onClick={(e) => { e.stopPropagation(); setEditing(true); }}
                title="Edit the number to pick"
              >{count || 1}</span>
            )}
            &nbsp;of
          </>
        ) : (
          <>+ Pick one of…</>
        )}
      </span>
    </button>
  );
}

function isInteractiveTarget(el) {
  while (el && el !== document.body) {
    if (!el.tagName) { el = el.parentElement; continue; }
    const tag = el.tagName;
    if (tag === 'BUTTON' || tag === 'A' || tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'LABEL') return true;
    const role = el.getAttribute && el.getAttribute('role');
    if (role === 'button' || role === 'checkbox' || role === 'radio' || role === 'switch') return true;
    el = el.parentElement;
  }
  return false;
}

// ───────────────────────── Page shell ─────────────────────────
function ListDetailV2({ rootId, productMap, onBack, onOpenProduct, onUpdateListContext, onNavigateAdd, onShare, myUserId, userLists, onSwitchList }) {
  const [root, setRoot] = _v2s(null);
  const [votes, setVotes] = _v2s([]);
  const [comments, setComments] = _v2s([]);
  const [names, setNames] = _v2s({});            // { userId: displayName }
  const [claims, setClaims] = _v2s([]);
  const [loading, setLoading] = _v2s(true);
  const [err, setErr] = _v2s(null);
  // 'list' (compact rows, fast scanning) vs 'grid' (big card thumbnails,
  // browsy). Persists per session — lives in localStorage so the user's
  // last pick sticks across navigations.
  const [viewMode, setViewMode] = _v2s(() => {
    try { return localStorage.getItem('mr-list2-view') || 'list'; } catch { return 'list'; }
  });
  // Grid-card size — applies only when viewMode === 'grid'. Four
  // steps (sm / md / lg / xl) so the user can pick how visual the
  // browse feels. Default 'md' matches the original 180→240px column
  // that shipped before this setting existed.
  const [gridSize, setGridSize] = _v2s(() => {
    try { return localStorage.getItem('mr-list2-gridsize') || 'md'; } catch { return 'md'; }
  });
  // Activity drawer — toggled from the list header via a window event
  // (mr-open-activity) so the header doesn't need to thread state down
  // through props. Holds an array of activity rows fetched on open;
  // refreshed whenever the drawer is opened so the user sees fresh
  // partner activity.
  const [activityOpen, setActivityOpen] = _v2s(false);
  const [activity, setActivity] = _v2s([]);
  const [activityLoading, setActivityLoading] = _v2s(false);
  // Item the user clicked to inspect — the shared DetailPanel renders in
  // App.jsx; we keep the id locally so buildListContext can re-publish a
  // fresh payload whenever votes/comments/etc. change.
  const [openItemId, setOpenItemId] = _v2s(null);
  // Shortlist comparison modal — holds the slot id when open. Resolved
  // to a node + its candidates at render time so it always reflects the
  // latest tree (picks, votes, additions while the modal is open).
  const [compareSlotId, setCompareSlotId] = _v2s(null);
  // Placeholder replace modal — holds the placeholder item id when open.
  // Resolved to a node at render time so edits applied through the modal
  // (rename, repriced, replaced) reflect immediately.
  const [placeholderItemId, setPlaceholderItemId] = _v2s(null);
  // List-view controls — search/filter/sort applied to leaf items. These
  // are page-local UI state (don't persist across reloads); resetting them
  // is just a refresh away.
  const [searchQ, setSearchQ] = _v2s('');
  const [filterStatus, setFilterStatus] = _v2s(null);     // null | 'want' | 'need' | 'have'
  const [filterMust, setFilterMust] = _v2s(false);
  const [filterMaxPrice, setFilterMaxPrice] = _v2s(null); // number or null
  const [sortBy, setSortBy] = _v2s('manual');             // 'manual' | 'price-asc' | 'price-desc' | 'name' | 'newest'
  // Auto-decide on consensus — when ON, any unpicked candidate in a
  // shortlist that has 2+ up-votes (and no down-votes) is auto-picked.
  // Each item carries an internal "user has un-picked this" flag in
  // localStorage so the effect doesn't keep re-picking what the user
  // explicitly opted out of. Defaults to ON for shared (partner) lists,
  // OFF for private/registry.
  const [autoDecide, setAutoDecideState] = _v2s(() => {
    try {
      const v = localStorage.getItem(`mr-autodecide-${rootId}`);
      if (v === null) return null; // computed from root.kind below
      return v === 'true';
    } catch { return null; }
  });
  const effectiveAutoDecide = autoDecide == null
    ? (root && root.kind === 'partner') // default on for shared lists
    : autoDecide;
  const setAutoDecide = (v) => {
    try { localStorage.setItem(`mr-autodecide-${rootId}`, String(v)); } catch {}
    setAutoDecideState(v);
  };
  // Budget lives here (not in the dashboard) so the settings popover and
  // the dashboard share a single source of truth. Initialised from
  // localStorage; updates write through.
  const [budget, setBudgetState] = _v2s(() => {
    try {
      const v = localStorage.getItem(`mr-budget-${rootId}`);
      return v ? Number(v) : null;
    } catch { return null; }
  });
  const setBudget = (v) => {
    try {
      if (v == null || v === '') localStorage.removeItem(`mr-budget-${rootId}`);
      else localStorage.setItem(`mr-budget-${rootId}`, String(v));
    } catch {}
    setBudgetState(v == null || v === '' ? null : Number(v));
  };
  // Focus mode — once a shortlist has a picked candidate, the others are
  // noise. Default ON. Per-list localStorage so the preference sticks per
  // list (different lists have different patience for alternatives).
  // Per-shortlist "expand me anyway" lives in slotExpandedAlts.
  const [focusMode, setFocusModeState] = _v2s(() => {
    try {
      const v = localStorage.getItem(`mr-focus-${rootId}`);
      return v === null ? true : v === 'true';
    } catch { return true; }
  });
  const setFocusMode = (v) => {
    try { localStorage.setItem(`mr-focus-${rootId}`, String(v)); } catch {}
    setFocusModeState(v);
  };
  // Per-shortlist override map: { [slotId]: true } means "show alternatives
  // anyway even though focus mode is on for the list".
  const [slotExpandedAlts, setSlotExpandedAlts] = _v2s({});
  const toggleSlotExpand = (slotId) => {
    setSlotExpandedAlts(prev => ({ ...prev, [slotId]: !prev[slotId] }));
  };
  // Bulk selection. selectedIds is a Set; selectionMode lights up the
  // checkboxes on item rows + shows the floating action bar at the bottom.
  // Entering selection mode is implicit on first checkbox click.
  const [selectedIds, setSelectedIds] = _v2s(() => new Set());
  const selectionMode = selectedIds.size > 0;
  // toggleSelected now accepts an optional explicit `force` argument:
  //   • undefined → pure toggle
  //   • true      → select (idempotent — no-op if already selected)
  //   • false     → deselect (idempotent — no-op if not selected)
  // The checkbox's onClick passes `!isSelected` explicitly so two click
  // handlers firing for the same DOM event (e.g. React's synthetic
  // bubble racing the row's onClick) can't cancel each other out — the
  // failure mode that previously left the checkbox un-clickable.
  const toggleSelected = _v2cb((id, force) => {
    setSelectedIds(prev => {
      const isOn = prev.has(id);
      const wantOn = force === undefined ? !isOn : !!force;
      if (isOn === wantOn) return prev;          // idempotent: no change
      const next = new Set(prev);
      if (wantOn) next.add(id); else next.delete(id);
      return next;
    });
  }, []);
  const clearSelection = _v2cb(() => setSelectedIds(new Set()), []);

  // Bulk operations on the current selection.
  const bulkSetStatus = async (status) => {
    const ids = Array.from(selectedIds);
    clearSelection();
    await Promise.all(ids.map(id => window.MR.nodes.updateNode(id, { status })));
    await refreshRoot();
  };
  const bulkDelete = async () => {
    if (!window.confirm(`Delete ${selectedIds.size} item${selectedIds.size === 1 ? '' : 's'}? This can't be undone.`)) return;
    const ids = Array.from(selectedIds);
    clearSelection();
    await Promise.all(ids.map(id => window.MR.nodes.deleteNode(id)));
    await refreshRoot();
  };
  // Esc cancels selection mode.
  _v2e(() => {
    if (!selectionMode) return;
    const onKey = (e) => { if (e.key === 'Escape') clearSelection(); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [selectionMode]);
  // ── Drag-and-drop (pointer-event based) ─────────────────────────────
  // We rolled our own DnD instead of using HTML5 native drag because:
  // - Chrome's native drag had a race where dragstart fired but the first
  //   dragover saw stale state and refused all drops.
  // - The click/drag heuristic was unpredictable across browsers.
  // Pointer events give us full, synchronous control. On mousedown of a
  // drag handle we arm a drag; on mousemove we hit-test for drop targets;
  // on mouseup we commit. Visual state (dragNodeId, dropTarget) is the
  // same as before so all the existing styling keeps working.
  const dragNodeIdRef = _v2r(null);
  const [dragNodeIdState, setDragNodeIdState] = _v2s(null);
  const setDragNodeId = (id) => {
    dragNodeIdRef.current = id;
    setDragNodeIdState(id);
  };
  const dragNodeId = dragNodeIdState;
  const [dropTarget, setDropTarget] = _v2s(null);
  // Floating drag ghost element appended to document.body during a drag.
  const ghostRef = _v2r(null);
  // Mutable drag-in-progress info — start coordinates, source element, etc.
  // Lives in a ref so document-level mousemove handlers don't trigger React.
  const dragInfoRef = _v2r(null);

  // Start a custom drag on the drag-handle's pointerdown. nodeId is the
  // dragged node's id; sourceEl is the visual element to clone as a ghost.
  const startCustomDrag = (e, nodeId, sourceEl) => {
    if (e.button !== undefined && e.button !== 0) return; // left mouse only
    e.preventDefault();
    e.stopPropagation();
    const startX = e.clientX, startY = e.clientY;
    dragInfoRef.current = {
      nodeId,
      sourceEl,
      startX, startY,
      armed: false,        // turns true once mouse moves > THRESHOLD
      lastTarget: null,
    };
    console.log('[v2] pointer drag arming for', nodeId);
    document.addEventListener('mousemove', onDocMove);
    document.addEventListener('mouseup', onDocUp);
    document.addEventListener('keydown', onDocKey);
  };

  const THRESHOLD = 5;
  const onDocMove = (e) => {
    const info = dragInfoRef.current;
    if (!info) return;
    const dx = e.clientX - info.startX;
    const dy = e.clientY - info.startY;
    if (!info.armed) {
      if (Math.hypot(dx, dy) < THRESHOLD) return;
      info.armed = true;
      setDragNodeId(info.nodeId);
      // Build a small ghost pill — thumbnail + name — instead of cloning
      // the whole card. The full card is enormous in grid view and looks
      // awkward floating around the page.
      try {
        const src = info.sourceEl;
        const img = src.querySelector('img');
        const nameEl = src.querySelector('.list2-item-name, .list2-slot-title');
        const brandEl = src.querySelector('.list2-item-brand');
        const ghost = document.createElement('div');
        ghost.style.cssText = `
          position: fixed;
          top: 0; left: 0;
          display: flex;
          align-items: center;
          gap: 10px;
          padding: 8px 14px 8px 8px;
          max-width: 280px;
          pointer-events: none;
          opacity: 0.95;
          transform: translate(${e.clientX + 8}px, ${e.clientY + 8}px) rotate(-1.5deg);
          box-shadow: 0 18px 40px -12px rgba(20,16,8,0.5);
          z-index: 99999;
          border-radius: 999px;
          background: #1c1a14;
          color: #fffaf0;
          font-family: var(--display, system-ui);
          font-size: 13px;
          letter-spacing: 0.01em;
          transition: none;
          white-space: nowrap;
          overflow: hidden;
        `;
        if (img && img.src) {
          const thumb = document.createElement('span');
          thumb.style.cssText = `
            flex-shrink: 0;
            width: 32px;
            height: 32px;
            border-radius: 50%;
            background: #fffaf0 center/cover no-repeat url(${img.src});
            display: inline-block;
          `;
          ghost.appendChild(thumb);
        } else {
          const dot = document.createElement('span');
          dot.style.cssText = `
            flex-shrink: 0;
            width: 8px; height: 8px;
            border-radius: 50%;
            background: #fffaf0;
            margin-left: 6px;
          `;
          ghost.appendChild(dot);
        }
        const text = document.createElement('span');
        text.style.cssText = 'overflow: hidden; text-overflow: ellipsis; min-width: 0;';
        const brand = brandEl ? brandEl.textContent.trim() : '';
        const name = nameEl ? nameEl.textContent.trim() : 'Item';
        text.textContent = brand ? `${brand} · ${name}` : name;
        ghost.appendChild(text);
        document.body.appendChild(ghost);
        ghostRef.current = ghost;
      } catch (err) { console.warn('[v2] ghost failed', err); }
      document.body.style.userSelect = 'none';
      console.log('[v2] pointer drag started');
    }
    // Move ghost (offset so it sits below-right of the cursor, not under it)
    if (ghostRef.current) {
      ghostRef.current.style.transform = `translate(${e.clientX + 8}px, ${e.clientY + 8}px) rotate(-1.5deg)`;
    }
    // Hit-test for a drop target. Walk up from elementFromPoint looking
    // for [data-drop-target] markers we render on items and slots.
    if (ghostRef.current) ghostRef.current.style.display = 'none';
    const under = document.elementFromPoint(e.clientX, e.clientY);
    if (ghostRef.current) ghostRef.current.style.display = '';
    const target = findDropTargetFromEl(under, info.nodeId, e.clientY);
    if (target) {
      // Only update React state when the target changes — avoids
      // re-rendering on every pixel of motion.
      if (!info.lastTarget || info.lastTarget.parentId !== target.parentId || info.lastTarget.position !== target.position) {
        info.lastTarget = target;
        setDropTarget(target);
      }
    } else if (info.lastTarget) {
      info.lastTarget = null;
      setDropTarget(null);
    }
  };

  // Walk up the DOM from `el` finding the closest data-drop-target marker.
  // Returns { parentId, position } or null. Position is computed from
  // cursor Y relative to the target item; for slots, position = null
  // (append at end). Skips the dragged node itself + its descendants
  // (cycle protection).
  const findDropTargetFromEl = (el, srcId, cursorY) => {
    let cur = el;
    // Track whether we've encountered the list-page root while walking
    // up the tree. If we have, AND we didn't hit any drop-item or
    // drop-slot before reaching it, that means the user is dragging
    // over the page background — surface root as the drop target so
    // an item nested in a group can be pulled OUT to top-level just
    // by dragging into the empty space.
    let rootHit = null;
    while (cur && cur !== document.body) {
      // Item target — drop relative to it
      if (cur.dataset && cur.dataset.dropItem) {
        const itemId = cur.dataset.dropItem;
        const parentId = cur.dataset.dropParent;
        const idx = parseInt(cur.dataset.dropIndex || '0', 10);
        if (itemId === srcId) return null;       // dropping on self
        if (isInSubtree(srcId, itemId)) return null;
        if (parentId === srcId) return null;     // dropping into your own children
        const rect = cur.getBoundingClientRect();
        const insertAt = cursorY < rect.top + rect.height / 2 ? idx : idx + 1;
        return { parentId, position: insertAt };
      }
      // Slot target — drop into this slot (append)
      if (cur.dataset && cur.dataset.dropSlot) {
        const slotId = cur.dataset.dropSlot;
        if (slotId === srcId) return null;
        if (isInSubtree(srcId, slotId)) return null;
        return { parentId: slotId, position: null };
      }
      // Root container — remember it but keep walking in case the
      // cursor IS over an item/slot deeper in the tree (rare, but
      // possible with overflow or positioning).
      if (cur.dataset && cur.dataset.dropRoot && !rootHit) {
        rootHit = cur.dataset.dropRoot;
      }
      cur = cur.parentElement;
    }
    // Fallback: cursor is over the list page background. Drop at root,
    // appended at the end. This is what enables "drag out of a group
    // to send back to top-level". Without this, dropping in empty
    // space was a silent no-op and the user couldn't escape a group
    // by dragging.
    if (rootHit && rootHit !== srcId && !isInSubtree(srcId, rootHit)) {
      return { parentId: rootHit, position: null };
    }
    return null;
  };

  const onDocUp = (e) => {
    const info = dragInfoRef.current;
    document.removeEventListener('mousemove', onDocMove);
    document.removeEventListener('mouseup', onDocUp);
    document.removeEventListener('keydown', onDocKey);
    document.body.style.userSelect = '';
    if (ghostRef.current && ghostRef.current.parentNode) {
      ghostRef.current.parentNode.removeChild(ghostRef.current);
    }
    ghostRef.current = null;
    if (!info) return;
    if (info.armed && info.lastTarget) {
      console.log('[v2] pointer drop on', info.lastTarget, 'src=', info.nodeId);
      moveNodeToParent(info.nodeId, info.lastTarget.parentId, info.lastTarget.position);
    } else {
      console.log('[v2] pointer drag ended, no target');
    }
    // Suppress the click that fires right after a real drag — without this,
    // dropping over the source row would re-trigger openItem and pop the
    // detail panel. Window-level flag so ItemNode's onClick can check it.
    if (info.armed) {
      window.__mr_dragEndedAt = Date.now();
    }
    dragInfoRef.current = null;
    setDragNodeId(null);
    setDropTarget(null);
  };

  const onDocKey = (e) => {
    if (e.key === 'Escape' && dragInfoRef.current) {
      console.log('[v2] pointer drag cancelled by Escape');
      document.removeEventListener('mousemove', onDocMove);
      document.removeEventListener('mouseup', onDocUp);
      document.removeEventListener('keydown', onDocKey);
      document.body.style.userSelect = '';
      if (ghostRef.current && ghostRef.current.parentNode) {
        ghostRef.current.parentNode.removeChild(ghostRef.current);
      }
      ghostRef.current = null;
      dragInfoRef.current = null;
      setDragNodeId(null);
      setDropTarget(null);
    }
  };
  const setViewModePersisted = (mode) => {
    setViewMode(mode);
    try { localStorage.setItem('mr-list2-view', mode); } catch {}
  };
  const setGridSizePersisted = (size) => {
    setGridSize(size);
    try { localStorage.setItem('mr-list2-gridsize', size); } catch {}
  };

  // Re-fetch the entire subtree + votes + registry claims. Claims only matter
  // when the root is a registry but we fetch unconditionally for simplicity;
  // RLS will return [] for non-registry roots without share_token.
  //
  // Auth-aware: on hard refresh the Supabase session takes a tick to rehydrate
  // from storage. If we fetch before that, fetchMyRoots returns
  // { ok: false, reason: 'signed-out' }, even though the user *is* signed in.
  // We keep the loading state set and let the parent retry once myUserId
  // arrives (see the useEffect on [rootId, myUserId] below).
  const refreshRoot = _v2cb(async () => {
    if (!rootId) return;
    const claimsFetch = window.MR.supabase
      ? window.MR.supabase
          .from('registry_claims')
          .select('node_id, buyer_name, note')
          .eq('root_id', rootId)
          .then(r => ({ ok: !r.error, claims: r.data || [] }))
      : Promise.resolve({ ok: true, claims: [] });
    const [rootsRes, votesRes, claimsRes, commentsRes] = await Promise.all([
      window.MR.nodes.fetchMyRoots(),
      window.MR.nodes.fetchVotesForRoot(rootId),
      claimsFetch,
      window.MR.nodes.fetchCommentsForRoot(rootId),
    ]);
    if (!rootsRes.ok) {
      // Signed-out at the API layer usually means the session hasn't
      // rehydrated yet on hard refresh. Stay in loading state — when
      // myUserId becomes truthy the effect will re-run.
      if (rootsRes.reason === 'signed-out') {
        console.log('[v2] refreshRoot — session not ready yet, will retry');
        return;
      }
      setErr(rootsRes.reason || 'Could not load list');
      setLoading(false);
      return;
    }
    const found = rootsRes.roots.find(r => r.id === rootId);
    if (!found) {
      setErr('That list could not be found.');
      setLoading(false);
      return;
    }
    setRoot(found);
    const v = votesRes.ok ? (votesRes.votes || []) : [];
    const c = commentsRes.ok ? (commentsRes.comments || []) : [];
    setVotes(v);
    setComments(c);
    setClaims(claimsRes.ok ? (claimsRes.claims || []) : []);
    // Resolve display names for every voter + commenter on this list.
    const allIds = Array.from(new Set([
      ...v.map(x => x.voter_id),
      ...c.map(x => x.author_id),
      found.ownerId,
    ].filter(Boolean)));
    if (allIds.length > 0) {
      const nameMap = await window.MR.nodes.resolveDisplayNames(allIds);
      setNames(nameMap);
    }
    setLoading(false);
  }, [rootId]);

  // Helper: pretty-name a user id. "You" for the current user; the
  // resolved display name otherwise; "someone" as a safe fallback.
  const nameFor = (uid) => {
    if (uid === myUserId) return 'You';
    return names[uid] || 'someone';
  };

  // ── Drag-and-drop helpers ───────────────────────────────────────────────
  // Find any node in the tree by id. We use this to look up the new parent's
  // children when computing append positions, and to traverse a candidate
  // subtree to make sure we're not creating a cycle.
  const findNode = _v2cb((id) => {
    const walk = (n) => {
      if (!n) return null;
      if (n.id === id) return n;
      for (const c of (n.children || [])) {
        const r = walk(c);
        if (r) return r;
      }
      return null;
    };
    return walk(root);
  }, [root]);

  // True if `candidateId` is anywhere within the subtree rooted at `ancestorId`.
  // Blocks dragging a section into one of its own descendants (infinite loop).
  const isInSubtree = _v2cb((ancestorId, candidateId) => {
    const ancestor = findNode(ancestorId);
    if (!ancestor) return false;
    const walk = (n) => {
      if (n.id === candidateId) return true;
      return (n.children || []).some(walk);
    };
    return walk(ancestor);
  }, [findNode]);

  // Move a node to a new parent at a target index. `targetIndex` is the
  // index it should occupy after insert (0-based). Reorders siblings and
  // sends a renumbered batch of position updates so the canonical ordering
  // stays clean. Optimistic local update first, server reconcile on error.
  const moveNodeToParent = async (nodeId, newParentId, targetIndex) => {
    console.log('[v2] moveNodeToParent', { nodeId, newParentId, targetIndex });
    if (!nodeId || !newParentId) { console.warn('[v2] move aborted — missing ids'); return; }
    if (nodeId === newParentId) { console.warn('[v2] move aborted — self target'); return; }
    if (isInSubtree(nodeId, newParentId)) { console.warn('[v2] move aborted — cycle'); return; }

    // Compute the new ordered children for the destination parent, in
    // memory, then snapshot the resulting positions to persist.
    let renumberedNewParent = null;
    let renumberedOldParent = null;
    let newPositionForNode = null;

    // Run the local mutation in a view-transition for FLIP-style smoothness
    // when supported. Falls back to a plain setState in unsupported browsers.
    const applyLocal = () => {
      setRoot(prev => {
        if (!prev) return prev;
        const clone = JSON.parse(JSON.stringify(prev));
        const findById = (n) => {
          if (n.id === nodeId) return { node: n, parent: null };
          for (const c of (n.children || [])) {
            if (c.id === nodeId) return { node: c, parent: n };
            const r = findById(c);
            if (r) return r;
          }
          return null;
        };
        const findParent = (n, pid) => {
          if (n.id === pid) return n;
          for (const c of (n.children || [])) {
            const r = findParent(c, pid);
            if (r) return r;
          }
          return null;
        };
        const found = findById(clone);
        if (!found || !found.parent) return prev;
        // 1. Pull out of old parent
        const oldParent = found.parent;
        oldParent.children = (oldParent.children || []).filter(c => c.id !== nodeId);
        // 2. Insert at targetIndex in new parent (or append)
        const newParent = findParent(clone, newParentId);
        if (!newParent) return prev;
        found.node.parentId = newParentId;
        newParent.children = newParent.children || [];
        const insertAt = (targetIndex == null) ? newParent.children.length : Math.max(0, Math.min(newParent.children.length, targetIndex));
        newParent.children.splice(insertAt, 0, found.node);
        // 3. Renumber positions on both parents
        newParent.children.forEach((c, i) => { c.position = i; });
        oldParent.children.forEach((c, i) => { c.position = i; });
        renumberedNewParent = newParent.children.slice();
        renumberedOldParent = oldParent.id === newParent.id ? null : oldParent.children.slice();
        newPositionForNode = insertAt;
        return clone;
      });
    };
    if (typeof document !== 'undefined' && document.startViewTransition) {
      try { document.startViewTransition(() => applyLocal()); } catch { applyLocal(); }
    } else {
      applyLocal();
    }

    // Persist — move the node + renumber siblings on both parents.
    // We batch the renumber to keep the server consistent with the client.
    const mvRes = await window.MR.nodes.moveNode(nodeId, {
      parentId: newParentId,
      position: newPositionForNode || 0,
    });
    console.log('[v2] moveNode result', mvRes);
    if (!mvRes.ok) {
      console.error('[v2] moveNode failed, reverting', mvRes);
      await refreshRoot();
      return;
    }
    // Fire off the sibling renumbers in parallel; these are non-critical
    // (the user can still drop again on a stale order) so we ignore errors.
    const pendingUpdates = [];
    (renumberedNewParent || []).forEach((c) => {
      if (c.id !== nodeId) {  // already updated above
        pendingUpdates.push(window.MR.nodes.updateNode(c.id, { position: c.position }));
      }
    });
    (renumberedOldParent || []).forEach((c) => {
      pendingUpdates.push(window.MR.nodes.updateNode(c.id, { position: c.position }));
    });
    await Promise.all(pendingUpdates).catch(() => {});
  };

  // Comment handlers
  const addComment = async (nodeId, body) => {
    const res = await window.MR.nodes.addComment({ nodeId, rootId, body });
    if (res.ok) {
      setComments(prev => [...prev, res.comment]);
    }
    return res;
  };
  const deleteComment = async (commentId) => {
    const res = await window.MR.nodes.deleteComment(commentId);
    if (res.ok) {
      setComments(prev => prev.filter(c => c.id !== commentId));
    }
    return res;
  };

  // Vote handler — toggles: tapping the same vote you already cast clears
  // it; otherwise sets to the requested direction.
  const voteOnNode = async (nodeId, dir) => {
    const existing = votes.find(v => v.node_id === nodeId && v.voter_id === myUserId);
    const next = existing && existing.vote === dir ? null : dir;
    // Optimistic update
    setVotes(prev => {
      const filtered = prev.filter(v => !(v.node_id === nodeId && v.voter_id === myUserId));
      return next ? [...filtered, { node_id: nodeId, voter_id: myUserId, vote: next }] : filtered;
    });
    await window.MR.nodes.voteNode(nodeId, next);
    // Activity log — partner can see "Sarah voted 👍 on Cybex Cloud Q".
    // We log only the affirmative direction (vote cast) — not the un-vote
    // (next === null) — to keep the timeline focused on decisions.
    if (next && window.MR && window.MR.lists && window.MR.lists.logActivity && root) {
      try {
        const findIt = (n) => {
          if (n.id === nodeId) return n;
          for (const c of (n.children || [])) {
            const r = findIt(c); if (r) return r;
          }
          return null;
        };
        const node = findIt(root);
        const itemName = (() => {
          if (!node) return null;
          if (node.productId) {
            const p = (window.PRODUCTS || []).find(x => x.id === node.productId);
            return (p && p.name) || node.name || null;
          }
          return (node.custom && node.custom.name) || node.name || null;
        })();
        const itemImage = (() => {
          if (!node) return null;
          if (node.productId) {
            const p = (window.PRODUCTS || []).find(x => x.id === node.productId);
            return (p && p.img) || null;
          }
          return (node.custom && node.custom.image) || null;
        })();
        window.MR.lists.logActivity(root.id, 'voted', {
          itemId: nodeId,
          meta: { vote: next, item_name: itemName, image_url: itemImage },
        });
      } catch {}
    }
  };

  // Re-run on rootId OR myUserId change. On hard refresh, myUserId is null
  // for a tick while Supabase rehydrates the session — when it lands, this
  // effect re-fires and refreshRoot completes successfully. Without the
  // myUserId dep, we'd be stuck on the "Hmm — we can't open this list"
  // screen with a "signed-out" reason despite being signed in.
  _v2e(() => { refreshRoot(); }, [refreshRoot, myUserId]);

  // Auto-decide effect — walks the tree, finds unpicked candidates inside
  // shortlists that have 2+ up-votes and no down-votes, and auto-picks them.
  // Once auto-picked, sets a per-item flag so the user can manually unpick
  // without the effect re-picking on the next vote/comment update. Only
  // runs when effectiveAutoDecide is true.
  const AUTO_PICK_THRESHOLD = 2;
  _v2e(() => {
    if (!effectiveAutoDecide || !root) return;
    const toPick = [];
    const walk = (n) => {
      (n.children || []).forEach(c => {
        if (c.type === 'slot' && c.slotKind === 'shortlist') {
          const cands = (c.children || []).filter(k => k.type === 'item');
          cands.forEach(k => {
            if (k.picked) return;
            try {
              if (localStorage.getItem(`mr-autopick-skip-${k.id}`) === 'true') return;
            } catch {}
            const ks = (votes || []).filter(v => v.node_id === k.id);
            const ups = ks.filter(v => v.vote === 'up').length;
            const downs = ks.filter(v => v.vote === 'down').length;
            if (ups >= AUTO_PICK_THRESHOLD && downs === 0) {
              toPick.push(k.id);
            }
          });
        } else {
          walk(c);
        }
      });
    };
    walk(root);
    if (toPick.length === 0) return;
    // Mark them auto-picked locally so we don't fire again, then push to
    // server. Each call also runs the auto status bump (want→need) via
    // updateNode.
    toPick.forEach(id => {
      try { localStorage.setItem(`mr-autopick-fired-${id}`, '1'); } catch {}
      updateNode(id, { picked: true });
    });
  }, [root, votes, effectiveAutoDecide]);

  // Whenever a user manually unpicks something the auto-decide effect
  // would otherwise re-pick, set a "skip" flag so this item is exempt
  // from auto-decide in the future. We hook the togglePicked path via
  // the same updateNode helper. Look for explicit picked:false transitions
  // on a node that was auto-picked previously.
  // (Done inline in updateNode via a localStorage check below.)
  // Expose refreshRoot on window so deeply-nested helpers (budget swap
  // CTAs, etc.) can poke a re-fetch without re-threading the callback.
  _v2e(() => {
    window.__mr_refreshRoot = () => refreshRoot();
    return () => { if (window.__mr_refreshRoot && window.__mr_refreshRoot.toString().includes('refreshRoot')) delete window.__mr_refreshRoot; };
  }, [refreshRoot]);

  // Scroll-to-item from QuickAdd. When the user saves an item via the
  // paste-link / bookmarklet flow, page-add.jsx writes a sessionStorage
  // signal and navigates to this list. We pick up the signal here, find
  // the newly-added item's row, scroll it into view, and flash it.
  //
  // Retries aggressively (up to 10 seconds) because:
  //   - Data fetch can take several seconds on a slow connection
  //   - The tree-render after refreshRoot can lag a frame or two
  //   - The new item might land in a sub-group that doesn't render
  //     until its parent slot's contents are hydrated
  // We only remove the sessionStorage signal AFTER a successful match,
  // so even if the first run can't find the row, later re-renders of
  // this effect (e.g. when root updates from refreshRoot) get another
  // chance with the signal still intact.
  _v2e(() => {
    if (!rootId) return;
    let signal;
    try {
      const raw = sessionStorage.getItem('mr-scroll-to-item');
      signal = raw ? JSON.parse(raw) : null;
    } catch { signal = null; }
    if (!signal || signal.listId !== rootId || !signal.itemId) return;

    // Soft staleness — ignore signals older than 60s (probably left
    // over from a previous session that never completed the scroll).
    if (signal.ts && Date.now() - signal.ts > 60000) {
      try { sessionStorage.removeItem('mr-scroll-to-item'); } catch {}
      return;
    }

    let tries = 0;
    let cancelled = false;
    const MAX_TRIES = 40;  // 10s total at 250ms intervals
    const tick = () => {
      if (cancelled) return;
      // data-drop-item is set on every item row; data-node-id is a
      // fallback in case the row's drag-drop wiring isn't applied
      // (e.g. inside a special slot variant).
      const el =
        document.querySelector(`[data-drop-item="${signal.itemId}"]`)
        || document.querySelector(`[data-node-id="${signal.itemId}"]`);
      if (el) {
        // `block: center` keeps the row out from under the sticky header.
        el.scrollIntoView({ behavior: 'smooth', block: 'center' });
        el.classList.add('list2-item--justadded');
        setTimeout(() => {
          if (el && el.classList) el.classList.remove('list2-item--justadded');
        }, 2600);
        // Only burn the signal on successful match — slow data loads
        // get to keep retrying through subsequent effect re-runs.
        try { sessionStorage.removeItem('mr-scroll-to-item'); } catch {}
        return;
      }
      if (tries++ < MAX_TRIES) setTimeout(tick, 250);
    };
    setTimeout(tick, 120);
    return () => { cancelled = true; };
  }, [rootId, root]);

  // ── Activity drawer plumbing ───────────────────────────────────────
  // The Activity button in ListHeaderV2 dispatches a window event
  // because the header is its own component and we don't want to
  // prop-drill toggle state through every settings/share callback.
  // Fetches fresh activity on open so partner actions (votes,
  // additions, picks) feel live.
  const loadActivity = _v2cb(async () => {
    if (!rootId || !window.MR || !window.MR.lists) return;
    setActivityLoading(true);
    try {
      const res = await window.MR.lists.fetchActivity(rootId, 80);
      if (res && res.ok) setActivity(res.activity || []);
    } finally { setActivityLoading(false); }
  }, [rootId]);
  _v2e(() => {
    const onOpen = () => { setActivityOpen(true); loadActivity(); };
    window.addEventListener('mr-open-activity', onOpen);
    return () => window.removeEventListener('mr-open-activity', onOpen);
  }, [loadActivity]);
  // Esc closes the drawer.
  _v2e(() => {
    if (!activityOpen) return;
    const onKey = (e) => { if (e.key === 'Escape') setActivityOpen(false); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [activityOpen]);

  // Auto-scroll while dragging — when the cursor gets near the top or
  // bottom edge of the viewport, scroll the page so the user can drop
  // somewhere off-screen without releasing the drag. Edge size + speed
  // ramp up the closer to the edge you are. Tracks `mousemove` now —
  // we drive drag with pointer events, not HTML5 native drag.
  _v2e(() => {
    if (!dragNodeId) return;
    const EDGE = 80;
    const MAX_SPEED = 18;
    let raf = null;
    let lastDelta = 0;
    const onMove = (e) => {
      const y = e.clientY;
      const h = window.innerHeight;
      let delta = 0;
      if (y < EDGE)              delta = -Math.ceil((1 - y / EDGE) * MAX_SPEED);
      else if (y > h - EDGE)     delta =  Math.ceil((1 - (h - y) / EDGE) * MAX_SPEED);
      lastDelta = delta;
      if (delta !== 0 && raf == null) {
        const tick = () => {
          if (lastDelta === 0) { raf = null; return; }
          window.scrollBy(0, lastDelta);
          raf = requestAnimationFrame(tick);
        };
        raf = requestAnimationFrame(tick);
      }
    };
    window.addEventListener('mousemove', onMove);
    return () => {
      window.removeEventListener('mousemove', onMove);
      if (raf != null) cancelAnimationFrame(raf);
    };
  }, [dragNodeId]);

  // ── Mutation handlers — all hit the DB then refresh. ────────────────────
  const addItem = async ({ parentId, productId = null, custom = null, name, ageMin = null, ageMax = null }) => {
    const res = await window.MR.nodes.addChild({
      parentId,
      rootId: root.id,
      type: 'item',
      name: name || (custom && custom.name) || 'Item',
      productId,
      custom,
    });
    // Age fields are applied as a follow-up patch because addChild
    // doesn't take them at creation time. Silently noop when the
    // paste-link extractor didn't return either bound — most pages
    // don't expose age. Fire-and-forget: the item itself has landed
    // already; a missed age update isn't worth blocking the user.
    if (res.ok && res.node && (ageMin != null || ageMax != null)) {
      try {
        await window.MR.nodes.updateNode(res.node.id, {
          age_min: ageMin != null ? Number(ageMin) : null,
          age_max: ageMax != null ? Number(ageMax) : null,
        });
      } catch (err) {
        console.warn('[addItem] age patch failed', err);
      }
    }
    // Placeholder flag — written to localStorage keyed by the new node's
    // id so subsequent reads (via nodeFromRow) re-hydrate the flag onto
    // the custom blob. We strip it from the API payload because the DB
    // schema doesn't carry it.
    if (res.ok && res.node && custom && custom.placeholder) {
      try { localStorage.setItem(`mr-placeholder-${res.node.id}`, 'true'); } catch {}
    }
    // Gallery — same localStorage fallback. og-fetch returns up to 12
    // images; we store the whole array so the detail panel can show the
    // full set. The primary image stays in custom_image_url for cards.
    if (res.ok && res.node && custom && Array.isArray(custom.gallery) && custom.gallery.length > 1) {
      try { localStorage.setItem(`mr-gallery-${res.node.id}`, JSON.stringify(custom.gallery)); } catch {}
    }
    // Materials + certifications — same localStorage fallback as the
    // gallery. The schema doesn't carry them yet, but localStorage keeps
    // the feature working until we add columns. Per-node key keeps reads
    // O(1) when rendering the row.
    if (res.ok && res.node && custom && (custom.materials || (Array.isArray(custom.certifications) && custom.certifications.length))) {
      try {
        localStorage.setItem(`mr-meta-${res.node.id}`, JSON.stringify({
          materials: custom.materials || null,
          certifications: Array.isArray(custom.certifications) ? custom.certifications : [],
        }));
      } catch {}
    }
    if (res.ok) {
      // Activity feed entry. Capture enough to render a readable line
      // without needing to fetch the product separately ("Sarah added
      // Cybex Cloud Q"). Source URL is kept so the timeline can link out.
      // Image URL is snapshotted at log time so even when the item is
      // later removed or its image swapped, the activity row still
      // shows a thumbnail of what was added.
      if (window.MR && window.MR.lists && window.MR.lists.logActivity) {
        const product = productId ? (window.PRODUCTS || []).find(p => p.id === productId) : null;
        try {
          window.MR.lists.logActivity(root.id, 'added_item', {
            itemId: res.node && res.node.id,
            meta: {
              product_name: product ? product.name : null,
              custom_name: custom ? custom.name : null,
              source_url: custom ? custom.sourceUrl : null,
              image_url: (product && product.img) || (custom && custom.image) || null,
            },
          });
        } catch {}
      }
      await refreshRoot();
    }
    return res;
  };

  // addSlot now takes an explicit slotKind ('group' | 'shortlist').
  // - 'group'     = a heading + items, no decision UI.
  // - 'shortlist' = "I'm picking one of these" candidates, items get the
  //                 Pick toggle; quantityNeeded controls how many to pick.
  // Defaults to 'group' so accidental calls give the safer affordance.
  const addSlot = async ({ parentId, name, slotKind = 'group', quantityNeeded = null, blurb = null, necessity = null }) => {
    const qty = quantityNeeded != null
      ? quantityNeeded
      : (slotKind === 'shortlist' ? 1 : 0);
    const res = await window.MR.nodes.addChild({
      parentId,
      rootId: root.id,
      type: 'slot',
      slotKind,
      name,
      quantityNeeded: qty,
      blurb,
      necessity,
    });
    if (res.ok) await refreshRoot();
    return res;
  };

  const updateNode = async (id, patch) => {
    // Manual unpick — when a user explicitly unpicks an item that the
    // auto-decide effect previously picked, stamp a "skip" flag so it
    // doesn't get auto-re-picked next time votes update. The flag is
    // cleared if the user manually picks it again.
    if (patch.picked === false) {
      try { localStorage.setItem(`mr-autopick-skip-${id}`, 'true'); } catch {}
    } else if (patch.picked === true) {
      try { localStorage.removeItem(`mr-autopick-skip-${id}`); } catch {}
    }
    // Auto status transition: picking a candidate also bumps its status
    // from want → need ("Getting"). Conceptually: a pick is the moment
    // you've decided you're acquiring this thing. Don't downgrade if the
    // user has already marked it Have; only nudge from the default state.
    if (patch.picked === true && !('status' in patch)) {
      const findIt = (n) => {
        if (n.id === id) return n;
        for (const c of (n.children || [])) {
          const r = findIt(c); if (r) return r;
        }
        return null;
      };
      const cur = root ? findIt(root) : null;
      if (cur && (cur.status || 'want') === 'want') {
        patch = { ...patch, status: 'need' };
      }
    }
    // Optimistic — apply the patch to the local tree immediately so the
    // UI flips before the round-trip lands. Without this, the toggle
    // feels broken (no visible change for ~half a second) and we depend
    // entirely on the refresh after a successful write to repaint.
    // Particularly important when slot_kind is being stripped server-side
    // and only persists in localStorage — refreshRoot would otherwise
    // walk the tree back to a stale value before nodeFromRow re-applies
    // the LS override.
    // Snapshot the node BEFORE we mutate the tree — we need its display
    // name (and old status) for an honest activity-feed line.
    const findIt = (n, target) => {
      if (n.id === target) return n;
      for (const c of (n.children || [])) {
        const r = findIt(c, target); if (r) return r;
      }
      return null;
    };
    const beforeNode = root ? findIt(root, id) : null;
    const displayName = (() => {
      if (!beforeNode) return 'an item';
      if (beforeNode.productId) {
        const p = (window.PRODUCTS || []).find(x => x.id === beforeNode.productId);
        return (p && p.name) || beforeNode.name || 'an item';
      }
      return (beforeNode.custom && beforeNode.custom.name) || beforeNode.name || 'an item';
    })();
    // Snapshot the item's image at update time for the activity feed
    // thumbnail. Same lookup as displayName but for the picture.
    const displayImage = (() => {
      if (!beforeNode) return null;
      if (beforeNode.productId) {
        const p = (window.PRODUCTS || []).find(x => x.id === beforeNode.productId);
        return (p && p.img) || null;
      }
      return (beforeNode.custom && beforeNode.custom.image) || null;
    })();
    setRoot(prev => {
      if (!prev) return prev;
      const clone = JSON.parse(JSON.stringify(prev));
      const apply = (n) => {
        if (n.id === id) Object.assign(n, patch);
        else (n.children || []).forEach(apply);
      };
      apply(clone);
      return clone;
    });
    const res = await window.MR.nodes.updateNode(id, patch);
    if (res.ok) {
      // Activity entries — log only the meaningful transitions, not
      // every keystroke of a rename. Picked, status changes, and
      // registry toggles are the high-signal events for a partner.
      if (window.MR && window.MR.lists && window.MR.lists.logActivity) {
        try {
          if ('picked' in patch) {
            window.MR.lists.logActivity(root.id, 'updated_item', {
              itemId: id,
              meta: { picked: !!patch.picked, name: displayName, image_url: displayImage },
            });
          }
          if ('status' in patch && beforeNode && (beforeNode.status || 'want') !== patch.status) {
            window.MR.lists.logActivity(root.id, 'updated_item', {
              itemId: id,
              meta: { status_from: beforeNode.status || 'want', status_to: patch.status, name: displayName, image_url: displayImage },
            });
          }
          if ('inRegistry' in patch && beforeNode && !!beforeNode.inRegistry !== !!patch.inRegistry) {
            window.MR.lists.logActivity(root.id, 'updated_item', {
              itemId: id,
              meta: { in_registry: !!patch.inRegistry, name: displayName, image_url: displayImage },
            });
          }
        } catch {}
      }
      await refreshRoot();
    }
    return res;
  };

  const deleteNode = async (id) => {
    // Snapshot the doomed node's display name so the activity line reads
    // "Sarah removed Cybex Cloud Q" not "Sarah removed an item".
    const findIt = (n, target) => {
      if (n.id === target) return n;
      for (const c of (n.children || [])) {
        const r = findIt(c, target); if (r) return r;
      }
      return null;
    };
    const node = root ? findIt(root, id) : null;
    const displayName = (() => {
      if (!node) return null;
      if (node.productId) {
        const p = (window.PRODUCTS || []).find(x => x.id === node.productId);
        return (p && p.name) || node.name || null;
      }
      return (node.custom && node.custom.name) || node.name || null;
    })();
    // Snapshot the image at delete time — once the row's gone, this is
    // the only place the activity feed can get a thumbnail.
    const displayImage = (() => {
      if (!node) return null;
      if (node.productId) {
        const p = (window.PRODUCTS || []).find(x => x.id === node.productId);
        return (p && p.img) || null;
      }
      return (node.custom && node.custom.image) || null;
    })();
    const res = await window.MR.nodes.deleteNode(id);
    if (res.ok) {
      if (window.MR && window.MR.lists && window.MR.lists.logActivity) {
        try {
          window.MR.lists.logActivity(root.id, 'removed_item', {
            itemId: id,
            meta: {
              [node && node.type === 'slot' ? 'slot_name' : 'item_name']: displayName,
              image_url: displayImage,
            },
          });
        } catch {}
      }
      await refreshRoot();
    }
    return res;
  };

  // Locate a node + its parent in the current tree.
  const findNodeWithParent = _v2cb((id) => {
    if (!root) return { node: null, parent: null };
    let foundNode = null, foundParent = null;
    const walk = (n) => {
      (n.children || []).forEach(c => {
        if (c.id === id) { foundNode = c; foundParent = n; }
        else walk(c);
      });
    };
    walk(root);
    return { node: foundNode, parent: foundParent };
  }, [root]);

  // Synthesize a product object for the shared DetailPanel — catalog items
  // pass through the canonical product; custom items get a hand-built shape
  // that exposes name/brand/img/gallery/price/description/retailers so the
  // gallery, "Find retailer in Australia", and source-URL CTAs all work.
  const synthesizeProductForNode = (node) => {
    if (!node) return null;
    const catalog = node.productId && productMap ? productMap[node.productId] : null;
    if (catalog) return catalog;
    const custom = node.custom || {};
    const name = custom.name || node.name || 'Item';
    const img = custom.image || '';
    // Source URL has been stored under different key names by different
    // code paths (sourceUrl, source_url, url, link). Check them all so
    // the detail panel reliably shows the Source link and the more-menu
    // shows Refetch regardless of which path created the item.
    const sourceUrl = custom.sourceUrl || custom.source_url || custom.url || custom.link || '';
    // Full gallery: when the user multi-selected images in QuickAdd, we
    // saved the picked set as custom.gallery. The old synth always
    // returned [primaryImage], losing the rest — that's why even after
    // picking 3 images the detail panel only showed one. Prefer the
    // saved gallery; fall back to a single-image array.
    const gallery = (Array.isArray(custom.gallery) && custom.gallery.length)
      ? custom.gallery
      : (img ? [img] : []);
    let primaryUrl = null;
    let retailers = [];
    if (sourceUrl) {
      let hostname = '';
      try { hostname = new URL(sourceUrl).hostname.replace(/^www\./, ''); } catch {}
      primaryUrl = sourceUrl;
      retailers = [{ name: hostname || 'Source', url: sourceUrl }];
    }
    // Age range — stored on the node (not the custom blob) by the
    // QuickAdd auto-detect + the manual age picker. Surfacing it on the
    // synthesized product lets the DetailPanel show "0–3 mo" etc in the
    // header chip row, same as for catalog items.
    const ageMin = node.ageMin != null ? Number(node.ageMin) : 0;
    const ageMax = node.ageMax != null ? Number(node.ageMax) : 0;
    // Client-side fallback for items saved BEFORE the deep AI extractor
    // shipped — scan the description for material composition patterns
    // like "80% recycled polyester, 20% elastane" or "Made of organic
    // cotton" so the structured Specs block can render even without a
    // server-side materials field. Refetch is still the way to get the
    // FULL deep-extracted payload (multi-component splits, certs in
    // prose, dimensions, made-in), but this gives sensible structure
    // for older items without forcing a re-fetch.
    const inferMaterialsFromDesc = (desc) => {
      if (!desc) return null;
      const text = String(desc).slice(0, 4000);
      // Pattern 1: comma-separated "X% A, Y% B"
      const pctMulti = text.match(/(\d{1,3}\s*%\s*[a-zA-Z][a-zA-Z\s\-]+?(?:\s*,\s*\d{1,3}\s*%\s*[a-zA-Z][a-zA-Z\s\-]+?){1,5})(?=[\s.,;])/i);
      if (pctMulti) return pctMulti[1].replace(/\s+/g, ' ').trim();
      // Pattern 2: "Made of/from X" with a fibre word
      const madeFrom = text.match(/\b(?:made\s+(?:of|from|with))\s+([^.;\n]{4,140}?(?:cotton|wool|polyester|linen|silk|bamboo|hemp|merino|cashmere|nylon|spandex|elastane|tencel|modal|viscose|lyocell|leather|down|fleece|jersey|alpaca|mohair|recycled|organic|beechwood|maple|oak|silicone|plastic|rubber|bpa-?free)[^.;\n]{0,80})(?=[.;])/i);
      if (madeFrom) return madeFrom[1].replace(/\s+/g, ' ').trim();
      // Pattern 3: "100% X"
      const singlePct = text.match(/(100\s*%\s*[a-zA-Z][a-zA-Z\s\-]{2,40}?)(?=[\s.,;])/i);
      if (singlePct) return singlePct[1].replace(/\s+/g, ' ').trim();
      return null;
    };
    const fallbackMaterials = !custom.materials ? inferMaterialsFromDesc(custom.description) : null;

    // Clean up the description for display:
    //   1. Pull out bracket-tagged feature sections like "[Peace in Mind]:
    //      ..." or "[Soft and Flexible]: ..." — these are highlight chips,
    //      not narrative prose.
    //   2. Strip leading key:value prefix junk like "Material Silicone
    //      Color X Brand Y" that Amazon-style listings dump at the start.
    //   3. Drop a literal leading "Description" word that some retailers
    //      prepend.
    // The original description is preserved as descriptionRaw so the
    // detail panel can offer "Read more" / "Show full description" toggles
    // without losing info. Each of these cleanups is conservative — if
    // the description doesn't match the heuristic, we pass it through
    // unchanged.
    const cleanDescription = (raw) => {
      if (!raw) return { description: '', features: [], descriptionRaw: '' };
      let text = String(raw);
      const features = [];
      // Pull "[Label]: value" sections (run greedily for multiple tags).
      const bracketRe = /\[([^\]\n]{2,60})\]\s*:?\s*([^\[\n]{0,400}?)(?=\[|$)/g;
      let m;
      while ((m = bracketRe.exec(text))) {
        const label = m[1].replace(/\s+/g, ' ').trim();
        const value = m[2].replace(/\s+/g, ' ').trim().replace(/^[:\s.]+/, '');
        if (label && value && value.length >= 8) {
          features.push(`${label}: ${value.slice(0, 120)}${value.length > 120 ? '…' : ''}`);
        }
      }
      // Now strip the bracket sections from the description text so we
      // don't show them twice.
      if (features.length) {
        text = text.replace(/\[[^\]\n]{2,60}\]\s*:?\s*[^\[\n]{0,400}/g, ' ').trim();
      }
      // Strip leading "Description" / "Product description" / "About this item"
      text = text.replace(/^\s*(?:Description|Product\s+description|About\s+this\s+item)[:.\s]*/i, '');
      // Strip Amazon-style key:value prefix runs. Pattern:
      //   "Word Word Word Word Word Word…" with no punctuation up front,
      //   then a real sentence following. We detect when the description
      //   begins with > 3 successive "Capital Word ValueWord" key:value
      //   pairs (Material Silicone Color X Brand Y Material Type Free).
      // Drop them until we hit a real sentence (starts with a capital
      // followed by a clear noun/verb, has a period, etc.)
      const kvPrefix = text.match(/^((?:[A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,3}\s+[A-Za-z][a-zA-Z0-9,\/\-]*\s+){3,10})/);
      if (kvPrefix && kvPrefix[1].length > 30 && kvPrefix[1].length < text.length * 0.8) {
        text = text.slice(kvPrefix[1].length).trim();
      }
      // Trim leftover whitespace runs.
      text = text.replace(/\s+/g, ' ').trim();
      return { description: text, features, descriptionRaw: String(raw) };
    };
    const cleaned = cleanDescription(custom.description);

    return {
      id: node.id,
      name,
      brand: custom.brand || '',
      img,
      gallery,
      price: custom.price != null ? custom.price : 0,
      currency: custom.currency || 'AUD',
      description: cleaned.description,
      descriptionRaw: cleaned.descriptionRaw,
      why: custom.description || '',
      benefit: '',
      ageMin,
      ageMax,
      learning: [],
      retailers,
      primaryUrl,
      // Structured spec fields — pulled through so the detail panel
      // can render them in the new Specs block for custom items.
      // Falls back to description-scanned materials when no structured
      // field was saved (older items, pre-deep-extractor).
      materials: custom.materials || fallbackMaterials || null,
      certifications: Array.isArray(custom.certifications) ? custom.certifications : [],
      // Dimensions (server-extracted in og-fetch). Object with keys
      // like { width, height, depth, weight }, each in display-ready
      // strings ("30 cm" / "1.2 kg") — see extractDimensions in og-fetch.
      dimensions: custom.dimensions && typeof custom.dimensions === 'object' ? custom.dimensions : null,
      // Country of origin and 1-4 short feature highlights — AI-only
      // extraction; populated for items added/refetched after the
      // structured-spec rewrite. Falls back to bracket-tagged sections
      // pulled from the description (Amazon-style "[Peace in Mind]: ...")
      // so older items still get structured Highlights chips.
      countryOfOrigin: custom.madeIn || null,
      features: (() => {
        const stored = Array.isArray(custom.features) ? custom.features : [];
        if (stored.length) return stored;
        return cleaned.features.slice(0, 4);
      })(),
      // Marker so other code can branch if needed.
      __custom: true,
    };
  };

  // Build the listContext payload consumed by DetailPanel's ListContextExtras.
  // Captures the current snapshot of votes/comments/claims for the node and
  // wires callbacks back to our mutation handlers.
  const buildListContext = (nodeId) => {
    const { node, parent } = findNodeWithParent(nodeId);
    if (!node) return null;
    const isUnderSlot = parent && parent.type === 'slot';
    // "Pick this one" only belongs inside a shortlist — a group container
    // suppresses it. Legacy slots default to 'group' after the migration so
    // existing lists don't surface the pick affordance unexpectedly.
    const isUnderShortlist = isUnderSlot && parent.slotKind === 'shortlist';
    const claim = (claims || []).find(c => c.node_id === node.id) || null;
    // Source URL is what powers the "Re-fetch info from page" action.
    // Different code paths have stored this under different keys over
    // the months — newer in-list pastes use `sourceUrl` (camelCase),
    // the QuickAdd / bookmarklet flow uses `source_url` (snake_case),
    // and a handful of very early imports stash it as plain `url`.
    // Catalog items expose it via `retailers[0].url` / `primaryUrl`
    // on the matched product. Check all of these so the menu shows
    // Refetch for every item that could possibly be re-grabbed.
    const customSourceUrl = node.custom && (
      node.custom.sourceUrl
      || node.custom.source_url
      || node.custom.url
      || node.custom.link
    );
    const catalogProduct = node.productId && productMap ? productMap[node.productId] : null;
    const catalogSourceUrl = catalogProduct && (
      catalogProduct.primaryUrl
      || (Array.isArray(catalogProduct.retailers) && catalogProduct.retailers[0] && catalogProduct.retailers[0].url)
      || null
    );
    const sourceUrl = customSourceUrl || catalogSourceUrl || null;
    // Other writable lists the user owns — populates the Move/Copy
    // submenu in the 3-dot more menu. Excludes the current list and
    // any list they don't own (shared lists where they aren't the
    // creator) since we don't have permission to write there.
    const otherLists = (userLists || [])
      .filter(l => l.id !== root.id && l.isMine !== false && l._supabase)
      .map(l => ({ id: l.id, name: l.name }));
    return {
      nodeId: node.id,
      nodeName: node.name || (node.custom && node.custom.name) || 'Item',
      nodeStatus: node.status || 'want',
      nodePicked: !!node.picked,
      nodeInRegistry: !!node.inRegistry,
      isUnderSlot,
      isUnderShortlist,
      rootKind: root && root.kind,
      claim,
      nodeVotes: (votes || []).filter(v => v.node_id === node.id),
      comments: (comments || []).filter(c => c.node_id === node.id),
      myUserId,
      nameFor,
      onSetStatus:    (s) => updateNode(node.id, { status: s }),
      onSetPicked:    (v) => updateNode(node.id, { picked: !!v }),
      onSetInRegistry:(v) => updateNode(node.id, { inRegistry: !!v }),
      onVote:         (dir) => voteOnNode(node.id, dir),
      onDelete:       () => { deleteNode(node.id); setOpenItemId(null); },
      onAddComment:   (body) => addComment(node.id, body),
      onDeleteComment:(cid) => deleteComment(cid),
      // ── More-menu actions ───────────────────────────────────────────
      // Source URL is exposed so the menu can show/hide the "Re-fetch"
      // option. Null for catalog items.
      sourceUrl,
      // Other lists the user can copy/move to. Empty array (not null)
      // when the user only owns this one list — the menu can then
      // hide the submenu items entirely.
      otherLists,
      // Re-run og-fetch on the source URL and merge the freshest
      // image / price / brand / description back into the custom blob.
      // No-op for catalog items. Resolves to { ok, reason? } so the
      // menu can show a toast on failure.
      onRefetch: async () => {
        if (!sourceUrl) return { ok: false, reason: 'no-source-url' };
        try {
          // Pass deep=1 so og-fetch runs the AI-powered materials
          // pass in addition to the regex extractor. The user
          // explicitly asked for a thorough re-check, so the extra
          // ~5s + per-call cost is justified — this is the "really
          // get it right this time" button.
          const resp = await fetch(`/api/og-fetch?url=${encodeURIComponent(sourceUrl)}&deep=1`);
          const json = await resp.json();
          if (!resp.ok || !json || !json.ok || !json.data) {
            return { ok: false, reason: (json && json.error) || 'fetch-failed' };
          }
          const d = json.data;
          // Build a minimal patch — only replace fields the user
          // hasn't already curated. Name + brand stay untouched
          // (the user may have renamed) unless they're currently
          // blank; everything else gets the fresh values.
          const cur = node.custom || {};
          const nextCustom = {
            ...cur,
            // Always overwrite the visual fields — these are what
            // tend to break (low-res image, missing price).
            image: d.image || cur.image || '',
            price: d.price != null ? Number(d.price) : (cur.price ?? null),
            currency: d.currency || cur.currency || 'AUD',
            description: d.description || cur.description || '',
            gallery: (Array.isArray(d.gallery) && d.gallery.length)
              ? d.gallery
              : (cur.gallery || (d.image ? [d.image] : [])),
            // Brand only if we didn't have one before.
            brand: cur.brand || d.brand || '',
            // Structured spec fields — overwrite even when the current
            // value exists, because the whole point of refetch is
            // "the data we have is wrong/incomplete." Fall back to
            // existing values only when og-fetch returned nothing.
            materials: d.materials || cur.materials || null,
            certifications: (Array.isArray(d.certifications) && d.certifications.length)
              ? d.certifications
              : (cur.certifications || []),
            dimensions: (d.dimensions && Object.keys(d.dimensions || {}).length)
              ? d.dimensions
              : (cur.dimensions || null),
            madeIn: d.madeIn || cur.madeIn || null,
            features: (Array.isArray(d.features) && d.features.length)
              ? d.features
              : (cur.features || []),
          };
          // Age range — only update from fetched data if not already
          // set on the node (preserves any manual override).
          const agePatch = {};
          if (node.ageMin == null && d.ageMin != null) agePatch.age_min = Number(d.ageMin);
          if (node.ageMax == null && d.ageMax != null) agePatch.age_max = Number(d.ageMax);
          await updateNode(node.id, { custom: nextCustom, ...agePatch });
          return { ok: true };
        } catch (err) {
          console.warn('[refetch] failed', err);
          return { ok: false, reason: err.message || 'fetch-failed' };
        }
      },
      // Duplicate creates a sibling with the same payload. Same parent,
      // appended at the end. Useful for cloning a starting-point item
      // when iterating on variants.
      onDuplicate: async () => {
        if (!window.MR || !window.MR.nodes || !parent) return { ok: false };
        const res = await window.MR.nodes.addChild({
          parentId: parent.id,
          rootId: root.id,
          type: 'item',
          name: node.name || (node.custom && node.custom.name) || 'Item',
          productId: node.productId || null,
          custom: node.custom ? { ...node.custom } : null,
        });
        if (res.ok && res.node) {
          // Carry over status / age / category — the addChild call
          // doesn't take these at creation time.
          const patch = {};
          if (node.status) patch.status = node.status;
          if (node.inRegistry) patch.in_registry = !!node.inRegistry;
          if (node.ageMin != null) patch.age_min = node.ageMin;
          if (node.ageMax != null) patch.age_max = node.ageMax;
          if (node.category) patch.category = node.category;
          if (Object.keys(patch).length) {
            try { await window.MR.nodes.updateNode(res.node.id, patch); } catch {}
          }
          await refreshRoot();
        }
        return res;
      },
      // Copy / move into a different list root. Both walk the same
      // addChild path; move just deletes the source after the copy
      // lands. parentId becomes the target root (i.e. lands at the
      // top of the destination list, not inside a group — keeps the
      // mental model simple).
      onCopyToList: async (targetListId) => {
        if (!window.MR || !window.MR.nodes || !targetListId) return { ok: false };
        const res = await window.MR.nodes.addChild({
          parentId: targetListId,
          rootId: targetListId,
          type: 'item',
          name: node.name || (node.custom && node.custom.name) || 'Item',
          productId: node.productId || null,
          custom: node.custom ? { ...node.custom } : null,
        });
        return res;
      },
      onMoveToList: async (targetListId) => {
        if (!window.MR || !window.MR.nodes || !targetListId) return { ok: false };
        const res = await window.MR.nodes.addChild({
          parentId: targetListId,
          rootId: targetListId,
          type: 'item',
          name: node.name || (node.custom && node.custom.name) || 'Item',
          productId: node.productId || null,
          custom: node.custom ? { ...node.custom } : null,
        });
        if (res.ok) {
          // Only delete the source once the copy actually landed.
          await window.MR.nodes.deleteNode(node.id);
          setOpenItemId(null);
          await refreshRoot();
        }
        return res;
      },
    };
  };

  // Open a list-item in the shared DetailPanel. Captures the product
  // synthesis + listContext, and hands the App a teardown so closing the
  // panel clears our local openItemId.
  // Flat list of all item nodes in display order — used to power the
  // DetailPanel's ⟵ / ⟶ keyboard nav across items in the same list.
  // We compute this on demand (rather than memoising) because it's
  // only walked when an item is opened, and the result depends on the
  // current tree. Skips slots/groups (they're not items) and
  // placeholders (which open their own focused replace modal instead
  // of the rich DetailPanel — see handleOpenListItem below).
  const flattenItemsInOrder = () => {
    const out = [];
    const visit = (n) => {
      (n.children || []).forEach(c => {
        if (c.type === 'item' && !(c.custom && c.custom.placeholder)) out.push(c);
        else if (c.type === 'slot') visit(c);
      });
    };
    if (root) visit(root);
    return out;
  };

  const handleOpenListItem = (itemId) => {
    const { node } = findNodeWithParent(itemId);
    if (!node) return;
    // Placeholders get their own focused modal — the DetailPanel's
    // gallery/retailer/comparison sections don't apply when there's no
    // product yet. The PlaceholderReplaceModal is built around the
    // "fill this in" workflow instead.
    if (node.custom && node.custom.placeholder) {
      setPlaceholderItemId(itemId);
      return;
    }
    setOpenItemId(itemId);
    const product = synthesizeProductForNode(node);
    const listContext = buildListContext(itemId);
    // contextList unlocks the DetailPanel's prev/next keyboard nav
    // (ArrowLeft / ArrowRight). One synthesized product per item, in
    // the same order the user sees on the page.
    const contextList = flattenItemsInOrder().map(synthesizeProductForNode).filter(Boolean);
    if (onOpenProduct) {
      onOpenProduct(product, {
        listContext,
        contextList,
        onClose: () => setOpenItemId(null),
      });
    }
  };

  // When the DetailPanel's prev/next nav fires (it calls openProduct
  // with just the product, no opts), the host App's openProduct
  // dispatches a `mr-detail-product-change` event so we can sync our
  // local openItemId to the new product. That re-runs the
  // buildListContext effect below and keeps the right-rail status /
  // vote / comment actions wired to the right node.
  _v2e(() => {
    const onNav = (ev) => {
      const newId = ev && ev.detail && ev.detail.productId;
      if (!newId) return;
      // Only react if the new id matches one of our items — otherwise
      // it's a navigation outside the list and shouldn't clobber our
      // state. findNodeWithParent returns {} if not found in our tree.
      const found = findNodeWithParent(newId);
      if (found && found.node) setOpenItemId(newId);
    };
    window.addEventListener('mr-detail-product-change', onNav);
    return () => window.removeEventListener('mr-detail-product-change', onNav);
  }, [findNodeWithParent]);

  // Keep listContext fresh while the panel is open — re-build on every
  // change to root / votes / comments / claims so the right rail sees the
  // latest data immediately after a vote/status/comment mutation.
  _v2e(() => {
    if (!openItemId || !onUpdateListContext) return;
    const ctx = buildListContext(openItemId);
    if (ctx) onUpdateListContext(ctx);
  }, [openItemId, root, votes, comments, claims, names]);

  // ── Render states ───────────────────────────────────────────────────────
  // Missing rootId — usually a malformed URL like `#list2/` (no id), a
  // stale bookmark, or a stripped link. Render the same "couldn't open"
  // affordance instead of an indefinite "Loading list…" spinner so the
  // user has a route back to a working page.
  if (!rootId) return (
    <div className="page list2-page">
      <div className="empty">
        <h3 className="empty-title">No list selected</h3>
        <p>The link looks incomplete. Head back to your lists and pick the one you meant to open.</p>
        <button className="btn" onClick={onBack} style={{ width: 'auto', padding: '10px 18px', marginTop: 12 }}>
          <span>← Back to My lists</span>
        </button>
      </div>
    </div>
  );
  if (loading) return (
    <div className="page list2-page">
      <div className="list2-loading">Loading list…</div>
    </div>
  );
  if (err) return (
    <div className="page list2-page">
      <div className="empty">
        <h3 className="empty-title">Hmm — we can't open this list</h3>
        <p>{err}</p>
        <button className="btn btn-ghost" onClick={onBack} style={{ width: 'auto', padding: '10px 18px', marginTop: 12 }}>
          <span>← Back to My lists</span>
        </button>
      </div>
    </div>
  );
  if (!root) return null;

  return (
    <div className="page list2-page" data-drop-root={root.id}>
      <ListHeaderV2
        root={root}
        onBack={onBack}
        onUpdate={(patch) => updateNode(root.id, patch)}
        onShare={onShare ? () => onShare(root) : null}
        userLists={userLists}
        onSwitchList={onSwitchList}
        focusMode={focusMode}
        onFocusMode={setFocusMode}
        budget={budget}
        onBudgetChange={setBudget}
        autoDecide={effectiveAutoDecide}
        onAutoDecideChange={setAutoDecide}
        onDuplicate={async () => {
          const res = await window.MR.nodes.duplicateRoot(root);
          if (res.ok) {
            try { location.hash = '#list/' + res.root.id; } catch {}
          } else {
            alert('Could not duplicate: ' + (res.reason || 'unknown error'));
          }
        }}
        onSaveAsTemplate={async () => {
          const res = await window.MR.nodes.duplicateRoot(root, {
            stripItems: true,
            nameOverride: `${root.name || 'List'} template`,
          });
          if (res.ok) {
            try { location.hash = '#list/' + res.root.id; } catch {}
          } else {
            alert('Could not save as template: ' + (res.reason || 'unknown error'));
          }
        }}
        onArchive={() => {
          if (!confirm(`Archive "${root.name || 'this list'}"? You can find it again in the Archived tab on the Lists page.`)) return;
          window.MR.nodes.setArchived(root.id, true);
          try { location.hash = '#lists'; } catch {}
        }}
        onPrint={() => {
          // Add a body class so print CSS targets this view, then trigger
          // the system print dialog.
          document.body.classList.add('mr-printing');
          setTimeout(() => {
            try { window.print(); } finally {
              setTimeout(() => document.body.classList.remove('mr-printing'), 400);
            }
          }, 50);
        }}
        onEnrichItems={async () => {
          if (!window.MR.enrich) return;
          // Walk the current root and count enrichable items first.
          const enrichable = window.MR.enrich.findEnrichable(root);
          if (enrichable.length === 0) {
            window.__mr_showToast && window.__mr_showToast('No items with URLs to enrich');
            return;
          }
          if (!window.confirm(`Fetch product details for ${enrichable.length} item${enrichable.length === 1 ? '' : 's'}? This may take a moment.`)) return;
          window.__mr_showToast && window.__mr_showToast(`Enriching ${enrichable.length} item${enrichable.length === 1 ? '' : 's'}…`);
          const res = await window.MR.enrich.enrichRoot(root, {
            onProgress: ({ done, total }) => {
              window.__mr_showToast && window.__mr_showToast(`Enriching items… ${done}/${total}`);
            },
          });
          const msg = res.failed > 0
            ? `Enriched ${res.succeeded}/${res.total} items (${res.failed} couldn't be fetched)`
            : `Enriched ${res.succeeded} item${res.succeeded === 1 ? '' : 's'} ✓`;
          window.__mr_showToast && window.__mr_showToast(msg);
          await refreshRoot();
        }}
        // Open every item's source URL in a new browser tab — useful
        // for opening a shortlist of prams to flip between in one go.
        // Catalog items use the first retailer URL; custom (paste-link)
        // items use their saved source URL. Skips items with neither.
        onOpenAllInTabs={() => {
          const items = flattenItemsInOrder();
          const targets = items.map(n => {
            if (n.productId && productMap && productMap[n.productId]) {
              const p = productMap[n.productId];
              return {
                name: p.name,
                url: p.primaryUrl
                  || (Array.isArray(p.retailers) && p.retailers[0] && p.retailers[0].url)
                  || null,
              };
            }
            return {
              name: (n.custom && n.custom.name) || n.name || 'Item',
              url: (n.custom && n.custom.sourceUrl) || null,
            };
          }).filter(t => {
            if (!t.url) return false;
            // safeUrl bounce — same XSS/scheme guard the rest of the
            // app uses for outbound links. Skips javascript:, data:, etc.
            if (window.MR && window.MR.safeUrl) {
              return !!window.MR.safeUrl(t.url);
            }
            return /^https?:\/\//i.test(t.url);
          });
          if (targets.length === 0) {
            window.__mr_showToast && window.__mr_showToast('No items with links to open');
            return;
          }
          // Confirm before spawning many tabs — popup blockers usually
          // allow ≤5 from one click; beyond that, browsers warn or
          // truncate. We still let the user proceed; they'll get a
          // browser-level dialog if anything's blocked.
          if (targets.length > 5) {
            const ok = window.confirm(
              `Open ${targets.length} tabs?\n\n` +
              `Your browser may ask permission for the first one. ` +
              `Tip: many browsers block popups by default — if only one tab opens, ` +
              `look for the blocked-popup icon in the address bar and allow it for magicrascals.com.`
            );
            if (!ok) return;
          }
          // Open in REVERSE order so the FIRST item ends up as the
          // rightmost (most recently opened) tab — browsers focus the
          // newest tab, so the user lands on item #1 when we're done.
          let opened = 0;
          for (let i = targets.length - 1; i >= 0; i--) {
            try {
              const w = window.open(targets[i].url, '_blank', 'noopener,noreferrer');
              if (w) opened++;
            } catch {}
          }
          if (opened < targets.length) {
            window.__mr_showToast && window.__mr_showToast(
              `Opened ${opened}/${targets.length} tabs — check for a blocked-popup notice in your address bar.`
            );
          } else {
            window.__mr_showToast && window.__mr_showToast(`Opened ${opened} tab${opened === 1 ? '' : 's'} ✦`);
          }
        }}
      />
      <ListDashboard
        root={root}
        productMap={productMap}
        budget={budget}
        onFilterUndecided={() => {
          setFilterStatus(null);
          setFilterMust(false);
          setFilterMaxPrice(null);
          setSearchQ('');
          setFocusMode(false);
        }}
        onFilterAcquired={() => {
          setFilterStatus('have');
          setFilterMust(false);
          setFilterMaxPrice(null);
          setSearchQ('');
        }}
      />
      <ListControlsBar
        root={root}
        productMap={productMap}
        searchQ={searchQ} onSearchQ={setSearchQ}
        filterStatus={filterStatus} onFilterStatus={setFilterStatus}
        filterMaxPrice={filterMaxPrice} onFilterMaxPrice={setFilterMaxPrice}
        sortBy={sortBy} onSortBy={setSortBy}
      />
      <ListToolbarV2
        rootName={root.name}
        rootId={root.id}
        onAddItem={(p) => addItem({ parentId: root.id, ...p })}
        onAddSlot={(p) => addSlot({ parentId: root.id, ...p })}
        onAddSublist={(name) => addSlot({ parentId: root.id, name, quantityNeeded: 0 })}
        productMap={productMap}
        viewMode={viewMode}
        onViewMode={setViewModePersisted}
        gridSize={gridSize}
        onGridSize={setGridSizePersisted}
        onItemAdded={refreshRoot}
      />
      {/* List-item detail is rendered by the shared DetailPanel in App.
          handleOpenListItem builds a synthesized product + listContext and
          hands them up via onOpenProduct. We keep openItemId locally so we
          can re-publish listContext when votes/comments/etc. change. */}
      {/* "What's missing?" AI chip — sits above the tree on every list
          that has items. One tap opens the AI chat sidebar with a focused
          "what's missing from this list?" prompt; the sidebar renders the
          response with full markdown, inline product links, and add-to-list
          actions. Only mounted when window.ListAIHelper has loaded. */}
      {(root.children || []).length > 0 && window.ListAIHelper && (
        <div className="list2-ai-row">
          <window.ListAIHelper.WhatsMissingButton root={root} />
        </div>
      )}
      <div
        className="list2-tree"
        // ── AI sidebar drop target ──────────────────────────────────────
        // Accept HTML5 drops from the AI sidebar's product suggestions
        // (and anywhere else that sets the `application/x-mr-product`
        // dataTransfer type). On drop, route the productId through
        // addItem so the new item lands at the root with the correct
        // parentId — same pipe that powers the toolbar's catalog search.
        // We DO NOT preventDefault on dragenter if the payload type isn't
        // ours, so the existing pointer-based reorder drag keeps working.
        onDragOver={(e) => {
          if (!e.dataTransfer) return;
          const types = Array.from(e.dataTransfer.types || []);
          if (types.includes('application/x-mr-product')) {
            e.preventDefault();
            e.dataTransfer.dropEffect = 'copy';
            e.currentTarget.classList.add('is-ai-drop-target');
          }
        }}
        onDragLeave={(e) => {
          // Only clear when actually leaving the container (not bubbling
          // through a child) — relatedTarget is the element we entered.
          if (!e.currentTarget.contains(e.relatedTarget)) {
            e.currentTarget.classList.remove('is-ai-drop-target');
          }
        }}
        onDrop={async (e) => {
          if (!e.dataTransfer) return;
          const raw = e.dataTransfer.getData('application/x-mr-product');
          if (!raw) return;
          e.preventDefault();
          e.currentTarget.classList.remove('is-ai-drop-target');
          try {
            const data = JSON.parse(raw);
            if (!data || !data.productId) return;
            await addItem({
              parentId: root.id,
              productId: data.productId,
              name: data.name || 'Item',
            });
            if (typeof window.__mr_showToast === 'function') {
              window.__mr_showToast(`Added "${data.name || 'item'}"`);
            }
            if (typeof window.__mr_celebrate === 'function') {
              window.__mr_celebrate(e.currentTarget, { variant: 'add' });
            }
          } catch (err) {
            console.error('[v2] AI drop failed:', err);
          }
        }}
      >
        {(root.children || []).length === 0 ? (
          // ──────────────────────────────────────────────────────────────
          // EMPTY STATE — "what this list could become"
          //
          // Old version was a bare line "This list is empty" — accurate but
          // dispiriting. Users churn at this exact moment. New version sells
          // the value: hero illustration, focused CTAs for the two highest-
          // intent actions (paste link, ask AI), and a preview of what the
          // list will grow into (shortlist decisions, partner voting, budget,
          // gallery view). Inspires by showing the future state, not the
          // current emptiness.
          // ──────────────────────────────────────────────────────────────
          <div className="list2-empty list2-empty--rich">
            <div className="list2-empty-hero">
              <div className="list2-empty-orb" aria-hidden="true">
                <span className="orb-a"></span>
                <span className="orb-b"></span>
                <span className="orb-c"></span>
                <span className="orb-sparkle orb-sparkle--1">✦</span>
                <span className="orb-sparkle orb-sparkle--2">✧</span>
                <span className="orb-sparkle orb-sparkle--3">✦</span>
              </div>
              <h2 className="list2-empty-title">
                Let's build <em>{root.name}</em>
              </h2>
              <p className="list2-empty-sub">
                Drop in anything you're considering — a link, a photo, or just a name.
                Decisions, comparisons, and a tidy list grow from there.
              </p>
            </div>

            <div className="list2-empty-actions">
              <button
                type="button"
                className="list2-empty-action list2-empty-action--primary"
                onClick={() => {
                  // Scroll up to the toolbar and focus the search input
                  // so a paste lands in the right place. Sets a one-shot
                  // focus signal that QuickSearchV2 will pick up below.
                  const input = document.querySelector('.list2-qsearch-input');
                  if (input) { input.focus(); input.scrollIntoView({ behavior: 'smooth', block: 'center' }); }
                }}
              >
                <span className="list2-empty-action-icon" aria-hidden="true">🔗</span>
                <span className="list2-empty-action-body">
                  <span className="list2-empty-action-title">Paste a link</span>
                  <span className="list2-empty-action-sub">Found something? Drop the URL in the search box up top.</span>
                </span>
              </button>

              {window.ListAIHelper && window.ListAIHelper.EmptyStateAI ? (
                <div className="list2-empty-action list2-empty-action--ai">
                  <span className="list2-empty-action-icon" aria-hidden="true">✦</span>
                  <span className="list2-empty-action-body">
                    <span className="list2-empty-action-title">Ask AI to start it for you</span>
                    <span className="list2-empty-action-sub">Get a curated starter set based on what this list is for.</span>
                    <div style={{ marginTop: 10 }}>
                      <window.ListAIHelper.EmptyStateAI root={root} parentNode={root} />
                    </div>
                  </span>
                </div>
              ) : null}
            </div>

            <div className="list2-empty-preview">
              <div className="list2-empty-preview-h">What this list will become</div>
              <div className="list2-empty-preview-grid">
                <div className="list2-empty-feature">
                  <div className="list2-empty-feature-icon">🎯</div>
                  <div className="list2-empty-feature-title">Decide between options</div>
                  <div className="list2-empty-feature-sub">Group items as a "pick one of…" shortlist and we'll surface the winner.</div>
                </div>
                <div className="list2-empty-feature">
                  <div className="list2-empty-feature-icon">⚖️</div>
                  <div className="list2-empty-feature-title">Compare side-by-side</div>
                  <div className="list2-empty-feature-sub">Three options? Open Compare to see specs, prices, and photos lined up.</div>
                </div>
                <div className="list2-empty-feature">
                  <div className="list2-empty-feature-icon">💞</div>
                  <div className="list2-empty-feature-title">Decide together</div>
                  <div className="list2-empty-feature-sub">Invite your partner — they can vote, leave notes, and see what's left.</div>
                </div>
                <div className="list2-empty-feature">
                  <div className="list2-empty-feature-icon">💰</div>
                  <div className="list2-empty-feature-title">Track the budget</div>
                  <div className="list2-empty-feature-sub">Set a target and we'll do the math as picks come in.</div>
                </div>
                <div className="list2-empty-feature">
                  <div className="list2-empty-feature-icon">📸</div>
                  <div className="list2-empty-feature-title">Beautiful gallery</div>
                  <div className="list2-empty-feature-sub">Every link pulls in product photos — your list looks like a magazine spread.</div>
                </div>
                <div className="list2-empty-feature">
                  <div className="list2-empty-feature-icon">✨</div>
                  <div className="list2-empty-feature-title">Magical moments</div>
                  <div className="list2-empty-feature-sub">Confetti when you finish. Celebrations on decisions. We bring the joy.</div>
                </div>
              </div>
            </div>
          </div>
        ) : (
          <NodeChildren
            parent={root}
            depth={0}
            productMap={productMap}
            onOpenItem={handleOpenListItem}
            onAddItem={addItem}
            onAddSlot={addSlot}
            onUpdate={updateNode}
            onDelete={deleteNode}
            votes={votes}
            myUserId={myUserId}
            onVote={voteOnNode}
            rootKind={root.kind}
            claims={claims}
            comments={comments}
            viewMode={viewMode}
            gridSize={gridSize}
            listControls={{ searchQ, filterStatus, filterMust, filterMaxPrice, sortBy }}
            dragNodeId={dragNodeId}
            dropTarget={dropTarget}
            startCustomDrag={startCustomDrag}
            onCompareShortlist={(slotId) => setCompareSlotId(slotId)}
            onMoveWithin={(nodeId, parentId, dir) => {
              // Alt+Arrow keyboard reorder — find sibling index, move +/- 1.
              const findP = (n) => {
                if (n.id === parentId) return n;
                for (const c of (n.children || [])) {
                  const r = findP(c); if (r) return r;
                }
                return null;
              };
              const parent = findP(root);
              if (!parent) return;
              const sibs = (parent.children || []);
              const idx = sibs.findIndex(c => c.id === nodeId);
              if (idx < 0) return;
              const targetIdx = Math.max(0, Math.min(sibs.length - 1, idx + dir));
              if (targetIdx === idx) return;
              moveNodeToParent(nodeId, parentId, targetIdx);
            }}
          />
        )}
      </div>
      {/* Side-by-side comparison modal for shortlists. Re-resolves the slot
          node from the live tree on every render so picks / votes / new
          candidates added during the session update in-place. */}
      {/* Bulk action bar — appears when ≥1 item is selected. Floating
          dock at the bottom of the viewport. Esc cancels selection. */}
      {selectionMode && (
        <div className="list2-bulkbar" role="region" aria-label="Bulk actions">
          <div className="list2-bulkbar-info">
            <span className="list2-bulkbar-count">{selectedIds.size}</span>
            <span>selected</span>
          </div>
          <div className="list2-bulkbar-actions">
            <button type="button" className="list2-bulkbar-btn" onClick={() => bulkSetStatus('want')}>
              Mark Loved
            </button>
            <button type="button" className="list2-bulkbar-btn" onClick={() => bulkSetStatus('need')}>
              Mark Getting
            </button>
            <button type="button" className="list2-bulkbar-btn" onClick={() => bulkSetStatus('have')}>
              Mark Have
            </button>
            <button type="button" className="list2-bulkbar-btn list2-bulkbar-btn--danger" onClick={bulkDelete}>
              Delete
            </button>
          </div>
          <button type="button" className="list2-bulkbar-clear" onClick={clearSelection}>
            Cancel
          </button>
        </div>
      )}
      {placeholderItemId && (() => {
        const { node } = findNodeWithParent(placeholderItemId);
        if (!node) return null;
        return (
          <PlaceholderReplaceModal
            node={node}
            productMap={productMap}
            onClose={() => setPlaceholderItemId(null)}
            onEdit={(patch) => updateNode(node.id, patch)}
            onReplaceWithProduct={(p) => {
              // Strip the placeholder flag from LS, swap to the catalog
              // product id. Custom blob is cleared because product_id now
              // owns the display fields.
              try { localStorage.removeItem(`mr-placeholder-${node.id}`); } catch {}
              updateNode(node.id, { productId: p.id, custom: null, name: p.name });
              setPlaceholderItemId(null);
            }}
            onReplaceWithCustom={(custom) => {
              // Replace via paste-link or photo upload. Strips placeholder
              // flag and writes a normal custom blob.
              try { localStorage.removeItem(`mr-placeholder-${node.id}`); } catch {}
              const { placeholder, ...rest } = custom;
              updateNode(node.id, { custom: rest, name: rest.name || node.name });
              setPlaceholderItemId(null);
            }}
            onDelete={() => {
              try { localStorage.removeItem(`mr-placeholder-${node.id}`); } catch {}
              deleteNode(node.id);
              setPlaceholderItemId(null);
            }}
          />
        );
      })()}
      {compareSlotId && (() => {
        const find = (n) => {
          if (n.id === compareSlotId) return n;
          for (const c of (n.children || [])) {
            const r = find(c);
            if (r) return r;
          }
          return null;
        };
        const slot = root ? find(root) : null;
        if (!slot) return null;
        return (
          <ShortlistCompareModal
            slot={slot}
            productMap={productMap}
            votes={votes}
            myUserId={myUserId}
            nameFor={nameFor}
            onClose={() => setCompareSlotId(null)}
            onPick={(itemId, picked) => updateNode(itemId, { picked: !!picked })}
            onVote={voteOnNode}
            onSetNotes={(itemId, notes) => updateNode(itemId, { notes })}
            onOpenProduct={onOpenProduct}
          />
        );
      })()}
      {activityOpen && (
        <ActivityDrawer
          activity={activity}
          loading={activityLoading}
          nameFor={nameFor}
          myUserId={myUserId}
          productMap={productMap}
          root={root}
          onClose={() => setActivityOpen(false)}
          onRefresh={loadActivity}
        />
      )}
    </div>
  );
}

// ───────────────────────── Activity drawer ─────────────────────────
// Slides in from the right with a list of events for the current list.
// Each row pairs an actor avatar, a verb-phrase, the affected item, and a
// time-ago stamp. Renders the same set of action types the rest of the
// app logs (added_item, removed_item, voted, picked, status changes,
// partner_joined, etc.) and degrades gracefully on unknown actions.
function ActivityDrawer({ activity, loading, nameFor, myUserId, root, onClose, onRefresh, onOpenItem, productMap }) {
  // Verb renderer — same vocabulary as page-lists.jsx's drawer so the
  // copy stays consistent across the app. Falls back to the raw action
  // name when we encounter something newly-introduced.
  const verb = (a) => {
    const m = a.meta || {};
    const statusLabel = (s) => s === 'have' ? 'Have' : s === 'need' ? 'Getting' : s === 'want' ? 'Loved' : s;
    switch (a.action) {
      case 'added_item':   return `added ${m.product_name || m.custom_name || 'an item'}`;
      case 'updated_item': {
        if (m.status_to) return `moved ${m.name || 'an item'} to ${statusLabel(m.status_to)}`;
        if (m.in_registry === true)  return `added ${m.name || 'an item'} to the registry`;
        if (m.in_registry === false) return `removed ${m.name || 'an item'} from the registry`;
        if (m.picked === true)       return `picked ${m.name || 'an item'} ✨`;
        if (m.picked === false)      return `unpicked ${m.name || 'an item'}`;
        return `updated ${m.name || 'an item'}`;
      }
      case 'removed_item':      return `removed ${m.item_name || m.slot_name || 'an item'}`;
      case 'voted':             return `voted ${m.vote === 'up' ? '👍' : '👎'} on ${m.item_name || 'an item'}`;
      case 'noted':             return `added a note`;
      case 'joined':            return `joined the list`;
      case 'partner_joined':    {
        const name = m && m.name;
        return name ? `joined the list as ${name} 💞` : `joined the list 💞`;
      }
      case 'invited':           return `invited ${a.target_email || 'someone'}`;
      case 'invite_sent':       return `invited ${a.target_email || 'a partner'}`;
      case 'created_list':      return `created this list`;
      case 'shortlist_decided': return `decided "${m && m.slot_name || 'a shortlist'}" ✨`;
      case 'list_completed':    return `finished this list 🎉`;
      default:                  return a.action;
    }
  };
  const timeAgo = (ts) => {
    if (!ts) return '';
    const t = new Date(ts).getTime();
    const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
    if (s < 60)    return 'just now';
    if (s < 3600)  return Math.floor(s / 60) + 'm ago';
    if (s < 86400) return Math.floor(s / 3600) + 'h ago';
    const d = Math.floor(s / 86400);
    if (d < 7)     return d + 'd ago';
    return new Date(ts).toLocaleDateString();
  };
  const actorLabel = (a) => {
    if (a.actor_id === myUserId) return 'You';
    const fromMap = nameFor ? nameFor(a.actor_id) : null;
    return fromMap || a.actor_email || 'Someone';
  };
  // Group activity by day for readability (Today / Yesterday / Date)
  const groups = (() => {
    const out = [];
    let lastKey = '';
    const dayKey = (ts) => {
      const d = new Date(ts);
      d.setHours(0, 0, 0, 0);
      return d.getTime();
    };
    const dayLabel = (ts) => {
      const k = dayKey(ts);
      const today = dayKey(Date.now());
      const oneDay = 86400000;
      if (k === today) return 'Today';
      if (k === today - oneDay) return 'Yesterday';
      return new Date(ts).toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric' });
    };
    for (const a of (activity || [])) {
      const label = a.created_at ? dayLabel(a.created_at) : 'Unknown';
      if (label !== lastKey) { out.push({ label, items: [] }); lastKey = label; }
      out[out.length - 1].items.push(a);
    }
    return out;
  })();

  return (
    <>
      <div className="list2-act-scrim" onClick={onClose} aria-hidden="true" />
      <aside className="list2-act-drawer" role="dialog" aria-modal="true" aria-label="List activity">
        <div className="list2-act-head">
          <div>
            <div className="list2-act-eyebrow">Activity</div>
            <h2 className="list2-act-title">{root ? root.name : 'List'}</h2>
          </div>
          <div className="list2-act-head-actions">
            <button type="button" className="list2-act-refresh" onClick={onRefresh} aria-label="Refresh activity" title="Refresh">
              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 12a9 9 0 0115.5-6.4L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 01-15.5 6.4L3 16"/><path d="M3 21v-5h5"/></svg>
            </button>
            <button type="button" className="list2-act-close" onClick={onClose} aria-label="Close">×</button>
          </div>
        </div>
        <div className="list2-act-body">
          {loading && (!activity || !activity.length) ? (
            <div className="list2-act-empty">Loading…</div>
          ) : groups.length === 0 ? (
            <div className="list2-act-empty">
              <div className="list2-act-empty-icon">📭</div>
              <div className="list2-act-empty-title">No activity yet</div>
              <div className="list2-act-empty-sub">As you and your partner add, vote, and decide, the timeline fills in here.</div>
            </div>
          ) : (
            groups.map((g, gi) => (
              <div key={gi} className="list2-act-group">
                <div className="list2-act-day">{g.label}</div>
                {g.items.map(a => {
                  const isYou = a.actor_id === myUserId;
                  const initials = (actorLabel(a) || '?').trim().charAt(0).toUpperCase() || '?';
                  // Resolve a thumbnail. Prefer the snapshot stored in
                  // meta at log time (works for removed items too); fall
                  // back to looking up the live product image when the
                  // catalog has it. Custom items are always meta-only.
                  const m = a.meta || {};
                  const thumb = (() => {
                    if (m.image_url) return m.image_url;
                    // For active rows on this page we can find them on
                    // the current root tree; this fallback gracefully
                    // degrades for cross-list global rows.
                    return null;
                  })();
                  // The row is clickable when the item still exists —
                  // click → open the detail modal at that item.
                  const canOpen = !!(onOpenItem && a.target_item_id && a.action !== 'removed_item');
                  const handleClick = canOpen
                    ? () => { onOpenItem(a.target_item_id); onClose && onClose(); }
                    : null;
                  return (
                    <div
                      key={a.id || `${a.created_at}-${a.action}-${a.target_item_id || ''}`}
                      className={`list2-act-row${canOpen ? ' is-clickable' : ''}`}
                      onClick={handleClick}
                      role={canOpen ? 'button' : undefined}
                      tabIndex={canOpen ? 0 : undefined}
                      onKeyDown={canOpen ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleClick(); } } : undefined}
                      title={canOpen ? 'Open this item' : undefined}
                    >
                      {thumb ? (
                        <div className="list2-act-thumb" aria-hidden="true"><img src={thumb} alt="" /></div>
                      ) : (
                        <div className={`list2-act-avatar${isYou ? ' is-you' : ''}`} aria-hidden="true">{initials}</div>
                      )}
                      <div className="list2-act-body-row">
                        <div className="list2-act-line">
                          <strong>{actorLabel(a)}</strong> {verb(a)}
                        </div>
                        <div className="list2-act-meta">{timeAgo(a.created_at)}</div>
                      </div>
                    </div>
                  );
                })}
              </div>
            ))
          )}
        </div>
      </aside>
    </>
  );
}

// ───────────────────────── Header ─────────────────────────
function ListHeaderV2({ root, onBack, onUpdate, onShare, focusMode, onFocusMode, budget, onBudgetChange, autoDecide, onAutoDecideChange, onDuplicate, onSaveAsTemplate, onArchive, onPrint, onEnrichItems, onOpenAllInTabs, userLists, onSwitchList }) {
  const [editingName, setEditingName] = _v2s(false);
  const [draftName, setDraftName] = _v2s(root.name);
  const [editingDesc, setEditingDesc] = _v2s(false);
  const [draftDesc, setDraftDesc] = _v2s(root.description || '');
  const [settingsOpen, setSettingsOpen] = _v2s(false);
  // List-switcher popover. Clicking the title opens it; "Rename" inside
  // is now where the rename action lives (replaces the previous
  // click-name-to-rename pattern). Lists are sourced from userLists
  // (passed from index.html), excluding the current one + any list
  // the user doesn't own.
  const [switcherOpen, setSwitcherOpen] = _v2s(false);
  // Close switcher on outside click.
  _v2e(() => {
    if (!switcherOpen) return;
    const onDown = (e) => {
      if (!e.target.closest('.list2-switcher-pop') && !e.target.closest('.list2-switcher-trigger')) {
        setSwitcherOpen(false);
      }
    };
    document.addEventListener('mousedown', onDown);
    return () => document.removeEventListener('mousedown', onDown);
  }, [switcherOpen]);
  // Close switcher on Esc.
  _v2e(() => {
    if (!switcherOpen) return;
    const onKey = (e) => { if (e.key === 'Escape') setSwitcherOpen(false); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [switcherOpen]);
  // Other-list options for the switcher list — everything the user
  // owns or collaborates on (isMine !== false catches both), minus the
  // currently-open one. Sorted alphabetically for predictable scanning.
  const otherLists = _v2m(() => {
    if (!Array.isArray(userLists)) return [];
    return userLists
      .filter(l => l && l.id !== root.id && l.isMine !== false)
      .slice()
      .sort((a, b) => (a.name || '').localeCompare(b.name || ''));
  }, [userLists, root.id]);
  _v2e(() => { setDraftName(root.name); }, [root.name]);
  _v2e(() => { setDraftDesc(root.description || ''); }, [root.description]);
  const commitName = () => {
    const v = (draftName || '').trim();
    if (!v || v === root.name) { setEditingName(false); return; }
    onUpdate({ name: v });
    setEditingName(false);
  };
  // Description commits empty strings (clearing the description) as well
  // as edits. Persist null when blank so the DB stays clean.
  const commitDesc = () => {
    const v = (draftDesc || '').trim();
    const cur = (root.description || '').trim();
    if (v === cur) { setEditingDesc(false); return; }
    onUpdate({ description: v || null });
    setEditingDesc(false);
  };
  // Click-outside to close the settings popover.
  _v2e(() => {
    if (!settingsOpen) return;
    const onDown = (e) => {
      if (!e.target.closest('.list2-settings-pop') && !e.target.closest('.list2-settings-btn')) {
        setSettingsOpen(false);
      }
    };
    document.addEventListener('mousedown', onDown);
    return () => document.removeEventListener('mousedown', onDown);
  }, [settingsOpen]);

  const kindLabel = (() => {
    const k = root.kind || 'private';
    if (k === 'partner')  return 'Shared';
    if (k === 'registry') return 'Registry';
    return 'Private';
  })();

  return (
    <div className="list2-head list2-head--compact">
      <div className="list2-head-row">
        <button type="button" className="list2-back" onClick={onBack} aria-label="Back">
          <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
          <span>My lists</span>
        </button>
        <div className="list2-head-actions">
          {onShare && (
            <button type="button" className="list2-share-btn" onClick={onShare} title="Get a link to share this list">
              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 13.5l6.8 4M15.4 6.5l-6.8 4"/></svg>
              <span>Share</span>
            </button>
          )}
          {/* Activity / timeline opener. Sits next to Share so partners
              naturally find it when they're already thinking "who's done
              what?". The drawer itself lives inside ListDetailV2 and is
              toggled via a window event so we don't have to thread state
              through the header→detail props. */}
          <button
            type="button"
            className="list2-share-btn"
            onClick={() => { try { window.dispatchEvent(new CustomEvent('mr-open-activity')); } catch {} }}
            title="See what's happened on this list"
          >
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
              <circle cx="12" cy="12" r="9"/>
              <path d="M12 7v5l3 2"/>
            </svg>
            <span>Activity</span>
          </button>
          <div className="list2-settings-wrap">
            <button
              type="button"
              className="list2-settings-btn"
              onClick={() => setSettingsOpen(s => !s)}
              aria-label="List settings"
              aria-expanded={settingsOpen}
              title="List settings"
            >
              <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
                <circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>
              </svg>
            </button>
            {settingsOpen && (
              <ListSettingsPopover
                root={root}
                onUpdate={onUpdate}
                focusMode={focusMode}
                onFocusMode={onFocusMode}
                budget={budget}
                onBudgetChange={onBudgetChange}
                autoDecide={autoDecide}
                onAutoDecideChange={onAutoDecideChange}
                onClose={() => setSettingsOpen(false)}
                onDuplicate={onDuplicate}
                onSaveAsTemplate={onSaveAsTemplate}
                onArchive={onArchive}
                onPrint={onPrint}
                onEnrichItems={onEnrichItems}
                onOpenAllInTabs={onOpenAllInTabs}
              />
            )}
          </div>
        </div>
      </div>
      <div className="list2-head-body">
        {editingName ? (
          <input
            type="text"
            autoFocus
            value={draftName}
            onChange={(e) => setDraftName(e.target.value)}
            onBlur={commitName}
            onKeyDown={(e) => {
              if (e.key === 'Enter') { e.preventDefault(); commitName(); }
              else if (e.key === 'Escape') { setDraftName(root.name); setEditingName(false); }
            }}
            className="list2-head-h-input"
          />
        ) : (
          <div className="list2-switcher-wrap">
            <h1
              className="list2-head-h list2-switcher-trigger"
              onClick={() => setSwitcherOpen(s => !s)}
              title="Switch list or rename"
              aria-haspopup="menu"
              aria-expanded={switcherOpen}
            >
              {root.name}
              <span className="list2-head-h-switchchev" aria-hidden="true">▾</span>
            </h1>
            {switcherOpen && (
              <div className="list2-switcher-pop" role="menu">
                <div className="list2-switcher-section">
                  <div className="list2-switcher-section-h">Actions</div>
                  <button
                    type="button"
                    role="menuitem"
                    className="list2-switcher-item list2-switcher-item--action"
                    onClick={() => { setSwitcherOpen(false); setEditingName(true); }}
                  >
                    <span className="list2-switcher-icon">✎</span>
                    <span>Rename "{root.name}"</span>
                  </button>
                </div>
                {otherLists.length > 0 && (
                  <div className="list2-switcher-section">
                    <div className="list2-switcher-section-h">Jump to</div>
                    <div className="list2-switcher-scroll">
                      {otherLists.map(l => (
                        <button
                          key={l.id}
                          type="button"
                          role="menuitem"
                          className="list2-switcher-item"
                          onClick={() => {
                            setSwitcherOpen(false);
                            if (onSwitchList) onSwitchList(l.id);
                          }}
                        >
                          <span className="list2-switcher-icon" aria-hidden="true">{l.cover_icon || '📄'}</span>
                          <span className="list2-switcher-name">{l.name || 'Untitled'}</span>
                          {l.items && l.items.length > 0 && (
                            <span className="list2-switcher-count">{l.items.length}</span>
                          )}
                        </button>
                      ))}
                    </div>
                  </div>
                )}
              </div>
            )}
          </div>
        )}
        {/* Description — click-to-edit, just like the name. When empty,
            shows a quiet "Add a description…" placeholder the user can
            click to start typing. Empty submits clear the description. */}
        {editingDesc ? (
          <textarea
            autoFocus
            value={draftDesc}
            onChange={(e) => setDraftDesc(e.target.value)}
            onBlur={commitDesc}
            onKeyDown={(e) => {
              // Enter (no shift) commits; Shift+Enter inserts newline; Esc cancels.
              if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); commitDesc(); }
              else if (e.key === 'Escape') { setDraftDesc(root.description || ''); setEditingDesc(false); }
            }}
            rows={Math.max(1, (draftDesc || '').split('\n').length)}
            placeholder="Add a description…"
            className="list2-head-sub-input"
          />
        ) : root.description ? (
          <p
            className="list2-head-sub list2-head-sub--editable"
            onClick={() => setEditingDesc(true)}
            title="Click to edit description"
          >
            {root.description}
            <span className="list2-head-h-edithint" aria-hidden="true">✎</span>
          </p>
        ) : (
          <p
            className="list2-head-sub list2-head-sub--placeholder"
            onClick={() => setEditingDesc(true)}
            title="Click to add a description"
          >
            Add a description…
          </p>
        )}
      </div>
    </div>
  );
}

// ───────────────────────── Settings popover ─────────────────────────
// Tucked behind the … button next to the title. Houses list-level prefs
// that don't need to be visible all the time: list type (Private / Shared
// / Registry), focus mode, and the budget setter.
function ListSettingsPopover({ root, onUpdate, focusMode, onFocusMode, budget, onBudgetChange, autoDecide, onAutoDecideChange, onClose, onDuplicate, onSaveAsTemplate, onArchive, onPrint, onEnrichItems, onOpenAllInTabs }) {
  const [budgetDraft, setBudgetDraft] = _v2s(budget != null ? String(budget) : '');
  _v2e(() => { setBudgetDraft(budget != null ? String(budget) : ''); }, [budget]);
  const KIND_OPTS = [
    { value: 'private',  label: 'Private',  sub: 'Only you can see this',                 tone: 'private' },
    { value: 'partner',  label: 'Shared',   sub: 'You and people you invite',             tone: 'shared'  },
    { value: 'registry', label: 'Registry', sub: 'Public — friends can claim items',      tone: 'public'  },
  ];
  const commitBudget = () => {
    const v = budgetDraft.trim();
    if (!v) onBudgetChange(null);
    else onBudgetChange(Math.max(0, Number(v) || 0));
  };
  return (
    <div className="list2-settings-pop" role="dialog" aria-label="List settings">
      <div className="list2-settings-section">
        <div className="list2-settings-h">List type</div>
        <div className="list2-settings-kinds" role="radiogroup">
          {KIND_OPTS.map(k => (
            <button
              key={k.value}
              type="button"
              role="radio"
              aria-checked={(root.kind || 'private') === k.value}
              className={`list2-settings-kind list2-settings-kind--${k.tone}${(root.kind || 'private') === k.value ? ' is-on' : ''}`}
              onClick={() => onUpdate({ kind: k.value })}
            >
              <span className="list2-settings-kind-label">{k.label}</span>
              <span className="list2-settings-kind-sub">{k.sub}</span>
            </button>
          ))}
        </div>
      </div>
      <div className="list2-settings-section">
        <div className="list2-settings-h">Budget</div>
        <label className="list2-settings-budget">
          <span>A$</span>
          <input
            type="number"
            min="0"
            placeholder="e.g. 2000"
            value={budgetDraft}
            onChange={(e) => setBudgetDraft(e.target.value)}
            onBlur={commitBudget}
            onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitBudget(); } }}
          />
        </label>
        <div className="list2-settings-hint">Optional target for this list's planned spend.</div>
      </div>
      <div className="list2-settings-section">
        <button
          type="button"
          className={`list2-settings-toggle${focusMode ? ' is-on' : ''}`}
          onClick={() => onFocusMode && onFocusMode(!focusMode)}
          aria-pressed={focusMode}
        >
          <span className="list2-settings-switch">
            <span className="list2-settings-knob" />
          </span>
          <span className="list2-settings-toggle-body">
            <span className="list2-settings-toggle-h">Focus picks</span>
            <span className="list2-settings-toggle-sub">Hide rejected alternatives once a shortlist has a pick.</span>
          </span>
        </button>
      </div>
      <div className="list2-settings-section">
        <button
          type="button"
          className={`list2-settings-toggle${autoDecide ? ' is-on' : ''}`}
          onClick={() => onAutoDecideChange && onAutoDecideChange(!autoDecide)}
          aria-pressed={autoDecide}
        >
          <span className="list2-settings-switch">
            <span className="list2-settings-knob" />
          </span>
          <span className="list2-settings-toggle-body">
            <span className="list2-settings-toggle-h">Auto-decide on consensus</span>
            <span className="list2-settings-toggle-sub">
              Mark a candidate Decided automatically once it has 2 thumbs-up votes and no thumbs-down. Default on for Shared lists.
            </span>
          </span>
        </button>
      </div>
      {/* List-level actions — Duplicate / Save as template / Print /
          Archive. Live at the bottom of the popover because they're
          rare-but-occasionally-needed operations on the list itself,
          not on its contents. */}
      <div className="list2-settings-section list2-settings-actions">
        <div className="list2-settings-h">Actions</div>
        <div className="list2-settings-actions-grid">
          <button type="button" className="list2-settings-action" onClick={() => { onClose && onClose(); onDuplicate && onDuplicate(); }} title="Make a full copy of this list">
            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15 L5 5 a1 1 0 011-1 h10"/></svg>
            <span>Duplicate</span>
          </button>
          <button type="button" className="list2-settings-action" onClick={() => { onClose && onClose(); onSaveAsTemplate && onSaveAsTemplate(); }} title="Copy the structure (no items) as a fresh list">
            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M4 9 L20 9"/><path d="M9 13 L15 13"/></svg>
            <span>Save as template</span>
          </button>
          <button type="button" className="list2-settings-action" onClick={() => { onClose && onClose(); onPrint && onPrint(); }} title="Open a print-friendly view">
            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 9 L6 3 L18 3 L18 9"/><rect x="4" y="9" width="16" height="9" rx="2"/><path d="M7 15 L17 15 L17 21 L7 21 Z"/></svg>
            <span>Print</span>
          </button>
          {onEnrichItems && (
            <button type="button" className="list2-settings-action" onClick={() => { onClose && onClose(); onEnrichItems && onEnrichItems(); }} title="Fetch product details (image, brand, price) for items with URLs">
              <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12a9 9 0 11-3-6.7"/><path d="M21 4 v5 h-5"/></svg>
              <span>Fetch details</span>
            </button>
          )}
          {onOpenAllInTabs && (
            <button
              type="button"
              className="list2-settings-action"
              onClick={() => { onClose && onClose(); onOpenAllInTabs(); }}
              title="Open each item's source URL in a new browser tab"
            >
              <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
                <rect x="3" y="6" width="13" height="13" rx="2"/>
                <path d="M16 6 L21 6 L21 11"/>
                <path d="M21 6 L13 14"/>
              </svg>
              <span>Open all in tabs</span>
            </button>
          )}
          <button type="button" className="list2-settings-action list2-settings-action--danger" onClick={() => { onClose && onClose(); onArchive && onArchive(); }} title="Hide this list from the main view">
            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="5" rx="1"/><path d="M5 9 L5 19 a2 2 0 002 2 h10 a2 2 0 002 -2 L19 9"/><path d="M10 13 L14 13"/></svg>
            <span>Archive</span>
          </button>
        </div>
      </div>
    </div>
  );
}

// ───────────────────────── Dashboard ─────────────────────────
// One horizontal row that consolidates: the big completion %, the two
// thin progress bars (Decisions / Have), and the planned-spend summary.
// Replaces the old stats row + ListProgressBar + ListBudgetMeter trio.
// Designed to read left-to-right as: how am I doing → on what → for how
// much → vs my budget.
function ListDashboard({ root, productMap, budget, onFilterUndecided, onFilterAcquired }) {

  const priceOf = (c) => {
    const p = c.productId && productMap ? productMap[c.productId] : null;
    return Number((p && p.price) || (c.custom && c.custom.price) || 0) || 0;
  };

  const stats = _v2m(() => {
    let shortlists = 0, decided = 0;
    let actionable = 0, acquired = 0;
    // Three spend numbers, each accumulated independently:
    //   • plan       = what's committed so far (decided shortlists + groups).
    //                  Undecided slots contribute nothing — we don't know
    //                  what the user will pick.
    //   • cheapestPossible = lower bound. Decided contribute their actual
    //                  cost; undecided contribute the cheapest available
    //                  candidate(s). Group items as-is.
    //   • highestPossible = upper bound. Decided contribute actual cost;
    //                  undecided contribute the most expensive available
    //                  candidate(s). Group items as-is.
    //   • groupSpend = subtotal of items in groups (not in shortlists).
    //                  Used in the hover breakdown.
    //   • decidedSpend = subtotal of picked-shortlist choices.
    //                  Used in the hover breakdown.
    let plan = 0, cheapestPossible = 0, highestPossible = 0;
    let groupSpend = 0, decidedSpend = 0, undecidedCount = 0;
    let pickedSwaps = [];
    const walk = (n) => {
      (n.children || []).forEach(c => {
        if (c.type === 'item') {
          actionable++;
          const price = priceOf(c);
          plan += price;
          cheapestPossible += price;
          highestPossible += price;
          groupSpend += price;
          if ((c.status || 'want') === 'have') acquired++;
        } else if (c.type === 'slot') {
          if (c.slotKind === 'shortlist') {
            const need = c.quantityNeeded || 1;
            shortlists++;
            actionable += need;
            const cands = (c.children || []).filter(k => k.type === 'item');
            const pickedCands = cands.filter(k => k.picked);
            const pickedHaveCount = pickedCands.filter(k => (k.status || 'want') === 'have').length;
            acquired += Math.min(pickedHaveCount, need);
            if (pickedCands.length >= need) decided++;

            // Sum of prices for picked candidates — counts toward plan
            // and toward both extremes (you've already committed).
            const pickedSum = pickedCands.reduce((s, k) => s + priceOf(k), 0);
            plan += pickedSum;
            cheapestPossible += pickedSum;
            highestPossible += pickedSum;
            decidedSpend += pickedSum;

            // For the *remaining* unfilled spots, project the range.
            const remaining = Math.max(0, need - pickedCands.length);
            if (remaining > 0) {
              undecidedCount += remaining;
              const unpickedPrices = cands
                .filter(k => !k.picked)
                .map(priceOf)
                .filter(p => p > 0)
                .sort((a, b) => a - b);
              if (unpickedPrices.length > 0) {
                const cheapN = unpickedPrices.slice(0, remaining)
                  .reduce((s, p) => s + p, 0);
                const expN   = unpickedPrices.slice(-remaining)
                  .reduce((s, p) => s + p, 0);
                cheapestPossible += cheapN;
                highestPossible  += expN;
              }
            }

            // Over-budget swap suggestions (kept on picks that have a
            // cheaper sibling).
            if (budget != null && pickedCands.length > 0 && cands.length > 1) {
              const cheapCand = cands.slice().sort((a, b) => priceOf(a) - priceOf(b))[0];
              pickedCands.forEach(p => {
                const saves = priceOf(p) - priceOf(cheapCand);
                if (saves > 0 && cheapCand.id !== p.id) {
                  const cheapName = (cheapCand.productId && productMap ? productMap[cheapCand.productId] : null);
                  pickedSwaps.push({
                    shortlistName: c.name,
                    currentName: ((p.productId && productMap ? productMap[p.productId] : null) || {}).name
                      || (p.custom && p.custom.name) || p.name || 'current pick',
                    swapName: (cheapName && cheapName.name) || (cheapCand.custom && cheapCand.custom.name) || cheapCand.name || 'cheaper option',
                    swapId: cheapCand.id,
                    currentId: p.id,
                    saves,
                  });
                }
              });
            }
            return;
          }
          walk(c);
        }
      });
    };
    walk(root);
    pickedSwaps.sort((a, b) => b.saves - a.saves);
    return {
      shortlists, decided, actionable, acquired,
      plan, cheapestPossible, highestPossible,
      groupSpend, decidedSpend, undecidedCount,
      swaps: pickedSwaps.slice(0, 3),
    };
  }, [root, productMap, budget]);

  // ──────────────────────────────────────────────────────────────────
  // IMPORTANT: All hooks must be called BEFORE any early return so the
  // hook ordering stays consistent across renders. The previous version
  // returned null when the list was empty, then called _v2e() further
  // down — the moment a user added their first item, stats.actionable
  // flipped from 0 to 1, the early return stopped firing, and React saw
  // an extra useEffect appear on that render ("Rendered more hooks than
  // during the previous render" → blank screen). Keep this hook here.
  // ──────────────────────────────────────────────────────────────────
  const decisionPct = stats.shortlists > 0 ? Math.round((stats.decided / stats.shortlists) * 100) : 100;
  const havePct     = stats.actionable > 0 ? Math.round((stats.acquired / stats.actionable) * 100) : 0;
  const overallPct  = stats.shortlists > 0
    ? Math.round(0.3 * decisionPct + 0.7 * havePct)
    : havePct;
  const isComplete = overallPct === 100;
  const fmt = (n) => `A$${Math.round(n).toLocaleString()}`;
  // Over budget when even the cheapest possible path exceeds the budget.
  // (If only the highest exceeds, the user can still hit budget by picking
  // the cheapest of each undecided — no need to alarm them yet.)
  const overBudget = budget != null && stats.cheapestPossible > budget;

  // First-time-hits-100 confetti, gated by localStorage so it doesn't
  // refire on every render. MUST stay above the early return below.
  _v2e(() => {
    if (!root || !root.id) return;
    const key = `mr-completed-${root.id}`;
    if (isComplete) {
      try {
        if (!localStorage.getItem(key)) {
          fireConfetti();
          localStorage.setItem(key, '1');
        }
      } catch {}
    } else {
      try { localStorage.removeItem(key); } catch {}
    }
  }, [isComplete, root && root.id]);

  // Early-out for the "truly empty" state — now AFTER all hooks fire.
  if (stats.shortlists === 0 && stats.actionable === 0 && stats.plan === 0 && budget == null) return null;

  const commitSwap = async (s) => {
    await window.MR.nodes.updateNode(s.currentId, { picked: false });
    await window.MR.nodes.updateNode(s.swapId,    { picked: true });
    if (typeof window.__mr_refreshRoot === 'function') window.__mr_refreshRoot();
  };

  // Layout flag: which cells are present determines the grid template.
  // - With decisions: 4-cell (% / Decisions / Have / Cost)
  // - Without decisions: 3-cell (% / Have / Cost) — re-balanced for breathing room
  // - Without spend data either: 2-cell (% / Have)
  const hasDecisions = stats.shortlists > 0;
  const hasSpend     = stats.plan > 0 || stats.cheapestPossible > 0 || budget != null;
  const layoutClass  = hasDecisions
    ? 'list2-dash--4col'
    : hasSpend ? 'list2-dash--3col' : 'list2-dash--2col';
  const avgItemCost = stats.actionable > 0 ? Math.round(stats.cheapestPossible / stats.actionable) : 0;

  return (
    <div className={`list2-dash ${layoutClass}${isComplete ? ' is-complete' : ''}${overBudget ? ' is-overbudget' : ''}`}>
      <div className="list2-dash-row">
        {/* Cell 1: completion %. Always shown. The status line below the
            big number adapts so it reads sensibly in both modes:
              "Just getting started"  when nothing acquired yet
              "Halfway there"         around 50%
              "Almost done"           > 80%
              "Complete ✓"            at 100% */}
        <div className="list2-dash-cell list2-dash-cell--pct">
          <div className="list2-dash-pct">{overallPct}%</div>
          <div className="list2-dash-label">
            {isComplete ? 'Complete ✓'
              : overallPct === 0 ? 'Just starting'
              : overallPct < 30  ? 'In progress'
              : overallPct < 70  ? 'Halfway there'
              : overallPct < 100 ? 'Almost done'
              : 'Complete'}
          </div>
        </div>

        {/* Cell 2: decisions bar — only when shortlists exist. */}
        {hasDecisions && (
          <button type="button" className="list2-dash-cell list2-dash-cell--bar" onClick={onFilterUndecided} title="Show undecided shortlists">
            <div className="list2-dash-cell-head">
              <span className="list2-dash-label">Decisions</span>
              <span className="list2-dash-count">{stats.decided} of {stats.shortlists}</span>
            </div>
            <div className="list2-dash-bar">
              <div className="list2-dash-fill list2-dash-fill--decisions" style={{ width: `${decisionPct}%` }} />
            </div>
            <div className="list2-dash-sub">
              {stats.undecidedCount > 0
                ? `${stats.undecidedCount} candidate${stats.undecidedCount === 1 ? '' : 's'} still to pick`
                : 'All decided'}
            </div>
          </button>
        )}

        {/* Cell 3: have bar. Adds a contextual sub-line for "left to get". */}
        {stats.actionable > 0 && (
          <button type="button" className="list2-dash-cell list2-dash-cell--bar" onClick={onFilterAcquired} title="Show items you have">
            <div className="list2-dash-cell-head">
              <span className="list2-dash-label">Have</span>
              <span className="list2-dash-count">{stats.acquired} of {stats.actionable}</span>
            </div>
            <div className="list2-dash-bar">
              <div className="list2-dash-fill list2-dash-fill--have" style={{ width: `${havePct}%` }} />
            </div>
            <div className="list2-dash-sub">
              {stats.acquired === stats.actionable
                ? 'You\'re all set'
                : stats.acquired === 0
                  ? `${stats.actionable} left to get`
                  : `${stats.actionable - stats.acquired} left to get`}
            </div>
          </button>
        )}

        {/* Cell 4: spend summary. Cleaner sub-lines:
              - No decisions, no budget: "21 items · avg A$24"
              - No decisions, with budget: budget bar + % used
              - With undecided decisions: range + ⓘ
              - All decisions made (had decisions): "All committed" */}
        {hasSpend && (
          <div className="list2-dash-cell list2-dash-cell--spend">
            <div className="list2-dash-cell-head">
              <span className="list2-dash-label">{budget != null ? 'Spend' : 'Cost'}</span>
              {budget != null
                ? <span className={`list2-dash-count${overBudget ? ' is-over' : ''}`}>
                    {fmt(stats.cheapestPossible)} <span className="of">of</span> {fmt(budget)}
                  </span>
                : <span className="list2-dash-count">{fmt(stats.cheapestPossible)}</span>
              }
            </div>
            {budget != null && (
              <div className="list2-dash-bar">
                <div
                  className={`list2-dash-fill list2-dash-fill--spend${overBudget ? ' is-over' : ''}`}
                  style={{ width: `${Math.min(100, (stats.cheapestPossible / budget) * 100)}%` }}
                />
              </div>
            )}
            {stats.undecidedCount > 0 && stats.highestPossible !== stats.cheapestPossible ? (
              <div className="list2-dash-sub list2-dash-sub--hover" title={
                `Committed already: ${fmt(stats.decidedSpend + stats.groupSpend)}\n` +
                `Cheapest if you pick the lowest of every undecided: ${fmt(stats.cheapestPossible)}\n` +
                `Most expensive if you pick the highest: ${fmt(stats.highestPossible)}\n` +
                `${stats.undecidedCount} undecided · ${stats.decided} of ${stats.shortlists} shortlists decided`
              }>
                Range: {fmt(stats.cheapestPossible)} – {fmt(stats.highestPossible)}
                <span className="list2-dash-info" aria-hidden="true">ⓘ</span>
              </div>
            ) : !hasDecisions && stats.actionable > 0 ? (
              // No-decisions mode: show item count + avg cost as helpful context
              <div className="list2-dash-sub">
                {stats.actionable} item{stats.actionable === 1 ? '' : 's'}
                {avgItemCost > 0 && ` · avg ${fmt(avgItemCost)}`}
              </div>
            ) : hasDecisions && (stats.decidedSpend > 0 || stats.groupSpend > 0) ? (
              <div className="list2-dash-sub" title={
                `Decided: ${fmt(stats.decidedSpend)}\nGroup items: ${fmt(stats.groupSpend)}`
              }>
                All committed
              </div>
            ) : null}
          </div>
        )}
      </div>

      {/* Swap suggestions surface only when over budget. */}
      {overBudget && stats.swaps.length > 0 && (
        <div className="list2-dash-swaps">
          <div className="list2-dash-swaps-h">
            <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M7 17l-4-4 4-4"/><path d="M3 13h14"/><path d="M17 7l4 4-4 4"/><path d="M21 11H7"/></svg>
            <span>Swap to fit your budget</span>
          </div>
          <div className="list2-dash-swaps-list">
            {stats.swaps.map(s => (
              <button key={s.currentId} type="button" className="list2-dash-swap" onClick={() => commitSwap(s)}>
                <span className="list2-dash-swap-from">{s.currentName}</span>
                <span className="list2-dash-swap-arrow" aria-hidden="true">→</span>
                <span className="list2-dash-swap-to">{s.swapName}</span>
                <span className="list2-dash-swap-saves">−{fmt(s.saves)}</span>
              </button>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

// ───────────────────────── Stats row (legacy, unused) ─────────────────────────
// Decision-aware counting. A shortlist with 4 candidates counts as ONE
// decision (not 4 items). Items inside groups count as items. Picked
// candidates inside shortlists count as items (since they're the things
// you're actually going to buy). Alternatives don't appear anywhere here
// — they're not separate "things", they're decision fodder.
function ListStatsRow({ root, productMap }) {
  const stats = _v2m(() => {
    let decisions = 0;
    let items = 0;
    let acquired = 0;
    let total = 0;
    const priceOf = (c) => {
      const p = c.productId && productMap ? productMap[c.productId] : null;
      return Number((p && p.price) || (c.custom && c.custom.price) || 0) || 0;
    };
    const walk = (n) => {
      (n.children || []).forEach(c => {
        if (c.type === 'item') {
          items++;
          total += priceOf(c);
          if ((c.status || 'want') === 'have') acquired++;
        } else if (c.type === 'slot') {
          if (c.slotKind === 'shortlist') {
            decisions++;
            const cands = (c.children || []).filter(k => k.type === 'item');
            const picks = cands.filter(k => k.picked);
            picks.forEach(p => {
              items++;
              total += priceOf(p);
              if ((p.status || 'want') === 'have') acquired++;
            });
            // Don't walk into a shortlist's candidates as separate items —
            // they're alternatives, not actionable to-buys.
            return;
          }
          walk(c);
        }
      });
    };
    walk(root);
    return { decisions, items, acquired, total };
  }, [root, productMap]);
  if (stats.decisions === 0 && stats.items === 0) return null;
  return (
    <div className="list2-stats">
      {stats.decisions > 0 && (
        <span className="list2-stat list2-stat--decisions">
          {stats.decisions} {stats.decisions === 1 ? 'decision' : 'decisions'}
        </span>
      )}
      {stats.items > 0 && (
        <span className="list2-stat">{stats.items} {stats.items === 1 ? 'item' : 'items'}</span>
      )}
      {stats.acquired > 0 && (
        <span className="list2-stat list2-stat--acquired">✓ {stats.acquired} have</span>
      )}
      {stats.total > 0 && (
        <span className="list2-stat list2-stat--money">A${stats.total.toLocaleString()}</span>
      )}
    </div>
  );
}

// ───────────────────────── List controls (search/filter/sort) ─────────────────────────
// Slim bar between the budget meter and the toolbar. Only renders when
// the list has enough items to make controls useful (>= 6) or when the
// user has actively engaged one of them (active state shouldn't disappear
// just because we crossed back below the threshold).
function ListControlsBar({ root, productMap, searchQ, onSearchQ, filterStatus, onFilterStatus, filterMaxPrice, onFilterMaxPrice, sortBy, onSortBy }) {
  // Walk the tree to count items + compute price range + count picked
  // shortlists. We use these to (a) decide whether to render at all,
  // (b) tune the max-price slider, and (c) show the focus toggle only
  // when it'd actually do something (≥1 shortlist has a picked candidate).
  const summary = _v2m(() => {
    let count = 0, max = 0, pickedShortlists = 0;
    const walk = (n) => {
      (n.children || []).forEach(c => {
        if (c.type === 'item') {
          count++;
          const p = c.productId && productMap ? productMap[c.productId] : null;
          const price = Number((p && p.price) || (c.custom && c.custom.price) || 0) || 0;
          if (price > max) max = price;
        }
        if (c.type === 'slot') {
          if (c.slotKind === 'shortlist') {
            const hasPick = (c.children || []).some(k => k.type === 'item' && k.picked);
            if (hasPick) pickedShortlists++;
          }
          walk(c);
        }
      });
    };
    walk(root);
    return { count, max, pickedShortlists };
  }, [root, productMap]);
  const hasActiveControl = !!searchQ || !!filterStatus || filterMaxPrice != null || sortBy !== 'manual';
  if (summary.count < 6 && !hasActiveControl) return null;

  const clearAll = () => {
    onSearchQ('');
    onFilterStatus(null);
    onFilterMaxPrice(null);
    onSortBy('manual');
  };

  return (
    <div className="list2-controls">
      <div className="list2-controls-search">
        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
        <input
          type="text"
          placeholder={`Search ${summary.count} items…`}
          value={searchQ}
          onChange={(e) => onSearchQ(e.target.value)}
        />
        {searchQ && (
          <button
            type="button"
            className="list2-controls-search-clear"
            onClick={() => onSearchQ('')}
            aria-label="Clear search"
          >×</button>
        )}
      </div>
      <div className="list2-controls-chips">
        {['want', 'need', 'have'].map(s => {
          const meta = (window.STATUS_META || {})[s] || { label: s, tone: s };
          return (
            <button
              key={s}
              type="button"
              className={`list2-controls-chip list2-controls-chip--${meta.tone}${filterStatus === s ? ' is-on' : ''}`}
              onClick={() => onFilterStatus(filterStatus === s ? null : s)}
              title={`Show only ${meta.label}`}
            >{meta.label}</button>
          );
        })}
        {summary.max > 0 && (
          <label className="list2-controls-priceslider" title="Max price">
            <span>Under A${filterMaxPrice != null ? filterMaxPrice : Math.ceil(summary.max)}</span>
            <input
              type="range"
              min={0}
              max={Math.ceil(summary.max)}
              step={Math.max(1, Math.floor(summary.max / 100))}
              value={filterMaxPrice != null ? filterMaxPrice : Math.ceil(summary.max)}
              onChange={(e) => {
                const v = Number(e.target.value);
                onFilterMaxPrice(v >= Math.ceil(summary.max) ? null : v);
              }}
            />
          </label>
        )}
      </div>
      <div className="list2-controls-sort">
        <label>
          <span>Sort</span>
          <select
            value={sortBy}
            onChange={(e) => onSortBy(e.target.value)}
          >
            <option value="manual">Manual</option>
            <option value="price-asc">Price ↑</option>
            <option value="price-desc">Price ↓</option>
            <option value="name">Name A→Z</option>
            <option value="newest">Recently added</option>
          </select>
        </label>
      </div>
      {/* Focus mode toggle moved into the settings popover next to the
          list title. It belongs in settings — it's a list-level pref,
          not a filter. */}
      {hasActiveControl && (
        <button
          type="button"
          className="list2-controls-clear"
          onClick={clearAll}
          title="Clear all filters"
        >Clear</button>
      )}
    </div>
  );
}

// ───────────────────────── Progress tracker ─────────────────────────
// Two thin progress bars at the top of every list — the at-a-glance
// answer to "how far along am I?". Designed to make the user feel
// momentum and to give them a clear destination.
//
// • DECISIONS — what fraction of the list's shortlists have a picked
//   candidate. The "deciding" phase of the list's lifecycle.
// • HAVE      — what fraction of the actionable items (plain items +
//   picked shortlist items) you've actually marked as Have. The
//   "acquisition" phase.
//
// Each row is tappable: Undecided → opens the list with shortlists
// unfocused so the un-picked ones are visible; Have → filters to
// status=have. Two filter shortcuts that map neatly to the two phases.
function ListProgressBar({ root, onFilterUndecided, onFilterAcquired }) {
  const stats = _v2m(() => {
    let shortlists = 0, decided = 0;
    let actionable = 0, acquired = 0;
    const walk = (n) => {
      (n.children || []).forEach(c => {
        if (c.type === 'item') {
          actionable++;
          if ((c.status || 'want') === 'have') acquired++;
        } else if (c.type === 'slot') {
          if (c.slotKind === 'shortlist') {
            shortlists++;
            const cands = (c.children || []).filter(k => k.type === 'item');
            const pickedCands = cands.filter(k => k.picked);
            if (pickedCands.length > 0) {
              decided++;
              // Only the picked candidates count as actionable acquisitions.
              pickedCands.forEach(k => {
                actionable++;
                if ((k.status || 'want') === 'have') acquired++;
              });
            }
            // Skip walking the shortlist's children (alternatives don't
            // count as separate acquisitions).
            return;
          }
          walk(c);
        }
      });
    };
    walk(root);
    return { shortlists, decided, actionable, acquired };
  }, [root]);

  if (stats.shortlists === 0 && stats.actionable === 0) return null;
  const decisionPct = stats.shortlists > 0 ? Math.round((stats.decided / stats.shortlists) * 100) : 100;
  const havePct     = stats.actionable > 0 ? Math.round((stats.acquired / stats.actionable) * 100) : 0;
  // Composite "journey" percentage — a single headline number. Decisions
  // weight as 30% (you can decide before acquiring; harder to acquire
  // without deciding). Have weights 70%.
  const overallPct = stats.shortlists > 0
    ? Math.round(0.3 * decisionPct + 0.7 * havePct)
    : havePct;
  const isComplete = overallPct === 100;

  // First-time-hits-100 confetti. Persists in localStorage so we don't
  // re-celebrate on every render once the list is complete. Resets if
  // the user un-marks something (overallPct drops below 100).
  _v2e(() => {
    if (!root || !root.id) return;
    const key = `mr-completed-${root.id}`;
    if (isComplete) {
      try {
        if (!localStorage.getItem(key)) {
          fireConfetti();
          localStorage.setItem(key, '1');
        }
      } catch {}
    } else {
      try { localStorage.removeItem(key); } catch {}
    }
  }, [isComplete, root && root.id]);

  return (
    <div className={`list2-progress${isComplete ? ' is-complete' : ''}`}>
      <div className="list2-progress-overall">
        <span className="list2-progress-overall-pct">{overallPct}%</span>
        <span className="list2-progress-overall-label">complete</span>
      </div>
      <div className="list2-progress-tracks">
        {stats.shortlists > 0 && (
          <button
            type="button"
            className="list2-progress-track"
            onClick={onFilterUndecided}
            title="Show undecided shortlists"
          >
            <div className="list2-progress-track-row">
              <span className="list2-progress-track-label">Decisions</span>
              <span className="list2-progress-track-count">
                {stats.decided} of {stats.shortlists}
              </span>
            </div>
            <div className="list2-progress-bar">
              <div
                className="list2-progress-fill list2-progress-fill--decisions"
                style={{ width: `${decisionPct}%` }}
              />
            </div>
          </button>
        )}
        {stats.actionable > 0 && (
          <button
            type="button"
            className="list2-progress-track"
            onClick={onFilterAcquired}
            title="Show items you have"
          >
            <div className="list2-progress-track-row">
              <span className="list2-progress-track-label">Have</span>
              <span className="list2-progress-track-count">
                {stats.acquired} of {stats.actionable}
              </span>
            </div>
            <div className="list2-progress-bar">
              <div
                className="list2-progress-fill list2-progress-fill--have"
                style={{ width: `${havePct}%` }}
              />
            </div>
          </button>
        )}
      </div>
    </div>
  );
}

// ───────────────────────── Budget meter ─────────────────────────
// Local-first budget tracker. Persists to localStorage per root id (no
// schema change). Computes three numbers from the tree:
//   • Plan:  the sum of intended buys — picked candidates inside shortlists
//            plus every plain item in groups / at the root. Reflects what
//            the user is on track to spend if they commit to current picks.
//   • Spent: items with status === 'have' — what's already in the cart/home.
//   • Cheapest: lower bound — cheapest candidate in each shortlist + every
//            plain item. Useful when deciding if the budget is even feasible.
// All three are summed in whatever currency dominates the list (defaults
// to AUD). Mixed-currency lists fall back to AUD — fine for v0.
function ListBudgetMeter({ root, productMap }) {
  const KEY = `mr-budget-${root.id}`;
  const [budget, setBudgetState] = _v2s(() => {
    try {
      const v = localStorage.getItem(KEY);
      return v ? Number(v) : null;
    } catch { return null; }
  });
  const [editing, setEditing] = _v2s(false);
  const [draft, setDraft] = _v2s(budget != null ? String(budget) : '');
  _v2e(() => { setDraft(budget != null ? String(budget) : ''); }, [budget]);

  const commit = () => {
    const v = draft.trim();
    if (!v) {
      try { localStorage.removeItem(KEY); } catch {}
      setBudgetState(null);
    } else {
      const n = Math.max(0, Number(v.replace(/[^0-9.]/g, '')) || 0);
      try { localStorage.setItem(KEY, String(n)); } catch {}
      setBudgetState(n);
    }
    setEditing(false);
  };

  const priceOf = (node) => {
    const p = node.productId && productMap ? productMap[node.productId] : null;
    return Number((p && p.price) || (node.custom && node.custom.price) || 0) || 0;
  };

  const totals = _v2m(() => {
    let plan = 0, spent = 0, cheapest = 0;
    const walk = (n) => {
      const isShortlist = n.type === 'slot' && n.slotKind === 'shortlist';
      if (isShortlist) {
        const cands = (n.children || []).filter(c => c.type === 'item');
        const prices = cands.map(priceOf).filter(p => p > 0);
        // Picked → counts as Plan. If multiple picked, sum them.
        const pickedSum = cands.filter(c => c.picked).reduce((s, c) => s + priceOf(c), 0);
        if (pickedSum > 0) plan += pickedSum;
        // Cheapest contributes the min candidate price.
        if (prices.length > 0) cheapest += Math.min(...prices);
        // Spent: any candidate marked Have.
        spent += cands.filter(c => (c.status || 'want') === 'have').reduce((s, c) => s + priceOf(c), 0);
        // Don't double-walk shortlist children — items inside a shortlist
        // are *alternatives*, not separately accountable.
        return;
      }
      (n.children || []).forEach(c => {
        if (c.type === 'item') {
          const price = priceOf(c);
          plan += price;
          cheapest += price;
          if ((c.status || 'want') === 'have') spent += price;
        } else if (c.type === 'slot') {
          walk(c);
        }
      });
    };
    walk(root);
    return { plan, spent, cheapest };
  }, [root, productMap]);

  // Swap suggestions — only surface when over budget. Walks shortlists
  // with a picked candidate and looks for a cheaper sibling. Returns
  // the top 3 swaps ranked by savings. Each one tells the user exactly
  // how much they'd save by switching to the cheaper alternative.
  const swaps = _v2m(() => {
    if (budget == null || totals.plan <= budget) return [];
    const out = [];
    const walk = (n) => {
      const isShortlist = n.type === 'slot' && n.slotKind === 'shortlist';
      if (isShortlist) {
        const cands = (n.children || []).filter(c => c.type === 'item' && priceOf(c) > 0);
        const picked = cands.filter(c => c.picked);
        if (picked.length === 0) return;
        const cheapestCand = cands.slice().sort((a, b) => priceOf(a) - priceOf(b))[0];
        picked.forEach(p => {
          const saves = priceOf(p) - priceOf(cheapestCand);
          if (saves > 0 && cheapestCand.id !== p.id) {
            const cheapestProduct = cheapestCand.productId && productMap ? productMap[cheapestCand.productId] : null;
            const cheapestName = (cheapestProduct && cheapestProduct.name)
              || (cheapestCand.custom && cheapestCand.custom.name)
              || cheapestCand.name
              || 'cheaper option';
            out.push({
              shortlistName: n.name,
              currentName: ((p.productId && productMap ? productMap[p.productId] : null) || {}).name
                || (p.custom && p.custom.name) || p.name || 'current pick',
              swapName: cheapestName,
              swapId: cheapestCand.id,
              currentId: p.id,
              saves,
            });
          }
        });
        return; // don't walk into shortlist children
      }
      (n.children || []).forEach(c => { if (c.type === 'slot') walk(c); });
    };
    walk(root);
    out.sort((a, b) => b.saves - a.saves);
    return out.slice(0, 3);
  }, [root, productMap, budget, totals.plan]);

  // Hide entirely if there's nothing priced AND no budget set. Otherwise
  // always show — the meter is a primary affordance, not a stat tweak.
  if (totals.plan === 0 && totals.spent === 0 && totals.cheapest === 0 && budget == null) return null;

  const currency = 'A$';   // see comment in the header — v0 always AUD
  const fmt = (n) => `${currency}${Math.round(n).toLocaleString()}`;
  const pct = budget != null && budget > 0 ? Math.min(100, Math.round((totals.plan / budget) * 100)) : null;
  const over = budget != null && totals.plan > budget;

  return (
    <div className={`list2-budget${over ? ' is-over' : ''}`}>
      <div className="list2-budget-head">
        <div className="list2-budget-numbers">
          <span className="list2-budget-plan">
            <span className="list2-budget-plan-num">{fmt(totals.plan)}</span>
            {budget != null && (
              <>
                <span className="list2-budget-of">of</span>
                <span className="list2-budget-target">{fmt(budget)}</span>
              </>
            )}
          </span>
          {budget == null && totals.plan > 0 && (
            <span className="list2-budget-sublabel">planned spend</span>
          )}
          {budget != null && pct != null && (
            <span className={`list2-budget-pct${over ? ' is-over' : ''}`}>
              {pct}%{over ? ` · ${fmt(totals.plan - budget)} over` : ''}
            </span>
          )}
        </div>
        {editing ? (
          <form
            className="list2-budget-edit"
            onSubmit={(e) => { e.preventDefault(); commit(); }}
          >
            <span className="list2-budget-edit-prefix">{currency}</span>
            <input
              type="text"
              inputMode="numeric"
              autoFocus
              value={draft}
              onChange={(e) => setDraft(e.target.value)}
              onBlur={commit}
              placeholder="2000"
            />
          </form>
        ) : (
          <button
            type="button"
            className="list2-budget-edit-btn"
            onClick={() => setEditing(true)}
            title={budget != null ? 'Change budget' : 'Set a budget for this list'}
          >
            {budget != null ? 'Edit' : '+ Set budget'}
          </button>
        )}
      </div>
      {budget != null && (
        <div className="list2-budget-bar">
          <div
            className={`list2-budget-bar-fill${over ? ' is-over' : ''}`}
            style={{ width: `${Math.min(100, (totals.plan / budget) * 100)}%` }}
          />
          {totals.spent > 0 && (
            <div
              className="list2-budget-bar-spent"
              style={{ width: `${Math.min(100, (totals.spent / budget) * 100)}%` }}
              title={`Spent: ${fmt(totals.spent)}`}
            />
          )}
        </div>
      )}
      <div className="list2-budget-foot">
        {totals.spent > 0 && (
          <span className="list2-budget-chip list2-budget-chip--spent">
            {fmt(totals.spent)} spent
          </span>
        )}
        {totals.cheapest > 0 && totals.cheapest !== totals.plan && (
          <span className="list2-budget-chip">
            Cheapest possible: {fmt(totals.cheapest)}
          </span>
        )}
      </div>
      {/* Swap suggestions — surface when over budget. Each row shows
          the shortlist + a one-click swap to a cheaper sibling + the
          dollar savings. Tap to commit the swap (unpicks current,
          picks the cheaper one). */}
      {swaps.length > 0 && (
        <div className="list2-budget-swaps">
          <div className="list2-budget-swaps-h">
            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M7 17l-4-4 4-4"/><path d="M3 13h14"/><path d="M17 7l4 4-4 4"/><path d="M21 11H7"/></svg>
            <span>Swap to fit your budget</span>
          </div>
          <ul className="list2-budget-swaps-list">
            {swaps.map(s => (
              <li key={s.currentId} className="list2-budget-swap">
                <div className="list2-budget-swap-body">
                  <span className="list2-budget-swap-shortlist">{s.shortlistName}</span>
                  <span className="list2-budget-swap-from-to">
                    <span className="list2-budget-swap-from">{s.currentName}</span>
                    <span className="list2-budget-swap-arrow" aria-hidden="true">→</span>
                    <span className="list2-budget-swap-to">{s.swapName}</span>
                  </span>
                </div>
                <button
                  type="button"
                  className="list2-budget-swap-cta"
                  onClick={async () => {
                    // Commit the swap — unpick current, pick the alt.
                    await window.MR.nodes.updateNode(s.currentId, { picked: false });
                    await window.MR.nodes.updateNode(s.swapId,    { picked: true });
                    // The page re-fetch happens via the existing
                    // refresh-on-mutation pattern. The user sees totals
                    // update immediately on re-render.
                    if (typeof window.__mr_refreshRoot === 'function') window.__mr_refreshRoot();
                  }}
                  title={`Switch to ${s.swapName}`}
                >
                  Save {fmt(s.saves)}
                </button>
              </li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
}

// ───────────────────────── Toolbar + Quick add ─────────────────────────
function ListToolbarV2({ rootName, rootId, onAddItem, onAddSlot, onAddSublist, productMap, viewMode, onViewMode, gridSize, onGridSize, onItemAdded }) {
  // Add modes: 'group' (just a heading) | 'shortlist' (pick one of…) | 'link'.
  // The old single "section" button is gone — users now pick their intent
  // up front, and the affordances on the resulting container differ.
  const [mode, setMode] = _v2s(null);
  const [draft, setDraft] = _v2s('');
  const [linkUrl, setLinkUrl] = _v2s('');
  const [linkBusy, setLinkBusy] = _v2s(false);
  const [linkErr, setLinkErr] = _v2s('');

  // Submit either kind. Group ⇒ quantityNeeded 0 (it's just a heading);
  // Shortlist ⇒ quantityNeeded 1 (pick one — can later be tweaked).
  const submitContainer = (slotKind) => (e) => {
    if (e) e.preventDefault();
    const name = draft.trim();
    if (!name) return;
    onAddSlot({ name, slotKind });
    setDraft(''); setMode(null);
  };
  // Paste a URL → og-fetch → drop directly into this list as an item.
  // No status picker / list selector — we're already inside the list the
  // user wants it to go into. Tries to match to the catalog first; falls
  // back to a custom item with the fetched name/brand/price/image.
  const submitLink = async (e) => {
    if (e) e.preventDefault();
    const url = linkUrl.trim();
    if (!url) return;
    setLinkErr(''); setLinkBusy(true);
    try {
      const resp = await fetch(`/api/og-fetch?url=${encodeURIComponent(url)}&deep=1`);
      const json = await resp.json();
      if (!resp.ok || !json || !json.ok || !json.data) {
        setLinkErr((json && json.error) || "Couldn't read that page");
        setLinkBusy(false);
        return;
      }
      const data = json.data;
      // Bot-protected retailer (Amazon, David Jones, Macy's, eBay,
      // Walmart, …) — surface the error instead of polluting the list
      // with an "Amazon.com" stub item that has no real product data.
      if (data.blocked) {
        setLinkErr("That retailer blocks automated fetches. Open the page in a new tab and use the 'Save to Magic Rascals' bookmarklet (Settings) — it runs in your browser, so it sees the real page.");
        setLinkBusy(false);
        return;
      }
      const decode = (s) => s ? s.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#039;/g, "'") : '';
      const title = decode(data.title) || url;
      // Paste-link policy: NEVER fuzzy-match into the catalog. A URL is
      // an unambiguous reference to a specific product page — silently
      // swapping it for a "similar" catalog item (e.g. pasting a pram
      // link and getting back a car seat because both are made by Cybex)
      // is a serious wrong-product bug. We use the real og-fetched data
      // every time. Catalog items can still come in via the search box.
      // CRITICAL: await onAddItem. The underlying addItem is async and
      // calls refreshRoot internally on success — if we don't await it,
      // the subsequent onItemAdded() refresh races the insert and the
      // user sees the OLD list state. The toolbar's onItemAdded ends up
      // being a no-op because addItem already refreshes on success.
      {
        await onAddItem({
          custom: {
            name: title,
            brand: data.brand || '',
            image: data.image || '',
            price: data.price || null,
            currency: data.currency || 'AUD',
            sourceUrl: data.sourceUrl || url,
            description: decode(data.description) || '',
            // og-fetch now returns up to 12 deduped images from JSON-LD,
            // og:image variants, Shopify __NEXT_DATA__, and <img> scrapes.
            // addItem will stash this array in localStorage so the detail
            // panel can render the full carousel.
            gallery: Array.isArray(data.gallery) && data.gallery.length
              ? data.gallery
              : (data.image ? [data.image] : []),
          },
          name: title,
          // og-fetch extracts age range from JSON-LD audience / page
          // text when available. Forwarded silently — the in-list paste
          // flow has no form for the user to confirm, so the age just
          // lands on the item. They can change it later from the detail
          // panel if it's wrong.
          ageMin: data.ageMin != null ? Number(data.ageMin) : null,
          ageMax: data.ageMax != null ? Number(data.ageMax) : null,
        });
      }
      setLinkUrl(''); setMode(null); setLinkBusy(false);
      // Small celebration toast — feedback that the item actually landed.
      if (typeof window.__mr_showToast === 'function') {
        window.__mr_showToast(`Added "${title.slice(0, 40)}${title.length > 40 ? '…' : ''}"`);
      }
    } catch (e) {
      setLinkErr(e.message || 'Network problem — try again?');
      setLinkBusy(false);
    }
  };

  // Smart paste flow for QuickSearchV2: same machinery as submitLink but
  // called from the search input when the user pastes a URL. Returns a
  // promise so the QuickSearchV2 caller can show its own busy state.
  // Throws on failure with a user-friendly message (caller surfaces it).
  const addByPastedUrl = async (url) => {
    const resp = await fetch(`/api/og-fetch?url=${encodeURIComponent(url)}&deep=1`);
    const json = await resp.json();
    if (!resp.ok || !json || !json.ok || !json.data) {
      throw new Error((json && json.error) || "Couldn't read that page");
    }
    const data = json.data;
    const decode = (s) => s ? s.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#039;/g, "'") : '';
    if (data.blocked) {
      // Bot-protected retailer (DJ, Macy's, etc.) returned an interstitial.
      // Don't pollute the list with a half-baked item — be honest and tell
      // the user to use the bookmarklet for these sites. The error
      // surfaces in QuickSearchV2's URL-mode UI.
      throw new Error("That retailer blocks automated fetches (Amazon, David Jones, Macy's, eBay all do this). Open the page in a new tab and use the 'Save to Magic Rascals' bookmarklet (Settings) — it runs in your browser, so it sees the real page.");
    }
    const title = decode(data.title) || url;
    // Paste-link policy: NEVER fuzzy-match into the catalog. A pasted
    // URL is an unambiguous reference; silently swapping it for a
    // "similar" catalog item by brand-token overlap (the bug that turned
    // a Cybex pram URL into a Cybex car seat) is unacceptable. We
    // always add the real og-fetched product as a custom item. The
    // catalog item path is reserved for the catalog-search dropdown,
    // where the user is explicitly clicking a specific match.
    //
    // CRITICAL: await onAddItem (it's async and addItem internally calls
    // refreshRoot). Without the await, the catch-block of QuickSearchV2's
    // runUrlAdd may clear the busy state and refocus the input BEFORE the
    // node actually lands in Supabase, and the user sees nothing change.
    await onAddItem({
      custom: {
        name: title,
        brand: data.brand || '',
        image: data.image || '',
        price: data.price || null,
        currency: data.currency || 'AUD',
        sourceUrl: data.sourceUrl || url,
        description: decode(data.description) || '',
        gallery: Array.isArray(data.gallery) && data.gallery.length
          ? data.gallery
          : (data.image ? [data.image] : []),
        materials: data.materials || null,
        certifications: Array.isArray(data.certifications) ? data.certifications : [],
        dimensions: data.dimensions || null,
      },
      name: title,
      ageMin: data.ageMin != null ? Number(data.ageMin) : null,
      ageMax: data.ageMax != null ? Number(data.ageMax) : null,
    });
    // Celebration toast + tiny sparkle on the search input — visible
    // confirmation that the item landed in the list.
    if (typeof window.__mr_showToast === 'function') {
      window.__mr_showToast(`Added "${title.slice(0, 40)}${title.length > 40 ? '…' : ''}"`);
    }
    if (typeof window.__mr_celebrate === 'function') {
      window.__mr_celebrate(document.querySelector('.list2-qsearch-input'), { variant: 'add' });
    }
  };

  return (
    <div className="list2-toolbar">
      <div className="list2-toolbar-bar">
        <QuickSearchV2 productMap={productMap} onPickProduct={(p) => onAddItem({ productId: p.id, name: p.name })} onAddCustom={(name) => onAddItem({ custom: { name }, name })} onAddByUrl={addByPastedUrl} />
        <div className="list2-toolbar-divider" aria-hidden="true" />
        <button type="button" className={`list2-toolbar-btn${mode === 'link' ? ' is-on' : ''}`} onClick={() => { setMode(mode === 'link' ? null : 'link'); setLinkUrl(''); setLinkErr(''); }} title="Paste a product link to drop straight in">
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M10 14L21 3M21 3h-7M21 3v7"/></svg>
          <span>By link</span>
        </button>
        <button type="button" className={`list2-toolbar-btn${mode === 'placeholder' ? ' is-on' : ''}`} onClick={() => { setMode(mode === 'placeholder' ? null : 'placeholder'); setDraft(''); }} title="Drop a placeholder for something you'll find later (e.g. 'a baby monitor')">
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" strokeDasharray="2 3"/><path d="M9 9a3 3 0 015.8 1c0 2-3 3-3 3"/><circle cx="12" cy="17" r="0.6" fill="currentColor"/></svg>
          <span>Placeholder</span>
        </button>
        <button type="button" className={`list2-toolbar-btn${mode === 'group' ? ' is-on' : ''}`} onClick={() => { setMode(mode === 'group' ? null : 'group'); setDraft(''); }} title='A heading with items underneath. Toggle "Pick one of" on the row to turn it into a decision shortlist.'>
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 10h18"/></svg>
          <span>Group</span>
        </button>
        {/* View mode picker — list (compact rows) vs grid (big card thumbs).
            Persists per user in localStorage; default 'list'. */}
        {onViewMode && (
          <div className="list2-viewmode" role="radiogroup" aria-label="View mode">
            <button
              type="button"
              role="radio"
              aria-checked={viewMode === 'list'}
              className={`list2-viewmode-btn${viewMode === 'list' ? ' is-on' : ''}`}
              onClick={() => onViewMode('list')}
              title="List view"
              aria-label="List view"
            >
              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><circle cx="4" cy="6" r="1.4"/><circle cx="4" cy="12" r="1.4"/><circle cx="4" cy="18" r="1.4"/></svg>
            </button>
            <button
              type="button"
              role="radio"
              aria-checked={viewMode === 'grid'}
              className={`list2-viewmode-btn${viewMode === 'grid' ? ' is-on' : ''}`}
              onClick={() => onViewMode('grid')}
              title="Grid view"
              aria-label="Grid view"
            >
              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
            </button>
          </div>
        )}
        {/* Card-size picker — only relevant when in grid mode. Four steps
            so users can dial card size from "see more at a glance" to
            "really see what each thing is." Persists alongside view mode
            so a fresh visit lands on the same density. */}
        {onViewMode && onGridSize && viewMode === 'grid' && (
          <div className="list2-gridsize" role="radiogroup" aria-label="Card size">
            {[
              { v: 'sm', label: 'S', title: 'Small cards' },
              { v: 'md', label: 'M', title: 'Medium cards' },
              { v: 'lg', label: 'L', title: 'Large cards' },
              { v: 'xl', label: 'XL', title: 'Extra-large cards' },
            ].map(opt => (
              <button
                key={opt.v}
                type="button"
                role="radio"
                aria-checked={gridSize === opt.v}
                className={`list2-gridsize-btn${gridSize === opt.v ? ' is-on' : ''}`}
                onClick={() => onGridSize(opt.v)}
                title={opt.title}
                aria-label={opt.title}
              >
                {opt.label}
              </button>
            ))}
          </div>
        )}
      </div>
      {mode === 'link' && (
        <form className="list2-toolbar-inline" onSubmit={submitLink}>
          <input
            type="url"
            autoFocus
            placeholder="Paste a product link from anywhere…"
            value={linkUrl}
            onChange={(e) => setLinkUrl(e.target.value)}
            className="list2-toolbar-inline-name"
            inputMode="url"
            autoComplete="off"
          />
          <button type="submit" className="btn" disabled={!linkUrl.trim() || linkBusy} style={{ width: 'auto', padding: '8px 14px' }}>
            <span>{linkBusy ? 'Reading…' : 'Add to list'}</span><span className="arrow">→</span>
          </button>
          <button type="button" className="btn btn-ghost" onClick={() => { setMode(null); setLinkUrl(''); setLinkErr(''); }} style={{ width: 'auto', padding: '8px 12px' }}>Cancel</button>
          {linkErr && <div className="quick-add-err" style={{ width: '100%', marginTop: 6 }}>{linkErr}</div>}
        </form>
      )}
      {mode === 'placeholder' && (
        <PlaceholderForm
          onSubmit={(spec) => {
            // Use a dummy id while we wait for the server. The real one
            // arrives via refresh; the placeholder flag is keyed by id so
            // we use the returned id to set the localStorage flag.
            onAddItem({
              custom: { name: spec.name, price: spec.price, placeholder: true },
              name: spec.name,
            });
            setMode(null);
            // The placeholder flag is stamped on the new row after the
            // refresh-from-server resolves — the inline handler isn't easy
            // to get the id back from, so we hook addItem in v2 instead.
          }}
          onCancel={() => setMode(null)}
        />
      )}
      {mode === 'group' && (
        <form className="list2-toolbar-inline" onSubmit={submitContainer('group')}>
          <input
            type="text"
            autoFocus
            placeholder={'Group name — e.g. "Sleep" or "Bath time"'}
            value={draft}
            onChange={(e) => setDraft(e.target.value)}
            className="list2-toolbar-inline-name"
          />
          <button type="submit" className="btn" disabled={!draft.trim()} style={{ width: 'auto', padding: '8px 14px' }}>
            <span>Add group</span><span className="arrow">→</span>
          </button>
          <button type="button" className="btn btn-ghost" onClick={() => setMode(null)} style={{ width: 'auto', padding: '8px 12px' }}>Cancel</button>
        </form>
      )}
    </div>
  );
}

// Inline catalog search. Picks a product → onPickProduct; "add as custom" →
// onAddCustom with whatever the user typed.
function QuickSearchV2({ productMap, onPickProduct, onAddCustom, onAddByUrl }) {
  const [q, setQ] = _v2s('');
  const [focusIdx, setFocusIdx] = _v2s(0);
  // URL-paste mode: when the input value looks like a URL we hide the
  // catalog-search dropdown and show a "Add link" action / spinner / error.
  const [urlBusy, setUrlBusy] = _v2s(false);
  const [urlErr, setUrlErr] = _v2s('');
  const inputRef = _v2r(null);

  // A pasted URL should never be treated as a catalog search query — it
  // would just produce zero matches and a confusing "Add as custom item"
  // row. Detect it cheaply by the protocol prefix.
  const isUrl = /^https?:\/\/\S+\.\S+/i.test(q.trim());

  const results = _v2m(() => {
    if (isUrl) return [];
    const term = q.trim().toLowerCase();
    if (term.length < 2) return [];
    const all = window.PRODUCTS || [];
    const out = [];
    for (const p of all) {
      const hay = (p.name + ' ' + (p.brand || '')).toLowerCase();
      if (hay.includes(term)) out.push(p);
      if (out.length >= 6) break;
    }
    return out;
  }, [q, isUrl]);

  const total = results.length + (q.trim().length >= 2 ? 1 : 0); // last row is "add custom"

  const runUrlAdd = async (url) => {
    if (!onAddByUrl) return;
    setUrlErr(''); setUrlBusy(true);
    try {
      await onAddByUrl(url);
      // Success — clear the input and refocus for the next add.
      setQ(''); setFocusIdx(0); setUrlBusy(false);
      if (inputRef.current) inputRef.current.focus();
    } catch (err) {
      setUrlErr(err.message || "Couldn't fetch that link");
      setUrlBusy(false);
    }
  };

  const onKey = (e) => {
    if (e.key === 'ArrowDown') { e.preventDefault(); setFocusIdx(i => Math.min(total - 1, i + 1)); }
    else if (e.key === 'ArrowUp') { e.preventDefault(); setFocusIdx(i => Math.max(0, i - 1)); }
    else if (e.key === 'Enter') {
      // URL path takes priority — pressing Enter on a pasted/typed URL
      // triggers the link-fetch instead of a custom-name fallback.
      if (isUrl && onAddByUrl) {
        e.preventDefault();
        runUrlAdd(q.trim());
        return;
      }
      if (q.trim().length >= 2) {
        e.preventDefault();
        if (focusIdx < results.length) onPickProduct(results[focusIdx]);
        else onAddCustom(q.trim());
        setQ(''); setFocusIdx(0); if (inputRef.current) inputRef.current.focus();
      }
    }
    else if (e.key === 'Escape') { setQ(''); setUrlErr(''); }
  };

  // Paste handler — when the pasted content is a URL, kick off the fetch
  // immediately. We can't rely on `q` here because setQ is async; read
  // the pasted text directly from the clipboard event.
  const onPaste = (e) => {
    if (!onAddByUrl) return;
    const txt = (e.clipboardData && e.clipboardData.getData('text')) || '';
    if (/^https?:\/\/\S+\.\S+/i.test(txt.trim())) {
      // Let the input reflect what they pasted (helps if og-fetch fails
      // and we surface the URL in the error), then fetch on next tick.
      setQ(txt.trim());
      e.preventDefault();
      // Defer until the next microtask so the input visibly updates first.
      Promise.resolve().then(() => runUrlAdd(txt.trim()));
    }
  };

  return (
    <div className="list2-qsearch">
      <svg className="list2-qsearch-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
      <input
        ref={inputRef}
        type="text"
        className="list2-qsearch-input"
        placeholder="Type to add — search, paste a product link, or type something new…"
        value={q}
        onChange={(e) => { setQ(e.target.value); setFocusIdx(0); if (urlErr) setUrlErr(''); }}
        onKeyDown={onKey}
        onPaste={onPaste}
        disabled={urlBusy}
      />
      {q && !urlBusy && <button type="button" className="list2-qsearch-clear" onClick={() => { setQ(''); setUrlErr(''); }} aria-label="Clear">×</button>}
      {/* URL mode — replaces the catalog dropdown with a focused action row. */}
      {isUrl && (
        <div className="list2-qsearch-results">
          {urlBusy ? (
            <div className="list2-qsearch-row" style={{ cursor: 'default' }}>
              <span className="list2-qsearch-thumb list2-qsearch-thumb--custom" style={{ animation: 'spin 0.9s linear infinite' }}>⟳</span>
              <span className="list2-qsearch-body">
                <span className="list2-qsearch-brand">FETCHING</span>
                <span className="list2-qsearch-name">Reading product details from the page…</span>
              </span>
            </div>
          ) : urlErr ? (
            <>
              <div className="list2-qsearch-row" style={{ cursor: 'default', alignItems: 'flex-start' }}>
                <span className="list2-qsearch-thumb list2-qsearch-thumb--custom" style={{ color: '#b03a2e' }}>!</span>
                <span className="list2-qsearch-body">
                  <span className="list2-qsearch-brand" style={{ color: '#b03a2e' }}>COULDN'T FETCH</span>
                  <span className="list2-qsearch-name">{urlErr}</span>
                </span>
              </div>
              <button
                type="button"
                className="list2-qsearch-row list2-qsearch-row--custom"
                onClick={() => runUrlAdd(q.trim())}
              >
                <span className="list2-qsearch-thumb list2-qsearch-thumb--custom">↻</span>
                <span className="list2-qsearch-body">
                  <span className="list2-qsearch-brand">RETRY</span>
                  <span className="list2-qsearch-name">Try fetching the link again</span>
                </span>
                <span className="list2-qsearch-add">+</span>
              </button>
            </>
          ) : (
            <button
              type="button"
              className="list2-qsearch-row list2-qsearch-row--custom is-focused"
              onClick={() => runUrlAdd(q.trim())}
            >
              <span className="list2-qsearch-thumb list2-qsearch-thumb--custom">🔗</span>
              <span className="list2-qsearch-body">
                <span className="list2-qsearch-brand">LINK</span>
                <span className="list2-qsearch-name">Fetch this page and add it</span>
              </span>
              <span className="list2-qsearch-add">↵</span>
            </button>
          )}
        </div>
      )}
      {!isUrl && q.trim().length >= 2 && (
        <div className="list2-qsearch-results">
          {results.map((p, i) => (
            <button
              key={p.id}
              type="button"
              className={`list2-qsearch-row${i === focusIdx ? ' is-focused' : ''}`}
              onMouseEnter={() => setFocusIdx(i)}
              onClick={() => { onPickProduct(p); setQ(''); inputRef.current && inputRef.current.focus(); }}
            >
              <span className="list2-qsearch-thumb">{p.img ? <img src={p.img} alt="" /> : '—'}</span>
              <span className="list2-qsearch-body">
                <span className="list2-qsearch-brand">{p.brand || '—'}</span>
                <span className="list2-qsearch-name">{p.name}</span>
              </span>
              <span className="list2-qsearch-add">+</span>
            </button>
          ))}
          <button
            type="button"
            className={`list2-qsearch-row list2-qsearch-row--custom${focusIdx === results.length ? ' is-focused' : ''}`}
            onMouseEnter={() => setFocusIdx(results.length)}
            onClick={() => { onAddCustom(q.trim()); setQ(''); inputRef.current && inputRef.current.focus(); }}
          >
            <span className="list2-qsearch-thumb list2-qsearch-thumb--custom">+</span>
            <span className="list2-qsearch-body">
              <span className="list2-qsearch-brand">CUSTOM</span>
              <span className="list2-qsearch-name">Add "{q.trim()}" as a custom item</span>
            </span>
            <span className="list2-qsearch-add">+</span>
          </button>
        </div>
      )}
    </div>
  );
}

// ───────────────────────── Recursive renderer ─────────────────────────
function NodeChildren({ parent, depth, productMap, onOpenItem, onAddItem, onAddSlot, onUpdate, onDelete, votes, myUserId, onVote, rootKind, claims, comments, viewMode, gridSize, listControls, dragNodeId, dropTarget, startCustomDrag, onCompareShortlist, onMoveWithin, selectedIds, toggleSelected, selectionMode, focusMode, slotExpandedAlts, toggleSlotExpand, __extraAfter }) {
  const rawChildren = parent.children || [];
  // Apply view-control filters/sort to leaf items at THIS level. Slot
  // containers always pass through (their visibility depends on having
  // any matching items inside, but we leave them shown so the user keeps
  // structural context — empty groups get a small "0 matches" hint).
  const ctrl = listControls || {};
  const priceFor = (c) => {
    const p = c.productId && productMap ? productMap[c.productId] : null;
    return Number((p && p.price) || (c.custom && c.custom.price) || 0) || 0;
  };
  const nameFor = (c) => {
    const p = c.productId && productMap ? productMap[c.productId] : null;
    return ((p && p.name) || (c.custom && c.custom.name) || c.name || '').toLowerCase();
  };
  const brandFor = (c) => {
    const p = c.productId && productMap ? productMap[c.productId] : null;
    return ((p && p.brand) || (c.custom && c.custom.brand) || '').toLowerCase();
  };
  const matchesFilters = (c) => {
    if (c.type !== 'item') return true;
    if (ctrl.filterStatus && (c.status || 'want') !== ctrl.filterStatus) return false;
    if (ctrl.filterMust && c.necessity !== 'must') return false;
    if (ctrl.filterMaxPrice != null && priceFor(c) > ctrl.filterMaxPrice) return false;
    if (ctrl.searchQ) {
      const q = ctrl.searchQ.toLowerCase().trim();
      if (q && !(nameFor(c).includes(q) || brandFor(c).includes(q))) return false;
    }
    return true;
  };
  const sortItems = (arr) => {
    const sb = ctrl.sortBy || 'manual';
    if (sb === 'manual') return arr;
    const copy = arr.slice();
    if (sb === 'price-asc')  copy.sort((a, b) => priceFor(a) - priceFor(b));
    if (sb === 'price-desc') copy.sort((a, b) => priceFor(b) - priceFor(a));
    if (sb === 'name')       copy.sort((a, b) => nameFor(a).localeCompare(nameFor(b)));
    if (sb === 'newest')     copy.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
    return copy;
  };
  const filteredItems = sortItems(rawChildren.filter(c => c.type === 'item' && matchesFilters(c)));
  const filteredSlots = rawChildren.filter(c => c.type === 'slot');
  // Reassemble children preserving slot positions (slots stay in original
  // order between filtered/sorted items).
  const children = [
    ...filteredItems,
    ...filteredSlots,
  ];
  // Grid view: render the leaf-item children of THIS node in a card grid.
  // Applies at every level (root, groups, shortlists) so the toggle is a
  // true page-wide preference, not "only when items live at the root".
  // Slots/sub-lists themselves still render as full-width sections — only
  // the leaf items inside flip to a grid layout.
  const isGrid = viewMode === 'grid';
  // Partition into items-that-grid and structural-children (slots/sub-lists).
  const looseItems = children.filter(c => c.type === 'item');
  const structuralChildren = children.filter(c => c.type !== 'item');
  if (isGrid && looseItems.length > 0) {
    // Card-size modifier — 'md' is the default that matches the
    // historical layout; sm makes cards denser; lg/xl scale them up
    // for users who want a more visual browse.
    const sz = gridSize || 'md';
    return (
      <>
        <ul className={`list2-children list2-children--grid list2-children--grid-${sz}`}>
          {looseItems.map(node => (
            <li key={node.id}>
              <ItemNode
                node={node}
                productMap={productMap}
                onOpenItem={onOpenItem}
                onUpdate={onUpdate}
                onDelete={onDelete}
                isUnderSlot={parent.type === 'slot'}
                isUnderShortlist={parent.type === 'slot' && parent.slotKind === 'shortlist'}
                slotQty={null}
                votes={votes}
                myUserId={myUserId}
                onVote={onVote}
                rootKind={rootKind}
                claims={claims}
                comments={comments}
                grid
                dragNodeId={dragNodeId}
                dropTarget={dropTarget}
                parentId={parent.id}
                indexInParent={looseItems.indexOf(node)}
                startCustomDrag={startCustomDrag}
            onCompareShortlist={onCompareShortlist}
            listControls={listControls}
            onMoveWithin={onMoveWithin}
            selectedIds={selectedIds}
            toggleSelected={toggleSelected}
            selectionMode={selectionMode}
            focusMode={focusMode}
            slotExpandedAlts={slotExpandedAlts}
            toggleSlotExpand={toggleSlotExpand}
              />
            </li>
          ))}
        </ul>
        {structuralChildren.length > 0 && (
          <ul className={`list2-children list2-children--d${Math.min(depth, 3)}`}>
            {structuralChildren.map(node => (
              <li key={node.id}>
                <SlotNode
                  node={node}
                  depth={depth}
                  productMap={productMap}
                  onOpenItem={onOpenItem}
                  onAddItem={onAddItem}
                  onAddSlot={onAddSlot}
                  onUpdate={onUpdate}
                  onDelete={onDelete}
                  votes={votes}
                  myUserId={myUserId}
                  onVote={onVote}
                  rootKind={rootKind}
                  claims={claims}
                  comments={comments}
                  viewMode={viewMode}
                  gridSize={gridSize}
                  dragNodeId={dragNodeId}
                  dropTarget={dropTarget}
                  startCustomDrag={startCustomDrag}
            onCompareShortlist={onCompareShortlist}
            listControls={listControls}
            onMoveWithin={onMoveWithin}
            selectedIds={selectedIds}
            toggleSelected={toggleSelected}
            selectionMode={selectionMode}
            focusMode={focusMode}
            slotExpandedAlts={slotExpandedAlts}
            toggleSlotExpand={toggleSlotExpand}
                />
              </li>
            ))}
          </ul>
        )}
        {__extraAfter}
      </>
    );
  }
  return (
    <ul className={`list2-children list2-children--d${Math.min(depth, 3)}`}>
      {children.map(node => (
        <li key={node.id}>
          {node.type === 'slot' && (
            <SlotNode
              node={node}
              depth={depth}
              productMap={productMap}
              onOpenItem={onOpenItem}
              onAddItem={onAddItem}
              onAddSlot={onAddSlot}
              onUpdate={onUpdate}
              onDelete={onDelete}
              votes={votes}
              myUserId={myUserId}
              onVote={onVote}
              rootKind={rootKind}
              claims={claims}
              comments={comments}
              viewMode={viewMode}
              gridSize={gridSize}
              dragNodeId={dragNodeId}
              dropTarget={dropTarget}
              startCustomDrag={startCustomDrag}
            onCompareShortlist={onCompareShortlist}
            listControls={listControls}
            onMoveWithin={onMoveWithin}
            selectedIds={selectedIds}
            toggleSelected={toggleSelected}
            selectionMode={selectionMode}
            focusMode={focusMode}
            slotExpandedAlts={slotExpandedAlts}
            toggleSlotExpand={toggleSlotExpand}
            />
          )}
          {node.type === 'item' && (
            <ItemNode
              node={node}
              productMap={productMap}
              onOpenItem={onOpenItem}
              onUpdate={onUpdate}
              onDelete={onDelete}
              isUnderSlot={parent.type === 'slot'}
              isUnderShortlist={parent.type === 'slot' && parent.slotKind === 'shortlist'}
              slotQty={parent.type === 'slot' ? parent.quantityNeeded : null}
              votes={votes}
              myUserId={myUserId}
              onVote={onVote}
              rootKind={rootKind}
              claims={claims}
              comments={comments}
              dragNodeId={dragNodeId}
              dropTarget={dropTarget}
              parentId={parent.id}
              indexInParent={children.indexOf(node)}
              startCustomDrag={startCustomDrag}
            onCompareShortlist={onCompareShortlist}
            listControls={listControls}
            onMoveWithin={onMoveWithin}
            selectedIds={selectedIds}
            toggleSelected={toggleSelected}
            selectionMode={selectionMode}
            focusMode={focusMode}
            slotExpandedAlts={slotExpandedAlts}
            toggleSlotExpand={toggleSlotExpand}
            />
          )}
        </li>
      ))}
      {__extraAfter && <li className="list2-children-extra">{__extraAfter}</li>}
    </ul>
  );
}

// Tiny up/down vote widget rendered next to a node's actions. Shows
// aggregate counts; tap to toggle your own vote. Also surfaces a tiny
// consensus badge once there's enough signal:
//   • All-agree   — every known voter voted up; nobody down.
//   • Veto        — anyone voted down. ("One veto is enough to pause.")
//   • Mixed       — some up + some down, but no clear winner.
// We don't have a perfect "voter count" without fetching collaborators —
// for v1, "all-agree" means ≥2 ups with zero downs. Refine later.
function VoteWidget({ nodeId, votes, myUserId, onVote }) {
  if (!onVote || !myUserId) return null;
  const nodeVotes = (votes || []).filter(v => v.node_id === nodeId);
  const up = nodeVotes.filter(v => v.vote === 'up').length;
  const down = nodeVotes.filter(v => v.vote === 'down').length;
  const mine = nodeVotes.find(v => v.voter_id === myUserId)?.vote || null;
  // Consensus signal
  const total = up + down;
  let consensus = null;
  if (total >= 2 && down === 0)                       consensus = 'agreed';
  else if (down > 0)                                  consensus = 'veto';
  else if (up > 0 && down === 0 && total === 1)       consensus = 'pending';
  return (
    <span className="list2-vote">
      {consensus === 'agreed' && (
        <span className="list2-vote-consensus list2-vote-consensus--agreed" title={`${up} all agreed`}>
          <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" aria-hidden="true"><path d="M5 12l5 5L20 7"/></svg>
          All agreed
        </span>
      )}
      {consensus === 'veto' && (
        <span className="list2-vote-consensus list2-vote-consensus--veto" title={`${down} voted no`}>Veto</span>
      )}
      <button
        type="button"
        className={`list2-vote-btn list2-vote-btn--up${mine === 'up' ? ' is-on' : ''}`}
        onClick={(e) => { e.stopPropagation(); onVote(nodeId, 'up'); }}
        aria-label="Vote yes"
        title={mine === 'up' ? 'You voted yes — tap to clear' : 'Vote yes'}
      >
        <svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 22V11h3l4-9c.8 0 1.5.4 2 1 .5.7.5 1.5.3 2.3L15 11h6c1.1 0 2 .9 2 2v3c0 .3-.1.6-.2.9l-3.3 5.4c-.4.6-1 1-1.7 1H7z"/></svg>
        {up > 0 && <span>{up}</span>}
      </button>
      <button
        type="button"
        className={`list2-vote-btn list2-vote-btn--down${mine === 'down' ? ' is-on' : ''}`}
        onClick={(e) => { e.stopPropagation(); onVote(nodeId, 'down'); }}
        aria-label="Vote no"
        title={mine === 'down' ? 'You voted no — tap to clear' : 'Vote no'}
      >
        <svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style={{ transform: 'rotate(180deg)' }}><path d="M7 22V11h3l4-9c.8 0 1.5.4 2 1 .5.7.5 1.5.3 2.3L15 11h6c1.1 0 2 .9 2 2v3c0 .3-.1.6-.2.9l-3.3 5.4c-.4.6-1 1-1.7 1H7z"/></svg>
        {down > 0 && <span>{down}</span>}
      </button>
    </span>
  );
}

// ───────────────────────── Slot node ─────────────────────────
function SlotNode({ node, depth, productMap, onOpenItem, onAddItem, onAddSlot, onUpdate, onDelete, votes, myUserId, onVote, rootKind, claims, comments, viewMode, gridSize, listControls, dragNodeId, dropTarget, startCustomDrag, onCompareShortlist, onMoveWithin, selectedIds, toggleSelected, selectionMode, focusMode, slotExpandedAlts, toggleSlotExpand }) {
  const [expanded, setExpanded] = _v2s(true);
  // Add modes for things going INTO this container.
  // null | 'search' | 'link' | 'group' | 'shortlist'
  const [addMode, setAddMode] = _v2s(null);
  const [sectionDraft, setSectionDraft] = _v2s('');
  const [linkUrl, setLinkUrl] = _v2s('');
  const [linkBusy, setLinkBusy] = _v2s(false);
  const [linkErr, setLinkErr] = _v2s('');
  // Explicit ref + effect to focus the URL input when the user opens
  // "+ link" inside this slot. `autoFocus` on the JSX attribute is
  // unreliable here — the form mounts in response to a click on a
  // button which still owns focus, and React doesn't always re-bid
  // for it when the input first appears. Imperative .focus() on the
  // mount tick is rock-solid across browsers.
  const linkInputRef = _v2r(null);
  _v2e(() => {
    if (addMode === 'link' && linkInputRef.current) {
      // Defer one frame so React commits the form first.
      const t = setTimeout(() => {
        try { linkInputRef.current && linkInputRef.current.focus(); } catch {}
      }, 0);
      return () => clearTimeout(t);
    }
  }, [addMode]);
  // Inline-edit state for the slot's name and blurb. Mirrors the list
  // header's click-to-edit pattern so both feel identical.
  const [editingName, setEditingName] = _v2s(false);
  const [nameDraft, setNameDraft] = _v2s(node.name || '');
  const [editingBlurb, setEditingBlurb] = _v2s(false);
  const [blurbDraft, setBlurbDraft] = _v2s(node.blurb || '');
  _v2e(() => { setNameDraft(node.name || ''); }, [node.name]);
  _v2e(() => { setBlurbDraft(node.blurb || ''); }, [node.blurb]);
  const commitSlotName = () => {
    const v = (nameDraft || '').trim();
    if (!v || v === node.name) { setEditingName(false); setNameDraft(node.name || ''); return; }
    onUpdate(node.id, { name: v });
    setEditingName(false);
  };
  const commitSlotBlurb = () => {
    const v = (blurbDraft || '').trim();
    const cur = (node.blurb || '').trim();
    if (v === cur) { setEditingBlurb(false); return; }
    onUpdate(node.id, { blurb: v || null });
    setEditingBlurb(false);
  };
  // Container kind: 'group' (heading + items) or 'shortlist' (pick one of).
  // Legacy slots without a slot_kind fall back to 'group'.
  const slotKind = node.slotKind === 'shortlist' ? 'shortlist' : 'group';
  const isShortlist = slotKind === 'shortlist';
  const itemChildren = (node.children || []).filter(c => c.type === 'item');
  const pickedCount = itemChildren.filter(c => c.picked).length;
  const need = node.quantityNeeded || 1;
  const decided = isShortlist && pickedCount >= need;
  // Watch the moment `decided` flips from false → true and fire a small
  // confetti rain on the slot's header. Persists across renders via a
  // ref so we don't replay the celebration when the user toggles other
  // properties on the node (label edits, etc.). Only fires for shortlist
  // slots that are properly "decided" (not over-picked) so the moment is
  // earned and unambiguous.
  const _wasDecidedRef = _v2r(decided);
  _v2e(() => {
    if (decided && !_wasDecidedRef.current) {
      _wasDecidedRef.current = true;
      if (typeof window.__mr_celebrate === 'function') {
        // Try to find the slot's header element to anchor the burst.
        const headerEl = document.querySelector(`[data-slot-id="${node.id}"] .list2-slot-head`)
          || document.querySelector(`[data-slot-id="${node.id}"]`);
        window.__mr_celebrate(headerEl, { variant: 'decide' });
      }
      if (typeof window.__mr_showToast === 'function') {
        window.__mr_showToast(`✨ Decided "${node.name}"`);
      }
    } else if (!decided) {
      _wasDecidedRef.current = false;
    }
  }, [decided, node.id, node.name]);
  // Over-pick — when the user has picked MORE than the shortlist needs
  // (e.g. "Pick 1 of" but two are marked Decided). Surfaces a warning chip
  // in the header inviting the user to either unpick one or raise the
  // quota via the PickOfToggle.
  const overpicked = isShortlist && pickedCount > need;

  const submitNestedContainer = (kind) => (e) => {
    if (e) e.preventDefault();
    const name = sectionDraft.trim();
    if (!name) return;
    onAddSlot({ parentId: node.id, name, slotKind: kind });
    setSectionDraft(''); setAddMode(null);
  };
  // Programmatic version of submitNestedLink used by QuickSearchV2 when
  // the user pastes a URL into the slot's search box. Mirrors the
  // toolbar's addByPastedUrl: throws on failure so QuickSearchV2 can
  // surface the error in its inline URL-mode UI, awaits the insert so
  // the slot's refreshRoot doesn't race.
  const addByPastedUrlInSlot = async (url) => {
    const resp = await fetch(`/api/og-fetch?url=${encodeURIComponent(url)}&deep=1`);
    const json = await resp.json();
    if (!resp.ok || !json || !json.ok || !json.data) {
      throw new Error((json && json.error) || "Couldn't read that page");
    }
    const data = json.data;
    const decode = (s) => s ? s.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#039;/g, "'") : '';
    if (data.blocked) {
      throw new Error("That retailer blocks automated fetches. Open the page in a new tab and use the bookmarklet (Settings) — it runs in your browser, so it sees the real page.");
    }
    const title = decode(data.title) || url;
    await onAddItem({
      parentId: node.id,
      custom: {
        name: title,
        brand: data.brand || '',
        image: data.image || '',
        price: data.price || null,
        currency: data.currency || 'AUD',
        sourceUrl: data.sourceUrl || url,
        description: decode(data.description) || '',
        gallery: Array.isArray(data.gallery) && data.gallery.length
          ? data.gallery
          : (data.image ? [data.image] : []),
        materials: data.materials || null,
        certifications: Array.isArray(data.certifications) ? data.certifications : [],
        dimensions: data.dimensions || null,
      },
      name: title,
      ageMin: data.ageMin != null ? Number(data.ageMin) : null,
      ageMax: data.ageMax != null ? Number(data.ageMax) : null,
    });
    if (typeof window.__mr_showToast === 'function') {
      window.__mr_showToast(`Added "${title.slice(0, 40)}${title.length > 40 ? '…' : ''}"`);
    }
    if (typeof window.__mr_celebrate === 'function') {
      const anchor = document.querySelector(`[data-slot-id="${node.id}"] .list2-slot-head`)
        || document.querySelector(`[data-slot-id="${node.id}"]`);
      window.__mr_celebrate(anchor, { variant: 'add' });
    }
  };

  const submitNestedLink = async (e) => {
    if (e) e.preventDefault();
    const url = linkUrl.trim();
    if (!url) return;
    setLinkErr(''); setLinkBusy(true);
    try {
      const resp = await fetch(`/api/og-fetch?url=${encodeURIComponent(url)}&deep=1`);
      const json = await resp.json();
      if (!resp.ok || !json || !json.ok || !json.data) {
        setLinkErr((json && json.error) || "Couldn't read that page");
        setLinkBusy(false); return;
      }
      const data = json.data;
      // Same bot-block guard as the root toolbar — don't add a useless
      // stub for Amazon / DJ / Macy's / eBay / Walmart / similar.
      if (data.blocked) {
        setLinkErr("That retailer blocks automated fetches. Open the page in a new tab and use the bookmarklet (Settings).");
        setLinkBusy(false); return;
      }
      const decode = (s) => s ? s.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#039;/g, "'") : '';
      const title = decode(data.title) || url;
      // Paste-link policy: no fuzzy catalog matching (would silently swap
      // the user's specific URL for a brand-sibling product). Add as a
      // custom item with the real og-fetched payload.
      //
      // MUST await — addItem is async, calls refreshRoot internally on
      // success. Without the await, the immediate setLinkUrl('') /
      // setAddMode(null) below clears the form before the insert lands,
      // refreshRoot races the not-yet-written row, and the user sees a
      // half-built item (no brand, no image, no price). Same race I
      // fixed in submitLink / addByPastedUrl earlier in this session.
      // Gallery payload mirrors those flows so the detail-panel
      // carousel works for nested adds too.
      await onAddItem({
        parentId: node.id,
        custom: {
          name: title,
          brand: data.brand || '',
          image: data.image || '',
          price: data.price || null,
          currency: data.currency || 'AUD',
          sourceUrl: data.sourceUrl || url,
          description: decode(data.description) || '',
          gallery: Array.isArray(data.gallery) && data.gallery.length
            ? data.gallery
            : (data.image ? [data.image] : []),
          // Materials line ("95% organic cotton, 5% elastane") + cert
          // tokens (gots / oeko-tex / organic / recycled / fairtrade /
          // bluesign). Both null/empty when the page didn't expose them.
          materials: data.materials || null,
          certifications: Array.isArray(data.certifications) ? data.certifications : [],
        dimensions: data.dimensions || null,
        },
        name: title,
        ageMin: data.ageMin != null ? Number(data.ageMin) : null,
        ageMax: data.ageMax != null ? Number(data.ageMax) : null,
      });
      setLinkUrl(''); setAddMode(null); setLinkBusy(false);
      // Match the visible feedback the toolbar flow gives — toast +
      // tiny sparkle on the slot the item just landed under.
      if (typeof window.__mr_showToast === 'function') {
        window.__mr_showToast(`Added "${title.slice(0, 40)}${title.length > 40 ? '…' : ''}"`);
      }
      if (typeof window.__mr_celebrate === 'function') {
        const anchor = document.querySelector(`[data-slot-id="${node.id}"] .list2-slot-head`)
          || document.querySelector(`[data-slot-id="${node.id}"]`);
        window.__mr_celebrate(anchor, { variant: 'add' });
      }
    } catch (e) {
      setLinkErr(e.message || 'Network problem — try again?');
      setLinkBusy(false);
    }
  };

  const isDropping = dropTarget && dropTarget.parentId === node.id;
  const slotRef = _v2r(null);

  return (
    <div
      ref={slotRef}
      data-drop-slot={node.id}
      data-slot-id={node.id}
      className={`list2-slot list2-slot--${slotKind}${decided ? ' is-decided' : ''}${expanded ? ' is-expanded' : ''}${isDropping ? ' is-drop-target' : ''}${dragNodeId === node.id ? ' is-dragging' : ''}`}
    >
      <div
        className="list2-slot-head"
        onMouseDown={(e) => {
          if (isInteractiveTarget(e.target)) return;
          if (startCustomDrag) startCustomDrag(e, node.id, slotRef.current);
        }}
      >
        <span
          className="list2-drag-handle"
          title="Drag to move"
          onMouseDown={(e) => startCustomDrag && startCustomDrag(e, node.id, slotRef.current)}
          onClick={(e) => e.stopPropagation()}
          role="button"
          aria-label="Drag to reorder"
        >
          <svg width="12" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.6"/><circle cx="15" cy="6" r="1.6"/><circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/><circle cx="9" cy="18" r="1.6"/><circle cx="15" cy="18" r="1.6"/></svg>
        </span>
        <button
          type="button"
          className="list2-slot-toggle"
          onClick={() => setExpanded(x => !x)}
          aria-label={expanded ? 'Collapse' : 'Expand'}
        >
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" style={{ transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform .14s ease' }}><path d="M9 6l6 6-6 6"/></svg>
        </button>
        <div className="list2-slot-titlewrap">
          <h3 className="list2-slot-title">
            {/* Editable name — click to swap into an input. Enter commits,
                Esc cancels, blur saves. The h3 wraps everything so the
                badges (Decided / over-pick) sit alongside even while
                editing. */}
            {editingName ? (
              <input
                type="text"
                autoFocus
                value={nameDraft}
                onChange={(e) => setNameDraft(e.target.value)}
                onBlur={commitSlotName}
                onClick={(e) => e.stopPropagation()}
                onMouseDown={(e) => e.stopPropagation()}
                onKeyDown={(e) => {
                  if (e.key === 'Enter') { e.preventDefault(); commitSlotName(); }
                  else if (e.key === 'Escape') { setNameDraft(node.name || ''); setEditingName(false); }
                }}
                className="list2-slot-title-input"
              />
            ) : (
              <span
                className="list2-slot-title-text"
                onClick={(e) => { e.stopPropagation(); setEditingName(true); }}
                title="Click to rename"
              >
                {node.name}
                <span className="list2-head-h-edithint" aria-hidden="true">✎</span>
              </span>
            )}
            {/* "Decided" badge — only when a shortlist has met its quota
                AND isn't over-picked (the over-pick warning takes priority). */}
            {isShortlist && decided && !overpicked && (
              <span className="list2-slot-kindbadge list2-slot-kindbadge--shortlist is-decided" title={`Decided — ${pickedCount} picked`}>
                <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M5 12l5 5L20 7"/></svg>
                Decided
              </span>
            )}
            {/* Over-pick warning — the user has picked more candidates than
                the shortlist needs. Offers an instant "increase the quota"
                action to make the situation consistent. */}
            {overpicked && (
              <span className="list2-slot-overpick" title={`Picked ${pickedCount} but only need ${need}`}>
                <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                  <path d="M12 9v4"/><circle cx="12" cy="17" r="0.6" fill="currentColor"/>
                  <path d="M10.3 3.9 L1.8 18.3 a2 2 0 001.7 3 h17 a2 2 0 001.7-3 L13.7 3.9 a2 2 0 00-3.4 0 z"/>
                </svg>
                <span>{pickedCount} picked, only need {need}</span>
                <button
                  type="button"
                  className="list2-slot-overpick-fix"
                  onClick={(e) => { e.stopPropagation(); onUpdate(node.id, { quantityNeeded: pickedCount }); }}
                  title={`Raise the quota to ${pickedCount}`}
                >Raise to {pickedCount}</button>
              </span>
            )}
            {node.necessity === 'must' && <span className="list2-slot-must">Must-have</span>}
          </h3>
          {/* Unified pick-of toggle — replaces the old badge + button combo.
              Tap to turn this container into a "Pick N of" decision; the N
              is inline-editable so changing 1 → 2 → 3 is one tap. */}
          <PickOfToggle
            isOn={isShortlist}
            count={node.quantityNeeded || 1}
            onChange={(patch) => onUpdate(node.id, patch)}
          />
          {/* Editable blurb — same pattern as the title. Shows a quiet
              "Add a description…" placeholder when empty so it's
              discoverable without being noisy. */}
          {editingBlurb ? (
            <textarea
              autoFocus
              value={blurbDraft}
              onChange={(e) => setBlurbDraft(e.target.value)}
              onBlur={commitSlotBlurb}
              onClick={(e) => e.stopPropagation()}
              onMouseDown={(e) => e.stopPropagation()}
              onKeyDown={(e) => {
                if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); commitSlotBlurb(); }
                else if (e.key === 'Escape') { setBlurbDraft(node.blurb || ''); setEditingBlurb(false); }
              }}
              rows={Math.max(1, (blurbDraft || '').split('\n').length)}
              placeholder="Add a description…"
              className="list2-slot-blurb-input"
            />
          ) : node.blurb ? (
            <p
              className="list2-slot-blurb list2-slot-blurb--editable"
              onClick={(e) => { e.stopPropagation(); setEditingBlurb(true); }}
              title="Click to edit description"
            >
              {node.blurb}
              <span className="list2-head-h-edithint" aria-hidden="true">✎</span>
            </p>
          ) : (
            <p
              className="list2-slot-blurb list2-slot-blurb--placeholder"
              onClick={(e) => { e.stopPropagation(); setEditingBlurb(true); }}
              title="Click to add a description"
            >
              Add a description…
            </p>
          )}
        </div>
        <div className="list2-slot-actions">
          {/* Compare candidates — only on shortlists with 2+ candidates.
              stopPropagation on both events: prevents the slot-head's
              draggable mousedown from arming a drag on the button, and
              keeps the click event from bubbling up to any parent
              handler. Inline cursor: 'pointer' guards against any global
              cursor inheritance from the slot-head's `cursor: grab`. */}
          {isShortlist && itemChildren.length >= 2 && onCompareShortlist && (
            <button
              type="button"
              className="list2-tiny-btn list2-tiny-btn--primary"
              onMouseDown={(e) => e.stopPropagation()}
              onClick={(e) => { e.stopPropagation(); onCompareShortlist(node.id); }}
              style={{ cursor: 'pointer' }}
              title="Compare candidates side by side"
            >
              Compare
            </button>
          )}
          <VoteWidget nodeId={node.id} votes={votes} myUserId={myUserId} onVote={onVote} />
          <button type="button" className="list2-tiny-btn list2-tiny-btn--danger" onClick={() => {
            if (window.confirm(`Delete "${node.name}" and everything in it?`)) onDelete(node.id);
          }} title="Delete this section">×</button>
        </div>
      </div>
      {expanded && (
        <>
          {/* Same three add-modes as the root toolbar, scoped to this section.
              Lets users type to search, paste a link, or nest another section
              without leaving the row. */}
          <div className="list2-slot-mini-toolbar">
            {!addMode && (
              <div className="list2-slot-mini-buttons">
                <button type="button" className="list2-slot-mini-btn" onClick={() => setAddMode('search')}>
                  <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
                  <span>Search</span>
                </button>
                <button type="button" className="list2-slot-mini-btn" onClick={() => { setAddMode('link'); setLinkUrl(''); setLinkErr(''); }}>
                  <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M10 14L21 3M21 3h-7M21 3v7"/></svg>
                  <span>By link</span>
                </button>
                <button type="button" className="list2-slot-mini-btn" onClick={() => setAddMode('placeholder')} title="A placeholder you'll fill in later">
                  <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" strokeDasharray="2 3"/><path d="M9 9a3 3 0 015.8 1c0 2-3 3-3 3"/><circle cx="12" cy="17" r="0.6" fill="currentColor"/></svg>
                  <span>Placeholder</span>
                </button>
                <button type="button" className="list2-slot-mini-btn" onClick={() => { setAddMode('group'); setSectionDraft(''); }} title='A nested group of related items. Toggle "Pick one of" on the row to turn it into a decision shortlist.'>
                  <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 10h18"/></svg>
                  <span>Group</span>
                </button>
              </div>
            )}
            {addMode === 'search' && (
              <div className="list2-slot-add">
                <QuickSearchV2
                  productMap={productMap}
                  onPickProduct={async (p) => { await onAddItem({ parentId: node.id, productId: p.id, name: p.name }); setAddMode(null); }}
                  onAddCustom={async (name) => { await onAddItem({ parentId: node.id, custom: { name }, name }); setAddMode(null); }}
                  // Smart paste-link inside the slot — without this, a URL
                  // pasted into the slot's search box fell through to the
                  // "add as custom" branch and was saved with the raw URL
                  // as the name (no brand/image/price). Matches the
                  // toolbar QuickSearchV2's wiring.
                  onAddByUrl={addByPastedUrlInSlot}
                />
                <button type="button" className="btn btn-ghost" onClick={() => setAddMode(null)} style={{ width: 'auto', padding: '8px 12px', marginTop: 6 }}>Cancel</button>
              </div>
            )}
            {addMode === 'link' && (
              <form className="list2-toolbar-inline" onSubmit={submitNestedLink}>
                <input
                  type="url"
                  ref={linkInputRef}
                  autoFocus
                  placeholder="Paste a product link…"
                  value={linkUrl}
                  onChange={(e) => setLinkUrl(e.target.value)}
                  className="list2-toolbar-inline-name"
                  inputMode="url"
                  autoComplete="off"
                />
                <button type="submit" className="btn" disabled={!linkUrl.trim() || linkBusy} style={{ width: 'auto', padding: '8px 14px' }}>
                  <span>{linkBusy ? 'Reading…' : 'Add'}</span><span className="arrow">→</span>
                </button>
                <button type="button" className="btn btn-ghost" onClick={() => { setAddMode(null); setLinkUrl(''); setLinkErr(''); }} style={{ width: 'auto', padding: '8px 12px' }}>Cancel</button>
                {linkErr && <div className="quick-add-err" style={{ width: '100%', marginTop: 6 }}>{linkErr}</div>}
              </form>
            )}
            {addMode === 'placeholder' && (
              <PlaceholderForm
                onSubmit={(spec) => {
                  onAddItem({
                    parentId: node.id,
                    custom: { name: spec.name, price: spec.price, placeholder: true },
                    name: spec.name,
                  });
                  setAddMode(null);
                }}
                onCancel={() => setAddMode(null)}
              />
            )}
            {addMode === 'group' && (
              <form className="list2-toolbar-inline" onSubmit={submitNestedContainer('group')}>
                <input
                  type="text"
                  autoFocus
                  placeholder='Group name — e.g. "Sleep" or "Bath time"'
                  value={sectionDraft}
                  onChange={(e) => setSectionDraft(e.target.value)}
                  className="list2-toolbar-inline-name"
                />
                <button type="submit" className="btn" disabled={!sectionDraft.trim()} style={{ width: 'auto', padding: '8px 14px' }}>
                  <span>Add group</span><span className="arrow">→</span>
                </button>
                <button type="button" className="btn btn-ghost" onClick={() => setAddMode(null)} style={{ width: 'auto', padding: '8px 12px' }}>Cancel</button>
              </form>
            )}
          </div>
          {/* Focus mode — for picked shortlists, hide unpicked candidates
              and show a "+ N alternatives" chip the user can expand. The
              chip remembers expansion per-shortlist (slotExpandedAlts). */}
          {(() => {
            const overridden = !!(slotExpandedAlts && slotExpandedAlts[node.id]);
            const hideAlts = isShortlist && focusMode && !overridden && pickedCount > 0;
            const hiddenCount = hideAlts
              ? itemChildren.filter(c => !c.picked).length
              : 0;
            const renderParent = hideAlts
              ? { ...node, children: (node.children || []).filter(c => c.type !== 'item' || c.picked) }
              : node;
            return (
              <>
                <NodeChildren
                  parent={renderParent}
                  depth={depth + 1}
                  __extraAfter={
                    isShortlist && pickedCount > 0 && focusMode ? (
                      hideAlts && hiddenCount > 0 ? (
                        <button
                          type="button"
                          className="list2-slot-altchip"
                          onClick={() => toggleSlotExpand(node.id)}
                          title="Show the alternatives you weighed before picking"
                        >
                          <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
                          <span>Show {hiddenCount} alternative{hiddenCount === 1 ? '' : 's'}</span>
                        </button>
                      ) : overridden ? (
                        <button
                          type="button"
                          className="list2-slot-altchip is-expanded"
                          onClick={() => toggleSlotExpand(node.id)}
                        >
                          <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" style={{ transform: 'rotate(90deg)' }}><path d="M9 6l6 6-6 6"/></svg>
                          <span>Hide alternatives</span>
                        </button>
                      ) : null
                    ) : null
                  }
            productMap={productMap}
            onOpenItem={onOpenItem}
            onAddItem={onAddItem}
            onAddSlot={onAddSlot}
            onUpdate={onUpdate}
            onDelete={onDelete}
            votes={votes}
            myUserId={myUserId}
            onVote={onVote}
            rootKind={rootKind}
            claims={claims}
            comments={comments}
            viewMode={viewMode}
            gridSize={gridSize}
            dragNodeId={dragNodeId}
            dropTarget={dropTarget}
            startCustomDrag={startCustomDrag}
            onCompareShortlist={onCompareShortlist}
            listControls={listControls}
            onMoveWithin={onMoveWithin}
            selectedIds={selectedIds}
            toggleSelected={toggleSelected}
            selectionMode={selectionMode}
            focusMode={focusMode}
            slotExpandedAlts={slotExpandedAlts}
            toggleSlotExpand={toggleSlotExpand}
          />
              </>
            );
          })()}
        </>
      )}
    </div>
  );
}

// ───────────────────────── Item node ─────────────────────────
function ItemNode({ node, productMap, onOpenItem, onUpdate, onDelete, isUnderSlot, isUnderShortlist, slotQty, votes, myUserId, onVote, rootKind, claims, comments, grid, dragNodeId, dropTarget, parentId, indexInParent, startCustomDrag, onMoveWithin, selectedIds, toggleSelected, selectionMode }) {
  const product = node.productId && productMap ? productMap[node.productId] : null;
  const name = product ? product.name : (node.custom && node.custom.name) || node.name;
  const brand = product ? product.brand : (node.custom && node.custom.brand);
  const image = product ? product.img : (node.custom && node.custom.image);
  const price = product ? product.price : (node.custom && node.custom.price);
  const currency = product ? (product.currency || 'AUD') : (node.custom && node.custom.currency) || 'AUD';
  // Placeholder = a custom item flagged as TO-DO. Visual treatment is
  // distinct (dashed border, question-mark thumb) so it's clear at a
  // glance that this row is waiting to be filled in with a real product.
  const isPlaceholder = !!(node.custom && node.custom.placeholder);
  // Materials + certifications come from the og-fetch pass (paste-link
  // flow) and live in the custom blob. Catalog items don't surface
  // these yet — when we add columns to the catalog products table we
  // can hydrate the same fields here.
  const materials = (node.custom && node.custom.materials) || null;
  const certifications = (node.custom && Array.isArray(node.custom.certifications))
    ? node.custom.certifications
    : [];
  // Inline name editing — only available for non-catalog items because
  // catalog items pull their display name from the shared product
  // record (renaming locally would diverge from every other surface
  // that shows that product). Paste-link items often arrive with a
  // raw URL slug as the title (e.g. when og-fetch can't parse the
  // page) — letting the user clean that up on the spot is the whole
  // point of this affordance.
  const canRename = !product;
  const [editingName, setEditingName] = _v2s(false);
  const [nameDraft, setNameDraft] = _v2s(name || '');
  _v2e(() => { setNameDraft(name || ''); }, [name]);
  const commitName = () => {
    const v = (nameDraft || '').trim();
    if (!v || v === name) { setEditingName(false); setNameDraft(name || ''); return; }
    // Patch BOTH node.name and custom.name so the display (which reads
    // custom.name first) and any consumer reading node.name agree.
    const nextCustom = node.custom ? { ...node.custom, name: v } : { name: v };
    onUpdate(node.id, { name: v, custom: nextCustom });
    setEditingName(false);
  };

  // ── Inline price editing ───────────────────────────────────────────
  // Two prices per item: the canonical "new" price (catalog product or
  // custom blob) plus a per-node `secondhandPrice` annotation the user
  // sets to track "or I could get one used for $X". The "new" price is
  // editable only for custom items (catalog product prices are shared);
  // the secondhand price is always editable.
  const secondhandPrice = node.secondhandPrice != null ? Number(node.secondhandPrice) : null;
  const canEditNewPrice = !product;  // catalog price is canonical
  const [editingPrice, setEditingPrice] = _v2s(null);
  // 'new' | 'used' | null. Draft holds the in-progress string so users
  // can type partial values (e.g. "1") without us coercing to 0.
  const [priceDraft, setPriceDraft] = _v2s('');
  const startEditPrice = (which) => {
    const cur = which === 'new' ? price : secondhandPrice;
    setPriceDraft(cur != null ? String(cur) : '');
    setEditingPrice(which);
  };
  const commitPrice = () => {
    if (!editingPrice) return;
    const raw = (priceDraft || '').trim();
    // Empty input → null (clears the price). Otherwise parse a number.
    const next = raw === '' ? null : Number(raw.replace(/[^\d.]/g, ''));
    if (raw !== '' && (!Number.isFinite(next) || next < 0)) { setEditingPrice(null); return; }
    if (editingPrice === 'new') {
      // Only custom items can edit the "new" price.
      if (!canEditNewPrice) { setEditingPrice(null); return; }
      const nextCustom = { ...(node.custom || {}), price: next };
      onUpdate(node.id, { custom: nextCustom });
    } else if (editingPrice === 'used') {
      onUpdate(node.id, { secondhandPrice: next });
    }
    setEditingPrice(null);
  };
  const cancelEditPrice = () => { setEditingPrice(null); setPriceDraft(''); };

  // Currency prefix helper shared between display + edit.
  const ccyPrefix = currency === 'AUD' ? 'A$' : (currency === 'GBP' ? '£' : (currency === 'EUR' ? '€' : '$'));

  const togglePicked = (originEl) => {
    const willBePicked = !node.picked;
    onUpdate(node.id, { picked: willBePicked });
    // Magical moment — a small sparkle burst when the user lands a
    // decision. Only fires on the affirmative ("yes, this is my pick")
    // direction so un-picking doesn't celebrate a step backwards. The
    // helper auto-no-ops if it can't find a target element.
    if (willBePicked && typeof window.__mr_celebrate === 'function') {
      window.__mr_celebrate(originEl || document.querySelector(`[data-node-id="${node.id}"]`), { variant: 'pick' });
    }
  };
  const toggleInRegistry = () => onUpdate(node.id, { inRegistry: !node.inRegistry });
  // Owner sees a "Claimed by X" chip on items that a gift-buyer has claimed.
  const claim = (claims || []).find(c => c.node_id === node.id);
  const isRegistryMode = rootKind === 'registry';

  // Click anywhere on the row → open the item-detail modal. Works for
  // catalog items (the modal shows the product info) AND custom items
  // (the modal shows the editable custom blob).
  const isSelected = selectedIds && selectedIds.has(node.id);
  // In selection mode, the whole row toggles selection rather than
  // opening the detail panel. Outside selection mode, clicks open.
  // Also: drop a click if we just finished a drag (window-level flag set
  // in onDocUp). Without this, dropping over the row pops the modal.
  const openItem = (e) => {
    if (window.__mr_dragEndedAt && Date.now() - window.__mr_dragEndedAt < 200) return;
    // If the click originated on an interactive child (checkbox, vote
    // button, status pill, inline rename input, etc.) the child has
    // already handled it. Don't double-fire openItem/toggle — the bug
    // that previously cancelled the checkbox state by toggling twice.
    if (e && e.target && isInteractiveTarget(e.target)) return;
    if (selectionMode) {
      if (toggleSelected) toggleSelected(node.id);
      return;
    }
    onOpenItem && onOpenItem(node.id);
  };
  const itemRef = _v2r(null);
  // Drop-line indicators above/below this row when this row is the drop
  // target. Renders a 2px coral line as a ::before / ::after via classes.
  const showLineAbove = dropTarget && dropTarget.parentId === parentId && dropTarget.position === indexInParent;
  const showLineBelow = dropTarget && dropTarget.parentId === parentId && dropTarget.position === indexInParent + 1;
  return (
    <div
      ref={itemRef}
      data-drop-item={node.id}
      data-drop-parent={parentId}
      data-drop-index={indexInParent}
      className={`list2-item${grid ? ' list2-item--grid' : ''}${node.picked ? ' is-picked' : ''}${claim ? ' is-claimed' : ''}${dragNodeId === node.id ? ' is-dragging' : ''}${showLineAbove ? ' has-line-above' : ''}${showLineBelow ? ' has-line-below' : ''}${isSelected ? ' is-selected' : ''}${selectionMode ? ' is-selectmode' : ''}${isPlaceholder ? ' is-placeholder' : ''} is-clickable`}
      onClick={openItem}
      // Whole-card drag: mousedown anywhere on the row arms a drag,
      // unless the user pressed an inner control (Pick/Status/Vote/etc.).
      // The drag handle still works as before — it's an explicit affordance
      // but no longer the *only* surface.
      onMouseDown={(e) => {
        if (selectionMode) return;
        if (isInteractiveTarget(e.target)) return;
        if (startCustomDrag) startCustomDrag(e, node.id, itemRef.current);
      }}
      tabIndex={0}
      onKeyDown={(e) => {
        // Alt+Up / Alt+Down reorder within parent. Enter / Space open.
        if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
          e.preventDefault();
          if (typeof onMoveWithin === 'function') {
            onMoveWithin(node.id, parentId, e.key === 'ArrowUp' ? -1 : 1);
          }
          return;
        }
        if (e.key === 'Enter' || e.key === ' ') {
          if (e.target === e.currentTarget) {
            e.preventDefault();
            openItem();
          }
        }
      }}
    >
      {/* Bulk-select checkbox — only visible on hover OR when selection
          mode is active. Click anywhere on the row in selection mode
          also toggles; this is the explicit affordance.
          NB: pass `!isSelected` as the explicit intent, not a bare
          toggle. If the row's onClick somehow also fires (event
          ordering quirks in React 18), the row sees the same intent
          and stays idempotent instead of cancelling out. Also call
          preventDefault to suppress any default button activation and
          stop the bubbling phase explicitly. */}
      <button
        type="button"
        className={`list2-item-selbox${isSelected ? ' is-on' : ''}`}
        onClick={(e) => {
          e.preventDefault();
          e.stopPropagation();
          if (toggleSelected) toggleSelected(node.id, !isSelected);
        }}
        onMouseDown={(e) => { e.stopPropagation(); }}
        aria-pressed={isSelected}
        aria-label={isSelected ? 'Deselect' : 'Select'}
        title={isSelected ? 'Deselect' : 'Select for bulk action'}
      >
        {isSelected && (
          <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12l5 5L20 7"/></svg>
        )}
      </button>
      {/* Drag handle is the only drag affordance. Clicking the rest of
          the row opens the detail panel; pressing the handle starts a
          pointer-event drag we drive ourselves (see startCustomDrag in
          ListDetailV2). */}
      <span
        className="list2-drag-handle"
        title="Drag to move"
        onMouseDown={(e) => startCustomDrag && startCustomDrag(e, node.id, itemRef.current)}
        onClick={(e) => e.stopPropagation()}
        role="button"
        aria-label="Drag to reorder"
      >
        <svg width="12" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.6"/><circle cx="15" cy="6" r="1.6"/><circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/><circle cx="9" cy="18" r="1.6"/><circle cx="15" cy="18" r="1.6"/></svg>
      </span>
      {/* Pick affordance moved to the right side near the vote widget —
          see the .list2-item-decide button further down. */}
      <div className="list2-item-thumb">
        {isPlaceholder ? (
          <span className="list2-item-thumb-placeholder" aria-hidden="true">
            <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
              <circle cx="12" cy="12" r="9" strokeDasharray="2 3"/>
              <path d="M9 9a3 3 0 015.8 1c0 2-3 3-3 3"/>
              <circle cx="12" cy="17" r="0.6" fill="currentColor"/>
            </svg>
          </span>
        ) : image
          ? <SmartImage src={image} alt={name} fallbackLabel={brand || name} />
          : <span className="list2-item-thumb-empty">—</span>}
      </div>
      <div className="list2-item-body">
        {isPlaceholder && <div className="list2-item-placeholderchip">PLACEHOLDER · tap to replace</div>}
        {!isPlaceholder && brand && <div className="list2-item-brand">{brand}</div>}
        {canRename && editingName ? (
          <input
            type="text"
            autoFocus
            value={nameDraft}
            onChange={(e) => setNameDraft(e.target.value)}
            onBlur={commitName}
            onKeyDown={(e) => {
              if (e.key === 'Enter') { e.preventDefault(); commitName(); }
              else if (e.key === 'Escape') { setEditingName(false); setNameDraft(name || ''); }
            }}
            // Block row-level click handlers (which would otherwise close
            // the input or open the detail modal) while editing.
            onClick={(e) => e.stopPropagation()}
            onMouseDown={(e) => e.stopPropagation()}
            className="list2-item-name list2-item-name--edit"
          />
        ) : (
          <div
            className={`list2-item-name${canRename ? ' is-renameable' : ''}`}
            onClick={canRename ? (e) => { e.stopPropagation(); setEditingName(true); } : undefined}
            title={canRename ? 'Click to rename' : undefined}
          >{name}</div>
        )}
        <div className="list2-item-meta">
          {/* ── Cert badges — small green pills with a tick, e.g.
              "✓ GOTS organic". Render BEFORE the price so they're
              visually adjacent to the brand/name in the row. Each cert
              has its own short label; the tooltip carries the longer
              materials line for context on hover. */}
          {certifications.length > 0 && (
            <span className="list2-item-certs">
              {(() => {
                const labels = {
                  'gots':      'GOTS organic',
                  'oeko-tex':  'OEKO-TEX',
                  'organic':   'Organic',
                  'recycled':  'Recycled',
                  'fairtrade': 'Fair Trade',
                  'bluesign':  'bluesign',
                };
                return certifications.map(c => (
                  <span key={c} className={`list2-item-cert list2-item-cert--${c}`} title={materials || labels[c] || c}>
                    <span className="list2-item-cert-tick" aria-hidden="true">✓</span>
                    {labels[c] || c}
                  </span>
                ));
              })()}
            </span>
          )}
          {/* ── "New" price chip ────────────────────────────────────
              Editable on custom items (so the user can fix og-fetch
              misses or override currency). Catalog items render the
              shared product price as-is — no inline edit on those.
              Shows a placeholder "+ price" chip on custom items when
              no price exists yet so the affordance is discoverable. */}
          {editingPrice === 'new' ? (
            <span className="list2-item-price-editwrap" onClick={(e) => e.stopPropagation()}>
              <span className="list2-item-price-prefix">{ccyPrefix}</span>
              <input
                type="text"
                inputMode="decimal"
                autoFocus
                className="list2-item-price-input"
                value={priceDraft}
                onChange={(e) => setPriceDraft(e.target.value)}
                onBlur={commitPrice}
                onKeyDown={(e) => {
                  if (e.key === 'Enter') { e.preventDefault(); commitPrice(); }
                  else if (e.key === 'Escape') { cancelEditPrice(); }
                }}
                onMouseDown={(e) => e.stopPropagation()}
                placeholder="0"
              />
            </span>
          ) : price != null ? (
            <span
              className={`list2-item-price${canEditNewPrice ? ' is-editable' : ''}`}
              onClick={canEditNewPrice ? (e) => { e.stopPropagation(); startEditPrice('new'); } : undefined}
              title={canEditNewPrice ? 'Click to edit price' : undefined}
            >{ccyPrefix}{Number(price).toLocaleString()}</span>
          ) : canEditNewPrice ? (
            <button
              type="button"
              className="list2-item-price-add"
              onClick={(e) => { e.stopPropagation(); startEditPrice('new'); }}
              title="Add a price"
            >+ price</button>
          ) : null}

          {/* ── Secondhand price chip ───────────────────────────────
              Always editable. When unset, shows a subtle "+ used"
              affordance the user can tap to add their second-hand
              note. When set, renders as a distinct grey-tone chip
              so it's visually obvious which is which. */}
          {editingPrice === 'used' ? (
            <span className="list2-item-price-editwrap list2-item-price-editwrap--used" onClick={(e) => e.stopPropagation()}>
              <span className="list2-item-price-prefix">used {ccyPrefix}</span>
              <input
                type="text"
                inputMode="decimal"
                autoFocus
                className="list2-item-price-input"
                value={priceDraft}
                onChange={(e) => setPriceDraft(e.target.value)}
                onBlur={commitPrice}
                onKeyDown={(e) => {
                  if (e.key === 'Enter') { e.preventDefault(); commitPrice(); }
                  else if (e.key === 'Escape') { cancelEditPrice(); }
                }}
                onMouseDown={(e) => e.stopPropagation()}
                placeholder="0"
              />
            </span>
          ) : secondhandPrice != null ? (
            <span
              className="list2-item-price-used is-editable"
              onClick={(e) => { e.stopPropagation(); startEditPrice('used'); }}
              title="Click to edit secondhand price"
            >or {ccyPrefix}{Number(secondhandPrice).toLocaleString()} used</span>
          ) : (
            <button
              type="button"
              className="list2-item-price-used-add"
              onClick={(e) => { e.stopPropagation(); startEditPrice('used'); }}
              title="Add a secondhand price"
            >+ used</button>
          )}
          {/* Stage chip — uses MR.stage to compare the item's age range
              with the user's kids. Shows nothing when the item fits or
              when no age info exists. Tooltip explains the reasoning. */}
          {(() => {
            const ageMinM = product ? product.ageMin : (node.ageMin != null ? node.ageMin : null);
            const ageMaxM = product ? product.ageMax : (node.ageMax != null ? node.ageMax : null);
            const cls = window.MR && window.MR.stage
              ? window.MR.stage.classifyItem(ageMinM, ageMaxM)
              : null;
            if (!cls || cls === 'fits') return null;
            const label = window.MR.stage.stageLabel(cls);
            const fmt = (m) => m == null ? '?' : (m < 12 ? `${m}m` : `${Math.floor(m/12)}y`);
            const tip = `${label} — item rated ${fmt(ageMinM)} to ${fmt(ageMaxM)}, your kids are outside that range right now.`;
            return (
              <span className={`list2-item-stage list2-item-stage--${cls}`} title={tip}>
                {cls === 'outgrown' ? '⤴' : cls === 'soon' ? '⤳' : '⤵'}
                <span>{label}</span>
              </span>
            );
          })()}
          {claim && (
            <span className="list2-item-claim-chip" title={claim.note ? `Note: ${claim.note}` : 'Claimed by a gift-giver'}>
              <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round"><path d="M5 12l5 5L20 7"/></svg>
              Claimed by {claim.buyer_name || 'someone'}
            </span>
          )}
        </div>
      </div>
      {/* Comment count badge — shown only when this item has at least
          one comment. Tap also opens the detail modal (whole row is
          clickable) so the user can read the thread. */}
      {(() => {
        const myComments = (comments || []).filter(c => c.node_id === node.id);
        if (myComments.length === 0) return null;
        return (
          <span className="list2-item-comments" title={`${myComments.length} ${myComments.length === 1 ? 'comment' : 'comments'}`} aria-label={`${myComments.length} comments`}>
            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
            <span>{myComments.length}</span>
          </span>
        );
      })()}
      {/* Decide button — for shortlist candidates. Sits LEFT of the status
          chip in list view; in grid view it gets pushed to its own row
          at the bottom of the card via CSS order. */}
      {isUnderShortlist && (
        <button
          type="button"
          className={`list2-item-decide${node.picked ? ' is-on' : ''}`}
          onClick={(e) => { e.stopPropagation(); togglePicked(e.currentTarget); }}
          aria-pressed={node.picked}
          title={node.picked ? 'Decided — tap to undo' : 'Mark this as decided'}
        >
          {node.picked ? (
            <>
              <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12l5 5L20 7"/></svg>
              <span>Decided</span>
            </>
          ) : (
            <span>Decide</span>
          )}
        </button>
      )}
      {/* Status chip — shows current Loved / Getting / Have on the row
          itself so the user doesn't have to open the modal to see it.
          Tap cycles to the next status (loved → getting → have → loved). */}
      <button
        type="button"
        className={`list2-item-status list2-item-status--${(STATUS_META[node.status || 'want'] || STATUS_META.want).tone}`}
        onClick={(e) => {
          e.stopPropagation();
          const cur = node.status || 'want';
          const idx = STATUS_ORDER.indexOf(cur);
          const next = STATUS_ORDER[(idx + 1) % STATUS_ORDER.length];
          onUpdate(node.id, { status: next });
        }}
        title={`${STATUS_META[node.status || 'want'].label} — tap to change`}
        aria-label={`Status: ${STATUS_META[node.status || 'want'].label}. Tap to cycle.`}
      >
        <StatusIcon status={node.status || 'want'} size={13} />
        <span className="list2-item-status-label">{STATUS_META[node.status || 'want'].label}</span>
      </button>
      {isRegistryMode && (
        <button
          type="button"
          className={`list2-item-reg${node.inRegistry ? ' is-on' : ''}`}
          onClick={(e) => { e.stopPropagation(); toggleInRegistry(); }}
          title={node.inRegistry ? 'On the registry — tap to remove' : 'Add this item to the registry'}
          aria-label={node.inRegistry ? 'Remove from registry' : 'Add to registry'}
        >
          <svg width="12" height="12" viewBox="0 0 24 24" fill={node.inRegistry ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round"><path d="M20 12v9H4v-9M22 7H2v5h20zM12 22V7M12 7H7.5a2.5 2.5 0 010-5C11 2 12 7 12 7zM12 7h4.5a2.5 2.5 0 000-5C13 2 12 7 12 7z"/></svg>
          <span>{node.inRegistry ? 'Registry' : 'Add to registry'}</span>
        </button>
      )}
      <VoteWidget nodeId={node.id} votes={votes} myUserId={myUserId} onVote={onVote} />
      <button
        type="button"
        className="list2-tiny-btn list2-tiny-btn--danger"
        onClick={(e) => { e.stopPropagation(); onDelete(node.id); }}
        title="Remove from list"
        aria-label="Remove"
      >×</button>
    </div>
  );
}

// ───────────────────────── Shortlist compare modal ─────────────────────────
// Opens when the user taps "⇆ Compare" on a shortlist with 2+ candidates.
// Renders candidates as side-by-side columns (stacks on narrow screens),
// with spec rows where the value differs from sibling candidates lit up so
// the differences are visible at a glance.
//
// What you can do here:
//  • Pick a single candidate (radio across columns; commits picked=true on
//    the chosen one and clears it on the others).
//  • Add a free-form note per candidate (persists on node.notes).
//  • Vote up/down on each candidate.
//  • Tap a column to open the full product detail.
function ShortlistCompareModal({ slot, productMap, votes, myUserId, nameFor, onClose, onPick, onVote, onSetNotes, onOpenProduct }) {
  _v2e(() => {
    const onKey = (e) => { if (e.key === 'Escape') onClose(); };
    window.addEventListener('keydown', onKey);
    const prev = document.body.style.overflow;
    document.body.style.overflow = 'hidden';
    return () => {
      window.removeEventListener('keydown', onKey);
      document.body.style.overflow = prev;
    };
  }, [onClose]);

  const candidates = (slot.children || []).filter(c => c.type === 'item');

  // Resolve each candidate's display fields (catalog or custom).
  const rows = candidates.map(node => {
    const p = node.productId && productMap ? productMap[node.productId] : null;
    const custom = node.custom || {};
    const name     = p ? p.name           : (custom.name || node.name || 'Item');
    const brand    = p ? p.brand          : (custom.brand || '');
    const image    = p ? p.img            : (custom.image || '');
    const price    = p ? p.price          : custom.price;
    const currency = p ? (p.currency || 'AUD') : (custom.currency || 'AUD');
    const dims     = p && (p.dimensionsUnfolded || p.size) || '';
    const weight   = p && p.weight       || '';
    const materials= p && p.materials    || '';
    const ageMin   = p ? p.ageMin        : null;
    const ageMax   = p ? p.ageMax        : null;
    const madeIn   = p && (p.countryOfOrigin || p.madeIn) || '';
    const warranty = p && p.warranty     || '';
    const ageLabel = (ageMin == null && ageMax == null) ? ''
      : typeof formatAge === 'function' ? formatAge(ageMin, ageMax) : `${ageMin}–${ageMax}m`;
    return {
      node, product: p,
      name, brand, image, price, currency,
      specs: {
        Price:      price != null ? `${currency === 'AUD' ? 'A$' : '$'}${Number(price).toLocaleString()}` : '—',
        Brand:      brand || '—',
        Dimensions: dims || '—',
        Weight:     weight || '—',
        Materials:  materials || '—',
        Age:        ageLabel || '—',
        'Made in':  madeIn || '—',
        Warranty:   warranty || '—',
      },
    };
  });

  // Spec rows we render. Skip rows where every candidate is '—' (all blank).
  const SPEC_KEYS = ['Price', 'Brand', 'Dimensions', 'Weight', 'Materials', 'Age', 'Made in', 'Warranty'];
  const visibleSpecs = SPEC_KEYS.filter(k => rows.some(r => r.specs[k] && r.specs[k] !== '—'));

  // For each spec row, mark cells that differ from at least one sibling.
  const differs = (key, idx) => {
    const me = rows[idx].specs[key];
    if (!me || me === '—') return false;
    return rows.some((r, i) => i !== idx && r.specs[key] && r.specs[key] !== '—' && r.specs[key] !== me);
  };

  // Notes draft state (per-candidate, keyed by id).
  const [notesDraft, setNotesDraft] = _v2s(() => {
    const m = {}; candidates.forEach(c => { m[c.id] = c.notes || ''; }); return m;
  });
  const commitNotes = (id) => {
    const v = (notesDraft[id] || '').trim();
    const orig = candidates.find(c => c.id === id);
    if (!orig) return;
    if (v === (orig.notes || '')) return;
    onSetNotes(id, v);
  };

  const pick = (id) => {
    candidates.forEach(c => {
      if (c.id === id) { if (!c.picked) onPick(c.id, true); }
      else { if (c.picked) onPick(c.id, false); }
    });
  };

  if (candidates.length === 0) {
    return (
      <>
        <div className="cmp-scrim" onClick={onClose} />
        <div className="cmp-modal" role="dialog" aria-modal="true" aria-label={`Compare ${slot.name}`}>
          <button type="button" className="cmp-close" onClick={onClose} aria-label="Close">×</button>
          <header className="cmp-head">
            <div className="cmp-eyebrow">Comparing</div>
            <h2 className="cmp-title">{slot.name}</h2>
          </header>
          <div style={{ padding: 24 }}>
            <p style={{ color: 'var(--ink-3)', fontStyle: 'italic' }}>No candidates in this shortlist yet.</p>
          </div>
        </div>
      </>
    );
  }

  return (
    <>
      <div className="cmp-scrim" onClick={onClose} />
      <div className="cmp-modal" role="dialog" aria-modal="true" aria-label={`Compare ${slot.name}`}>
        <button type="button" className="cmp-close" onClick={onClose} aria-label="Close">
          <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M6 6l12 12M6 18L18 6"/></svg>
        </button>
        <header className="cmp-head">
          <div className="cmp-eyebrow">Comparing {rows.length} candidates</div>
          <h2 className="cmp-title">{slot.name}</h2>
          {slot.blurb && <p className="cmp-blurb">{slot.blurb}</p>}
        </header>

        <div className="cmp-grid" style={{ gridTemplateColumns: `repeat(${rows.length}, minmax(0, 1fr))` }}>
          {rows.map((r, idx) => {
            const node = r.node;
            const nodeVotes = (votes || []).filter(v => v.node_id === node.id);
            const upVotes   = nodeVotes.filter(v => v.vote === 'up');
            const downVotes = nodeVotes.filter(v => v.vote === 'down');
            const myVote    = nodeVotes.find(v => v.voter_id === myUserId)?.vote || null;
            const upNames   = upVotes.map(v => (nameFor ? nameFor(v.voter_id) : 'someone')).join(', ');
            const downNames = downVotes.map(v => (nameFor ? nameFor(v.voter_id) : 'someone')).join(', ');
            return (
              <div key={node.id} className={`cmp-col${node.picked ? ' is-picked' : ''}`}>
                {/* Card head — image + brand + name + price (clickable to open detail). */}
                <button
                  type="button"
                  className="cmp-cardhead"
                  onClick={() => {
                    if (typeof onOpenProduct === 'function' && r.product) onOpenProduct(r.product);
                  }}
                  title={r.product ? 'View product details' : ''}
                >
                  <div className="cmp-thumb">
                    {r.image
                      ? <SmartImage src={r.image} alt={r.name} fallbackLabel={r.brand || r.name} />
                      : <span className="cmp-thumb-empty">—</span>}
                  </div>
                  {r.brand && <div className="cmp-brand">{r.brand}</div>}
                  <div className="cmp-name">{r.name}</div>
                  {r.price != null && (
                    <div className="cmp-price">{r.currency === 'AUD' ? 'A$' : '$'}{Number(r.price).toLocaleString()}</div>
                  )}
                </button>

                {/* Pick button */}
                <button
                  type="button"
                  className={`cmp-pick${node.picked ? ' is-on' : ''}`}
                  onClick={() => pick(node.id)}
                >
                  {node.picked ? (
                    <>
                      <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12l5 5L20 7"/></svg>
                      <span>Picked</span>
                    </>
                  ) : (
                    <span>Pick this one</span>
                  )}
                </button>

                {/* Vote chips */}
                {myUserId && (
                  <div className="cmp-votes">
                    <button
                      type="button"
                      className={`cmp-vote cmp-vote--up${myVote === 'up' ? ' is-on' : ''}`}
                      onClick={() => onVote(node.id, myVote === 'up' ? null : 'up')}
                      title={upNames ? `Yes: ${upNames}` : 'Vote yes'}
                    >
                      <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M7 22V11h3l4-9c.8 0 1.5.4 2 1 .5.7.5 1.5.3 2.3L15 11h6c1.1 0 2 .9 2 2v3c0 .3-.1.6-.2.9l-3.3 5.4c-.4.6-1 1-1.7 1H7z"/></svg>
                      {upVotes.length > 0 && <span>{upVotes.length}</span>}
                    </button>
                    <button
                      type="button"
                      className={`cmp-vote cmp-vote--down${myVote === 'down' ? ' is-on' : ''}`}
                      onClick={() => onVote(node.id, myVote === 'down' ? null : 'down')}
                      title={downNames ? `No: ${downNames}` : 'Vote no'}
                    >
                      <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style={{ transform: 'rotate(180deg)' }}><path d="M7 22V11h3l4-9c.8 0 1.5.4 2 1 .5.7.5 1.5.3 2.3L15 11h6c1.1 0 2 .9 2 2v3c0 .3-.1.6-.2.9l-3.3 5.4c-.4.6-1 1-1.7 1H7z"/></svg>
                      {downVotes.length > 0 && <span>{downVotes.length}</span>}
                    </button>
                  </div>
                )}

                {/* Spec rows. Each cell highlighted if it differs from at
                    least one sibling — focuses the eye on the deltas. */}
                <dl className="cmp-specs">
                  {visibleSpecs.map(key => (
                    <div
                      key={key}
                      className={`cmp-specrow${differs(key, idx) ? ' is-diff' : ''}`}
                    >
                      <dt>{key}</dt>
                      <dd>{r.specs[key] || '—'}</dd>
                    </div>
                  ))}
                </dl>

                {/* Per-candidate notes — what we liked / didn't. Persists
                    on node.notes via the existing API. */}
                <label className="cmp-notes">
                  <span className="cmp-notes-label">Notes</span>
                  <textarea
                    rows={3}
                    placeholder="Pros, cons, deal-breakers…"
                    value={notesDraft[node.id] || ''}
                    onChange={(e) => setNotesDraft(prev => ({ ...prev, [node.id]: e.target.value }))}
                    onBlur={() => commitNotes(node.id)}
                  />
                </label>
              </div>
            );
          })}
        </div>
      </div>
    </>
  );
}

// ───────────────────────── Placeholder replace modal ─────────────────────────
// Opens when the user taps a placeholder item. Top section: editable name
// and price. Middle section: three tabs for replacing with a real item:
//   • Search the catalog   — typeahead over window.PRODUCTS
//   • Paste a link         — og-fetches the page and fills in custom blob
//   • Drop a photo         — file picker / drop zone; data-URL encodes
// Bottom: Delete placeholder (with confirm) + Close.
function PlaceholderReplaceModal({ node, productMap, onClose, onEdit, onReplaceWithProduct, onReplaceWithCustom, onDelete }) {
  // Default to AI when the placeholder has a meaningful name — the
  // model is usually faster than search at finding the right match. Fall
  // back to search if the name is empty (e.g. fresh placeholder).
  const [tab, setTab] = _v2s(((node.custom && node.custom.name) || node.name || '').trim().length >= 3 ? 'ai' : 'search');
  const [draftName, setDraftName] = _v2s((node.custom && node.custom.name) || node.name || '');
  const [draftPrice, setDraftPrice] = _v2s(
    node.custom && node.custom.price != null ? String(node.custom.price) : ''
  );
  _v2e(() => {
    setDraftName((node.custom && node.custom.name) || node.name || '');
    setDraftPrice(node.custom && node.custom.price != null ? String(node.custom.price) : '');
  }, [node.id]);

  // Esc to close.
  _v2e(() => {
    const onKey = (e) => { if (e.key === 'Escape') onClose(); };
    window.addEventListener('keydown', onKey);
    const prev = document.body.style.overflow;
    document.body.style.overflow = 'hidden';
    return () => {
      window.removeEventListener('keydown', onKey);
      document.body.style.overflow = prev;
    };
  }, [onClose]);

  const commitMeta = () => {
    const next = {};
    const newName = draftName.trim();
    const newPrice = draftPrice.trim() ? Number(draftPrice) : null;
    const cur = node.custom || {};
    if (newName && newName !== cur.name) {
      next.custom = { ...cur, name: newName, placeholder: true };
      next.name = newName;
    }
    if (newPrice !== cur.price) {
      next.custom = { ...(next.custom || cur), price: newPrice, placeholder: true };
    }
    if (Object.keys(next).length > 0) onEdit(next);
  };

  return (
    <>
      <div className="ph-scrim" onClick={onClose} />
      <div className="ph-modal" role="dialog" aria-modal="true" aria-label="Replace placeholder">
        <button type="button" className="ph-close" onClick={onClose} aria-label="Close">
          <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M6 6l12 12M6 18L18 6"/></svg>
        </button>
        <header className="ph-head">
          <span className="ph-eyebrow">Placeholder · tap to replace with a real item</span>
          <div className="ph-meta-edit">
            <input
              type="text"
              className="ph-name-input"
              value={draftName}
              onChange={(e) => setDraftName(e.target.value)}
              onBlur={commitMeta}
              placeholder="What do you need?"
            />
            <label className="ph-price-input">
              <span>A$</span>
              <input
                type="number"
                min="0"
                step="1"
                value={draftPrice}
                onChange={(e) => setDraftPrice(e.target.value)}
                onBlur={commitMeta}
                placeholder="—"
              />
            </label>
          </div>
        </header>

        <div className="ph-tabs" role="tablist">
          {[
            { k: 'ai',     label: 'AI suggest',    icon: <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2 L13.8 8.2 L20 10 L13.8 11.8 L12 18 L10.2 11.8 L4 10 L10.2 8.2 Z"/></svg> },
            { k: 'search', label: 'Search catalog', icon: <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg> },
            { k: 'link',   label: 'Paste link',     icon: <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M10 14L21 3M21 3h-7M21 3v7"/></svg> },
            { k: 'photo',  label: 'Upload photo',   icon: <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7h4l2-2h6l2 2h4v12H3z"/><circle cx="12" cy="13" r="3.5"/></svg> },
          ].map(t => (
            <button
              key={t.k}
              type="button"
              role="tab"
              aria-selected={tab === t.k}
              className={`ph-tab${tab === t.k ? ' is-on' : ''}`}
              onClick={() => setTab(t.k)}
            >
              {t.icon}
              <span>{t.label}</span>
            </button>
          ))}
        </div>

        <div className="ph-tabbody">
          {tab === 'ai' && (
            <PlaceholderAITab
              query={draftName}
              productMap={productMap}
              onPick={onReplaceWithProduct}
            />
          )}
          {tab === 'search' && (
            <PlaceholderSearchTab
              query={draftName}
              productMap={productMap}
              onPick={onReplaceWithProduct}
            />
          )}
          {tab === 'link' && (
            <PlaceholderLinkTab
              onResolved={(custom) => onReplaceWithCustom(custom)}
            />
          )}
          {tab === 'photo' && (
            <PlaceholderPhotoTab
              currentName={draftName}
              currentPrice={draftPrice ? Number(draftPrice) : null}
              onResolved={(custom) => onReplaceWithCustom(custom)}
            />
          )}
        </div>

        <footer className="ph-foot">
          <button
            type="button"
            className="ph-delete"
            onClick={() => {
              if (window.confirm(`Remove this placeholder from the list?`)) onDelete();
            }}
          >
            Delete placeholder
          </button>
          <button type="button" className="btn btn-ghost" onClick={onClose} style={{ width: 'auto', padding: '10px 18px' }}>
            <span>Keep as placeholder</span>
          </button>
        </footer>
      </div>
    </>
  );
}

// AI tab — asks the assistant for the best catalog matches for this
// placeholder. The assistant has access to the user's kids/lists/memory
// context so suggestions are personalized. Renders the suggestions as
// add-able cards; user taps to replace the placeholder.
function PlaceholderAITab({ query, productMap, onPick }) {
  // ALL hooks at the top — Pro status is checked via a hook below, and
  // we use it to decide what to render at the end. No conditional hooks.
  const [busy, setBusy]   = _v2s(false);
  const [reply, setReply] = _v2s(null);
  const [err, setErr]     = _v2s('');
  const lastQ = _v2r(null);
  const proStatusObj = window.ProGate && window.ProGate.useStatus
    ? window.ProGate.useStatus() : { status: 'approved' };
  const isPro = proStatusObj.status === 'approved';

  const run = async (q) => {
    setBusy(true); setErr(''); setReply(null);
    lastQ.current = q;
    const products = (window.PRODUCTS || [])
      .filter(p => p && p.id && p.name)
      .slice(0, 400)
      .map(p => ({
        id: p.id, brand: p.brand || '', name: p.name || '',
        price: p.price != null ? Number(p.price) : null,
        category: p.category || p.subcategory || '',
        ageMin: p.ageMin != null ? p.ageMin : null,
        ageMax: p.ageMax != null ? p.ageMax : null,
        description: (p.description || '').slice(0, 140),
      }));
    let ctx = { currentDate: new Date().toISOString().slice(0, 10) };
    // Read user-scoped buckets (see index.html / ai-sidebar.jsx security
    // fix). The unscoped legacy keys leaked across accounts; we now
    // namespace by user_id (':guest' when signed-out).
    let uid = null;
    try {
      const cur = window.MR && window.MR.user && window.MR.user.current ? window.MR.user.current() : null;
      uid = (cur && cur.session && cur.session.user && cur.session.user.id) || null;
    } catch {}
    const bucket = uid || 'guest';
    try {
      const raw = localStorage.getItem('parentstack-v1:' + bucket);
      if (raw) {
        const ps = JSON.parse(raw) || {};
        const profile = ps.profile || {};
        if (Array.isArray(profile.kids) && profile.kids.length) {
          ctx.kids = profile.kids.map(k => ({
            name: k.name, birthday: k.birthday,
            interests: k.interests || [], notes: k.notes || '',
          }));
        }
      }
    } catch {}
    let memory = [];
    try { const r = localStorage.getItem('mr-ai-memory:' + bucket); memory = r ? JSON.parse(r) : []; } catch {}
    const prompt = `I have a placeholder on my list called "${q}". ` +
      `Find the best 3-6 specific products from the catalog that match this placeholder, ranked by fit for my kids' ages. ` +
      `Brief one-line rationale for each pick.`;
    const headers = { 'Content-Type': 'application/json' };
    try {
      const c = window.MR && window.MR.supabase;
      if (c) {
        const { data } = await c.auth.getSession();
        const tk = data && data.session && data.session.access_token;
        if (tk) headers['Authorization'] = 'Bearer ' + tk;
      }
    } catch {}
    try {
      const r = await fetch('/api/ai-list', {
        method: 'POST',
        headers,
        body: JSON.stringify({
          messages: [{ role: 'user', content: prompt }],
          catalog: products, context: ctx, memory,
        }),
      });
      const body = await r.json();
      if (body.ok && body.reply) setReply(body.reply);
      else setErr(body.error || 'Could not get suggestions.');
    } catch (e) {
      setErr('Network error: ' + (e.message || e));
    } finally {
      setBusy(false);
    }
  };

  // Auto-fetch once when the tab opens with a non-empty query.
  _v2e(() => {
    const q = (query || '').trim();
    if (q.length >= 3 && lastQ.current !== q) {
      run(q);
    }
  }, [query]);

  const cards = reply && Array.isArray(reply.productSuggestions)
    ? reply.productSuggestions.map(id => productMap[id]).filter(Boolean)
    : [];

  // Render pro gate if not approved.
  if (!isPro) {
    return (
      <div className="ph-ai">
        {React.createElement(window.ProGate.UpgradePanel, {
          status: proStatusObj.status,
          onRefresh: proStatusObj.refresh || (() => window.MR.pro && window.MR.pro.getMyStatus()),
        })}
      </div>
    );
  }
  return (
    <div className="ph-ai">
      {busy && (
        <div className="ph-ai-status">
          <span className="ai-thinking-dot" /><span className="ai-thinking-dot" /><span className="ai-thinking-dot" />
          <span style={{ marginLeft: 8 }}>Finding matches for "{query}"…</span>
        </div>
      )}
      {err && <div className="ph-ai-error">⚠ {err}</div>}
      {reply && !busy && (
        <>
          {reply.text && <div className="ph-ai-blurb">{reply.text}</div>}
          {cards.length === 0 ? (
            <div className="ph-ai-empty">
              No catalog matches. Try the Search or Paste-link tabs instead.
            </div>
          ) : (
            <div className="ph-ai-grid">
              {cards.map(p => (
                <button key={p.id} type="button" className="ph-ai-card" onClick={() => onPick(p.id)}>
                  <div className="ph-ai-card-thumb">
                    {p.img ? <img src={p.img} alt="" /> : <span className="ph-ai-card-empty">—</span>}
                  </div>
                  <div className="ph-ai-card-meta">
                    {p.brand && <div className="ph-ai-card-brand">{p.brand}</div>}
                    <div className="ph-ai-card-name">{p.name}</div>
                    {p.price != null && <div className="ph-ai-card-price">A${p.price}</div>}
                  </div>
                  <div className="ph-ai-card-cta">Use this</div>
                </button>
              ))}
            </div>
          )}
          <button type="button" className="ph-ai-refresh" onClick={() => run(query)} disabled={busy}>
            ↻ Other suggestions
          </button>
        </>
      )}
      {!busy && !reply && !err && (
        <div className="ph-ai-empty">
          <p>Type at least 3 characters in the name above and I'll suggest catalog products that match.</p>
          <button type="button" className="ai-btn ai-btn--primary" onClick={() => run(query)} disabled={!(query || '').trim()}>
            ✦ Suggest matches
          </button>
        </div>
      )}
    </div>
  );
}

// Search tab — typeahead over catalog. Pre-seeded with the placeholder
// name so the first results are usually relevant.
function PlaceholderSearchTab({ query, productMap, onPick }) {
  const [q, setQ] = _v2s(query || '');
  const results = _v2m(() => {
    const term = (q || '').trim().toLowerCase();
    if (term.length < 2) return [];
    const all = window.PRODUCTS || [];
    const out = [];
    for (const p of all) {
      const hay = `${p.name || ''} ${p.brand || ''}`.toLowerCase();
      if (hay.includes(term)) out.push(p);
      if (out.length >= 12) break;
    }
    return out;
  }, [q]);
  return (
    <div className="ph-search">
      <div className="ph-search-input">
        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
        <input
          type="text"
          autoFocus
          value={q}
          placeholder="Search the catalog…"
          onChange={(e) => setQ(e.target.value)}
        />
      </div>
      <div className="ph-search-results">
        {results.length === 0 ? (
          <div className="ph-search-empty">
            {(q || '').trim().length < 2
              ? 'Type to search…'
              : 'No matches. Try a different word or use Paste link.'}
          </div>
        ) : results.map(p => (
          <button
            key={p.id}
            type="button"
            className="ph-search-row"
            onClick={() => onPick(p)}
          >
            <span className="ph-search-thumb">
              {p.img ? <img src={p.img} alt="" /> : <span>—</span>}
            </span>
            <span className="ph-search-body">
              <span className="ph-search-brand">{p.brand || '—'}</span>
              <span className="ph-search-name">{p.name}</span>
              {p.price != null && (
                <span className="ph-search-price">A${Number(p.price).toLocaleString()}</span>
              )}
            </span>
            <span className="ph-search-cta">Replace →</span>
          </button>
        ))}
      </div>
    </div>
  );
}

// Link tab — paste a URL, run it through /api/og-fetch, fill the custom
// blob with whatever metadata comes back.
function PlaceholderLinkTab({ onResolved }) {
  const [url, setUrl] = _v2s('');
  const [busy, setBusy] = _v2s(false);
  const [err, setErr] = _v2s('');
  const submit = async (e) => {
    if (e) e.preventDefault();
    const u = url.trim();
    if (!u) return;
    setBusy(true); setErr('');
    try {
      const resp = await fetch(`/api/og-fetch?url=${encodeURIComponent(u)}&deep=1`);
      const json = await resp.json();
      if (!resp.ok || !json || !json.ok || !json.data) {
        setErr((json && json.error) || "Couldn't read that page");
        setBusy(false); return;
      }
      const data = json.data;
      const decode = (s) => s ? s.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#039;/g, "'") : '';
      const custom = {
        name: decode(data.title) || u,
        brand: data.brand || '',
        image: data.image || '',
        price: data.price || null,
        currency: data.currency || 'AUD',
        sourceUrl: data.sourceUrl || u,
        description: decode(data.description) || '',
      };
      onResolved(custom);
    } catch (e) {
      setErr(e.message || 'Network problem — try again?');
      setBusy(false);
    }
  };
  return (
    <form className="ph-link" onSubmit={submit}>
      <div className="ph-link-row">
        <input
          type="url"
          inputMode="url"
          autoComplete="off"
          autoFocus
          placeholder="Paste a product link from any store…"
          value={url}
          onChange={(e) => setUrl(e.target.value)}
        />
        <button type="submit" className="btn" disabled={!url.trim() || busy} style={{ width: 'auto', padding: '10px 18px' }}>
          <span>{busy ? 'Reading…' : 'Replace'}</span><span className="arrow">→</span>
        </button>
      </div>
      {err && <div className="ph-link-err">⚠ {err}</div>}
      <p className="ph-link-hint">
        We'll read the page and fill in the name, brand, price, and an image for you.
      </p>
    </form>
  );
}

// Photo tab — file input + drop zone. Encodes to a data URL (no Supabase
// Storage bucket dependency for the prototype). Keeps the placeholder's
// name and price, just swaps in the image.
function PlaceholderPhotoTab({ currentName, currentPrice, onResolved }) {
  const [dragHover, setDragHover] = _v2s(false);
  const inputRef = _v2r(null);
  const handleFile = (f) => {
    if (!f || !f.type || !f.type.startsWith('image/')) return;
    const reader = new FileReader();
    reader.onload = () => {
      onResolved({
        name: currentName || 'Item',
        price: currentPrice,
        image: reader.result,
        currency: 'AUD',
      });
    };
    reader.readAsDataURL(f);
  };
  return (
    <div className="ph-photo">
      <div
        className={`ph-photo-drop${dragHover ? ' is-hover' : ''}`}
        onDragEnter={(e) => { e.preventDefault(); setDragHover(true); }}
        onDragOver={(e) => { e.preventDefault(); setDragHover(true); }}
        onDragLeave={() => setDragHover(false)}
        onDrop={(e) => {
          e.preventDefault();
          setDragHover(false);
          const f = e.dataTransfer.files && e.dataTransfer.files[0];
          if (f) handleFile(f);
        }}
        onClick={() => inputRef.current && inputRef.current.click()}
      >
        <svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 7h4l2-2h6l2 2h4v12H3z"/><circle cx="12" cy="13" r="3.5"/></svg>
        <div className="ph-photo-droptext">
          <strong>Drop a photo here</strong>
          <span>or click to choose a file</span>
        </div>
        <input
          ref={inputRef}
          type="file"
          accept="image/*"
          capture="environment"
          style={{ display: 'none' }}
          onChange={(e) => {
            const f = e.target.files && e.target.files[0];
            if (f) handleFile(f);
            e.target.value = '';
          }}
        />
      </div>
      <p className="ph-photo-hint">
        Keeps the name "<strong>{currentName || 'Item'}</strong>" and {currentPrice ? `price A$${currentPrice}` : 'no price'} — just attaches the photo.
      </p>
    </div>
  );
}

window.ListDetailV2 = ListDetailV2;
