[object Object]

← back to Commercialrealestate

crcp: full property→parcel/deal DETAIL drill-down (TK-10482)

9cde5e2e2c02408e54b82034231dfb36cf4d02d4 · 2026-08-12 08:27:51 -0700 · Steve Abrams

- New /parcel/<AIN> detail page (public/parcel.html) + /api/parcel/:ain: full
  assessor dossier + matched county sale event + computed LENDING ANGLE for Frank
  (deal-size class, financing signals, price÷assessed, vintage/refi windows,
  buyer/seller TEXT-only with declared deed-gap) + ULA candidates + gated-deed paths.
- deals-flow.html: every card data point is now an href to its deeper data —
  county title→/parcel/<AIN> + explicit 'parcel detail →', price/units/year/
  year_built/city/use/rate/holder/below → URL-addressable filtered views, source
  badge + confidence → filter drills, SEC ticker→EDGAR, EDGAR title→filing.
- URL-addressable filters (readURLFilters/writeURLFilters + popstate): every
  card drill-link is shareable/bookmarkable and applies on load. 0 dead-end cards.
- Public records only (LA Assessor + LA City ULA); party names text-only, no firm link-out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 9cde5e2e2c02408e54b82034231dfb36cf4d02d4
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Aug 12 08:27:51 2026 -0700

    crcp: full property→parcel/deal DETAIL drill-down (TK-10482)
    
    - New /parcel/<AIN> detail page (public/parcel.html) + /api/parcel/:ain: full
      assessor dossier + matched county sale event + computed LENDING ANGLE for Frank
      (deal-size class, financing signals, price÷assessed, vintage/refi windows,
      buyer/seller TEXT-only with declared deed-gap) + ULA candidates + gated-deed paths.
    - deals-flow.html: every card data point is now an href to its deeper data —
      county title→/parcel/<AIN> + explicit 'parcel detail →', price/units/year/
      year_built/city/use/rate/holder/below → URL-addressable filtered views, source
      badge + confidence → filter drills, SEC ticker→EDGAR, EDGAR title→filing.
    - URL-addressable filters (readURLFilters/writeURLFilters + popstate): every
      card drill-link is shareable/bookmarkable and applies on load. 0 dead-end cards.
    - Public records only (LA Assessor + LA City ULA); party names text-only, no firm link-out.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/deals-flow.html | 125 +++++++++++++++++++++++++++++----------
 public/parcel.html     | 155 +++++++++++++++++++++++++++++++++++++++++++++++++
 scripts/serve.js       |  77 ++++++++++++++++++++++++
 3 files changed, 326 insertions(+), 31 deletions(-)

diff --git a/public/deals-flow.html b/public/deals-flow.html
index 1d54c8d..53abd32 100644
--- a/public/deals-flow.html
+++ b/public/deals-flow.html
@@ -40,6 +40,11 @@ select{background:var(--card);border:1px solid var(--line);color:var(--ink);bord
 .dl .i{color:var(--mut);display:inline-flex;align-items:center;gap:4px}
 .dl .i b{color:var(--ink);font-weight:600}
 .dcard a.f{color:var(--blue);text-decoration:none;font-size:12px}
+.dcard a.f:hover{text-decoration:underline}
+/* href-drill atoms: card values link to their deeper/filtered data */
+.dl a.ddrill{color:inherit;text-decoration:none;border-bottom:1px dotted rgba(88,166,255,.35)}
+.dl a.ddrill:hover{color:var(--blue);border-bottom-color:var(--blue)}
+.dl .i a.ddrill b{color:inherit}
 .provline{font-size:10px;color:var(--mut);border-top:1px solid #20262f;padding-top:6px;margin-top:1px;display:flex;align-items:center;gap:5px}
 .conf{font-size:9.5px;padding:1px 6px;border-radius:20px}
 .conf.high{background:rgba(63,185,80,.16);color:var(--acc)} .conf.med{background:rgba(210,153,34,.16);color:var(--gold)} .conf.low{background:rgba(139,148,158,.16);color:var(--mut)}
@@ -174,8 +179,13 @@ let DATA=[],q='',sortKey='date',sortDir=-1;
 let ME=null, WATCH=new Set(), watchOnly=false;
 const dealKey=r=>String(r.ain||r.id||(r.kind+'|'+(r.name||'')+'|'+(r.date||'')));
 let dens=(function(){const v=parseInt(localStorage.getItem('dfDens'),10);return(v>=2&&v<=7)?v:4;})();
-const F={src:new Set(),use:new Set(),year:new Set(),conf:new Set(),pMin:null,uMin:null};
+// exact = ancillary drill fields (deal_city, year_built, holder, units, price, rate, below) that
+// aren't rail chips but ARE drillable + URL-addressable. Each is a Set of exact string values.
+const F={src:new Set(),use:new Set(),year:new Set(),conf:new Set(),pMin:null,uMin:null,exact:{}};
 const tog=(s,v)=>{s.has(v)?s.delete(v):s.add(v);};
+const EXACT_FIELDS=['deal_city','year_built','holder','units','price','rate','below']; // r-field getters below
+const exactVal=(r,f)=>f==='below'?(r.belowMarket?'1':''):(r[f]==null?'':(f==='rate'?(+r.rate).toFixed(2):String(r[f])));
+const togExact=(f,v)=>{const s=F.exact[f]||(F.exact[f]=new Set());s.has(v)?s.delete(v):s.add(v);if(!s.size)delete F.exact[f];};
 
 function pass(r){
   if(watchOnly&&!WATCH.has(dealKey(r)))return false;
@@ -185,9 +195,22 @@ function pass(r){
   if(F.conf.size&&!F.conf.has(r.confidence||'—'))return false;
   if(F.pMin!=null&&!(+r.price>=F.pMin))return false;
   if(F.uMin!=null&&!(+r.units>=F.uMin))return false;
+  for(const f of EXACT_FIELDS){const s=F.exact[f];if(s&&s.size&&!s.has(exactVal(r,f)))return false;}
   if(q){const t=q.toLowerCase();return [r.name,r.use,r.year,r.company,r.source_label].some(v=>v!=null&&String(v).toLowerCase().includes(t));}
   return true;
 }
+// Read filter state from the URL (?use=&year=&src=&conf=&deal_city=&year_built=&holder=&units=&price=&rate=&below=&pMin=&uMin=&q=)
+// so every card drill-link is a real, shareable/bookmarkable view — applied on page load.
+function readURLFilters(){
+  const u=new URLSearchParams(location.search);let any=false;
+  const setChips=(param,set)=>{const v=u.getAll(param);if(v.length){v.forEach(x=>set.add(x));any=true;}};
+  setChips('use',F.use);setChips('year',F.year);setChips('src',F.src);setChips('conf',F.conf);
+  for(const f of EXACT_FIELDS){const v=u.getAll(f);if(v.length){F.exact[f]=new Set(v);any=true;}}
+  if(u.get('pMin')){F.pMin=+u.get('pMin');any=true;}
+  if(u.get('uMin')){F.uMin=+u.get('uMin');any=true;}
+  if(u.get('q')){q=u.get('q');const qi=$('#q');if(qi)qi.value=q;any=true;}
+  return any;
+}
 function filtered(){
   let rows=DATA.filter(pass);
   rows=rows.slice().sort((a,b)=>{let x=a[sortKey],y=b[sortKey];const xm=(x==null||x===''),ym=(y==null||y==='');
@@ -202,35 +225,47 @@ function buildRail(){
   DATA.forEach(r=>{sc[r.source_label]=(sc[r.source_label]||0)+1; const u=r.use||'—';uc[u]=(uc[u]||0)+1; const y=String(r.year||'—');yc[y]=(yc[y]||0)+1; const cf=r.confidence||'—';cc[cf]=(cc[cf]||0)+1;});
   $('#fSrc').innerHTML=chip(sc,'s');$('#fUse').innerHTML=chip(uc,'u',10);$('#fYear').innerHTML=chip(yc,'y',8);$('#fConf').innerHTML=chip(cc,'c');
 }
+// href-drill atom: a card data point that SETS an in-memory filter (delegated click) AND is a real,
+// shareable href (/deals-flow.html?field=value → readURLFilters applies it on load). No dead-ends.
+function drill(field,val,inner,extra){
+  if(val==null||val==='')return '';
+  const href='/deals-flow.html?'+field+'='+encodeURIComponent(val);
+  return `<span class="i" data-drillf="${esc(field)}" data-drillv="${esc(val)}"><a class="ddrill" href="${href}" title="See all where ${esc(field)} = ${esc(val)}"${extra||''}>${inner}</a></span>`;
+}
 function card(r){
   const isE=r.kind==='reit-filing', isF=r.kind==='fha', isC=r.kind==='county-sale', isU=r.kind==='ula';
-  const srcBadge=`<span class="src ${isE?'edgar':isF?'fha':isU?'ula':'county'}" data-fs="${esc(r.source_label)}" style="cursor:pointer" title="Filter to ${esc(r.source_label)}">${isE?'📄 SEC EDGAR':isF?'🏦 HUD FHA':isU?'🏙️ LA City ULA':'🏛️ Assessor'}</span>`;
+  // Source badge → filters by source (delegated), and is a real shareable href.
+  const srcBadge=`<a class="src ${isE?'edgar':isF?'fha':isU?'ula':'county'} srcdrill" href="/deals-flow.html?src=${encodeURIComponent(r.source_label)}" data-fs="${esc(r.source_label)}" style="cursor:pointer;text-decoration:none" title="Filter to ${esc(r.source_label)}">${isE?'📄 SEC EDGAR':isF?'🏦 HUD FHA':isU?'🏙️ LA City ULA':'🏛️ Assessor'}</a>`;
   const price=money(r.price);
   const details=[];
-  if(price&&isE) details.push(`<span class="i">💰 <b>${price}</b></span>`);
-  if(r.rate!=null) details.push(`<span class="i">💵 <b>${(+r.rate).toFixed(2)}%</b> rate</span>`);
-  if(isF&&r.belowMarket) details.push(`<span class="i" style="color:var(--gold)">◆ assumable (below-market)</span>`);
-  if(r.units) details.push(`<span class="i">🏢 <b>${r.units}</b> units</span>`);
-  if(r.use) details.push(`<span class="i" data-fu="${esc(r.use)}" style="cursor:pointer" title="Filter to ${esc(r.use)}">🏷️ ${esc(r.use)}</span>`);
-  if(r.deal_city) details.push(`<span class="i">📍 ${esc(r.deal_city)}</span>`);
-  if(r.year_built) details.push(`<span class="i">🏗️ built ${r.year_built}</span>`);
-  if(r.date) details.push(`<span class="i">📅 ${esc(r.date)}</span>`);
-  if(r.ticker) details.push(`<span class="i">📈 ${esc(r.ticker)}</span>`);
-  if(r.holder) details.push(`<span class="i">🏦 ${esc(String(r.holder).slice(0,24))}</span>`);
-  // Property-detail drill (county cards have an AIN): opens the free public-record dossier inline.
-  const ainForDrill=r.ain?String(r.ain).replace(/\D/g,''):'';
-  if(isC&&ainForDrill) details.push(`<span class="i propdetail" data-ain="${ainForDrill}" style="cursor:pointer;color:var(--blue)" title="Full public-record dossier (assessor + ULA + deed)">🔍 property detail</span>`);
-  const conf=r.confidence?`<span class="conf ${r.confidence}">${r.confidence} confidence</span>`:'';
+  // Every data point drills: numeric/text values link to their filtered slice, or their deeper record.
+  if(price&&isE) details.push(drill('price',r.price,`💰 <b>${price}</b>`));
+  if(r.rate!=null) details.push(drill('rate',(+r.rate).toFixed(2),`💵 <b>${(+r.rate).toFixed(2)}%</b> rate`));
+  if(isF&&r.belowMarket) details.push(drill('below','1',`<span style="color:var(--gold)">◆ assumable (below-market)</span>`));
+  if(r.units) details.push(drill('units',r.units,`🏢 <b>${r.units}</b> units`));
+  if(r.use) details.push(drill('use',r.use,`🏷️ ${esc(r.use)}`));
+  if(r.deal_city) details.push(drill('deal_city',r.deal_city,`📍 ${esc(r.deal_city)}`));
+  if(r.year_built) details.push(drill('year_built',r.year_built,`🏗️ built ${r.year_built}`));
+  if(r.date) details.push(drill('year',r.year,`📅 ${esc(r.date)}`));
+  // ticker → external SEC EDGAR company filings (public); holder → filter by holder.
+  if(r.ticker) details.push(`<span class="i">📈 <a class="f" href="https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&company=${encodeURIComponent(r.ticker)}&type=8-K" target="_blank" rel="noopener noreferrer" title="SEC EDGAR filings for ${esc(r.ticker)}">${esc(r.ticker)} ↗</a></span>`);
+  if(r.holder) details.push(drill('holder',r.holder,`🏦 ${esc(String(r.holder).slice(0,24))}`));
+  const conf=r.confidence?`<a class="conf ${r.confidence} confdrill" href="/deals-flow.html?conf=${esc(r.confidence)}" data-fc="${esc(r.confidence)}" style="text-decoration:none;cursor:pointer" title="Filter to ${esc(r.confidence)} confidence">${r.confidence} confidence</a>`:'';
   const link=r.url?`<a class="f" href="${esc(r.url)}" target="_blank" rel="noopener noreferrer">🔗 SEC filing ↗</a>`:'';
   const bigPrice=(price&&(isC||isF))?`<div class="price">${price}${isC?' <span class="mut" style="font-size:11px">est sale</span>':isF?' <span class="mut" style="font-size:11px">loan</span>':''}</div>`:'';
-  // href-drill: county cards' address links to its LA County Assessor public record (AIN). EDGAR keeps its filing link; FHA has no per-parcel record.
+  // href-drill on the TITLE: county cards → the full parcel/deal DETAIL page (/parcel/<AIN>).
+  // EDGAR keeps its filing link; FHA/ULA (no AIN) keep a plain title.
   const ainDigits=r.ain?String(r.ain).replace(/\D/g,''):'';
-  const nameHtml=(isC&&ainDigits)?`<a class="name" href="https://portal.assessor.lacounty.gov/parceldetail/${ainDigits}" target="_blank" rel="noopener noreferrer" title="Open the LA County Assessor record (AIN ${ainDigits}) ↗">${esc(r.name)}</a>`:`<div class="name">${esc(r.name)}</div>`;
+  const nameHtml=(isC&&ainDigits)?`<a class="name" href="/parcel/${ainDigits}" title="Full parcel/deal detail — assessor + ULA + deed + lending angle (AIN ${ainDigits})">${esc(r.name)}</a>`
+    :(isE&&r.url)?`<a class="name" href="${esc(r.url)}" target="_blank" rel="noopener noreferrer" title="Open the SEC filing ↗">${esc(r.name)}</a>`
+    :`<div class="name">${esc(r.name)}</div>`;
+  // Explicit "detail →" affordance so the drill is discoverable, not just on the title.
+  const detailLink=(isC&&ainDigits)?`<a class="f" href="/parcel/${ainDigits}" title="Full parcel/deal detail page">🔍 parcel detail →</a>`:'';
   return `<div class="dcard">
     <div class="top">${nameHtml}<div style="display:flex;gap:6px;align-items:center;flex-shrink:0">${srcBadge}<button class="starbtn" data-k="${esc(dealKey(r))}" title="Star / watchlist" style="background:none;border:0;font-size:15px;line-height:1;cursor:pointer;color:${WATCH.has(dealKey(r))?'var(--gold)':'var(--mut)'}">${WATCH.has(dealKey(r))?'★':'☆'}</button></div></div>
     ${bigPrice}
     <div class="dl">${details.join('')}</div>
-    ${link}${conf?'<div>'+conf+'</div>':''}
+    ${detailLink}${link?(detailLink?' · ':'')+link:''}${conf?'<div>'+conf+'</div>':''}
     <div class="provline">📋 ${esc(r.source)}</div>
   </div>`;
 }
@@ -247,13 +282,13 @@ function render(){
   $('#count').textContent=`${rows.length.toLocaleString()} of ${DATA.length.toLocaleString()}`;
   $('#grid').innerHTML=rows.slice(0,600).map(card).join('')||'<div style="color:var(--mut);padding:40px">No deals match.</div>';
 }
-$('#fSrc').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.src,c.dataset.s);c.classList.toggle('active');render();});
-$('#fUse').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.use,c.dataset.u);c.classList.toggle('active');render();});
-$('#fYear').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.year,c.dataset.y);c.classList.toggle('active');render();});
-$('#fConf').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.conf,c.dataset.c);c.classList.toggle('active');render();});
-$('#pMin').addEventListener('input',()=>{F.pMin=$('#pMin').value?+$('#pMin').value:null;render();});
-$('#uMin').addEventListener('input',()=>{F.uMin=$('#uMin').value?+$('#uMin').value:null;render();});
-$('#q').addEventListener('input',()=>{q=$('#q').value.trim();render();});
+$('#fSrc').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.src,c.dataset.s);c.classList.toggle('active');writeURLFilters(true);render();});
+$('#fUse').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.use,c.dataset.u);c.classList.toggle('active');writeURLFilters(true);render();});
+$('#fYear').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.year,c.dataset.y);c.classList.toggle('active');writeURLFilters(true);render();});
+$('#fConf').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.conf,c.dataset.c);c.classList.toggle('active');writeURLFilters(true);render();});
+$('#pMin').addEventListener('input',()=>{F.pMin=$('#pMin').value?+$('#pMin').value:null;writeURLFilters();render();});
+$('#uMin').addEventListener('input',()=>{F.uMin=$('#uMin').value?+$('#uMin').value:null;writeURLFilters();render();});
+$('#q').addEventListener('input',()=>{q=$('#q').value.trim();writeURLFilters();render();});
 $('#sort').addEventListener('change',()=>{sortKey=$('#sort').value;sortDir=(sortKey==='name'||sortKey==='use')?1:-1;render();});
 $('#dens').addEventListener('input',()=>{dens=+$('#dens').value;localStorage.setItem('dfDens',dens);render();});
 $('#sort').innerHTML=[['date','Date'],['price','Price'],['units','Units'],['year','Year'],['name','Name']].map(([k,l])=>`<option value="${k}"${k==='date'?' selected':''}>Sort: ${l}</option>`).join('');
@@ -367,11 +402,34 @@ $('#pmBody').addEventListener('click',e=>{const pk=e.target.closest('.pdpick');i
 $('#propLookupBtn').addEventListener('click',()=>openPropDetailByAddress($('#propLookup').value));
 $('#propLookup').addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();openPropDetailByAddress(e.target.value);}});
 $('#propModal').addEventListener('click',e=>{if(e.target.id==='propModal')e.currentTarget.classList.remove('on');});
+// keep the URL in sync with the in-memory filter state so an in-page drill is still shareable/back-able.
+function writeURLFilters(push){
+  const u=new URLSearchParams();
+  F.use.forEach(v=>u.append('use',v));F.year.forEach(v=>u.append('year',v));F.src.forEach(v=>u.append('src',v));F.conf.forEach(v=>u.append('conf',v));
+  for(const f of EXACT_FIELDS){const s=F.exact[f];if(s)s.forEach(v=>u.append(f,v));}
+  if(F.pMin!=null)u.set('pMin',F.pMin);if(F.uMin!=null)u.set('uMin',F.uMin);if(q)u.set('q',q);
+  const s=u.toString();history[push?'pushState':'replaceState']({},'',s?('?'+s):location.pathname);
+}
 $('#grid').addEventListener('click',async e=>{
-  const pd=e.target.closest('.propdetail');if(pd){openPropDetail(pd.dataset.ain);return;}
-  const fu=e.target.closest('[data-fu]');if(fu){tog(F.use,fu.dataset.fu);syncRailActive('u',F.use);render();return;}
-  const fs=e.target.closest('[data-fs]');if(fs){tog(F.src,fs.dataset.fs);syncRailActive('s',F.src);render();return;}
-  const b=e.target.closest('.starbtn');if(!b)return;if(!ME||!ME.email){signIn();return;}const r=await api('/api/watchlist',{method:'POST',body:JSON.stringify({id:b.dataset.k})});WATCH=new Set(r.ids||[]);render();});
+  // let modified clicks (cmd/ctrl/middle/shift) follow the real href → open the deeper view in a new tab
+  if(e.metaKey||e.ctrlKey||e.shiftKey||e.button===1)return;
+  // Star button (must run before the drill catch since it has no href).
+  const b=e.target.closest('.starbtn');
+  if(b){e.preventDefault();if(!ME||!ME.email){signIn();return;}const r=await api('/api/watchlist',{method:'POST',body:JSON.stringify({id:b.dataset.k})});WATCH=new Set(r.ids||[]);render();return;}
+  // Title / SEC / parcel-detail links: real navigations — let them happen (do NOT intercept).
+  if(e.target.closest('a.name,a.f'))return;
+  // Rail-chip drill fields (use/src/conf) — filter in place + keep rail chips + URL in sync.
+  const fu=e.target.closest('[data-drillf]');
+  if(fu){e.preventDefault();const f=fu.dataset.drillf,v=fu.dataset.drillv;
+    if(f==='use'){tog(F.use,v);syncRailActive('u',F.use);}
+    else if(EXACT_FIELDS.includes(f)){togExact(f,v);}
+    else return; // unknown → let href navigate
+    writeURLFilters(true);render();return;}
+  const fs=e.target.closest('.srcdrill');if(fs){e.preventDefault();tog(F.src,fs.dataset.fs);syncRailActive('s',F.src);writeURLFilters(true);render();return;}
+  const fc=e.target.closest('.confdrill');if(fc){e.preventDefault();tog(F.conf,fc.dataset.fc);syncRailActive('c',F.conf);writeURLFilters(true);render();return;}
+});
+// back/forward re-applies the URL filter state.
+window.addEventListener('popstate',()=>{F.use.clear();F.year.clear();F.src.clear();F.conf.clear();F.exact={};F.pMin=F.uMin=null;q='';const qi=$('#q');if(qi)qi.value='';readURLFilters();syncRailActive('s',F.src);syncRailActive('u',F.use);syncRailActive('y',F.year);syncRailActive('c',F.conf);render();});
 Promise.all([
   fetch('/data/deals-flow.json').then(r=>r.json()).catch(()=>({deals:[]})),
   fetch('/data/edgar-reit-deals.json').then(r=>r.json()).catch(()=>({deals:[]})),
@@ -397,7 +455,12 @@ Promise.all([
   // ULA records already carry kind/name/use/price/date/year/deal_city/source_label — pass through.
   const u=(ula.deals||[]).map(d=>({...d,kind:d.kind||'ula',source_label:d.source_label||'LA City ULA'}));
   DATA=c.concat(e).concat(f).concat(u);
-  buildRail();render();loadMe();
+  buildRail();
+  // Apply any URL filter state (from a shared/bookmarked card drill-link) before the first render,
+  // then reflect it into the rail chips so the active filters are visible.
+  readURLFilters();
+  syncRailActive('s',F.src);syncRailActive('u',F.use);syncRailActive('y',F.year);syncRailActive('c',F.conf);
+  render();loadMe();
 }).catch(()=>{$('#count').textContent='failed to load deal data';});
 </script><script src="/corner-nav.js" defer></script>
 </body></html>
diff --git a/public/parcel.html b/public/parcel.html
new file mode 100644
index 0000000..702a4f3
--- /dev/null
+++ b/public/parcel.html
@@ -0,0 +1,155 @@
+<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Parcel Detail · CRCP</title>
+<style>
+:root{--bg:#0e1116;--card:#161b22;--line:#2a313c;--ink:#e6edf3;--mut:#8b949e;--acc:#3fb950;--blue:#58a6ff;--gold:#ffa600;--violet:#bc8cff;--bad:#f85149}
+*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:-apple-system,Helvetica,Arial,sans-serif;font-size:13.5px;line-height:1.5}
+header{padding:14px 20px;border-bottom:1px solid var(--line);display:flex;flex-wrap:wrap;gap:10px;align-items:center}
+h1{font-size:16px;margin:0}h1 b{color:#fff}
+.nav a{color:var(--blue);text-decoration:none;font-size:12px;margin-left:12px}.nav a:hover{text-decoration:underline}
+main{max-width:900px;margin:0 auto;padding:22px 20px}
+.crumb{font-size:12px;color:var(--mut);margin-bottom:12px}
+.crumb a{color:var(--blue);text-decoration:none}.crumb a:hover{text-decoration:underline}
+.h2row{display:flex;flex-wrap:wrap;align-items:baseline;gap:10px;margin:0 0 4px}
+.h2row h2{font-size:22px;margin:0;color:#fff;font-weight:600}
+.pill{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;padding:3px 8px;border-radius:6px;white-space:nowrap}
+.pill.county{background:rgba(63,185,80,.16);color:var(--acc)}
+.pill.size{background:rgba(255,166,0,.16);color:var(--gold)}
+.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px}
+@media(max-width:680px){.grid2{grid-template-columns:1fr}}
+.panel{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:15px 17px}
+.panel h3{margin:0 0 10px;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut)}
+.kv{display:flex;gap:10px;padding:4px 0;border-bottom:1px solid #20262f}
+.kv:last-child{border-bottom:0}
+.kv .k{color:var(--mut);min-width:150px;flex-shrink:0}
+.kv .v{color:var(--ink)}
+.kv .v b{color:#fff}
+a.drill{color:var(--blue);text-decoration:none;border-bottom:1px dotted rgba(88,166,255,.45)}
+a.drill:hover{color:#fff;border-bottom-color:var(--blue)}
+a.ext{color:var(--blue);text-decoration:none}a.ext:hover{text-decoration:underline}
+.money{font-size:26px;font-weight:700;color:var(--gold);font-variant-numeric:tabular-nums}
+.sig{background:rgba(88,166,255,.07);border:1px solid rgba(88,166,255,.25);border-radius:8px;padding:8px 11px;margin:6px 0;font-size:12.5px}
+.sig.note{background:rgba(255,166,0,.06);border-color:rgba(255,166,0,.25)}
+.ula{padding:7px 0;border-bottom:1px solid #20262f;font-size:12.5px}
+.ula:last-child{border-bottom:0}
+.ula b{color:var(--gold)}
+.gap{color:var(--mut);font-size:12px;line-height:1.55}
+.src{color:var(--mut);font-size:10.5px;margin-top:12px}
+.full{grid-column:1/-1}
+.loading{color:var(--mut);padding:40px 0}
+.err{color:var(--bad);padding:24px 0}
+</style></head><body>
+<header>
+  <h1><b>Parcel Detail</b> · CRCP</h1>
+  <span class="pill county">100% public record</span>
+  <div class="nav"><a href="/deals-flow.html">← Deal Flow</a><a href="/property">🔍 Property Lookup</a><a href="/">Explorer</a></div>
+</header>
+<main>
+  <div class="crumb"><a href="/deals-flow.html">Closed Deal Flow</a> › <span id="crumbAin">parcel</span></div>
+  <div id="out"><div class="loading">Loading the public-record dossier…</div></div>
+</main>
+<script>
+const esc=s=>(s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
+const money=n=>n==null||isNaN(+n)?null:'$'+Math.round(+n).toLocaleString();
+// AIN from the path (/parcel/<AIN>) or ?ain= for back-compat.
+const AIN=(location.pathname.match(/\/(?:parcel|deal)\/(\d+)/)||[])[1]
+  || new URLSearchParams(location.search).get('ain') || '';
+// href-drill helpers: every data point links to its deeper data.
+const dfLink=(field,val,label)=>val==null||val===''?esc(label==null?'':label)
+  :`<a class="drill" href="/deals-flow.html?${field}=${encodeURIComponent(val)}" title="See all ${esc(field)} = ${esc(val)} deals">${esc(label==null?val:label)}</a>`;
+const ext=(url,label,title)=>`<a class="ext" href="${esc(url)}" target="_blank" rel="noopener noreferrer" title="${esc(title||'')}">${esc(label)} ↗</a>`;
+const kv=(k,v)=>(v==null||v==='')?'':`<div class="kv"><span class="k">${esc(k)}</span><span class="v">${v}</span></div>`;
+
+async function load(){
+  const out=document.getElementById('out');
+  if(!AIN){out.innerHTML='<div class="err">No AIN in the URL. Use /parcel/&lt;AIN&gt;.</div>';return;}
+  document.getElementById('crumbAin').textContent='AIN '+AIN;
+  let d;
+  try{ const r=await fetch('/api/parcel/'+encodeURIComponent(AIN),{headers:{Accept:'application/json'}});
+       if(!r.ok)throw new Error('HTTP '+r.status); d=await r.json(); }
+  catch(e){ out.innerHTML='<div class="err">Lookup failed: '+esc(e.message)+'</div>'; return; }
+  if(!d||d.found===false){ out.innerHTML='<div class="err">No LA County assessor record for AIN '+esc(AIN)+'.</div>'; return; }
+  render(d);
+}
+function render(d){
+  const p=d.parcel||{}, deal=d.deal||null, la=d.loan_angle||{};
+  document.title='Parcel '+AIN+' · '+(p.address||'CRCP');
+  const salePrice=deal&&deal.price?deal.price:(la.est_price||null);
+  // ── header ──
+  let html=`<div class="h2row"><h2>${esc(p.address||('AIN '+AIN))}</h2>`
+    +`<span class="pill county">🏛️ LA County Assessor</span>`
+    +(la.deal_size?`<span class="pill size">${esc(la.deal_size)}</span>`:'')+`</div>`;
+  if(salePrice) html+=`<div class="money">${money(salePrice)} <span style="font-size:12px;color:var(--mut);font-weight:400">${deal?'est. sale (Prop-13 reassessment-derived)':'assessed'}</span></div>`;
+
+  html+=`<div class="grid2">`;
+
+  // ── Parcel identity + physical ──
+  html+=`<div class="panel"><h3>Parcel</h3>`
+    +kv('AIN', `<b>${esc(p.ain||AIN)}</b> · ${ext(d.portal_url||('https://portal.assessor.lacounty.gov/parceldetail/'+AIN),'assessor record','Open the LA County Assessor public record')}`)
+    +kv('Address', esc(p.address))
+    +kv('City', dfLink('deal_city',(deal&&deal.deal_city)||p.city, p.city))
+    +kv('ZIP', esc(p.zip))
+    +kv('Use / type', dfLink('use', (deal&&deal.use)||p.use_desc||p.use_type, p.use_desc||p.use_type||(deal&&deal.use)))
+    +kv('Year built', dfLink('year_built', p.year_built, p.year_built))
+    +kv('Sq ft', p.sqft?Number(p.sqft).toLocaleString():'')
+    +kv('Units', p.units)
+    +kv('Beds / baths', (p.bedrooms||p.bathrooms)?((p.bedrooms||'?')+' / '+(p.bathrooms||'?')):'')
+    +kv('Tax rate area', p.tax_rate_area)
+    +`</div>`;
+
+  // ── Assessed values + transfer ──
+  html+=`<div class="panel"><h3>Assessed value (${esc(p.assessed_as_of||p.roll_year||'roll')})</h3>`
+    +kv('Land', money(p.assessed_land))
+    +kv('Improvement', money(p.assessed_improvement))
+    +kv('Total', `<b>${money(p.assessed_total)||'—'}</b>`)
+    +kv('Roll year', p.assessed_as_of||p.roll_year)
+    +kv('Last transfer recorded', dfLink('year', deal&&deal.year, p.last_transfer_recorded||(deal&&deal.date)))
+    +(deal?kv('Transaction date', dfLink('year', deal.year, deal.date)):'')
+    +(deal?kv('Deal source', ext((deal.url||d.portal_url),(deal.source_label||'LA County Assessor'), deal.source||'')):'')
+    +(deal&&deal.confidence?kv('Confidence', esc(deal.confidence)):'')
+    +`</div>`;
+
+  // ── LOAN ANGLE (why Frank cares) ──
+  html+=`<div class="panel full"><h3>💰 Lending angle — for Frank the loan officer</h3>`;
+  if((la.signals||[]).length||(la.notes||[]).length){
+    (la.signals||[]).forEach(s=>html+=`<div class="sig">◆ ${esc(s)}</div>`);
+    (la.notes||[]).forEach(n=>html+=`<div class="sig note">• ${esc(n)}</div>`);
+  } else html+=`<div class="gap">No standout financing signal from public facts alone — pull the recorded deed for parties + exact terms.</div>`;
+  // buyer / seller as TEXT only
+  const pr=la.parties||{};
+  html+=`<div style="margin-top:11px">`
+    +kv('Buyer (text)', pr.buyer_text?esc(pr.buyer_text)+(pr.source?` <span class="gap">· ${esc(pr.source)}</span>`:''):'<span class="gap">not in free public record</span>')
+    +kv('Seller (text)', pr.seller_text?esc(pr.seller_text):'<span class="gap">not in free public record</span>')
+    +(la.price_to_assessed?kv('Sale ÷ assessed', '<b>'+la.price_to_assessed+'×</b>'):'')
+    +`</div>`;
+  if(pr.note) html+=`<div class="gap" style="margin-top:6px">${esc(pr.note)}</div>`;
+  html+=`</div>`;
+
+  // ── ULA candidate sales (same ZIP) ──
+  if((d.ula_zip_candidates||[]).length){
+    html+=`<div class="panel full"><h3>Recent $5.3M+ sales in ZIP ${esc(p.zip||'')} (LA City ULA · candidates)</h3>`;
+    d.ula_zip_candidates.slice(0,10).forEach(c=>{
+      const instr=c.instrument?` · <span class="gap">instr ${esc(c.instrument)}</span>`:'';
+      html+=`<div class="ula">${esc(c.date||'')} · <b>${money(c.price)}</b> · ${dfLink('use',c.use,c.use)}${instr}${c.basis?` <span class="gap">(${esc(c.basis)})</span>`:''}</div>`;
+    });
+    if(d.ula_note) html+=`<div class="gap" style="margin-top:7px">${esc(d.ula_note)}</div>`;
+    html+=`</div>`;
+  }
+
+  // ── Recorded-deed (gated) declaration ──
+  if(d.deed&&!d.deed.available){
+    html+=`<div class="panel full"><h3>Buyer / seller — recorded deed (gated source)</h3>`
+      +`<div class="gap">${esc(d.deed.reason)}</div>`;
+    (d.deed.paths||[]).forEach(pt=>{ html+=`<div class="ula"><b style="color:var(--blue)">${esc(pt.name)}</b> · <span class="gap">${esc(pt.cost)}</span> — ${esc(pt.gives)}</div>`; });
+    if(d.deed.bridge_hint) html+=`<div class="gap" style="margin-top:7px">${esc(d.deed.bridge_hint)}</div>`;
+    html+=`</div>`;
+  }
+
+  html+=`</div>`; // /grid2
+  html+=`<div class="src">Sources: ${esc((d.sources||[]).join(' · '))} · Public-record / address-level facts only, nothing proprietary. Company / party names are text-only (no firm link-out).</div>`;
+  document.getElementById('out').innerHTML=html;
+}
+load();
+</script>
+<script src="/corner-nav.js" defer></script>
+</body></html>
diff --git a/scripts/serve.js b/scripts/serve.js
index 671421a..d217ff3 100644
--- a/scripts/serve.js
+++ b/scripts/serve.js
@@ -846,6 +846,83 @@ app.get('/api/property/by-address', async (req, res) => {
   catch (e) { res.status(502).json({ error: 'assessor lookup failed', detail: String(e.message).split('\n')[0] }); }
 });
 
+// ── Parcel/deal DETAIL surface (TK-10482) ───────────────────────────────────────────
+// Every property/deal shown on the grids drills into its own full record. /api/parcel/:ain
+// wraps the free dossier (assessor + ULA + gated-deed declaration) with (a) the matched
+// deal-flow sale event, and (b) a computed LENDING ANGLE for Frank the loan officer — all from
+// PUBLIC records (LA Assessor + LA City ULA). Buyer/seller stay TEXT-only + declared-gap; nothing
+// proprietary, no firm link-out. The detail PAGE at /parcel/:ain hydrates from this endpoint.
+let _dealFlowIdx = null, _dealFlowAt = 0;
+function dealFlowByAin() {
+  // 5-min memo of the county deal-flow snapshot indexed by AIN (the recorded sale event per parcel).
+  if (_dealFlowIdx && (Date.now() - _dealFlowAt) < 5 * 60 * 1000) return _dealFlowIdx;
+  const idx = new Map();
+  try {
+    const j = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'deals-flow.json'), 'utf8'));
+    for (const d of (j.deals || [])) { const a = String(d.ain || '').replace(/\D/g, ''); if (a) idx.set(a, d); }
+  } catch (_) { /* snapshot absent — detail still renders parcel-only */ }
+  _dealFlowIdx = idx; _dealFlowAt = Date.now(); return idx;
+}
+// Derive a lending-relevant read on the parcel from public facts. No proprietary inputs — this is
+// the "why Frank should care" layer: financing signal, price-vs-assessed spread, deal size class.
+function computeLoanAngle(parcel, deal) {
+  const p = parcel || {}, angle = { signals: [], notes: [] };
+  const price = deal && +deal.price || null;
+  const assessed = +p.assessed_total || null;
+  const units = +p.units || null;
+  const yb = +p.year_built || null;
+  // Check ALL use sources, not just the first non-empty — use_type is often a code (e.g. "C3")
+  // that won't match the readable regex while deal.use / use_desc carry the plain-English label.
+  const use = [p.use_desc, p.use_type, deal && deal.use].filter(Boolean).join(' ').toLowerCase();
+  const yrNow = new Date().getFullYear();
+  // Deal-size class (drives which loan product fits: agency/CMBS vs bridge vs SBA vs conventional).
+  if (price) {
+    angle.deal_size = price >= 50e6 ? 'institutional ($50M+)' : price >= 10e6 ? 'mid-market ($10-50M)' : price >= 2e6 ? 'small-balance ($2-10M)' : 'sub-$2M';
+    angle.est_price = price;
+  }
+  // Recent transfer = a fresh acquisition loan / refi window Frank can pitch.
+  const xfer = (deal && deal.date) || p.last_transfer_recorded || null;
+  if (xfer) {
+    const yrs = (Date.now() - Date.parse(xfer)) / (365.25 * 864e5);
+    angle.last_transfer = xfer;
+    if (yrs >= 0 && yrs <= 1.5) angle.signals.push('recent acquisition — acquisition-loan / early-refi window open');
+    else if (yrs >= 4.5 && yrs <= 7.5) angle.signals.push('5-7yr hold — typical CMBS/agency loan maturity, refi likely near');
+  }
+  // Multifamily 5+ units → agency (Fannie/Freddie) or FHA 223(f) eligible.
+  if (units && units >= 5) angle.signals.push(`${units}-unit multifamily — Fannie/Freddie agency + FHA 223(f) eligible`);
+  else if (units && units >= 2) angle.signals.push(`${units}-unit — small-balance / DSCR product`);
+  if (/commercial|office|retail|industrial|store/.test(use)) angle.signals.push('commercial use — CMBS / conventional CRE / SBA 504 (owner-user) candidate');
+  // Price above assessed roll → equity to lend against; below → possible distress / reassessment lag.
+  if (price && assessed) {
+    const ratio = price / assessed;
+    angle.price_to_assessed = Math.round(ratio * 100) / 100;
+    if (ratio >= 1.15) angle.notes.push(`sale ~${angle.price_to_assessed}× assessed roll — equity headroom for a refi`);
+    else if (ratio <= 0.9) angle.notes.push(`sale below assessed roll (~${angle.price_to_assessed}×) — verify (lagged roll / distress / partial interest)`);
+  }
+  // Vintage → renovation/bridge angle.
+  if (yb && (yrNow - yb) >= 45) angle.notes.push(`built ${yb} (~${yrNow - yb}yr) — value-add / bridge-to-perm renovation candidate`);
+  // Buyer/seller: TEXT-only, and only where a public record names them (REIT filer). Deed parties gated.
+  angle.parties = (deal && deal.company) ? { buyer_text: String(deal.company), source: 'SEC EDGAR filer (public)' }
+    : { buyer_text: null, seller_text: null, note: 'grantor/grantee are not in the free public record (RR/CC recorded deed is gated) — declared, never guessed.' };
+  return angle;
+}
+// JSON for the detail page: dossier + matched sale event + loan angle. Cached like /api/property.
+app.get('/api/parcel/:ain(\\d+)', async (req, res) => {
+  const ain = String(req.params.ain || '').replace(/\D/g, '').slice(0, 20);
+  if (!ain) return res.status(400).json({ error: 'numeric AIN required' });
+  try {
+    const dossier = await _cachedEnrich('ain:' + ain, () => _enrichAin(ain));
+    if (!dossier || dossier.found === false) return res.json({ ain, found: false });
+    const deal = dealFlowByAin().get(ain) || null;   // the recorded county sale event for this parcel
+    const loan_angle = computeLoanAngle(dossier.parcel, deal);
+    res.json({ ...dossier, deal, loan_angle,
+      portal_url: `https://portal.assessor.lacounty.gov/parceldetail/${ain}` });
+  } catch (e) { res.status(502).json({ error: 'parcel lookup failed', detail: String(e.message).split('\n')[0] }); }
+});
+// The DETAIL PAGE — a URL-addressable, shareable view of one parcel. Serves the static shell; the
+// page hydrates itself from /api/parcel/:ain. /parcel/<AIN> (also /deal/<AIN> alias).
+app.get(['/parcel/:ain', '/deal/:ain'], (req, res) => res.sendFile(path.join(ROOT, 'public', 'parcel.html')));
+
 const CONDO_LABEL = 'FHA/VA-approval-based proxy, NOT lender-verified Fannie/Freddie warrantability.';
 app.get('/api/condos', async (req, res) => {
   const status = req.query.status;

← de54b6d just-listed: add CSV export of current filtered/sorted view  ·  back to Commercialrealestate  ·  crcp parcel detail: safeHref protocol whitelist (XSS fix) + 60799a1 →