← back to Nationalrealestate

public/contractors.html

401 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>CA Contractors · USRealEstate (internal)</title>
<link rel="stylesheet" href="/nav-drawer.css">
<style>
  :root{--bg:#0a0d13;--panel:#11151d;--line:#222936;--fg:#e8ecf2;--muted:#8a93a3;--gold:#c8a24b;--blue:#5a9be0;--card:236px;--fs:13px;}
  *{box-sizing:border-box;}
  html,body{margin:0;height:100%;background:var(--bg);color:var(--fg);font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;overflow:hidden;}
  a{color:var(--gold);text-decoration:none;} a:hover{text-decoration:underline;}
  #topbar{position:fixed;inset:0 0 auto 0;z-index:1200;display:flex;align-items:center;gap:16px;
    padding:9px 16px;background:rgba(17,21,29,.96);border-bottom:1px solid var(--line);backdrop-filter:blur(6px);}
  .brand{font-size:15px;font-weight:700;white-space:nowrap;} .brand b{color:var(--gold);}
  .brand .tag{font-size:10px;color:var(--muted);font-weight:600;border:1px solid var(--line);border-radius:6px;padding:1px 6px;margin-left:8px;vertical-align:middle;}
  nav{display:flex;gap:13px;font-size:13px;} nav a{color:var(--muted);} nav a:hover,nav a.active{color:var(--gold);text-decoration:none;}
  #controls{display:flex;gap:8px;align-items:center;margin-left:auto;flex-wrap:wrap;}
  #controls input,#controls select{background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:6px 10px;font:12px inherit;}
  #controls input:focus,#controls select:focus{outline:none;border-color:var(--gold);}
  #q{width:200px;}
  .lbl{font-size:11px;color:var(--muted);}
  #density{width:90px;accent-color:var(--gold);}
  #wrap{position:fixed;inset:49px 0 0 0;display:flex;}
  #side{width:245px;flex:none;border-right:1px solid var(--line);background:var(--panel);overflow-y:auto;padding:10px;}
  #side h3{margin:12px 0 6px;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--gold);}
  #side h3:first-child{margin-top:2px;}
  #totals{font-size:12px;color:var(--muted);margin-bottom:6px;font-variant-numeric:tabular-nums;line-height:1.7;}
  #totals b{color:var(--fg);}
  .facet{display:flex;justify-content:space-between;gap:6px;padding:4px 7px;border-radius:7px;cursor:pointer;font-size:12.5px;border:1px solid transparent;}
  .facet:hover{background:var(--bg);border-color:var(--line);}
  .facet.sel{background:var(--bg);border-color:var(--gold);color:var(--gold);}
  .facet .ct{color:var(--gold);font-variant-numeric:tabular-nums;flex:none;}
  .facet .nm{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
  .toggles{display:flex;flex-wrap:wrap;gap:6px;}
  .toggles label{font-size:11.5px;color:var(--muted);display:inline-flex;align-items:center;gap:4px;cursor:pointer;border:1px solid var(--line);border-radius:12px;padding:2px 8px;}
  .toggles label.on{color:var(--gold);border-color:var(--gold);}
  .toggles input{display:none;}
  #active{display:flex;flex-wrap:wrap;gap:5px;padding:8px 16px;background:var(--panel);border-bottom:1px solid var(--line);}
  #active:empty{display:none;}
  .chip{display:inline-flex;align-items:center;gap:5px;font-size:12px;padding:3px 9px;border-radius:12px;border:1px solid var(--gold);color:var(--gold);background:rgba(200,162,75,.08);}
  .chip b{color:var(--fg);font-weight:600;} .chip .rm{cursor:pointer;opacity:.7;} .chip .rm:hover{opacity:1;}
  #main{flex:1;display:flex;flex-direction:column;min-width:0;}
  #gridwrap{flex:1;overflow:auto;padding:14px 16px;}
  #grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--card),1fr));gap:12px;}
  .cd{border:1px solid var(--line);border-radius:10px;padding:12px 13px;background:var(--panel);font-size:var(--fs);overflow:hidden;}
  .cd:hover{border-color:var(--gold);}
  .cd .nm{font-weight:700;color:var(--fg);margin-bottom:2px;line-height:1.3;}
  .cd .lic{font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums;margin-bottom:8px;}
  .cd .lic a{color:var(--blue);}
  .cd .st{display:inline-block;font-size:10.5px;padding:1px 7px;border-radius:10px;border:1px solid var(--line);color:var(--muted);}
  .cd .st.active{color:#7fd18a;border-color:#2e5a37;}
  .cd .row{display:flex;gap:6px;font-size:12px;margin:3px 0;color:var(--fg);}
  .cd .row .k{color:var(--muted);flex:none;width:64px;}
  .cd .cls{display:flex;flex-wrap:wrap;gap:4px;margin-top:8px;}
  .cd .cls .c{font-size:10.5px;padding:1px 6px;border-radius:8px;border:1px solid var(--gold);color:var(--gold);background:rgba(200,162,75,.07);}
  .cd .cls .c[title]{cursor:help;}
  .cd .when{font-size:10.5px;color:var(--muted);margin-top:8px;border-top:1px solid var(--line);padding-top:6px;}
  #status{padding:6px 14px;border-top:1px solid var(--line);font-size:11px;color:var(--muted);background:var(--panel);display:flex;gap:14px;align-items:center;}
  #status button{background:var(--bg);border:1px solid var(--line);color:var(--muted);border-radius:6px;padding:3px 10px;cursor:pointer;font:11px inherit;}
  #status button:hover:not(:disabled){color:var(--fg);border-color:var(--muted);} #status button:disabled{opacity:.4;cursor:default;}
  .empty{color:var(--muted);font-size:13px;padding:50px;text-align:center;grid-column:1/-1;}
  /* view segment toggle */
  .cg-seg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden;}
  .cg-seg button{background:var(--bg);color:var(--muted);border:0;border-right:1px solid var(--line);padding:6px 9px;font:12px inherit;cursor:pointer;}
  .cg-seg button:last-child{border-right:0;}
  .cg-seg button.active{background:var(--gold);color:var(--bg);font-weight:700;}
  /* list view: one compact row per record */
  #grid.list{display:block;}
  #grid.list .cd{display:flex;align-items:baseline;gap:14px;border-radius:0;border:0;border-bottom:1px solid var(--line);padding:8px 10px;background:none;}
  #grid.list .cd:hover{background:#141a24;}
  #grid.list .cd .nm{margin:0;min-width:220px;flex:none;}
  #grid.list .cd .lic{margin:0;}
  #grid.list .cd .row{margin:0;} #grid.list .cd .row .k{display:none;}
  #grid.list .cd .cls{margin:0;} #grid.list .cd .when{border:0;padding:0;margin:0;}
  /* table view: dense all-columns spreadsheet with click-to-sort headers */
  #grid.table{display:block;}
  #grid.table + .ctrtbl{display:table;}
  table.ctrtbl{border-collapse:collapse;width:100%;font-size:var(--fs);font-variant-numeric:tabular-nums;}
  table.ctrtbl thead th{position:sticky;top:0;z-index:2;background:#0d1119;border-bottom:1px solid var(--line);color:var(--muted);
    text-align:left;padding:7px 10px;font-weight:600;white-space:nowrap;cursor:pointer;}
  table.ctrtbl thead th:hover{color:var(--gold);} table.ctrtbl thead th.sorted{color:var(--gold);}
  table.ctrtbl tbody td{border-bottom:1px solid var(--line);padding:5px 10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:280px;}
  table.ctrtbl tbody tr:hover{background:#141a24;}
  table.ctrtbl a{color:var(--blue);}
</style>
  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
</head>
<body>
<div id="topbar">
  <div class="brand">US<b>RealEstate</b> · Contractors <span class="tag">CSLB · internal</span></div>
  <nav>
    <a href="/index.html">Map</a><a href="/deals.html">Deals</a>
    <a href="/parcels.html">Parcels</a><a href="/contractors.html" class="active">Contractors</a>
  </nav>
  <div id="controls">
    <input id="q" type="search" placeholder="Search business name…" autocomplete="off">
    <select id="class" title="Trade / classification"><option value="">All trades</option></select>
    <select id="status" title="License status">
      <option value="Active">Active</option>
      <option value="all">Any status</option>
      <option value="Inactive">Inactive</option>
      <option value="Expired">Expired</option>
      <option value="Suspended">Suspended</option>
    </select>
    <select id="sort" title="Sort">
      <option value="business_name:asc">Name A→Z</option>
      <option value="business_name:desc">Name Z→A</option>
      <option value="license_no:asc">License # ↑</option>
      <option value="license_no:desc">License # ↓</option>
      <option value="city:asc">City A→Z</option>
      <option value="city:desc">City Z→A</option>
      <option value="county:asc">County A→Z</option>
      <option value="county:desc">County Z→A</option>
      <option value="zip:asc">ZIP ↑</option>
      <option value="zip:desc">ZIP ↓</option>
      <option value="license_status:asc">Status A→Z</option>
      <option value="issue_date:desc">Issued newest</option>
      <option value="issue_date:asc">Issued oldest</option>
      <option value="expire_date:desc">Expires latest</option>
      <option value="expire_date:asc">Expires soonest</option>
      <option value="primary_class:asc">Primary class A→Z</option>
      <option value="phone:asc">Phone ↑</option>
      <option value="phone:desc">Phone ↓</option>
    </select>
    <span class="cg-seg" id="viewseg">
      <button data-view="grid" class="active" title="Grid">▦ Grid</button>
      <button data-view="list" title="List">☰ List</button>
      <button data-view="table" title="Table">▤ Table</button>
    </span>
    <span class="lbl">density</span><input id="density" type="range" min="180" max="360" value="236">
  </div>
</div>

<div id="wrap">
  <aside id="side">
    <div id="totals">Loading…</div>
    <h3>Show fields</h3>
    <div class="toggles" id="fieldToggles"></div>
    <h3>County</h3><div id="fCounty"></div>
    <h3>City <span class="lbl">(top)</span></h3><div id="fCity"></div>
    <h3>Trade <span class="lbl">(this page)</span></h3><div id="fClass"></div>
  </aside>
  <div id="main">
    <div id="active"></div>
    <div id="gridwrap"><div id="grid"><div class="empty">Loading contractors…</div></div></div>
    <div id="status">
      <span id="count">—</span>
      <button id="prev" disabled>‹ Prev</button><button id="next" disabled>Next ›</button>
      <span style="margin-left:auto">CSLB public-record registry · internal only (not customer-facing)</span>
    </div>
  </div>
</div>

<script src="/nav-drawer.js"></script>
<script>
const $ = s => document.querySelector(s);
const fmt = n => n==null?'—':Number(n).toLocaleString();
const esc = s => (s==null?'':String(s)).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));

// server search params (URL-addressable). client-only bits: sort + field toggles + density.
const FILTERS = ['q','class','status','county','city'];
const limit = 60;
function readState(){
  const p=new URLSearchParams(location.search); const s={offset:0};
  for(const f of FILTERS){ const v=p.get(f); if(v) s[f]=v; }
  const off=parseInt(p.get('offset')||'0',10); if(off>0) s.offset=off;
  if(!s.status) s.status='Active';
  return s;
}
let state = readState();
// SORT is a server-side "col:dir" pair (col from the /api/contractors whitelist). Persisted.
let SORT = localStorage.getItem('ctr:sort') || 'business_name:asc';
if(!/:/.test(SORT)) SORT = 'business_name:asc';  // migrate old bare-key values
let VIEW = localStorage.getItem('ctr:view') || 'grid';  // grid | list | table
let PAGE = [];  // last-loaded results (this page)
const sortParts = () => { const [k,d]=SORT.split(':'); return {sort:k, order:(d==='desc'?'desc':'asc')}; };

// per-field visibility toggles (localStorage-persisted)
const FIELDS = [
  ['status','Status'],['address','Address'],['city','City'],['county','County'],
  ['zip','ZIP'],['phone','Phone'],['license','License #'],['dates','Dates'],
  ['classes','Trades'],['bond','Bond'],['wc','Workers Comp'],
];
let SHOW = JSON.parse(localStorage.getItem('ctr:fields')||'null') || Object.fromEntries(FIELDS.map(f=>[f[0],true]));

function drillUrl(over){
  const p=new URLSearchParams(); const merged={...state,...over};
  for(const f of FILTERS){ if(merged[f]) p.set(f,merged[f]); }
  if(merged.offset>0) p.set('offset',merged.offset);
  return '/contractors.html?'+p.toString();
}
function go(over,replace){
  state={...state,...over}; if(over && !('offset' in over)) state.offset=0;
  history[replace?'replaceState':'pushState']({},'',drillUrl({}));
  syncControls(); renderActive(); loadFeed();
}

function syncControls(){
  $('#q').value=state.q||''; $('#class').value=state.class||'';
  $('#status').value=state.status||'Active'; $('#sort').value=SORT;
  $('#viewseg').querySelectorAll('button').forEach(b=>b.classList.toggle('active',b.dataset.view===VIEW));
}

// ── field toggles ──
function renderFieldToggles(){
  const box=$('#fieldToggles'); box.innerHTML='';
  for(const [k,lbl] of FIELDS){
    const l=document.createElement('label'); l.className=SHOW[k]?'on':'';
    l.innerHTML=`<input type="checkbox" ${SHOW[k]?'checked':''}> ${esc(lbl)}`;
    l.querySelector('input').onchange=e=>{ SHOW[k]=e.target.checked; l.className=SHOW[k]?'on':'';
      localStorage.setItem('ctr:fields',JSON.stringify(SHOW)); renderGrid(); };
    box.appendChild(l);
  }
}

// ── active chips ──
function renderActive(){
  const box=$('#active'); box.innerHTML='';
  const chips=[];
  if(state.q) chips.push(['q','Search','“'+state.q+'”']);
  if(state.class) chips.push(['class','Trade',state.class]);
  if(state.county) chips.push(['county','County',state.county]);
  if(state.city) chips.push(['city','City',state.city]);
  if(state.status && state.status!=='Active') chips.push(['status','Status',state.status]);
  for(const [dim,lbl,val] of chips){
    const c=document.createElement('span'); c.className='chip';
    c.innerHTML=`${esc(lbl)}: <b>${esc(val)}</b> <span class="rm" title="Remove">✕</span>`;
    c.querySelector('.rm').onclick=()=>go({[dim]:dim==='status'?'Active':undefined});
    box.appendChild(c);
  }
  if(chips.length>1){
    const clr=document.createElement('a'); clr.className='chip'; clr.href='/contractors.html';
    clr.style.borderColor='var(--muted)'; clr.style.color='var(--muted)'; clr.textContent='clear all';
    clr.onclick=e=>{ e.preventDefault(); go({q:undefined,class:undefined,county:undefined,city:undefined,status:'Active'}); };
    box.appendChild(clr);
  }
}

// ── in-page facets (aggregated from the loaded page, click-to-filter) ──
function facetList(sel,dim,items){
  const box=$(sel); box.innerHTML='';
  for(const it of items){
    const isSel=String(state[dim]||'')===String(it.key);
    const a=document.createElement('a');
    a.className='facet'+(isSel?' sel':''); a.href=drillUrl({[dim]:isSel?undefined:it.key,offset:0});
    a.innerHTML=`<span class="nm">${esc(it.label)}</span><span class="ct">${fmt(it.n)}</span>`;
    a.onclick=e=>{ e.preventDefault(); go({[dim]:isSel?undefined:String(it.key)}); };
    box.appendChild(a);
  }
  if(!items.length) box.innerHTML='<div class="lbl" style="padding:4px 7px">—</div>';
}
function agg(rows,field){
  const m=new Map();
  for(const r of rows){ const v=r[field]; if(!v) continue; m.set(v,(m.get(v)||0)+1); }
  return [...m.entries()].sort((a,b)=>b[1]-a[1]).map(([k,n])=>({key:k,label:k,n}));
}
function aggClasses(rows){
  const m=new Map();
  for(const r of rows) for(const c of (r.classifications||[])) m.set(c,(m.get(c)||0)+1);
  return [...m.entries()].sort((a,b)=>b[1]-a[1]).slice(0,30).map(([k,n])=>({key:k,label:k,n}));
}

// ── card render ──
function card(r){
  const active = (r.license_status||'').toLowerCase()==='active';
  const parts=[];
  parts.push(`<div class="nm">${esc(r.business_name)}</div>`);
  const licBits=[];
  if(SHOW.license) licBits.push(`<a href="/contractors.html?q=${encodeURIComponent(r.business_name)}">#${esc(r.license_no)}</a>`);
  if(SHOW.status) licBits.push(`<span class="st${active?' active':''}">${esc(r.license_status||'—')}</span>`);
  if(licBits.length) parts.push(`<div class="lic">${licBits.join(' · ')}</div>`);
  const row=(k,v)=>`<div class="row"><span class="k">${k}</span><span>${v}</span></div>`;
  if(SHOW.address && r.address) parts.push(row('Address',esc(r.address)));
  if(SHOW.city && r.city) parts.push(row('City',`<a href="/contractors.html?city=${encodeURIComponent(r.city)}">${esc(r.city)}</a>`));
  if(SHOW.county && r.county) parts.push(row('County',`<a href="/contractors.html?county=${encodeURIComponent(r.county)}">${esc(r.county)}</a>`));
  if(SHOW.zip && r.zip) parts.push(row('ZIP',esc(r.zip)));
  if(SHOW.phone && r.phone) parts.push(row('Phone',esc(r.phone)));
  if(SHOW.dates && (r.issue_date||r.expire_date)) parts.push(row('Dates',`${esc(r.issue_date||'—')} → ${esc(r.expire_date||'—')}`));
  if(SHOW.bond && (r.bond&&r.bond.company)) parts.push(row('Bond',`${esc(r.bond.company)}${r.bond.amount!=null?' · $'+Number(r.bond.amount).toLocaleString():''}`));
  if(SHOW.wc && (r.workers_comp&&r.workers_comp.status)) parts.push(row('WC',`${esc(r.workers_comp.status)}${r.workers_comp.carrier?' · '+esc(r.workers_comp.carrier):''}`));
  if(SHOW.classes && (r.classification_titles||[]).length){
    const cs=r.classification_titles.map(c=>`<a class="c" title="${esc(c.title||c.code)}" href="/contractors.html?class=${encodeURIComponent(c.code)}">${esc(c.code)}</a>`).join('');
    parts.push(`<div class="cls">${cs}</div>`);
  }
  return `<div class="cd">${parts.join('')}</div>`;
}

// ── table view: dense all-columns spreadsheet, click-to-sort headers (server sort) ──
const TBL_COLS = [
  ['business_name','Business'],['license_no','License #'],['license_status','Status'],
  ['city','City'],['county','County'],['zip','ZIP'],['primary_class','Class'],
  ['issue_date','Issued'],['expire_date','Expires'],['phone','Phone'],
];
function renderTable(){
  const [sk,sd]=SORT.split(':');
  const arrow=k=> k===sk ? (sd==='desc'?' ▾':' ▴') : '';
  let h='<table class="ctrtbl"><thead><tr>'+
    TBL_COLS.map(([k,l])=>`<th data-k="${k}" class="${k===sk?'sorted':''}">${esc(l)}${arrow(k)}</th>`).join('')+
    '</tr></thead><tbody>';
  for(const r of PAGE){
    const cls=(r.classifications||[]).join(', ');
    h+='<tr>'+
      `<td><a href="/contractors.html?q=${encodeURIComponent(r.business_name)}">${esc(r.business_name)}</a></td>`+
      `<td>${esc(r.license_no)}</td>`+
      `<td>${esc(r.license_status||'—')}</td>`+
      `<td>${r.city?`<a href="/contractors.html?city=${encodeURIComponent(r.city)}">${esc(r.city)}</a>`:'—'}</td>`+
      `<td>${r.county?`<a href="/contractors.html?county=${encodeURIComponent(r.county)}">${esc(r.county)}</a>`:'—'}</td>`+
      `<td>${esc(r.zip||'—')}</td>`+
      `<td>${r.primary_class?`<a href="/contractors.html?class=${encodeURIComponent(r.primary_class)}">${esc(r.primary_class)}</a>`:esc(cls||'—')}</td>`+
      `<td>${esc(r.issue_date||'—')}</td>`+
      `<td>${esc(r.expire_date||'—')}</td>`+
      `<td>${esc(r.phone||'—')}</td>`+
      '</tr>';
  }
  h+='</tbody></table>';
  const grid=$('#grid'); grid.className='table'; grid.innerHTML='';
  // remove any prior table then insert
  const prev=$('.ctrtbl'); if(prev) prev.remove();
  grid.insertAdjacentHTML('afterend',h);
  $('.ctrtbl thead').querySelectorAll('th[data-k]').forEach(th=>th.onclick=()=>{
    const k=th.dataset.k; const [ck,cd]=SORT.split(':');
    SORT = (k===ck) ? `${k}:${cd==='asc'?'desc':'asc'}` : `${k}:asc`;
    localStorage.setItem('ctr:sort',SORT); $('#sort').value=SORT; loadFeed();
  });
}

function renderGrid(){
  const grid=$('#grid');
  // clear any stray table left by a previous table-view render
  const prev=$('.ctrtbl'); if(prev) prev.remove();
  if(!PAGE.length){ grid.className=''; grid.innerHTML='<div class="empty">No contractors match. (The CSLB registry may still be loading — this page is empty-safe.)</div>'; refreshFacets(); return; }
  if(VIEW==='table'){ renderTable(); refreshFacets(); return; }
  grid.className = (VIEW==='list'?'list':'');
  grid.innerHTML=PAGE.map(card).join('');
  refreshFacets();
}
function refreshFacets(){
  facetList('#fCounty','county',agg(PAGE,'county'));
  facetList('#fCity','city',agg(PAGE,'city').slice(0,25));
  facetList('#fClass','class',aggClasses(PAGE));
}

async function loadFeed(){
  const p=new URLSearchParams();
  for(const f of FILTERS){ if(state[f]) p.set(f,state[f]); }
  const sp=sortParts(); p.set('sort',sp.sort); p.set('order',sp.order);
  p.set('limit',limit); p.set('offset',state.offset||0);
  $('#count').textContent='loading…';
  let d; try{ d=await (await fetch('/api/contractors?'+p.toString())).json(); }
  catch(e){ $('#grid').innerHTML='<div class="empty">Load error.</div>'; return; }
  if(d.error){ $('#grid').innerHTML='<div class="empty">'+esc(d.error)+'</div>'; return; }
  PAGE=d.results||[];
  $('#totals').innerHTML=`<b>${fmt(d.count)}</b> contractors match`;
  const from=(state.offset||0)+ (PAGE.length?1:0), to=(state.offset||0)+PAGE.length;
  $('#count').textContent=`${fmt(from)}–${fmt(to)} of ${fmt(d.count)}`;
  $('#prev').disabled=(state.offset||0)<=0;
  $('#next').disabled=to>=d.count;
  renderGrid();
}

// ── load the trade dropdown from the class-ref (one-time) ──
async function loadClasses(){
  try{
    // no dedicated endpoint for the ref; derive the option list from the seeded codes we know
    // via a probe search per code would be wasteful — instead offer the canonical CSLB set.
    const CODES=[['','All trades'],['A','A · General Engineering'],['B','B · General Building'],
      ['C-8','C-8 · Concrete'],['C-10','C-10 · Electrical'],['C-36','C-36 · Plumbing'],
      ['C-20','C-20 · HVAC'],['C-39','C-39 · Roofing'],['C-33','C-33 · Painting'],
      ['C-35','C-35 · Plaster'],['C-15','C-15 · Flooring'],['C-54','C-54 · Tile'],
      ['C-5','C-5 · Framing'],['C-6','C-6 · Finish Carpentry'],['C-16','C-16 · Fire Protection'],
      ['C-27','C-27 · Landscaping'],['C-9','C-9 · Drywall'],['C-12','C-12 · Paving'],
      ['C-13','C-13 · Fencing'],['C-29','C-29 · Masonry'],['C-46','C-46 · Solar'],['C-53','C-53 · Pool']];
    $('#class').innerHTML=CODES.map(([v,l])=>`<option value="${v}">${esc(l)}</option>`).join('');
    $('#class').value=state.class||'';
  }catch(e){}
}

// ── wire controls ──
let qTimer;
$('#q').oninput=e=>{ clearTimeout(qTimer); const v=e.target.value.trim(); qTimer=setTimeout(()=>go({q:v||undefined}),300); };
$('#class').onchange=e=>go({class:e.target.value||undefined});
$('#status').onchange=e=>go({status:e.target.value});
$('#sort').onchange=e=>{ SORT=e.target.value; localStorage.setItem('ctr:sort',SORT); loadFeed(); };
$('#viewseg').querySelectorAll('button').forEach(b=>b.onclick=()=>{ VIEW=b.dataset.view; localStorage.setItem('ctr:view',VIEW);
  $('#viewseg').querySelectorAll('button').forEach(x=>x.classList.toggle('active',x.dataset.view===VIEW)); renderGrid(); });
$('#density').oninput=e=>{ document.documentElement.style.setProperty('--card',e.target.value+'px'); localStorage.setItem('ctr:card',e.target.value); };
$('#prev').onclick=()=>go({offset:Math.max(0,(state.offset||0)-limit)});
$('#next').onclick=()=>go({offset:(state.offset||0)+limit});
window.onpopstate=()=>{ state=readState(); syncControls(); renderActive(); loadFeed(); };

// ── restore persisted density + init ──
(function init(){
  const cz=localStorage.getItem('ctr:card'); if(cz){ $('#density').value=cz; document.documentElement.style.setProperty('--card',cz+'px'); }
  loadClasses(); renderFieldToggles(); syncControls(); renderActive(); loadFeed();
})();
</script>
  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
</body>
</html>