← 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));
}