← back to Fromental Internal

public/nav-agent/nav-agent.js

621 lines

/* ============================================================================
 * Nav Agent — universal grid-controls drop-in  (v1.2)
 *
 * Drop one <script src=".../nav-agent.js" defer></script> (+ the CSS) onto any
 * page. It auto-detects the primary data grid (a <table> or a .grid/.card list),
 * then injects:
 *   • a LEFT collapsible panel: text search, per-FIELD show/hide toggles,
 *     sort-by-any-field (asc/desc), group-by-any-field, density slider
 *   • a Grid / List view toggle — List is the compact "kv-list" third view:
 *     one scannable row per record, first visible field as the identity + the
 *     rest as inline LABEL·value chips (default OFF; the native grid is default)
 *   • movable / resizable / collapsible / removable / maximizable panel chrome
 *     on the grid itself
 * All state persists to localStorage, namespaced per page path, so every
 * surface remembers its own layout. Strictly additive & wrapped in try/catch —
 * if it can't understand a page it degrades (panel chrome + search + density)
 * and never throws into the host page.
 *
 * Manual control:  window.NavAgent.mount({ target:'#grid', title:'Deals' })
 * Opt-out a page:  <body data-nav-agent="off">
 * Force a target:  <div id="grid" data-nav-grid> ... </div>
 * ==========================================================================*/
(function () {
  'use strict';
  if (window.NavAgent) return;

  var _uid = 0;
  var navKey = function (target) {
    if (!target.getAttribute('data-na-uid')) target.setAttribute('data-na-uid', target.id || ('g' + (++_uid)));
    return 'navagent:' + location.pathname + ':' + target.getAttribute('data-na-uid');
  };
  var load = function (key) { try { return JSON.parse(localStorage.getItem(key)) || {}; } catch (e) { return {}; } };
  var save = function (key, s) { try { localStorage.setItem(key, JSON.stringify(s)); } catch (e) {} };
  var slug = function (t) { return String(t || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'f'; };
  var txt = function (el) { return (el && el.textContent || '').trim(); };
  // magnitude-aware numeric parse — $2.1B > $780M > 42.  Handles K/M/B/T + commas.
  var num = function (v) {
    var s = String(v).replace(/,/g, '').trim();
    var m = s.match(/-?\d*\.?\d+/); if (!m) return null;
    var n = parseFloat(m[0]); if (isNaN(n)) return null;
    var suf = (s.slice(m.index + m[0].length).match(/[kmbt]/i) || [''])[0].toLowerCase();
    var mult = { k: 1e3, m: 1e6, b: 1e9, t: 1e12 }[suf] || 1;
    return n * mult;
  };
  var el = function (tag, cls, html) { var e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; };

  /* -- target detection -------------------------------------------------- */
  function findTarget(opt) {
    if (opt && opt.target) return document.querySelector(opt.target);
    var q = document.querySelector('[data-nav-grid]');
    if (q) return q;
    // biggest visible table wins, else #grid, else first .grid, else .cards
    var tables = [].slice.call(document.querySelectorAll('table'))
      .filter(function (t) { return t.querySelector('tbody tr') && t.offsetParent !== null; })
      .sort(function (a, b) { return b.querySelectorAll('tr').length - a.querySelectorAll('tr').length; });
    if (tables[0] && tables[0].querySelectorAll('tr').length > 2) return tables[0];
    return document.querySelector('#grid') || document.querySelector('.grid') ||
           document.querySelector('.cards') || tables[0] || null;
  }

  /* -- field model ------------------------------------------------------- */
  // Returns { kind:'table'|'cards', fields:[{key,label,idx?,cls?}], items() }
  function model(target) {
    if (target.tagName === 'TABLE') {
      var ths = [].slice.call(target.querySelectorAll('thead th, thead td'));
      if (!ths.length) { // header-less table: synthesize from first row
        var r0 = target.querySelector('tbody tr, tr');
        ths = r0 ? [].slice.call(r0.children).map(function (_, i) { var s = el('th'); s.textContent = 'Col ' + (i + 1); return s; }) : [];
      }
      var fields = ths.map(function (th, i) { return { key: slug(txt(th)) + '-' + i, label: txt(th) || ('Col ' + (i + 1)), idx: i }; });
      return {
        kind: 'table', fields: fields,
        rows: function () { return [].slice.call(target.querySelectorAll('tbody tr')).filter(function (r) { return !r.classList.contains('na-group-h'); }); },
        cellText: function (row, f) { var c = row.children[f.idx]; return txt(c); }
      };
    }
    // card grid — infer fields from repeated child element classes / [data-field]
    var cards = [].slice.call(target.children).filter(function (c) { return c.nodeType === 1 && !c.classList.contains('na-group-h') && !c.classList.contains('na-group-body'); });
    var sample = cards.slice(0, 24), seen = {}, order = [];
    sample.forEach(function (card) {
      var dfCells = [].slice.call(card.querySelectorAll('[data-field]'));
      dfCells.forEach(function (n) {
        var k = n.getAttribute('data-field'); if (!seen[k]) { seen[k] = { key: slug(k), label: k, cls: '[data-field="' + k + '"]', df: k, hits: 0 }; order.push(seen[k]); } seen[k].hits++;
      });
      // coarse child-class fields ONLY when this card tags no [data-field] cells —
      // otherwise a cell with both a class and a data-field yields a duplicate field.
      if (dfCells.length) return;
      [].slice.call(card.children).forEach(function (n) {
        var cls = (n.className && typeof n.className === 'string') ? n.className.split(/\s+/)[0] : '';
        if (!cls || cls.indexOf('na-') === 0) return;
        var k = 'c-' + cls;
        if (!seen[k]) { seen[k] = { key: slug(cls), label: cls.replace(/[-_]/g, ' '), cls: '.' + cls, hits: 0 }; order.push(seen[k]); } seen[k].hits++;
      });
    });
    // keep fields present on a majority of sampled cards (stable structure)
    var min = Math.max(1, Math.floor(sample.length * 0.4));
    var fields = order.filter(function (f) { return f.hits >= min; });
    return {
      kind: 'cards', cards: true, fields: fields,
      rows: function () { return [].slice.call(target.children).filter(function (c) { return c.nodeType === 1 && !c.classList.contains('na-group-h') && !c.classList.contains('na-group-body') && c.offsetParent !== null || (c.nodeType === 1 && !c.classList.contains('na-group-h')); }); },
      cellText: function (card, f) { var n = f.df ? card.querySelector('[data-field="' + f.df + '"]') : card.querySelector(f.cls); return txt(n); }
    };
  }

  /* -- one instance ------------------------------------------------------ */
  function Agent(target, opt) {
    var key = navKey(target);
    var st = load(key);
    var M = model(target);
    var self = { target: target, model: M, state: st, key: key };
    var applyingSort = false;

    /* field show/hide via CONTAINER classes (survives host re-renders) */
    function fieldStyleTag() {
      var id = 'na-style-' + key.replace(/[^a-z0-9]/gi, '');
      var tag = document.getElementById(id) || (function () { var t = el('style'); t.id = id; document.head.appendChild(t); return t; })();
      var css = '';
      (st.hidden || []).forEach(function (key) {
        var f = M.fields.filter(function (x) { return x.key === key; })[0]; if (!f) return;
        if (M.kind === 'table') {
          css += 'table[data-na] thead th:nth-child(' + (f.idx + 1) + '),table[data-na] tbody td:nth-child(' + (f.idx + 1) + '){display:none}';
        } else {
          css += target.getAttribute('data-na-sel') + ' ' + f.cls + '{display:none}';
        }
      });
      tag.textContent = css;
    }

    function applySort() {
      if (!st.sort) return;
      var f = M.fields.filter(function (x) { return x.key === st.sort; })[0]; if (!f) return;
      var dir = st.dir === 'desc' ? -1 : 1;
      var rows = M.rows();
      if (!rows.length) return;
      applyingSort = true;
      // detect numeric
      var numeric = rows.slice(0, 12).every(function (r) { var v = M.cellText(r, f); return v === '' || num(v) !== null; });
      rows.sort(function (a, b) {
        var va = M.cellText(a, f), vb = M.cellText(b, f);
        if (numeric) { var na = num(va), nb = num(vb); na = na == null ? -Infinity : na; nb = nb == null ? -Infinity : nb; return (na - nb) * dir; }
        return va.localeCompare(vb, undefined, { numeric: true }) * dir;
      });
      var parent = M.kind === 'table' ? target.querySelector('tbody') : target;
      rows.forEach(function (r) { parent.appendChild(r); });
      applyGroup(); // grouping depends on sorted order
      applyingSort = false;
    }

    function clearGroups() {
      [].slice.call(target.querySelectorAll('.na-group-h')).forEach(function (n) { n.remove(); });
    }

    function applyGroup() {
      clearGroups();
      if (!st.group) return;
      var f = M.fields.filter(function (x) { return x.key === st.group; })[0]; if (!f) return;
      var rows = M.rows(), last = null, parent = M.kind === 'table' ? target.querySelector('tbody') : target;
      var counts = {};
      rows.forEach(function (r) { var v = M.cellText(r, f) || '—'; counts[v] = (counts[v] || 0) + 1; });
      rows.forEach(function (r) {
        var v = M.cellText(r, f) || '—';
        if (v !== last) {
          var h;
          if (M.kind === 'table') { h = el('tr', 'na-group-h'); var td = el('td'); td.colSpan = M.fields.length; td.innerHTML = '▸ ' + esc(v) + ' <span class="na-gcount">(' + counts[v] + ')</span>'; h.appendChild(td); }
          else { h = el('div', 'na-group-h', '▸ ' + esc(v) + ' <span class="na-gcount">(' + counts[v] + ')</span>'); }
          parent.insertBefore(h, r); last = v;
        }
      });
    }

    function applySearch() {
      var qraw = (st.q || '').toLowerCase();
      M.rows().forEach(function (r) {
        var show = !qraw || r.textContent.toLowerCase().indexOf(qraw) >= 0;
        r.style.display = show ? '' : 'none';
      });
    }

    function applyAll() { fieldStyleTag(); applySort(); applySearch(); if (self.renderList && st.view === 'list') self.renderList(); }

    /* re-apply after host re-renders (debounced, ignores our own mutations).
       If the FIELD SET changed (async data arrived, filter repopulated), tell
       mount() to rebuild the panel UI. */
    function fieldSig(m) { return m.kind + ':' + m.fields.map(function (f) { return f.key; }).join(','); }
    var sig = fieldSig(M);
    var t;
    var mo = new MutationObserver(function () {
      if (applyingSort) return;
      clearTimeout(t); t = setTimeout(function () {
        M = model(target); self.model = M;
        var ns = fieldSig(M);
        if (ns !== sig) { sig = ns; if (self.onFields) try { self.onFields(); } catch (e) {} }
        applyAll();
      }, 140);
    });
    try { mo.observe(M.kind === 'table' ? (target.querySelector('tbody') || target) : target, { childList: true }); } catch (e) {}

    self.applyAll = applyAll;
    self.applySort = applySort;
    self.applyGroup = applyGroup;
    self.applySearch = applySearch;
    self.save = function () { save(key, st); };
    return self;
  }

  function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]; }); }

  /* -- Grid / List view toggle (shared by standalone + merged panels) ---- */
  function viewToggle(agent) {
    var st = agent.state, wrap = el('div', 'na-viewseg');
    wrap.innerHTML = '<button data-v="grid">▦ Grid</button><button data-v="list">☰ List</button>';
    function mark() { [].slice.call(wrap.children).forEach(function (b) { b.classList.toggle('on', (st.view || 'grid') === b.getAttribute('data-v')); }); }
    [].slice.call(wrap.children).forEach(function (b) { b.onclick = function () { agent.setView(b.getAttribute('data-v')); mark(); }; });
    mark(); return wrap;
  }

  /* -- panel UI ---------------------------------------------------------- */
  function buildPanel(agent, opt) {
    var M = agent.model, st = agent.state;
    var panel = el('aside', 'na-panel');
    panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'View controls');
    var title = (opt && opt.title) || document.title || 'View';
    panel.appendChild(el('header', null,
      '<span class="na-title">⚙ View · ' + esc(title) + '</span>' +
      '<button class="na-x" title="Close">✕</button>'));
    var body = el('div', 'na-body'); panel.appendChild(body);

    // Grid / List view toggle (only meaningful when we have fields to lay out)
    if (M.fields && M.fields.length) body.appendChild(viewToggle(agent));

    // search
    var search = el('input', 'na-search'); search.type = 'search'; search.placeholder = 'Search all fields…';
    search.value = st.q || '';
    search.addEventListener('input', function () { st.q = search.value; agent.save(); agent.applySearch(); });
    body.appendChild(search);

    function section(label, openDefault) {
      var s = el('div', 'na-sec' + (openDefault ? ' open' : ''));
      s.appendChild(el('div', 'na-h', label));
      var c = el('div', 'na-c'); s.appendChild(c);
      s.querySelector('.na-h').addEventListener('click', function () { s.classList.toggle('open'); });
      body.appendChild(s); return c;
    }

    var haveFields = M.fields && M.fields.length;

    // FIELDS toggle
    if (haveFields) {
      var fc = section('Fields (' + M.fields.length + ')', true);
      st.hidden = st.hidden || [];
      M.fields.forEach(function (f) {
        var row = el('label', 'na-fieldrow');
        var cb = el('input'); cb.type = 'checkbox'; cb.checked = st.hidden.indexOf(f.key) < 0;
        cb.addEventListener('change', function () {
          var i = st.hidden.indexOf(f.key);
          if (cb.checked && i >= 0) st.hidden.splice(i, 1);
          else if (!cb.checked && i < 0) st.hidden.push(f.key);
          agent.save(); agent.applyAll();
        });
        row.appendChild(cb);
        row.appendChild(el('span', null, esc(f.label)));
        fc.appendChild(row);
      });
      var mini = el('div', 'na-mini',
        '<button data-all>All</button><button data-none>None</button>');
      mini.querySelector('[data-all]').onclick = function () { st.hidden = []; agent.save(); agent.applyAll(); refreshChecks(); };
      mini.querySelector('[data-none]').onclick = function () { st.hidden = M.fields.map(function (f) { return f.key; }); agent.save(); agent.applyAll(); refreshChecks(); };
      fc.appendChild(mini);
      function refreshChecks() { [].slice.call(fc.querySelectorAll('.na-fieldrow input')).forEach(function (cb, i) { cb.checked = st.hidden.indexOf(M.fields[i].key) < 0; }); }
    }

    // SORT
    if (haveFields) {
      var sc = section('Sort', true);
      var srow = el('div', 'na-row', '<label>Field</label>');
      var sel = el('select'); sel.appendChild(new Option('— none —', ''));
      M.fields.forEach(function (f) { sel.appendChild(new Option(f.label, f.key)); });
      sel.value = st.sort || ''; srow.appendChild(sel); sc.appendChild(srow);
      var dir = el('div', 'na-mini', '<button data-asc>▲ Asc</button><button data-desc>▼ Desc</button>');
      function markDir() { dir.querySelector('[data-asc]').classList.toggle('on', st.dir !== 'desc'); dir.querySelector('[data-desc]').classList.toggle('on', st.dir === 'desc'); }
      sel.addEventListener('change', function () { st.sort = sel.value; agent.save(); agent.applySort(); });
      dir.querySelector('[data-asc]').onclick = function () { st.dir = 'asc'; markDir(); agent.save(); agent.applySort(); };
      dir.querySelector('[data-desc]').onclick = function () { st.dir = 'desc'; markDir(); agent.save(); agent.applySort(); };
      markDir(); sc.appendChild(dir);
    }

    // GROUP
    if (haveFields) {
      var gc = section('Group by', false);
      var grow = el('div', 'na-row', '<label>Field</label>');
      var gsel = el('select'); gsel.appendChild(new Option('— none —', ''));
      M.fields.forEach(function (f) { gsel.appendChild(new Option(f.label, f.key)); });
      gsel.value = st.group || ''; grow.appendChild(gsel); gc.appendChild(grow);
      gsel.addEventListener('change', function () { st.group = gsel.value; agent.save(); agent.applyGroup(); });
    }

    // DENSITY (only if the target reacts to --cols or has a page density var)
    var dc = section('Density', false);
    var drow = el('div', 'na-row', '<label>Cols</label>');
    var rng = el('input'); rng.type = 'range'; rng.min = 2; rng.max = 12; rng.value = st.cols || getComputedCols(agent.target) || 5;
    rng.addEventListener('input', function () { st.cols = +rng.value; agent.save(); setCols(agent.target, +rng.value); });
    drow.appendChild(rng); dc.appendChild(drow);
    dc.appendChild(el('div', 'na-note', 'Adjusts grid columns where the layout supports it.'));
    if (st.cols) setCols(agent.target, st.cols);

    if (!haveFields) body.appendChild(el('div', 'na-note',
      'No structured fields detected on this grid — field toggles, sort & group need <code>&lt;table&gt;</code> headers or <code>[data-field]</code>/classed card elements. Search, density and the movable panel are active.'));

    var reset = el('button', 'na-reset', '↺ Reset this page’s view');
    reset.onclick = function () { try { localStorage.removeItem(agent.key); } catch (e) {} location.reload(); };
    body.appendChild(reset);

    panel.querySelector('.na-x').onclick = function () { panel.classList.remove('open'); };
    document.body.appendChild(panel);
    return panel;
  }

  function getComputedCols(t) { try { var v = getComputedStyle(t).getPropertyValue('--cols'); return v ? parseInt(v) : null; } catch (e) { return null; } }
  function setCols(t, n) {
    try {
      t.style.setProperty('--cols', n);
      // fallback for grids without a --cols hook
      if (!/grid-template-columns/.test(t.style.cssText) && getComputedStyle(t).display === 'grid')
        t.style.gridTemplateColumns = 'repeat(' + n + ',minmax(0,1fr))';
    } catch (e) {}
  }

  /* -- movable / resizable / removable panel chrome ---------------------- */
  function chrome(agent, opt) {
    var t = agent.target;
    if (t.closest('.na-wrap')) return;
    var wrap = el('div', 'na-wrap');
    var title = (opt && opt.title) || document.title || 'Data';
    var bar = el('div', 'na-bar',
      '<span class="na-bt">▤ ' + esc(title) + '</span>' +
      '<button data-collapse title="Collapse">▁</button>' +
      '<button data-max title="Maximize / float">⤢</button>' +
      '<button data-remove title="Remove panel">✕</button>');
    var inner = el('div', 'na-inner');
    t.parentNode.insertBefore(wrap, t);
    wrap.appendChild(bar); wrap.appendChild(inner); inner.appendChild(t);

    var g = agent.state.geo || {};
    bar.querySelector('[data-collapse]').onclick = function () { wrap.classList.toggle('na-collapsed'); persist(); };
    bar.querySelector('[data-max]').onclick = function () { wrap.classList.toggle('na-max'); wrap.classList.remove('na-float'); persist(); };
    bar.querySelector('[data-remove]').onclick = function () { wrap.classList.add('na-removed'); addRestore(); persist(); };

    // drag to float/move
    var down = null;
    bar.addEventListener('mousedown', function (e) {
      if (e.target.tagName === 'BUTTON') return;
      var r = wrap.getBoundingClientRect();
      if (!wrap.classList.contains('na-float')) { wrap.classList.add('na-float'); wrap.style.left = r.left + 'px'; wrap.style.top = r.top + 'px'; wrap.style.width = r.width + 'px'; }
      down = { x: e.clientX, y: e.clientY, l: parseFloat(wrap.style.left) || r.left, t: parseFloat(wrap.style.top) || r.top };
      e.preventDefault();
    });
    window.addEventListener('mousemove', function (e) {
      if (!down) return;
      wrap.style.left = (down.l + e.clientX - down.x) + 'px';
      wrap.style.top = Math.max(0, down.t + e.clientY - down.y) + 'px';
    });
    window.addEventListener('mouseup', function () { if (down) { down = null; persist(); } });

    function persist() {
      agent.state.geo = {
        float: wrap.classList.contains('na-float'), max: wrap.classList.contains('na-max'),
        collapsed: wrap.classList.contains('na-collapsed'), removed: wrap.classList.contains('na-removed'),
        left: wrap.style.left, top: wrap.style.top, width: wrap.style.width, height: wrap.style.height
      };
      agent.save();
    }
    function addRestore() {
      var rb = document.querySelector('.na-restorebar') || (function () { var b = el('div', 'na-restorebar'); document.body.appendChild(b); return b; })();
      var btn = el('button', null, '⟲ ' + esc(title));
      btn.onclick = function () { wrap.classList.remove('na-removed'); btn.remove(); persist(); };
      rb.appendChild(btn);
    }
    // restore saved geometry
    if (g.float) { wrap.classList.add('na-float'); ['left', 'top', 'width', 'height'].forEach(function (k) { if (g[k]) wrap.style[k] = g[k]; }); }
    if (g.max) wrap.classList.add('na-max');
    if (g.collapsed) wrap.classList.add('na-collapsed');
    // ROOT FIX (Steve-approved 2026-08-17): a "removed" grid must NEVER stay hidden across reloads —
    // that trap has no recovery and blanked whole pages (CRCP mls.html). Do NOT restore `removed` on
    // load: the grid always reappears; in-session Remove still works, it just no longer persist-hides.
    // (Float / max / collapsed still persist — those are safe, recoverable layout choices.)
  }

  /* -- merged mode: fold into an existing house panel ("2 into 1") ------- */
  function findHousePanel(target) {
    var asides = [].slice.call(document.querySelectorAll('aside, .sidebar, .panel-left, [data-house-panel]'));
    for (var i = 0; i < asides.length; i++) {
      var a = asides[i];
      if (a.classList.contains('na-panel')) continue;
      if (a.contains(target)) continue;            // a real control sidebar won't contain the grid
      if (a.querySelector('input, select, details, .toggle, .chip')) return a; // has controls
    }
    return null;
  }

  function buildMergeSection(agent, house, opt) {
    var M = agent.model, st = agent.state;
    var old = house.querySelector('.na-merge'); if (old) old.remove();
    var box = el('div', 'na-merge');
    box.appendChild(el('div', 'na-mh', '⚙ Nav Agent'));
    if (M.fields && M.fields.length) {
      // sort ANY field (superset of the house's fixed sort options)
      var srow = el('div', 'na-row', '<label>Sort</label>');
      var ssel = el('select'); ssel.appendChild(new Option('— none —', ''));
      M.fields.forEach(function (f) { ssel.appendChild(new Option(f.label, f.key)); });
      ssel.value = st.sort || ''; srow.appendChild(ssel); box.appendChild(srow);
      var dir = el('div', 'na-mini', '<button data-asc>▲ Asc</button><button data-desc>▼ Desc</button>');
      function markDir() { dir.querySelector('[data-asc]').classList.toggle('on', st.dir !== 'desc'); dir.querySelector('[data-desc]').classList.toggle('on', st.dir === 'desc'); }
      ssel.onchange = function () { st.sort = ssel.value; agent.save(); agent.applySort(); };
      dir.querySelector('[data-asc]').onclick = function () { st.dir = 'asc'; agent.save(); agent.applySort(); markDir(); };
      dir.querySelector('[data-desc]').onclick = function () { st.dir = 'desc'; agent.save(); agent.applySort(); markDir(); };
      markDir(); box.appendChild(dir);
      // group ANY field
      var grow = el('div', 'na-row', '<label>Group</label>');
      var gsel = el('select'); gsel.appendChild(new Option('— none —', ''));
      M.fields.forEach(function (f) { gsel.appendChild(new Option(f.label, f.key)); });
      gsel.value = st.group || ''; gsel.onchange = function () { st.group = gsel.value; agent.save(); agent.applyGroup(); };
      grow.appendChild(gsel); box.appendChild(grow);
    }
    if (M.fields && M.fields.length) box.appendChild(viewToggle(agent));
    box.appendChild(el('div', 'na-note', 'Drag the grid’s ▤ bar to float · ▁ collapse · ⤢ maximize · ✕ remove.'));
    house.appendChild(box);
    return box;
  }

  /* -- public mount ------------------------------------------------------ */
  var API = {
    instances: [],
    mount: function (opt) {
      opt = opt || {};
      try {
        var target = findTarget(opt);
        if (!target) return null;
        if (target.__naMounted) return target.__naAgent;
        target.__naMounted = true;
        if (target.tagName === 'TABLE') target.setAttribute('data-na', '1');
        else target.setAttribute('data-na-sel', target.id ? '#' + target.id : '.' + (target.className.split(/\s+/)[0] || 'grid'));
        var agent = Agent(target, opt);
        target.__naAgent = agent;

        // ---- compact LIST view (the kv-list "third view", v1.2) -----------
        // Renders each live grid row as one scannable line: first visible field
        // as the identity, the rest as inline LABEL·value chips. DOM-based (reads
        // M.cellText), so sort/search/field-toggle all flow through for free.
        // Default OFF — the page's native grid stays the default view.
        function ensureListEl() {
          if (agent._listEl) return agent._listEl;
          var lel = el('div', 'na-list');
          (target.parentNode || target).insertBefore(lel, target.nextSibling);
          agent._listEl = lel; return lel;
        }
        agent.renderList = function () {
          var lel = agent._listEl; if (!lel) return;
          var M2 = agent.model, hidden = agent.state.hidden || [];
          var vis = (M2.fields || []).filter(function (f) { return hidden.indexOf(f.key) < 0; });
          if (!vis.length) vis = (M2.fields || []).slice();
          if (!vis.length) { lel.innerHTML = '<div class="na-lempty">No structured fields detected on this grid.</div>'; return; }
          var titleF = vis[0], chipF = vis.slice(1);
          var rows = M2.rows().filter(function (r) { return r.style.display !== 'none'; });
          if (!rows.length) { lel.innerHTML = '<div class="na-lempty">No matching rows.</div>'; return; }
          lel.innerHTML = '<div class="na-lrows">' + rows.map(function (r) {
            var tt = esc(M2.cellText(r, titleF) || '—');
            var chips = chipF.map(function (f) {
              var v = M2.cellText(r, f); if (v === '' || v == null) return '';
              return '<span class="na-lm"><span class="na-lmk">' + esc(f.label) + '</span>' + esc(v) + '</span>';
            }).join('');
            return '<div class="na-lrow"><div class="na-lmain"><span class="na-la">' + tt + '</span></div>' +
                   '<div class="na-lmeta">' + chips + '</div></div>';
          }).join('') + '</div>';
        };
        agent.setView = function (v) {
          agent.state.view = (v === 'list') ? 'list' : 'grid'; agent.save();
          if (agent.state.view === 'list') { ensureListEl(); target.style.display = 'none'; agent._listEl.style.display = ''; agent.renderList(); }
          else { if (agent._listEl) agent._listEl.style.display = 'none'; target.style.display = ''; }
        };
        // "2 into 1": if the page already has a house control panel, fold into it
        var house = opt.merge === false ? null : findHousePanel(target);
        // (re)build the controls — called again by the observer when fields change
        function makePanel() {
          if (house) { agent._merge = buildMergeSection(agent, house, opt); return; }
          var wasOpen = agent._panel && agent._panel.classList.contains('open');
          if (agent._panel) agent._panel.remove();
          agent._panel = buildPanel(agent, opt);
          if (wasOpen) agent._panel.classList.add('open');
        }
        agent.onFields = makePanel;
        agent.merged = !!house;
        makePanel();
        chrome(agent, opt);
        agent.applyAll();
        // restore a persisted List-view choice (default is Grid — nothing changes)
        if (agent.state.view === 'list') agent.setView('list');

        // standalone launcher only when NOT merged into a house panel
        if (!house && !document.querySelector('.na-launch')) {
          var b = el('button', 'na-launch', '⚙'); b.title = 'View controls';
          b.onclick = function () {
            var anyOpen = document.querySelector('.na-panel.open');
            [].slice.call(document.querySelectorAll('.na-panel')).forEach(function (p) { p.classList.toggle('open', !anyOpen); });
          };
          document.body.appendChild(b);
        }
        API.instances.push(agent);
        return agent;
      } catch (e) { if (window.console) console.warn('[nav-agent] mount skipped:', e); return null; }
    }
  };
  window.NavAgent = API;

  /* -- host left-sidebar collapse (standard feature, v1.3, 2026-08-20) ----
   * The Nav Agent collapses the host page's primary LEFT sidebar on load and
   * adds a ☰ toggle to reveal/hide it. ALWAYS collapsed on load — the reveal is
   * an in-session choice (deliberately NOT persisted), so every page load starts
   * clean and collapsed. Runs even on pages with no data grid.
   *   Opt out:  <body data-nav-sidebar="off">
   *   Force:    <body data-nav-sidebar="#mySelector">  or  <aside data-nav-sidebar>
   *   Content:  the shifted main column auto-detects; force with [data-nav-main]. */
  function naIsSidebar(e) {
    if (!e || e === document.body) return false;
    if (e.classList && (e.classList.contains('na-panel') || e.classList.contains('na-list') ||
        e.classList.contains('na-launch') || e.classList.contains('na-side-toggle'))) return false;
    var cs; try { cs = getComputedStyle(e); } catch (x) { return false; }
    if (['fixed', 'sticky', 'absolute'].indexOf(cs.position) < 0) return false;
    var r = e.getBoundingClientRect();
    var atLeft = r.left <= 6;
    var tall = r.height >= Math.min(window.innerHeight * 0.55, 380);
    var narrow = r.width > 20 && r.width <= 360;
    var hasNav = e.querySelectorAll('a,button,nav,.nav').length >= 1;
    return atLeft && tall && narrow && hasNav;
  }
  function naFindSidebar() {
    var body = document.body, a = body.getAttribute('data-nav-sidebar');
    if (a === 'off') return null;
    if (a && a !== 'on' && a !== '') { var s = document.querySelector(a); if (s) return s; }
    var marked = document.querySelector('[data-nav-sidebar]');
    if (marked && marked !== body) return marked;
    var sel = ['#side', '#sidebar', '.sidebar', '#side-nav', '.side-nav', '#leftnav', '.leftnav', 'aside'];
    for (var i = 0; i < sel.length; i++) { var e = document.querySelector(sel[i]); if (naIsSidebar(e)) return e; }
    var kids = [].slice.call(body.children);
    for (var j = 0; j < kids.length; j++) { if (naIsSidebar(kids[j])) return kids[j]; }
    return null;
  }
  function naFindMain(side) {
    var m = document.querySelector('[data-nav-main]'); if (m) return m;
    var w = side.getBoundingClientRect().width;
    var pool = [].slice.call(document.body.children).filter(function (c) {
      return c.nodeType === 1 && c !== side &&
        !(c.classList && (c.classList.contains('na-panel') || c.classList.contains('na-launch') ||
          c.classList.contains('na-side-toggle') || c.classList.contains('na-list')));
    });
    for (var i = 0; i < pool.length; i++) {
      var ml = parseFloat(getComputedStyle(pool[i]).marginLeft) || 0;
      if (ml > 40 && Math.abs(ml - w) <= 28) return pool[i];
    }
    return side.nextElementSibling || null;
  }
  function naMountSidebar() {
    try {
      if (document.body.getAttribute('data-nav-agent') === 'off') return;
      if (document.querySelector('.na-side-toggle')) return;   // once per page
      var side = naFindSidebar(); if (!side) return;
      side.classList.add('na-side');
      var main = naFindMain(side); if (main) main.classList.add('na-side-main');
      var open = false;                                        // ALWAYS collapsed on load
      var tog = el('button', 'na-side-toggle', '☰'); tog.type = 'button';
      function apply() {
        side.classList.toggle('na-side-collapsed', !open);
        if (main) main.classList.toggle('na-side-shift', !open);
        tog.classList.toggle('on', open);
        tog.title = open ? 'Collapse sidebar' : 'Expand sidebar';
        tog.setAttribute('aria-expanded', open ? 'true' : 'false');
      }
      tog.onclick = function () { open = !open; apply(); };
      document.body.appendChild(tog);
      apply();
    } catch (e) { if (window.console) console.warn('[nav-agent] sidebar collapse skipped:', e); }
  }
  API.collapseSidebar = naMountSidebar;

  function boot() {
    try {
      var body = document.body;
      if (!body || body.getAttribute('data-nav-agent') === 'off') return;
      naMountSidebar(); // collapse the host left sidebar on load (grid-independent)
      // wait a tick for API-driven grids to render their first batch
      var tries = 0;
      (function attempt() {
        naMountSidebar(); // idempotent — catches a late-rendered sidebar too
        var t = findTarget();
        var populated = false;
        if (t) {
          if (t.tagName === 'TABLE') populated = !!t.querySelector('tbody tr');
          else {
            var kids = [].slice.call(t.children).filter(function (c) { return c.nodeType === 1; });
            // ignore an empty-state placeholder (a lone div, often "No … match")
            populated = !!t.querySelector('.card') || kids.length >= 2;
          }
        }
        if (populated || tries++ > 32) {
          var _agent = API.mount(); // mount anyway after ~8s; observer fills fields when data arrives
          // diagnosability (DTD C, 2026-08-13): a null mount is otherwise SILENT — md5/marker/200
          // all pass while no panel/launcher appears. Emit ONE passive console.info (never warn/error,
          // never throw, never alter flow) so a future silent-null-mount is greppable, not invisible.
          // Only nudge when there is GENUINELY no target. mount() also returns null when it
          // THREW mid-mount on a real grid (its own console.warn already fired) — re-check
          // findTarget() so we never emit a false "no grid" on a page that actually has one.
          if (!_agent) { try { if (findTarget() === null && window.console && console.info) console.info('[nav-agent] no grid target found on this marker-bearing page — add data-nav-grid to the real grid container, or data-nav-agent="off" to opt out (see _shared/nav-agent/README.md).'); } catch (e) {} }
          return;
        }
        setTimeout(attempt, 250);
      })();
    } catch (e) {}
  }
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
  else boot();
})();