← 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=>({'&':'&','<':'<','>':'>','"':'"'}[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>