← back to Small Business Builder

src/render/builds-index.js

1145 lines

// builds-index renderer — sortable + collapsible table of every ~/Projects build.
// Brutalist palette to match the Website Analysis app it lives next to.

import { esc } from '../lib/escape.js';
import { svgHeroStylesheet, classFor } from '../lib/svg-hero.js';

function fmtDate(iso) {
  if (!iso) return '<span style="color:#aaa">—</span>';
  const d = new Date(iso);
  if (Number.isNaN(d.getTime())) return '<span style="color:#aaa">—</span>';
  const today = new Date();
  const diffMs = today - d;
  const days = Math.floor(diffMs / 86400000);
  const ymd = d.toISOString().slice(0, 10);
  let rel = '';
  if (days === 0) rel = 'today';
  else if (days === 1) rel = '1d ago';
  else if (days < 7) rel = `${days}d ago`;
  else if (days < 30) rel = `${Math.floor(days/7)}w ago`;
  else if (days < 365) rel = `${Math.floor(days/30)}mo ago`;
  else rel = `${Math.floor(days/365)}y ago`;
  const stale = days > 30 ? '#aaa' : days > 7 ? '#666' : '#000';
  return `<span title="${ymd}" style="color:${stale}"><span style="font-weight:700">${ymd}</span>  <span style="font-size:11px;color:#666">${rel}</span></span>`;
}

function safeUrl(u) {
  if (!u) return null;
  try { const p = new URL(u); if (p.protocol === 'http:' || p.protocol === 'https:') return p.toString(); } catch {}
  return null;
}

function openLinks(row) {
  const parts = [];
  const u = safeUrl(row.url);
  if (u) {
    const label = u.replace(/^https?:\/\//, '').replace(/\/$/, '').slice(0, 30);
    parts.push(`<a href="${esc(u)}" target="_blank" rel="noopener" style="display:inline-block;background:#000;color:#facc15;padding:3px 8px;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;text-decoration:none;margin:1px 0">★ ${esc(label)} ↗</a>`);
  }
  if (row.port) {
    parts.push(`<a href="http://127.0.0.1:${row.port}" target="_blank" rel="noopener" style="display:inline-block;background:#facc15;color:#000;padding:3px 8px;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;text-decoration:none;margin:1px 0">★ :${row.port}</a>`);
  }
  // Always offer a Finder-open of the local dir.
  parts.push(`<a href="file://${esc(row.path)}" style="display:inline-block;background:#fff;color:#000;border:2px solid #000;padding:1px 6px;font-size:10px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;text-decoration:none;margin:1px 0">★ FINDER</a>`);
  // If we've built mockups for this project, link straight to them.
  if (row.analysis_slug) {
    parts.push(`<a href="/wa/${esc(row.analysis_slug)}" target="_blank" rel="noopener" style="display:inline-block;background:#22c55e;color:#000;padding:3px 8px;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;text-decoration:none;margin:1px 0">★ MOCKUPS ↗</a>`);
  }
  return parts.map(p => `<div>${p}</div>`).join('');
}

function analyzeBtn(row) {
  const u = safeUrl(row.url);
  if (u) {
    return `<form method="post" action="/api/website-analysis" style="display:inline;margin:0">
      <input type="hidden" name="url" value="${esc(u)}">
      <button type="submit" style="background:#facc15;color:#000;border:3px solid #000;padding:6px 12px;font-family:'Archivo Black',sans-serif;font-size:11px;letter-spacing:.04em;text-transform:uppercase;cursor:pointer;width:100%">★ ANALYZE</button>
    </form>`;
  }
  // No URL parsed — let user paste one inline.
  return `<form method="post" action="/api/website-analysis" style="display:flex;gap:0;margin:0;border:3px solid #000">
    <input name="url" type="text" placeholder="paste URL" style="flex:1;border:0;padding:6px 8px;font-family:'Space Mono',monospace;font-size:11px;font-weight:700;min-width:0">
    <button type="submit" style="background:#facc15;color:#000;border:0;border-left:3px solid #000;padding:6px 10px;font-family:'Archivo Black',sans-serif;font-size:11px;text-transform:uppercase;cursor:pointer">★</button>
  </form>`;
}

function pm2Badge(p) {
  if (!p) return '';
  if (p.status === 'online') {
    const port = p.port ? `:${p.port}` : '';
    const mem  = p.memory_mb ? ` ${p.memory_mb}M` : '';
    const rst  = p.restarts > 0 ? ` ↻${p.restarts}` : '';
    return `<div style="display:inline-block;background:#22c55e;color:#000;font-size:10px;font-weight:700;padding:1px 6px;letter-spacing:.04em;text-transform:uppercase">★ pm2 ${esc(p.name)}${esc(port)}${esc(mem)}${esc(rst)}</div>`;
  }
  return `<div style="display:inline-block;background:#dc2626;color:#fff;font-size:10px;font-weight:700;padding:1px 6px;letter-spacing:.04em;text-transform:uppercase">★ pm2 ${esc(p.name)} ${esc(p.status)}</div>`;
}

const HEAD = `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Builds — Index</title>
<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Space Mono',monospace;background:#fff;color:#000;line-height:1.4}
.tape{background:#facc15;border-bottom:4px solid #000;padding:8px 0;overflow:hidden;white-space:nowrap;font-size:13px;font-weight:700;letter-spacing:.04em}
.tape span{display:inline-block;animation:scroll 40s linear infinite}
.tape span::after{content:'  ★  STEVE / BUILDS INDEX  ★  SORTABLE  ★  COLLAPSIBLE  ★  '}
@keyframes scroll{0%{transform:translateX(0)}100%{transform:translateX(-50%)}}
.hd{background:#000;color:#fff;border-bottom:4px solid #000}
.hd-inner{max-width:1640px;margin:0 auto;padding:14px 24px;display:flex;align-items:center;justify-content:space-between;gap:24px}
.lg{font-family:'Archivo Black',sans-serif;font-size:28px;letter-spacing:-.04em;text-transform:uppercase}
.lg::after{content:'.';color:#facc15}
.nv{display:flex;font-size:12px;font-weight:700;text-transform:uppercase}
.nv a{padding:8px 14px;border:1px solid #fff;margin-right:-1px;text-decoration:none;color:#fff}
.nv a.active{background:#facc15;color:#000}

/* ===== Sidebar layout ===== */
.layout{display:grid;grid-template-columns:240px minmax(0, 1fr);gap:24px;max-width:1640px;margin:0 auto;padding:24px}
.sidebar{position:sticky;top:24px;align-self:start;max-height:calc(100vh - 48px);overflow-y:auto;background:#fafaf6;border:4px solid #000;padding:14px}
.sb-section{margin-bottom:18px}
.sb-section:last-child{margin-bottom:0}
.sb-h{font-family:'Archivo Black',sans-serif;font-size:11px;text-transform:uppercase;letter-spacing:.06em;background:#000;color:#facc15;padding:5px 8px;margin-bottom:6px}
.sidebar ul{list-style:none}
.sidebar li{margin-bottom:1px}
.sidebar li a{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:6px 10px;font-size:12px;font-weight:700;text-decoration:none;color:#000;border:2px solid transparent;font-family:'Space Mono',monospace}
.sidebar li a:hover{background:#fff;border-color:#000}
.sidebar li a.is-active{background:#facc15;border-color:#000}
.sidebar li a.grade-A{border-left:6px solid #22c55e;padding-left:6px}
.sidebar li a.grade-B{border-left:6px solid #84cc16;padding-left:6px}
.sidebar li a.grade-C{border-left:6px solid #facc15;padding-left:6px}
.sidebar li a.grade-D{border-left:6px solid #f97316;padding-left:6px}
.sidebar li a.grade-F{border-left:6px solid #dc2626;padding-left:6px}
.sb-count{background:#000;color:#facc15;font-size:9px;padding:1px 6px;letter-spacing:.04em;font-weight:700;font-family:'Space Mono',monospace}
.sidebar li a.is-active .sb-count{background:#000;color:#facc15}
.sb-empty{font-size:10px;color:#999;padding:6px;font-style:italic}

/* ===== Stats banner ===== */
.stats-banner{display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:6px;margin-bottom:24px;min-width:0}
@media (min-width:641px) and (max-width:1640px){
  .stats-banner{grid-template-columns:repeat(auto-fill,minmax(96px,1fr))}
  .stat-val{font-size:24px !important}
}
.stat-cell{border:3px solid #000;padding:12px 14px;display:flex;flex-direction:column;justify-content:space-between;min-height:84px}
.stat-val{font-family:'Archivo Black',sans-serif;font-size:32px;line-height:1;letter-spacing:-.02em}
.stat-label{font-family:'Space Mono',monospace;font-size:9px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;margin-top:8px}

/* ===== Action buttons (per-card) ===== */
.bbtn{display:inline-block;background:#fff;color:#000;border:2px solid #000;padding:4px 9px;font-family:'Space Mono',monospace;font-size:10px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;text-decoration:none;cursor:pointer;margin:1px 0}
.bbtn:hover{background:#facc15}
.bbtn-primary{background:#000;color:#facc15}
.bbtn-primary:hover{background:#facc15;color:#000}
.bbtn-port{background:#facc15;color:#000}
.bbtn-mock{background:#22c55e;color:#000}
.bbtn-deep{background:#dbeafe;color:#000}
.bbtn-deep:hover{background:#3b82f6;color:#fff}
.bbtn-star.is-starred{background:#facc15;color:#000;border-color:#000}
.bbtn-copy.is-copied{background:#22c55e;color:#000}

/* ===== View header (for filtered views) ===== */
.view-banner{background:#000;color:#facc15;padding:14px 20px;border:4px solid #000;margin-bottom:18px;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
.view-banner .vb-title{font-family:'Archivo Black',sans-serif;font-size:18px;text-transform:uppercase;letter-spacing:-.01em}
.view-banner .vb-meta{font-family:'Space Mono',monospace;font-size:11px;font-weight:700;letter-spacing:.04em}
.view-banner a{color:#facc15;text-decoration:underline}

@media (max-width:880px){
  .layout{grid-template-columns:1fr}
  .sidebar{position:static;max-height:none}
}

main{max-width:1640px;margin:0 auto;padding:32px 24px}
h1{font-family:'Archivo Black',sans-serif;font-size:64px;line-height:.9;text-transform:uppercase;letter-spacing:-.04em;margin-bottom:8px}
h1 mark{background:#facc15;padding:0 8px}
.lead{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:#666;margin-bottom:24px}
.controls{display:flex;gap:12px;align-items:center;margin-bottom:24px;flex-wrap:wrap}
.controls input{flex:1;min-width:240px;padding:14px 18px;border:4px solid #000;font-family:'Space Mono',monospace;font-size:14px;font-weight:700;text-transform:uppercase}
.controls button{background:#000;color:#facc15;border:4px solid #000;padding:14px 18px;font-family:'Archivo Black',sans-serif;font-size:13px;letter-spacing:.04em;text-transform:uppercase;cursor:pointer}
details{border:4px solid #000;margin-bottom:16px;background:#fff}
details summary{background:#000;color:#facc15;padding:14px 20px;font-family:'Archivo Black',sans-serif;font-size:18px;text-transform:uppercase;letter-spacing:-.01em;cursor:pointer;user-select:none;display:flex;justify-content:space-between;align-items:center}
details summary::-webkit-details-marker{display:none}
details summary::after{content:' ▼';font-size:14px}
details[open] summary::after{content:' ▲'}
details summary .count{background:#facc15;color:#000;padding:2px 10px;font-size:13px;letter-spacing:.04em}
table{width:100%;border-collapse:collapse;table-layout:fixed}
th,td{padding:10px 14px;text-align:left;border-bottom:2px solid #000;vertical-align:top;overflow:hidden;text-overflow:ellipsis}
th{background:#fef3c7;font-family:'Archivo Black',sans-serif;font-size:12px;text-transform:uppercase;letter-spacing:.04em;cursor:pointer;user-select:none;border-bottom:4px solid #000;position:sticky;top:0;z-index:1}
th.sortable:hover{background:#facc15}
th[data-dir="asc"]::after{content:' ▲'}
th[data-dir="desc"]::after{content:' ▼'}
tr:nth-child(even) td{background:#fafaf6}
tr.hidden{display:none}
.bcard:hover{transform:translateY(-2px);box-shadow:6px 6px 0 #000;border-color:#000;background:#fef3c7}
.bcard:active{transform:translateY(0);box-shadow:none}
.rail-card{aspect-ratio:auto !important;height:auto !important;scroll-snap-align:start}
@media (max-width:640px){
  #grid{grid-template-columns:repeat(2,1fr) !important}
  #cols,#cols-label{display:none}
  h1{font-size:40px !important}
  .bcard{padding:10px}
  .bcard div[style*='font-size:14px']{font-size:12px}
}
/* When grid columns ≥ 9, force NEEDS WORK to compact ★ */
#grid:has([style*='grid-template-columns:repeat(9'])  .needs-work-full,
#grid[style*='repeat(10'] .needs-work-full,
#grid[style*='repeat(11'] .needs-work-full,
#grid[style*='repeat(12'] .needs-work-full{display:none !important}
#grid[style*='repeat(9']  .needs-work-short,
#grid[style*='repeat(10'] .needs-work-short,
#grid[style*='repeat(11'] .needs-work-short,
#grid[style*='repeat(12'] .needs-work-short{display:inline !important}
@media (min-width:641px) and (max-width:1024px){
  #grid{grid-template-columns:repeat(4,1fr) !important}
}
@media (min-width:1025px) and (max-width:1640px){
  #grid{column-gap:8px}
}
@media (min-width:2400px){
  #grid:not([data-cols-locked]){grid-template-columns:repeat(14,1fr) !important}
}
.grade-rail{margin-bottom:18px}
.grade-rail summary{font-family:'Archivo Black',sans-serif;font-size:14px;text-transform:uppercase;cursor:pointer;user-select:none;padding:8px 14px;background:#fff;border:3px solid #000;display:flex;justify-content:space-between;align-items:center}
.grade-rail summary::-webkit-details-marker{display:none}
.grade-rail summary::after{content:' ▼';font-size:11px}
.grade-rail[open] summary::after{content:' ▲'}
.grade-rail-body{display:grid;grid-auto-flow:column;grid-auto-columns:200px;gap:10px;overflow-x:auto;scroll-snap-type:x mandatory;scroll-behavior:smooth;padding:12px 0 8px;border-left:3px solid #000;border-right:3px solid #000;border-bottom:3px solid #000;background:#fff}
.grade-rail-body .bcard{aspect-ratio:auto !important;height:160px}
/* ★ Detail drawer — slide-in from right */
.drawer{position:fixed;top:0;right:0;bottom:0;width:min(640px,90vw);background:#fff;border-left:4px solid #000;box-shadow:-12px 0 0 #000a;transform:translateX(105%);transition:transform .25s ease-out;z-index:50;overflow-y:auto;padding:24px}
.drawer.is-open{transform:translateX(0)}
.drawer-backdrop{position:fixed;inset:0;background:#0008;opacity:0;pointer-events:none;transition:opacity .2s;z-index:49}
.drawer-backdrop.is-open{opacity:1;pointer-events:auto}
.drawer-close{position:absolute;top:14px;right:14px;background:#000;color:#facc15;border:0;padding:6px 12px;font-family:'Archivo Black',sans-serif;font-size:14px;cursor:pointer}
.peek-btn{position:absolute;top:8px;left:8px;background:#000;color:#facc15;border:2px solid #000;padding:1px 6px;font-family:'Archivo Black',sans-serif;font-size:10px;letter-spacing:.04em;text-decoration:none;cursor:pointer;opacity:0;transition:opacity .15s}
.bcard:hover .peek-btn{opacity:1}
.bcard.is-starred{outline:3px solid #facc15;outline-offset:-3px}
.bcard.is-starred::before{content:'★';position:absolute;top:6px;left:6px;background:#facc15;color:#000;width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-family:'Archivo Black',sans-serif;font-size:14px;border:2px solid #000;z-index:2}
@keyframes pulse-update{0%{box-shadow:0 0 0 0 #22c55e;background-color:#dcfce7}50%{box-shadow:0 0 0 8px #22c55e00;background-color:#fff}100%{box-shadow:0 0 0 0 #22c55e00;background-color:#fff}}
/* ★ Spinner for in-progress drawer actions */
@keyframes dd-spin{to{transform:rotate(360deg)}}
.dd-spinner{display:inline-block;width:12px;height:12px;border:2px solid #000;border-right-color:transparent;border-radius:50%;animation:dd-spin .9s linear infinite;vertical-align:-2px;margin-right:6px}
/* ★ kbd hint pill */
.kbd-hints{position:fixed;bottom:14px;right:14px;background:#000;color:#facc15;font-family:'Space Mono',monospace;font-size:11px;font-weight:700;padding:8px 12px;border:3px solid #facc15;letter-spacing:.04em;z-index:40;display:none;max-width:300px}
.kbd-hints.is-open{display:block}
.kbd-hints kbd{background:#facc15;color:#000;padding:1px 5px;font-family:inherit;font-weight:700}
${svgHeroStylesheet()}
.col-name{width:18%}
.col-desc{width:30%;font-size:12px;color:#444;font-weight:400}
.col-cat{width:8%}
.col-last{width:13%;font-size:12px}
.col-open{width:18%}
.col-analyze{width:13%}
.cat-tag{display:inline-block;background:#000;color:#facc15;padding:2px 8px;font-size:10px;font-weight:700;letter-spacing:.04em;text-transform:uppercase}
.empty{padding:48px;text-align:center;color:#666;font-weight:700;text-transform:uppercase;letter-spacing:.04em}
footer{padding:32px 24px;border-top:4px solid #000;background:#000;color:#aaa;text-align:center;font-size:11px;text-transform:uppercase;letter-spacing:.04em;margin-top:48px}
footer a{color:#facc15;text-decoration:none}
</style></head><body>
<div class="tape"><span></span></div>
<header class="hd"><div class="hd-inner">
  <div class="lg">BUILDS / INDEX</div>
  <nav class="nv">
    <a class="active" href="/builds">BUILDS</a>
    <a href="/website-analysis">ANALYSIS</a>
    <a href="/">SMB BUILDER</a>
  </nav>
</div></header>
<div class="layout">
__SIDEBAR_PLACEHOLDER__
<main style="padding:0">
`;

const FOOT = `</main>
</div>
<footer>★ ${new Date().getFullYear()} AGENT ABRAMS ★ SCANS ~/Projects + MEMORY.md LIVE ★ <a href="/website-analysis">RUN A NEW ANALYSIS</a> ★</footer>
<script>
(function() {
  const filter = document.getElementById('filter');
  const onlineToggle = document.getElementById('filter-online');
  const gitToggle = document.getElementById('filter-git');
  const viewGrid = document.getElementById('view-grid');
  const viewTable = document.getElementById('view-table');
  const grid = document.getElementById('grid');
  const tableView = document.getElementById('table-view');
  const railSection = document.getElementById('rail-section');
  const gridControls = document.getElementById('grid-controls');
  const cols = document.getElementById('cols');
  const colsLabel = document.getElementById('cols-label');
  const gridCount = document.getElementById('grid-count');
  const rail = document.getElementById('rail');
  let onlyOnline = false, onlyGit = false, onlyStale = false, onlyTop = false;
  const staleToggle = document.getElementById('filter-stale');
  const topToggle = document.getElementById('filter-top');

  // ★ density slider — persist to localStorage so refresh keeps Steve's pick
  const SAVED_COLS = parseInt(localStorage.getItem('builds.cols') || '6', 10);
  cols.value = String(Math.min(12, Math.max(4, SAVED_COLS)));
  function applyCols() {
    const n = parseInt(cols.value, 10);
    grid.style.gridTemplateColumns = 'repeat(' + n + ', 1fr)';
    colsLabel.textContent = n;
    localStorage.setItem('builds.cols', String(n));
  }
  applyCols();
  cols?.addEventListener('input', applyCols);

  // ★ view toggle — grid (default) vs table (legacy)
  const SAVED_VIEW = localStorage.getItem('builds.view') || 'grid';
  function applyView(v) {
    const isGrid = v === 'grid';
    grid.style.display = isGrid ? 'grid' : 'none';
    railSection.style.display = isGrid ? '' : 'none';
    gridControls.style.display = isGrid ? 'flex' : 'none';
    tableView.style.display = isGrid ? 'none' : 'block';
    viewGrid.style.background = isGrid ? '#facc15' : '#000';
    viewGrid.style.color = isGrid ? '#000' : '#facc15';
    viewTable.style.background = isGrid ? '#000' : '#facc15';
    viewTable.style.color = isGrid ? '#facc15' : '#000';
    localStorage.setItem('builds.view', v);
  }
  applyView(SAVED_VIEW);
  viewGrid?.addEventListener('click', () => applyView('grid'));
  viewTable?.addEventListener('click', () => applyView('table'));

  // ★ rail nav — scroll by ~1.5 cards per click. data-rail picks the target.
  document.querySelectorAll('.rail-nav').forEach(b => {
    b.addEventListener('click', (e) => {
      e.preventDefault();
      const dir = parseInt(b.dataset.dir, 10);
      const targetId = b.dataset.rail || 'rail';
      const target = document.getElementById(targetId);
      if (target) target.scrollBy({ left: dir * 360, behavior: 'smooth' });
    });
  });

  function applyFilter() {
    const q = (filter.value || '').toLowerCase().trim();
    // table rows
    document.querySelectorAll('tr[data-row]').forEach(tr => {
      const t = tr.dataset.search || '';
      const matches = !q || t.includes(q);
      const pm2Ok = !onlyOnline || tr.dataset.pm2 === '1';
      const gitOk = !onlyGit || /★ git/i.test(tr.querySelector('.col-name').textContent);
      tr.classList.toggle('hidden', !(matches && pm2Ok && gitOk));
    });
    document.querySelectorAll('details').forEach(d => {
      const countSpan = d.querySelector('.count');
      if (!countSpan) return; // grade-rail details don't have .count
      const visible = d.querySelectorAll('tr[data-row]:not(.hidden)').length;
      countSpan.textContent = visible;
      if (visible === 0) d.style.display = 'none'; else d.style.display = '';
    });
    // grid + rail cards
    let shown = 0;
    document.querySelectorAll('.bcard').forEach(c => {
      const t = c.dataset.search || '';
      const matches = !q || t.includes(q);
      const pm2Ok = !onlyOnline || c.dataset.pm2 === '1';
      const g = c.dataset.grade || 'Z';
      const staleOk = !onlyStale || g === 'D' || g === 'F';
      const topOk = !onlyTop || g === 'A' || g === 'B';
      const visible = matches && pm2Ok && staleOk && topOk;
      c.style.display = visible ? '' : 'none';
      if (visible && c.parentElement.id === 'grid') shown += 1;
    });
    gridCount.textContent = shown + ' CARDS';
  }
  filter?.addEventListener('input', applyFilter);
  // ★ Filter changes reset card focus to top — a stale focusIdx after filtering
  // would point at a card that's now hidden.
  filter?.addEventListener('input', () => { focusIdx = -1; document.querySelectorAll('.bcard').forEach(c => { c.style.outline = ''; }); });
  onlineToggle?.addEventListener('click', () => {
    onlyOnline = !onlyOnline;
    onlineToggle.style.background = onlyOnline ? '#22c55e' : '#000';
    onlineToggle.style.color = onlyOnline ? '#000' : '#facc15';
    applyFilter();
  });
  gitToggle?.addEventListener('click', () => {
    onlyGit = !onlyGit;
    gitToggle.style.background = onlyGit ? '#facc15' : '#000';
    gitToggle.style.color = onlyGit ? '#000' : '#facc15';
    applyFilter();
  });
  staleToggle?.addEventListener('click', () => {
    onlyStale = !onlyStale;
    if (onlyStale) onlyTop = false;
    staleToggle.style.background = onlyStale ? '#dc2626' : '#000';
    staleToggle.style.color = onlyStale ? '#fff' : '#facc15';
    topToggle.style.background = '#000'; topToggle.style.color = '#facc15';
    applyFilter();
  });
  topToggle?.addEventListener('click', () => {
    onlyTop = !onlyTop;
    if (onlyTop) onlyStale = false;
    topToggle.style.background = onlyTop ? '#22c55e' : '#000';
    topToggle.style.color = onlyTop ? '#000' : '#facc15';
    staleToggle.style.background = '#000'; staleToggle.style.color = '#facc15';
    applyFilter();
  });

  // ★ Sync URL params + localStorage chip state on load. URL wins over storage
  // so a deep link still works.
  (function syncFromUrlAndStorage() {
    const params = new URLSearchParams(location.search);
    const view = (params.get('view') || '').toLowerCase();
    const grade = (params.get('grade') || '').toUpperCase();
    const stored = JSON.parse(localStorage.getItem('builds.chips') || '{}');
    if (stored.online || view === 'active') onlineToggle.click();
    if (stored.git) gitToggle.click();
    if (stored.stale || view === 'stale') staleToggle.click();
    if (stored.top || grade === 'A' || grade === 'B') topToggle.click();
  })();
  function persistChips() {
    localStorage.setItem('builds.chips', JSON.stringify({
      online: onlyOnline, git: onlyGit, stale: onlyStale, top: onlyTop,
    }));
  }
  [onlineToggle, gitToggle, staleToggle, topToggle].forEach(b =>
    b.addEventListener('click', () => setTimeout(persistChips, 0))
  );

  // ★ Sort dropdown — reorders #grid children by data attribute. Persisted.
  const sortBy = document.getElementById('sort-by');
  function applySort() {
    const v = sortBy.value;
    localStorage.setItem('builds.sort', v);
    const cards = Array.from(grid.querySelectorAll('.bcard'));
    cards.sort((a, b) => {
      const get = (el, k) => parseFloat(el.dataset[k] || '0');
      const getStr = (el, k) => (el.dataset[k] || '');
      switch (v) {
        case 'grade-score-desc':  return get(b, 'gradeScore') - get(a, 'gradeScore');
        case 'audit-score-desc':  return get(b, 'auditScore') - get(a, 'auditScore');
        case 'last-desc':         return get(b, 'last') - get(a, 'last');
        case 'last-asc':          return get(a, 'last') - get(b, 'last');
        case 'name-asc':          return getStr(a, 'name').localeCompare(getStr(b, 'name'));
        default: return 0;
      }
    });
    cards.forEach(c => grid.appendChild(c));
  }
  if (sortBy) {
    const SAVED_SORT = localStorage.getItem('builds.sort');
    if (SAVED_SORT) sortBy.value = SAVED_SORT;
    sortBy.addEventListener('change', applySort);
    applySort();
  }

  // ★ DRAWER — open with PEEK button or Enter on focused card
  const drawer = document.getElementById('drawer');
  const drawerBd = document.getElementById('drawer-bd');
  const drawerBody = document.getElementById('drawer-body');
  window._peekCurrentSlug = null;
  window.openPeek = function(slug) {
    window._peekCurrentSlug = slug;
    drawer.classList.add('is-open');
    drawerBd.classList.add('is-open');
    drawer.setAttribute('aria-hidden', 'false');
    drawerBody.innerHTML = '<div style="padding:32px;color:#666">★ Loading <strong>' + slug + '</strong>…</div>';
    fetch('/api/wa/' + encodeURIComponent(slug) + '/peek')
      .then(r => r.text())
      .then(html => {
        drawerBody.innerHTML = html;
        // Reflect star state in the drawer button — Steve's body click
        // listener flips the class, this just paints the initial label.
        try {
          const stars = new Set(JSON.parse(localStorage.getItem('agentabrams.builds.starred') || '[]'));
          drawerBody.querySelectorAll('button[data-star]').forEach(b => {
            const s = b.dataset.star;
            if (stars.has(s)) { b.classList.add('is-starred'); b.textContent = '★ UNSTAR'; }
            b.addEventListener('click', () => {
              setTimeout(() => {
                const cur = new Set(JSON.parse(localStorage.getItem('agentabrams.builds.starred') || '[]'));
                b.textContent = cur.has(s) ? '★ UNSTAR' : '★ STAR';
              }, 0);
            });
          });
        } catch {}
        // Intercept the inline DEEP-DIVE form so it doesn't navigate away —
        // POST in-place, then refresh the peek fragment.
        drawerBody.querySelectorAll('form[action$="/deep-dive"]').forEach(f => {
          f.addEventListener('submit', (e) => {
            e.preventDefault();
            const btn = f.querySelector('button');
            if (btn) { btn.disabled = true; btn.innerHTML = '<span class="dd-spinner"></span>RUNNING…'; }
            fetch(f.action, { method: 'POST' })
              .then(() => {
                window.openPeek(window._peekCurrentSlug || slug);
                // Mark the card itself dirty so a future filter pass / refresh
                // pulls the new score. Server-side cache is busted by the
                // deep-dive endpoint already; this is just visual.
                const card = document.querySelector('.bcard[data-slug="' + (window._peekCurrentSlug || slug) + '"]');
                if (card) {
                  card.style.animation = 'pulse-update 1.6s ease-out';
                  setTimeout(() => { card.style.animation = ''; }, 1700);
                }
              })
              .catch(() => { if (btn) { btn.disabled = false; btn.textContent = '★ DEEP-DIVE'; } });
          });
        });
      })
      .catch(() => { drawerBody.innerHTML = '<div style="padding:32px;color:#dc2626">★ Failed to load.</div>'; });
  };
  window.closePeek = function() {
    drawer.classList.remove('is-open');
    drawerBd.classList.remove('is-open');
    drawer.setAttribute('aria-hidden', 'true');
  };
  // ★ Drawer NEXT/PREV — step through currently-visible cards in #grid order.
  window.peekStep = function(dir) {
    const visible = Array.from(grid.querySelectorAll('.bcard'))
      .filter(c => c.style.display !== 'none' && c.dataset.slug);
    if (!visible.length) return;
    const cur = window._peekCurrentSlug;
    let idx = visible.findIndex(c => c.dataset.slug === cur);
    if (idx < 0) idx = 0;
    idx = (idx + dir + visible.length) % visible.length;
    window.openPeek(visible[idx].dataset.slug);
  };

  // ★ j/k card focus nav + Enter open + p peek
  let focusIdx = -1;
  function highlightFocus() {
    document.querySelectorAll('.bcard').forEach(c => { c.style.outline = ''; });
    const visible = Array.from(grid.querySelectorAll('.bcard')).filter(c => c.style.display !== 'none');
    if (focusIdx < 0 || focusIdx >= visible.length) return;
    const c = visible[focusIdx];
    c.style.outline = '4px solid #facc15';
    c.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
  }
  document.addEventListener('keydown', (e) => {
    const tag = e.target?.tagName?.toLowerCase();
    if (tag === 'input' || tag === 'textarea' || tag === 'select') {
      if (e.key === 'Escape') { e.target.blur(); }
      return;
    }
    if (e.key === 'Escape') { window.closePeek(); return; }
    if (e.metaKey || e.ctrlKey || e.altKey) return;
    if (e.key === 'j') { focusIdx += 1; highlightFocus(); }
    else if (e.key === 'k') { focusIdx = Math.max(0, focusIdx - 1); highlightFocus(); }
    else if (e.key === 'Enter') {
      const visible = Array.from(grid.querySelectorAll('.bcard')).filter(c => c.style.display !== 'none');
      if (focusIdx >= 0 && visible[focusIdx]) visible[focusIdx].click();
    } else if (e.key === 'p') {
      const visible = Array.from(grid.querySelectorAll('.bcard')).filter(c => c.style.display !== 'none');
      const c = visible[focusIdx];
      if (c?.dataset?.slug) window.openPeek(c.dataset.slug);
    } else if (e.key === '[' && drawer.classList.contains('is-open')) {
      e.preventDefault(); window.peekStep(-1);
    } else if (e.key === ']' && drawer.classList.contains('is-open')) {
      e.preventDefault(); window.peekStep(1);
    } else if (e.key === 's') {
      // ★ Toggle star on the focused or peeked card
      const target = window._peekCurrentSlug
        || (focusIdx >= 0 ? Array.from(grid.querySelectorAll('.bcard')).filter(c => c.style.display !== 'none')[focusIdx]?.dataset.slug : null);
      if (!target) return;
      e.preventDefault();
      const KEY = 'agentabrams.builds.starred';
      let stars; try { stars = new Set(JSON.parse(localStorage.getItem(KEY) || '[]')); } catch { stars = new Set(); }
      const wasStarred = stars.has(target);
      if (wasStarred) stars.delete(target); else stars.add(target);
      localStorage.setItem(KEY, JSON.stringify([...stars]));
      const card = document.querySelector('.bcard[data-slug="' + target + '"]');
      if (card) {
        card.classList.toggle('is-starred', !wasStarred);
        card.style.animation = 'pulse-update 1.2s ease-out';
        setTimeout(() => { card.style.animation = ''; }, 1300);
      }
      // Sync any open drawer button label
      drawerBody.querySelectorAll('button[data-star="' + target + '"]').forEach(b => {
        b.textContent = wasStarred ? '★ STAR' : '★ UNSTAR';
        b.classList.toggle('is-starred', !wasStarred);
      });
    }
  });

  // ★ Sidebar live count refresh — recompute view counts based on what's
  // currently passing the JS chip filters, not the static server-side filter.
  function refreshSidebarCounts() {
    const visible = Array.from(document.querySelectorAll('#grid .bcard'))
      .filter(c => c.style.display !== 'none');
    document.querySelectorAll('.sidebar a').forEach(link => {
      const span = link.querySelector('.sb-count');
      if (!span) return;
      const href = link.getAttribute('href') || '';
      const m = href.match(/[?&]view=([a-z]+)/);
      if (!m) return;
      const view = m[1];
      let n = 0;
      const now = Date.now();
      for (const c of visible) {
        const last = parseInt(c.dataset.last || '0', 10);
        const days = (now - last) / 86400000;
        if (view === 'all') n += 1;
        else if (view === 'active' && c.dataset.pm2 === '1') n += 1;
        else if (view === 'today' && days < 1) n += 1;
        else if (view === 'week' && days < 7) n += 1;
        else if (view === 'stale' && (last === 0 || days > 30)) n += 1;
        else if (view === 'graded' && c.dataset.grade !== 'Z') n += 1;
        else if (view === 'ungraded' && c.dataset.grade === 'Z') n += 1;
        else if (view === 'broken' && c.dataset.pm2 === '0') n += 1;
        else if (view === 'url') n += 1; // can't know URL from card data alone
      }
      span.textContent = n;
    });
  }
  // Hook into existing applyFilter via document-level event after every chip change.
  // applyFilter is a function declaration; we observe via setInterval-poll-once
  // pattern: dispatch a 'filter-applied' event after each call site triggers it.
  // Simpler: wrap the existing toggles to also call refreshSidebarCounts.
  const origs = [filter, onlineToggle, gitToggle, staleToggle, topToggle];
  origs.forEach(el => el?.addEventListener(el.tagName === 'INPUT' ? 'input' : 'click', () => setTimeout(refreshSidebarCounts, 0)));
  refreshSidebarCounts();

  // ★ STAR ALL VISIBLE — bulk-star every currently-shown card. Reuses Steve's
  // STAR_KEY so the sidebar Starred list updates immediately.
  const starAllBtn = document.getElementById('star-all-visible');
  if (starAllBtn) {
    starAllBtn.addEventListener('click', () => {
      const visible = Array.from(grid.querySelectorAll('.bcard'))
        .filter(c => c.style.display !== 'none' && c.dataset.slug);
      if (!visible.length) { alert('No visible cards.'); return; }
      if (!confirm('Star ' + visible.length + ' visible cards?')) return;
      const STAR_KEY = 'agentabrams.builds.starred';
      let stars; try { stars = new Set(JSON.parse(localStorage.getItem(STAR_KEY) || '[]')); } catch { stars = new Set(); }
      visible.forEach(c => { stars.add(c.dataset.slug); c.classList.add('is-starred'); });
      localStorage.setItem(STAR_KEY, JSON.stringify([...stars]));
      // Trigger Steve's existing refreshStarredSidebar by dispatching a synthetic click
      // on a star button — simplest cross-IIFE bridge. Actually just reload.
      const ul = document.getElementById('starred-list');
      if (ul) {
        ul.innerHTML = [...stars].slice(0, 20).map(s =>
          '<li><a href="#" data-jump-to="' + s + '"><span>' + s + '</span><span class="sb-count">★</span></a></li>'
        ).join('');
      }
    });
  }

  // ★ RESET button — clears filter input + all chip toggles + sort dropdown +
  // localStorage chips/sort. Strips URL query params via history.replaceState.
  const resetBtn = document.getElementById('filter-reset');
  if (resetBtn) {
    resetBtn.addEventListener('click', () => {
      filter.value = '';
      if (onlyOnline)  onlineToggle.click();
      if (onlyGit)     gitToggle.click();
      if (onlyStale)   staleToggle.click();
      if (onlyTop)     topToggle.click();
      if (sortBy) { sortBy.value = 'grade-score-desc'; applySort(); }
      localStorage.removeItem('builds.chips');
      localStorage.removeItem('builds.sort');
      history.replaceState(null, '', '/builds');
      applyFilter();
    });
  }

  // ★ Fresh-pulse on initial paint — cards whose audit_score changed in the
  // last 5 minutes get a brief green flash, so reloads after deep-dives clearly
  // signal which records moved. Tracks last-seen audit_score per slug in
  // localStorage and diffs against current data-audit-score.
  (function pulseFreshlyChanged() {
    let prev; try { prev = JSON.parse(localStorage.getItem('builds.lastscores') || '{}'); } catch { prev = {}; }
    const next = {};
    document.querySelectorAll('.bcard').forEach(c => {
      const slug = c.dataset.slug; if (!slug) return;
      const cur = parseInt(c.dataset.auditScore || '-1', 10);
      next[slug] = cur;
      if (prev[slug] != null && prev[slug] !== cur && cur >= 0) {
        c.style.animation = 'pulse-update 1.6s ease-out';
        setTimeout(() => { c.style.animation = ''; }, 1700);
      }
    });
    localStorage.setItem('builds.lastscores', JSON.stringify(next));
  })();

  // ★ Initial star badge — paint .is-starred on cards already in the
  // localStorage star set, so refreshing the page keeps stars visible.
  (function paintInitialStars() {
    let stars;
    try { stars = new Set(JSON.parse(localStorage.getItem('agentabrams.builds.starred') || '[]')); } catch { return; }
    if (stars.size === 0) return;
    document.querySelectorAll('.bcard').forEach(c => {
      if (stars.has(c.dataset.slug) || stars.has(c.dataset.name)) c.classList.add('is-starred');
    });
  })();

  // ★ FIX VISIBLE — populate slugs hidden input with currently-visible card slugs
  // at submit time, so the server only re-runs deep-dive on what's on screen.
  const fixVisibleForm = document.getElementById('fix-visible-form');
  const fixVisibleSlugs = document.getElementById('fix-visible-slugs');
  if (fixVisibleForm) {
    fixVisibleForm.addEventListener('submit', (e) => {
      const slugs = [];
      document.querySelectorAll('.bcard').forEach(c => {
        if (c.style.display === 'none') return;
        if (c.parentElement && c.parentElement.id !== 'grid') return; // grid only, skip rails
        const s = c.dataset.slug;
        if (s) slugs.push(s);
      });
      if (!slugs.length) {
        e.preventDefault();
        alert('No visible cards to fix.');
        return;
      }
      if (!confirm('Force-rerun deep-dive on ' + slugs.length + ' visible cards?')) {
        e.preventDefault(); return;
      }
      fixVisibleSlugs.value = slugs.join(',');
    });
  }
  document.getElementById('expand-all')?.addEventListener('click', () => {
    document.querySelectorAll('details').forEach(d => d.open = true);
  });
  document.getElementById('collapse-all')?.addEventListener('click', () => {
    document.querySelectorAll('details').forEach(d => d.open = false);
  });
  document.querySelectorAll('th.sortable').forEach(th => {
    th.addEventListener('click', () => {
      const tbody = th.closest('table').querySelector('tbody');
      const idx = Array.from(th.parentNode.children).indexOf(th);
      const key = th.dataset.key;
      const cur = th.dataset.dir;
      const dir = cur === 'asc' ? 'desc' : 'asc';
      th.parentNode.querySelectorAll('th').forEach(o => o.removeAttribute('data-dir'));
      th.dataset.dir = dir;
      const rows = Array.from(tbody.querySelectorAll('tr[data-row]'));
      rows.sort((a, b) => {
        const av = a.dataset[key] || '';
        const bv = b.dataset[key] || '';
        if (key === 'last') return (Number(bv) - Number(av)) * (dir === 'asc' ? -1 : 1);
        return av.localeCompare(bv) * (dir === 'asc' ? 1 : -1);
      });
      rows.forEach(r => tbody.appendChild(r));
    });
  });
})();
</script>
</body></html>`;

function renderCard(r) {
  const last = r.last_at ? new Date(r.last_at).toISOString().slice(0,10) : '—';
  const days = r.last_at ? Math.floor((Date.now() - Date.parse(r.last_at))/86400000) : 999;
  const rel = days === 0 ? 'today' : days === 1 ? '1d' : days < 30 ? `${days}d` : days < 365 ? `${Math.floor(days/30)}mo` : `${Math.floor(days/365)}y`;
  const search = [r.name, r.description, r.category, r.pm2?.name, r.pm2?.status, r.grade?.letter, r.deep_search].filter(Boolean).join(' ').toLowerCase();
  const u = safeUrl(r.url);
  const linkAttr = r.analysis_slug ? `href="/wa/${esc(r.analysis_slug)}" target="_blank"` : (u ? `href="${esc(u)}" target="_blank"` : `href="file://${esc(r.path)}"`);
  // SVG hero referenced by class — declared once in <style>. Deterministic per project.
  const bgClass = classFor(r.name, r.grade?.vertical || r.category);
  return `<a class="bcard ${bgClass}" data-card data-slug="${esc(r.analysis_slug || r.slug || '')}" data-search="${esc(search)}" data-name="${esc(r.name.toLowerCase())}" data-cat="${esc(r.category)}" data-grade="${esc(r.grade?.letter || 'Z')}" data-grade-score="${r.grade?.score || 0}" data-audit-score="${r.audit_score == null ? -1 : r.audit_score}" data-last="${r.last_at ? Date.parse(r.last_at) : 0}" data-pm2="${r.pm2?.status === 'online' ? '1' : '0'}" ${linkAttr} rel="noopener"
    style="display:block;border:4px solid #000;padding:14px;text-decoration:none;color:#000;position:relative;overflow:hidden;aspect-ratio:1;transition:transform .12s">
    ${r.analysis_slug ? `<button class="peek-btn" data-peek="${esc(r.analysis_slug)}" onclick="event.preventDefault();event.stopPropagation();window.openPeek('${esc(r.analysis_slug)}')" type="button">★ PEEK</button>` : ''}
    ${r.grade ? `<div style="position:absolute;top:8px;right:8px;background:${r.grade.color};color:#000;border:2px solid #000;font-family:'Archivo Black',sans-serif;font-size:18px;line-height:1;padding:2px 7px">${esc(r.grade.letter)}</div>` : ''}
    <div style="font-family:'Archivo Black',sans-serif;font-size:14px;line-height:1.05;text-transform:uppercase;letter-spacing:-.01em;margin-bottom:6px;padding-right:32px;word-break:break-word">${esc(r.name)}</div>
    <div style="font-size:9px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:#666;margin-bottom:6px"><span class="cat-tag" style="font-size:8px">${esc(r.category)}</span></div>
    <div style="font-size:10px;line-height:1.35;color:#333;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden;font-weight:500">${esc((r.description || 'no description').slice(0, 200))}</div>
    <div style="position:absolute;bottom:8px;left:14px;right:14px;display:flex;justify-content:space-between;align-items:center;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:#666;gap:6px;flex-wrap:wrap">
      <span>${esc(last)} ★ ${esc(rel)}</span>
      <div style="display:flex;gap:4px;align-items:center">
        ${r.audit_score != null && r.audit_score < 50 && r.analysis_slug ? `<a href="/wa/${esc(r.analysis_slug)}#audit-report" target="_blank" rel="noopener" onclick="event.stopPropagation()" title="Audit score ${r.audit_score}/100 — jump to quick-wins" style="background:#dc2626;color:#fff;padding:1px 5px;text-decoration:none;white-space:nowrap;font-size:9px"><span class="needs-work-full">★ NEEDS WORK</span><span class="needs-work-short" style="display:none">★</span></a>` : ''}
        ${r.pm2?.status === 'online' ? '<span style="background:#22c55e;color:#000;padding:1px 5px">★ LIVE</span>' : ''}
      </div>
    </div>
  </a>`;
}

// Per-card action buttons. Native + web links. Returns inline HTML.
function actionButtons(r) {
  const u = safeUrl(r.url);
  const vscodeHref = `vscode://file${r.path}`;
  const ghSearch = `https://github.com/search?q=${encodeURIComponent(r.name)}+stevestudio2&type=repositories`;
  const pm2Name = r.pm2?.name;
  const out = [];
  if (u) {
    const hostLabel = u.replace(/^https?:\/\//, '').replace(/\/$/, '').slice(0, 26);
    out.push(`<a href="${esc(u)}" target="_blank" rel="noopener" class="bbtn bbtn-primary" title="Open public URL">★ ${esc(hostLabel)} ↗</a>`);
  }
  if (r.port) out.push(`<a href="http://127.0.0.1:${r.port}" target="_blank" rel="noopener" class="bbtn bbtn-port" title="Local port :${r.port}">:${r.port}</a>`);
  if (r.analysis_slug) out.push(`<a href="/wa/${esc(r.analysis_slug)}" target="_blank" rel="noopener" class="bbtn bbtn-mock" title="Open mockup audit">★ Mockups</a>`);
  out.push(`<a href="/builds/build/${esc(r.slug || r.name)}" class="bbtn bbtn-deep" title="Deep-dive page · git log + deps + README">★ Deep Dive →</a>`);
  out.push(`<a href="${esc(vscodeHref)}" class="bbtn" title="Open in VS Code / Cursor">VS Code</a>`);
  out.push(`<button type="button" class="bbtn bbtn-copy" data-copy="${esc(r.path)}" title="Copy path to clipboard">Copy Path</button>`);
  out.push(`<a href="file://${esc(r.path)}" class="bbtn" title="Open in Finder">Finder</a>`);
  out.push(`<a href="${esc(ghSearch)}" target="_blank" rel="noopener" class="bbtn" title="GitHub repo search">GitHub</a>`);
  if (pm2Name) out.push(`<a href="/builds/pm2-logs?app=${encodeURIComponent(pm2Name)}" target="_blank" rel="noopener" class="bbtn" title="Tail pm2 logs">Logs</a>`);
  out.push(`<button type="button" class="bbtn bbtn-star" data-star="${esc(r.slug || r.name)}" title="Star this build">★ Star</button>`);
  return out.join(' ');
}

// Stats banner shown above the rail.
function statsBanner(rows) {
  const c = (fn) => rows.filter(fn).length;
  const cells = [
    { label: 'Total',       val: rows.length,                                                                  bg: '#000',     fg: '#fff' },
    { label: 'pm2 Online',  val: c(r => r.pm2?.status === 'online'),                                            bg: '#22c55e',  fg: '#000' },
    { label: 'pm2 Broken',  val: c(r => r.pm2 && r.pm2.status !== 'online'),                                    bg: '#dc2626',  fg: '#fff' },
    { label: 'Today',       val: c(r => r.last_at && (Date.now() - Date.parse(r.last_at)) < 86_400_000),       bg: '#facc15',  fg: '#000' },
    { label: 'This Week',   val: c(r => r.last_at && (Date.now() - Date.parse(r.last_at)) < 7 * 86_400_000),   bg: '#fef3c7',  fg: '#000' },
    { label: 'Stale 30d+',  val: c(r => !r.last_at || (Date.now() - Date.parse(r.last_at)) > 30 * 86_400_000), bg: '#f3f4f6',  fg: '#000' },
    { label: 'Graded',      val: c(r => r.grade),                                                               bg: '#000',     fg: '#facc15' },
    { label: 'A · top',     val: c(r => r.grade?.letter === 'A'),                                               bg: '#22c55e',  fg: '#000' },
    { label: 'F · low',     val: c(r => r.grade?.letter === 'F'),                                               bg: '#dc2626',  fg: '#fff' },
  ];
  return `<div class="stats-banner">
    ${cells.map(c => `<div class="stat-cell" style="background:${c.bg};color:${c.fg}"><div class="stat-val">${c.val.toLocaleString()}</div><div class="stat-label">${c.label}</div></div>`).join('')}
  </div>`;
}

// Sidebar nav driven by view list + grade jump + top categories.
function sidebarNav(rows, activeView, activeGrade, activeCat) {
  const filterFns = {
    all:      () => true,
    active:   r => r.pm2?.status === 'online',
    today:    r => r.last_at && (Date.now() - Date.parse(r.last_at)) < 86_400_000,
    week:     r => r.last_at && (Date.now() - Date.parse(r.last_at)) < 7 * 86_400_000,
    stale:    r => !r.last_at || (Date.now() - Date.parse(r.last_at)) > 30 * 86_400_000,
    graded:   r => r.grade,
    ungraded: r => !r.grade,
    broken:   r => r.pm2 && r.pm2.status !== 'online',
    url:      r => r.url,
    timeline: () => true,
  };
  const views = [
    ['all',      'All Builds'],
    ['active',   'Active · pm2'],
    ['today',    'Today'],
    ['week',     'This Week'],
    ['stale',    'Stale 30d+'],
    ['graded',   'Graded'],
    ['ungraded', 'Ungraded'],
    ['broken',   'pm2 Broken'],
    ['url',      'Has Public URL'],
    ['timeline', 'Timeline'],
  ];
  const items = views.map(([key, label]) => {
    const n = rows.filter(filterFns[key]).length;
    const isActive = key === activeView;
    return `<li><a href="/builds?view=${key}" class="${isActive ? 'is-active' : ''}"><span>${label}</span><span class="sb-count">${n}</span></a></li>`;
  }).join('');
  const gradeItems = ['A','B','C','D','F'].map(g => {
    const n = rows.filter(r => r.grade?.letter === g).length;
    if (n === 0) return '';
    const isActive = g === activeGrade;
    return `<li><a href="/builds?view=graded&grade=${g}" class="grade-${g} ${isActive ? 'is-active' : ''}"><span>Grade ${g}</span><span class="sb-count">${n}</span></a></li>`;
  }).filter(Boolean).join('');
  const catMap = new Map();
  for (const r of rows) catMap.set(r.category, (catMap.get(r.category) || 0) + 1);
  const topCats = [...catMap.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10);
  const catItems = topCats.map(([cat, n]) => {
    const isActive = cat === activeCat;
    return `<li><a href="/builds?cat=${encodeURIComponent(cat)}" class="${isActive ? 'is-active' : ''}"><span>${esc(cat)}</span><span class="sb-count">${n}</span></a></li>`;
  }).join('');
  return `<aside class="sidebar">
    <div class="sb-section"><div class="sb-h">★ Views</div><ul>${items}</ul></div>
    <div class="sb-section"><div class="sb-h">★ Grade</div><ul>${gradeItems || '<li class="sb-empty">no grades</li>'}</ul></div>
    <div class="sb-section"><div class="sb-h">★ Categories</div><ul>${catItems}</ul></div>
    <div class="sb-section"><div class="sb-h">★ Starred · Local</div><ul id="starred-list"><li class="sb-empty">no starred yet</li></ul></div>
    <div class="sb-section"><div class="sb-h">★ Quick Open</div><ul>
      <li><a href="/" target="_blank" rel="noopener noreferrer">/  Salon Directory</a></li>
      <li><a href="/admin/enrichment" target="_blank" rel="noopener noreferrer">/admin/enrichment</a></li>
      <li><a href="/near" target="_blank" rel="noopener noreferrer">/near drive map</a></li>
      <li><a href="/press" target="_blank" rel="noopener noreferrer">/press feed</a></li>
      <li><a href="/healthz" target="_blank" rel="noopener noreferrer">/healthz JSON</a></li>
    </ul></div>
  </aside>`;
}

export function renderBuildsIndex({ builds, allBuilds = null, activeView = 'all', activeGrade = '', activeCat = '' } = {}) {
  // allBuilds is the FULL list (used for sidebar + stats). builds is the
  // filtered slice for the current view. Older callers pass only `builds`.
  const fullList = allBuilds || builds;
  const groups = new Map();
  for (const b of builds) {
    if (!groups.has(b.category)) groups.set(b.category, []);
    groups.get(b.category).push(b);
  }
  // Top rail: highest-scoring 12 projects regardless of letter — gives a stable
  // "what's worth attention" surface even when the A/B band is sparse. Tiebreak
  // by recency so an A from 2024 doesn't outrank a B from yesterday.
  const topRail = [...builds]
    .filter(b => b.grade)
    .sort((a, b) => (b.grade.score - a.grade.score) || ((b.last_at ? Date.parse(b.last_at) : 0) - (a.last_at ? Date.parse(a.last_at) : 0)))
    .slice(0, 12);
  // Full grid: every build, sorted by grade score desc → last touched.
  const fullGrid = [...builds].sort((a, b) => {
    const ag = a.grade?.score || -1; const bg = b.grade?.score || -1;
    if (bg !== ag) return bg - ag;
    return (b.last_at ? Date.parse(b.last_at) : 0) - (a.last_at ? Date.parse(a.last_at) : 0);
  });
  // Render newest categories first (by max last_at among children).
  const ordered = [...groups.entries()].sort((a, b) => {
    const am = Math.max(...a[1].map(r => r.last_at ? Date.parse(r.last_at) : 0));
    const bm = Math.max(...b[1].map(r => r.last_at ? Date.parse(r.last_at) : 0));
    return bm - am;
  });

  const sections = ordered.map(([cat, rows]) => {
    const tbody = rows.map(r => {
      const search = [r.name, r.description, r.category].join(' ').toLowerCase();
      const lastTs = r.last_at ? Date.parse(r.last_at) : 0;
      const pm2Status = r.pm2?.status === 'online' ? '1' : (r.pm2 ? '0' : '');
      const descTrunc = esc(r.description || '').slice(0, 240) + ((r.description||'').length > 240 ? '…' : '');
      const descBlock = r.description
        ? `<div style="line-height:1.45">${descTrunc}</div>${r.description_source === 'package' ? '<div style="font-size:10px;color:#999;margin-top:4px">★ from package.json</div>' : ''}`
        : `<div style="color:#aaa;font-style:italic">no description</div>`;
      return `<tr data-row data-search="${esc(search)} ${esc(r.pm2?.name||'')} ${esc(r.pm2?.status||'')}" data-name="${esc(r.name.toLowerCase())}" data-cat="${esc(r.category)}" data-last="${lastTs}" data-desc="${esc((r.description||'').toLowerCase())}" data-pm2="${pm2Status}">
        <td class="col-name">
          <div style="font-family:'Archivo Black',sans-serif;font-size:14px;text-transform:uppercase;letter-spacing:-.01em">${esc(r.name)}</div>
          <div style="font-size:10px;color:#888;font-family:'Space Mono',monospace;margin-top:2px">~/Projects/${esc(r.name)}</div>
          <div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:6px">
            ${r.has_git ? '<div style="display:inline-block;background:#000;color:#fff;font-size:10px;font-weight:700;padding:1px 6px;letter-spacing:.04em;text-transform:uppercase">★ git</div>' : ''}
            ${pm2Badge(r.pm2)}
          </div>
        </td>
        <td class="col-cat">
          <div style="display:flex;flex-direction:column;gap:4px;align-items:flex-start">
            <span class="cat-tag">${esc(r.category)}</span>
            ${r.grade ? `<a href="/wa/${esc(r.analysis_slug || r.slug)}" target="_blank" rel="noopener" title="Audit grade ${r.grade.score}/100" style="display:inline-block;background:${r.grade.color};color:#000;padding:1px 7px;font-family:'Archivo Black',sans-serif;font-size:14px;line-height:1;letter-spacing:.04em;text-decoration:none;border:2px solid #000">${esc(r.grade.letter)}</a>` : ''}
          </div>
        </td>
        <td class="col-desc">${descBlock}</td>
        <td class="col-last">${fmtDate(r.last_at)}</td>
        <td class="col-open" style="min-width:240px;line-height:1.9">${actionButtons(r)}</td>
        <td class="col-analyze">${analyzeBtn(r)}</td>
      </tr>`;
    }).join('');
    // Open by default only the highest-signal categories (≤8 rows AND has at
    // least one row with a real URL or pm2 process) — keeps initial paint compact.
    const hasSignal = rows.some(r => r.url || r.pm2 || r.has_git);
    const autoOpen = rows.length <= 8 && hasSignal;
    return `<details ${autoOpen ? 'open' : ''}>
      <summary><span>★ ${esc(cat.toUpperCase())}</span><span class="count">${rows.length}</span></summary>
      <table>
        <thead><tr>
          <th class="sortable col-name" data-key="name">Name</th>
          <th class="sortable col-cat" data-key="cat">Cat</th>
          <th class="col-desc">Description</th>
          <th class="sortable col-last" data-key="last" data-dir="desc">Last Worked</th>
          <th class="col-open">Open</th>
          <th class="col-analyze">Analyze</th>
        </tr></thead>
        <tbody>${tbody}</tbody>
      </table>
    </details>`;
  }).join('');

  // Build the active-view banner (e.g. "Showing: Active · pm2 online · 12 of 142")
  const viewLabels = { all:'All Builds', active:'Active · pm2 online', today:'Touched today', week:'Touched this week', stale:'Stale 30d+', graded:'Graded only', ungraded:'Ungraded', broken:'pm2 Broken', url:'Has Public URL', timeline:'Timeline · newest first' };
  const viewLabel = viewLabels[activeView] || 'All Builds';
  const isFiltered = activeView !== 'all' || activeGrade || activeCat;
  const viewBanner = isFiltered
    ? `<div class="view-banner">
        <div>
          <div class="vb-title">★ ${esc(viewLabel)}${activeGrade ? ` · Grade ${esc(activeGrade)}` : ''}${activeCat ? ` · Category ${esc(activeCat)}` : ''}</div>
          <div class="vb-meta">${builds.length} of ${fullList.length} builds</div>
        </div>
        <a href="/builds">★ Clear filter →</a>
      </div>`
    : '';

  const html = HEAD + `
<h1>★ ${builds.length} <mark>BUILDS</mark>.</h1>
<div class="lead">★ EVERY DIRECTORY UNDER ~/PROJECTS — DESCRIPTIONS FROM MEMORY.md — DATES FROM GIT LOG ★</div>
${statsBanner(fullList)}
${viewBanner}
<div class="controls">
  <input id="filter" type="search" placeholder="★ FILTER BY NAME, DESCRIPTION, CATEGORY, PM2, GRADE..." autocomplete="off">
  <select id="sort-by" style="padding:14px 18px;border:4px solid #000;font-family:'Archivo Black',sans-serif;font-size:13px;text-transform:uppercase;letter-spacing:.04em;background:#fff;cursor:pointer">
    <option value="grade-score-desc">★ SORT: GRADE</option>
    <option value="audit-score-desc">★ AUDIT SCORE</option>
    <option value="last-desc">★ MOST RECENT</option>
    <option value="last-asc">★ STALEST</option>
    <option value="name-asc">★ A — Z</option>
  </select>
  <button id="filter-online" type="button">★ PM2 ONLINE</button>
  <button id="filter-git" type="button">★ HAS GIT</button>
  <button id="filter-stale" type="button">★ STALE (D/F)</button>
  <button id="filter-top" type="button">★ TOP (A/B)</button>
  <button id="filter-reset" type="button" title="Clear all filters + sort + chips" style="background:#dc2626;color:#fff;border:4px solid #000">✕ RESET</button>
  <form method="post" action="/api/builds/run-all-deep-dives" style="display:inline;margin:0">
    <button type="submit" title="Run deep-dive synthesis on every analysis missing a recent report">★ RUN ALL DEEP-DIVES</button>
  </form>
  <form id="fix-visible-form" method="post" action="/api/builds/fix-visible" style="display:inline;margin:0">
    <input type="hidden" name="slugs" id="fix-visible-slugs" value="">
    <button type="submit" title="Force-rerun deep-dive on currently-shown cards only">★ FIX VISIBLE</button>
  </form>
  <a href="/api/builds/export.csv" style="display:inline-block;background:#000;color:#facc15;border:4px solid #000;padding:14px 18px;font-family:'Archivo Black',sans-serif;font-size:13px;letter-spacing:.04em;text-transform:uppercase;cursor:pointer;text-decoration:none">★ CSV</a>
  <button id="star-all-visible" type="button" title="Star every currently-visible card">★ STAR ALL VISIBLE</button>
  <form method="post" action="/api/builds/rebuild-mockups" style="display:inline;margin:0" onsubmit="return confirm('Regenerate mockups for all 117+ analyses? Takes ~30s.')">
    <button type="submit" title="Re-run build-all-analyses --rebuild — picks up any theme code changes">★ REBUILD MOCKUPS</button>
  </form>
  <button id="view-grid" type="button" style="background:#facc15;color:#000">★ GRID</button>
  <button id="view-table" type="button">★ TABLE</button>
</div>

<!-- ★ TOP RAIL — A & B grade projects, horizontal scroll -->
<div id="rail-section" style="margin-bottom:24px">
  <div style="font-family:'Archivo Black',sans-serif;font-size:18px;text-transform:uppercase;letter-spacing:-.01em;margin-bottom:10px;display:flex;align-items:center;justify-content:space-between">
    <span>★ TOP RAIL <span style="font-size:11px;font-family:'Space Mono',monospace;color:#666;font-weight:700">★ TOP ${topRail.length} BY SCORE</span></span>
    <div style="display:flex;gap:6px">
      <button class="rail-nav" data-rail="rail" data-dir="-1" type="button" style="background:#000;color:#facc15;border:3px solid #000;padding:6px 12px;font-family:'Archivo Black',sans-serif;font-size:14px;cursor:pointer">←</button>
      <button class="rail-nav" data-rail="rail" data-dir="1" type="button" style="background:#000;color:#facc15;border:3px solid #000;padding:6px 12px;font-family:'Archivo Black',sans-serif;font-size:14px;cursor:pointer">→</button>
    </div>
  </div>
  <div id="rail" style="display:grid;grid-auto-flow:column;grid-auto-columns:240px;gap:12px;overflow-x:auto;scroll-snap-type:x mandatory;scroll-behavior:smooth;padding-bottom:8px">
    ${topRail.length ? topRail.map(renderCard).join('') : '<div style="padding:32px;color:#aaa">★ NO A/B GRADES YET</div>'}
  </div>
</div>

<!-- ★ GRADE RAILS — one per letter A/B/C/D/F, collapsible -->
${(() => {
  const order = ['A','B','C','D','F'];
  const colors = { A:'#22c55e', B:'#84cc16', C:'#facc15', D:'#f97316', F:'#dc2626' };
  const byGrade = new Map(order.map(g => [g, []]));
  builds.filter(b => b.grade).forEach(b => byGrade.get(b.grade.letter)?.push(b));
  byGrade.forEach((arr) => arr.sort((a, b) => (b.grade.score - a.grade.score) || ((b.last_at ? Date.parse(b.last_at) : 0) - (a.last_at ? Date.parse(a.last_at) : 0))));
  return order.map(g => {
    const arr = byGrade.get(g) || [];
    if (arr.length === 0) return '';
    const railId = 'rail-grade-' + g;
    return `<details class="grade-rail" ${g === 'A' || g === 'B' ? 'open' : ''}>
      <summary style="background:${colors[g]}"><span>★ GRADE ${g} <span style="font-family:'Space Mono',monospace;font-size:11px;font-weight:700;margin-left:8px">★ ${arr.length} BUILDS</span></span><span><button class="rail-nav" data-rail="${railId}" data-dir="-1" type="button" style="background:#000;color:#facc15;border:0;padding:3px 9px;font-family:'Archivo Black',sans-serif;font-size:11px;cursor:pointer;margin-right:4px">←</button><button class="rail-nav" data-rail="${railId}" data-dir="1" type="button" style="background:#000;color:#facc15;border:0;padding:3px 9px;font-family:'Archivo Black',sans-serif;font-size:11px;cursor:pointer">→</button></span></summary>
      <div id="${railId}" class="grade-rail-body">
        ${arr.map(renderCard).join('')}
      </div>
    </details>`;
  }).join('');
})()}

<!-- ★ DENSITY SLIDER -->
<div id="grid-controls" style="display:flex;align-items:center;gap:14px;margin-bottom:14px;flex-wrap:wrap">
  <span style="font-family:'Archivo Black',sans-serif;font-size:13px;text-transform:uppercase;letter-spacing:.04em">★ COLUMNS</span>
  <input id="cols" type="range" min="4" max="12" step="1" value="6" style="flex:1;min-width:200px;max-width:400px;accent-color:#000">
  <span id="cols-label" style="font-family:'Archivo Black',sans-serif;font-size:24px;background:#facc15;color:#000;padding:2px 12px;border:3px solid #000;min-width:48px;text-align:center">6</span>
  <span id="grid-count" style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:#666">${fullGrid.length} CARDS</span>
</div>

<!-- ★ FULL GRID — every project, scaled by slider -->
<div id="grid" style="display:grid;grid-template-columns:repeat(6, 1fr);gap:8px;margin-bottom:32px">
  ${fullGrid.map(renderCard).join('')}
</div>

<!-- ★ LEGACY TABLE VIEW (toggle) -->
<div id="table-view" style="display:none">
  ${sections || '<div class="empty">★ NO BUILDS FOUND</div>'}
</div>

<!-- ★ DETAIL DRAWER (PEEK button per card opens this) -->
<div id="drawer-bd" class="drawer-backdrop" onclick="window.closePeek()"></div>
<aside id="drawer" class="drawer" aria-hidden="true">
  <div style="position:absolute;top:14px;left:14px;display:flex;gap:6px">
    <button class="drawer-nav" id="drawer-prev" onclick="window.peekStep(-1)" title="← previous visible card" style="background:#000;color:#facc15;border:0;padding:6px 12px;font-family:'Archivo Black',sans-serif;font-size:14px;cursor:pointer">←</button>
    <button class="drawer-nav" id="drawer-next" onclick="window.peekStep(1)" title="next visible card →" style="background:#000;color:#facc15;border:0;padding:6px 12px;font-family:'Archivo Black',sans-serif;font-size:14px;cursor:pointer">→</button>
  </div>
  <button class="drawer-close" onclick="window.closePeek()">CLOSE ✕</button>
  <div id="drawer-body" style="padding-top:48px;font-family:'Space Mono',monospace">★ Loading…</div>
</aside>

<!-- ★ Per-card action bar (only on the focused selected card via JS) -->
<div id="card-actions-pop" style="display:none;position:fixed;bottom:0;left:0;right:0;background:#000;color:#facc15;border-top:4px solid #facc15;padding:14px 20px;z-index:99;font-family:'Space Mono',monospace;font-weight:700;font-size:12px;flex-wrap:wrap;gap:8px;align-items:center">
  <span id="cap-name" style="font-family:'Archivo Black',sans-serif;font-size:14px;text-transform:uppercase;margin-right:auto">—</span>
  <span id="cap-actions"></span>
  <button type="button" id="cap-close" style="background:#dc2626;color:#fff;border:2px solid #fff;padding:5px 12px;font-family:'Archivo Black',sans-serif;font-size:11px;cursor:pointer">CLOSE</button>
</div>

<script>
// ===== Click-to-pop action bar with all per-card buttons =====
// Cards remain quick-clickable (default action), but a long-press / right-click
// or a "more" affordance opens the bottom action bar with VS Code, Copy Path,
// GitHub, Logs, Star, etc. We pre-attach the buttons inline at server render so
// they don't need a round-trip.
(() => {
  const cap = document.getElementById('card-actions-pop');
  const capName = document.getElementById('cap-name');
  const capActions = document.getElementById('cap-actions');
  document.getElementById('cap-close')?.addEventListener('click', () => { cap.style.display = 'none'; });

  // Star state in localStorage. Updates the sidebar list + button class.
  const STAR_KEY = 'agentabrams.builds.starred';
  function loadStars() { try { return new Set(JSON.parse(localStorage.getItem(STAR_KEY) || '[]')); } catch { return new Set(); } }
  function saveStars(s) { localStorage.setItem(STAR_KEY, JSON.stringify([...s])); }
  function refreshStarredSidebar() {
    const s = loadStars();
    const ul = document.getElementById('starred-list');
    if (!ul) return;
    if (s.size === 0) { ul.innerHTML = '<li class="sb-empty">no starred yet</li>'; return; }
    ul.innerHTML = [...s].slice(0, 20).map(slug =>
      '<li><a href="#" data-jump-to="' + slug + '"><span>' + slug + '</span><span class="sb-count">★</span></a></li>'
    ).join('');
    ul.querySelectorAll('a[data-jump-to]').forEach(a => a.addEventListener('click', e => {
      e.preventDefault();
      const slug = a.dataset.jumpTo;
      const card = document.querySelector('a.bcard[data-name="' + slug.toLowerCase() + '"]');
      if (card) card.scrollIntoView({ behavior:'smooth', block:'center' });
    }));
  }
  refreshStarredSidebar();

  document.body.addEventListener('click', (e) => {
    const star = e.target.closest('button[data-star]');
    if (star) {
      e.preventDefault(); e.stopPropagation();
      const id = star.dataset.star;
      const stars = loadStars();
      if (stars.has(id)) { stars.delete(id); star.classList.remove('is-starred'); }
      else                { stars.add(id);    star.classList.add('is-starred'); }
      saveStars(stars);
      refreshStarredSidebar();
      return;
    }
    const copy = e.target.closest('button[data-copy]');
    if (copy) {
      e.preventDefault(); e.stopPropagation();
      const v = copy.dataset.copy;
      navigator.clipboard?.writeText(v).then(() => {
        copy.classList.add('is-copied');
        const old = copy.textContent;
        copy.textContent = '✓ Copied';
        setTimeout(() => { copy.textContent = old; copy.classList.remove('is-copied'); }, 1400);
      });
      return;
    }
  });

  // Mark already-starred buttons on initial paint.
  const stars = loadStars();
  document.querySelectorAll('button[data-star]').forEach(btn => {
    if (stars.has(btn.dataset.star)) btn.classList.add('is-starred');
  });

  // Keyboard shortcuts
  // /  → focus search
  // g a → /builds?view=active
  // g t → /builds?view=today
  // g g → /builds?view=graded
  // g s → /builds?view=stale
  let prefix = null;
  let prefixTimer = null;
  document.addEventListener('keydown', (e) => {
    const tag = e.target?.tagName?.toLowerCase();
    if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
    if (e.metaKey || e.ctrlKey || e.altKey) return;
    if (e.key === '/') {
      e.preventDefault();
      document.getElementById('filter')?.focus();
      return;
    }
    if (e.key === 'g' && !prefix) {
      prefix = 'g';
      clearTimeout(prefixTimer);
      prefixTimer = setTimeout(() => { prefix = null; }, 1000);
      return;
    }
    if (prefix === 'g') {
      const targets = { a:'active', t:'today', w:'week', g:'graded', s:'stale', b:'broken', u:'url', l:'timeline' };
      if (targets[e.key]) {
        location.href = '/builds?view=' + targets[e.key];
        return;
      }
      prefix = null;
    }
  });
})();
</script>

` + FOOT;
  // Substitute the sidebar placeholder with the rendered nav.
  return html.replace('__SIDEBAR_PLACEHOLDER__', sidebarNav(fullList, activeView, activeGrade, activeCat));
}