← back to Gallery Agentabrams
index.html
238 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>gallery.agentabrams.com — Builds</title>
<meta name="description" content="A gallery of Agent Abrams builds." />
<style>
:root {
--bg:#0c0d11; --panel:#14161d; --line:#262a35; --line-soft:#1c1f27;
--ink:#e9ebf2; --dim:#9aa0ad; --faint:#666c78; --accent:#5eead4;
--cols: 3;
--sans: ui-sans-serif, "Helvetica Neue", Arial, system-ui, sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, monospace;
}
* { box-sizing:border-box; }
html,body { margin:0; }
body { background:var(--bg); color:var(--ink); font-family:var(--sans); }
a { color:inherit; }
header.top {
display:flex; align-items:baseline; gap:1rem; flex-wrap:wrap;
padding:1.5rem clamp(1rem,4vw,2.5rem) .4rem;
}
.brand { font-family:var(--mono); font-size:1rem; letter-spacing:.14em; font-weight:700; }
.brand .dot { color:var(--accent); }
.brand .sub { color:var(--faint); font-weight:400; letter-spacing:.08em; }
header.top .count { margin-left:auto; color:var(--faint); font-size:.78rem; letter-spacing:.1em; font-family:var(--mono); }
.tagline { padding:0 clamp(1rem,4vw,2.5rem); color:var(--dim); font-size:.9rem; max-width:60ch; }
/* controls: sort + density (persisted) */
.controls {
display:flex; align-items:center; gap:1.2rem; flex-wrap:wrap;
padding:1.2rem clamp(1rem,4vw,2.5rem) 1.4rem;
border-bottom:1px solid var(--line-soft);
}
.controls label { font-size:.7rem; letter-spacing:.14em; text-transform:uppercase; color:var(--faint); margin-right:.5rem; }
.controls select {
font-family:var(--sans); font-size:.85rem; background:var(--panel); color:var(--ink);
border:1px solid var(--line); border-radius:4px; padding:.4rem .6rem;
}
.controls .density { display:flex; align-items:center; gap:.6rem; }
.controls input[type=range] { accent-color:var(--accent); width:130px; }
.grid {
display:grid; gap:1.2rem;
grid-template-columns:repeat(var(--cols), 1fr);
padding:1.6rem clamp(1rem,4vw,2.5rem) 4rem;
}
.card {
position:relative; background:var(--panel);
border:1px solid var(--line-soft); border-radius:10px; overflow:hidden;
display:flex; flex-direction:column;
transition:border-color .2s, transform .2s;
}
.card:hover { border-color:var(--line); transform:translateY(-2px); }
.card .cover { position:relative; aspect-ratio:16/9; background:#000; overflow:hidden; }
.card .cover video, .card .cover img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; display:block; }
.card .cover img { z-index:1; }
.card .cover video { z-index:2; opacity:0; transition:opacity .4s; }
.card.playing .cover video { opacity:1; }
.card .num {
position:absolute; top:.6rem; left:.6rem; z-index:3;
font-family:var(--mono); font-size:.7rem; letter-spacing:.12em;
background:rgba(0,0,0,.65); color:var(--accent);
border:1px solid rgba(94,234,212,.35); border-radius:4px; padding:.2rem .5rem;
}
.card .body { padding:.9rem 1rem 1.1rem; display:flex; flex-direction:column; gap:.5rem; flex:1; }
.card h2 { margin:0; font-size:1.08rem; line-height:1.2; }
.card .desc { margin:0; font-size:.82rem; color:var(--dim); line-height:1.5; }
.card .tags { display:flex; flex-wrap:wrap; gap:.35rem; margin-top:.1rem; }
.card .tag { font-size:.64rem; letter-spacing:.06em; text-transform:uppercase; color:var(--dim);
border:1px solid var(--line); border-radius:20px; padding:.15rem .5rem; }
.card .meta { display:flex; align-items:center; gap:.6rem; margin-top:auto; padding-top:.4rem; }
.card .when { font-family:var(--mono); font-size:.68rem; color:var(--faint); }
.card .actions { display:flex; gap:.4rem; margin-left:auto; }
.card .actions button {
font-family:var(--sans); font-size:.74rem; cursor:pointer;
background:transparent; color:var(--ink); border:1px solid var(--line);
border-radius:5px; padding:.32rem .6rem; transition:background .2s,border-color .2s;
}
.card .actions button:hover { border-color:var(--accent); }
.card .actions .open { background:var(--accent); color:#08110f; border-color:var(--accent); font-weight:600; }
.empty { padding:4rem; text-align:center; color:var(--faint); }
/* preview overlay */
.overlay { position:fixed; inset:0; z-index:100; background:rgba(0,0,0,.82);
display:none; flex-direction:column; }
.overlay.open { display:flex; }
.overlay .obar { display:flex; align-items:center; gap:1rem; padding:.7rem 1rem; background:var(--panel); border-bottom:1px solid var(--line); }
.overlay .obar .t { font-size:.9rem; font-weight:600; }
.overlay .obar .sp { flex:1; }
.overlay .obar button, .overlay .obar a {
font-size:.78rem; cursor:pointer; text-decoration:none;
background:transparent; color:var(--ink); border:1px solid var(--line);
border-radius:5px; padding:.35rem .7rem;
}
.overlay .obar .open { background:var(--accent); color:#08110f; border-color:var(--accent); font-weight:600; }
.overlay iframe { flex:1; width:100%; border:0; background:#000; }
:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
@media (max-width:900px){ :root{ --cols:2; } }
@media (max-width:560px){ :root{ --cols:1; } .controls{ gap:.8rem; } }
</style>
</head>
<body>
<header class="top">
<div class="brand"><span class="dot">◆</span> GALLERY <span class="sub">· agentabrams.com</span></div>
<div class="count" id="count"></div>
</header>
<p class="tagline">Finished builds — self-contained, standalone, and openable in their own window.</p>
<div class="controls">
<div><label for="sort">Sort</label>
<select id="sort">
<option value="num-asc">Build № ↑</option>
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="title">Title A→Z</option>
</select>
</div>
<div class="density"><label for="density">Density</label>
<input id="density" type="range" min="1" max="5" step="1" value="3" aria-label="Grid density" />
</div>
</div>
<main class="grid" id="grid" aria-live="polite"></main>
<!-- preview overlay -->
<div class="overlay" id="overlay" role="dialog" aria-modal="true" aria-label="Build preview">
<div class="obar">
<span class="t" id="ovTitle"></span>
<span class="sp"></span>
<a class="open" id="ovOpen" target="_blank" rel="noopener">Open in own window ↗</a>
<button id="ovClose">Close ✕</button>
</div>
<iframe id="ovFrame" title="Build preview" allow="autoplay; fullscreen"></iframe>
</div>
<script>
(function(){
"use strict";
const $ = s => document.querySelector(s);
const grid = $("#grid"), countEl = $("#count");
let builds = [];
fetch("builds.json").then(r=>r.json()).then(d=>{
builds = (d.builds||[]).slice();
countEl.textContent = builds.length + (builds.length===1?" build":" builds");
restoreControls();
render();
if (location.hash) openPreview(location.hash.slice(1));
}).catch(()=>{ grid.innerHTML = '<p class="empty">Could not load builds.json</p>'; });
const fmtDate = s => { try { return new Date(s+"T12:00:00").toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'});} catch(e){ return s; } };
function sorted(){
const s = $("#sort").value;
const a = builds.slice();
if (s==="num-asc") a.sort((x,y)=>x.num-y.num);
else if (s==="newest") a.sort((x,y)=>(y.added||"").localeCompare(x.added||""));
else if (s==="oldest") a.sort((x,y)=>(x.added||"").localeCompare(y.added||""));
else if (s==="title") a.sort((x,y)=>x.title.localeCompare(y.title));
return a;
}
function render(){
const a = sorted();
grid.innerHTML = a.map(b => `
<article class="card" data-id="${b.id}">
<div class="cover">
<span class="num">BUILD ${String(b.num).padStart(2,"0")}</span>
${b.cover_poster ? `<img src="${b.cover_poster}" alt="" loading="lazy" />` : ""}
${b.cover_video ? `<video muted loop playsinline preload="none" src="${b.cover_video}"></video>` : ""}
</div>
<div class="body">
<h2>${b.title}</h2>
<p class="desc">${b.desc}</p>
<div class="tags">${(b.tags||[]).map(t=>`<span class="tag">${t}</span>`).join("")}</div>
<div class="meta">
<span class="when" title="Added ${b.added}">🕓 ${fmtDate(b.added)}</span>
<span class="actions">
<button class="preview" data-id="${b.id}">Preview</button>
<button class="open" data-open="${b.path}">Open ↗</button>
</span>
</div>
</div>
</article>`).join("");
// hover-play the cover video (perf: only load on hover)
grid.querySelectorAll(".card").forEach(card => {
const v = card.querySelector("video");
card.addEventListener("mouseenter", ()=>{ if(v){ card.classList.add("playing"); v.play().catch(()=>{});} });
card.addEventListener("mouseleave", ()=>{ if(v){ card.classList.remove("playing"); v.pause(); } });
});
}
// Open build in its own window (fleet "open-in-own-window" UX)
grid.addEventListener("click", e => {
const openBtn = e.target.closest("[data-open]");
if (openBtn){ window.open(openBtn.dataset.open, "_blank", "noopener"); return; }
const prev = e.target.closest(".preview");
if (prev){ openPreview(prev.dataset.id); }
});
const overlay = $("#overlay"), ovFrame = $("#ovFrame"), ovTitle = $("#ovTitle"), ovOpen = $("#ovOpen");
function openPreview(id){
const b = builds.find(x=>x.id===id); if(!b) return;
ovTitle.textContent = `Build ${String(b.num).padStart(2,"0")} · ${b.title}`;
ovFrame.src = b.path; ovOpen.href = b.path;
overlay.classList.add("open");
history.replaceState(null,"","#"+id);
$("#ovClose").focus();
}
function closePreview(){
overlay.classList.remove("open"); ovFrame.src = "about:blank";
history.replaceState(null,"",location.pathname);
}
$("#ovClose").addEventListener("click", closePreview);
overlay.addEventListener("mousedown", e => { if(e.target===overlay) closePreview(); });
document.addEventListener("keydown", e => { if(e.key==="Escape" && overlay.classList.contains("open")) closePreview(); });
// controls: persist sort + density (Steve's grid rule)
function restoreControls(){
const s = localStorage.getItem("gallery:sort"); if(s) $("#sort").value = s;
const d = localStorage.getItem("gallery:density"); if(d){ $("#density").value = d; document.documentElement.style.setProperty("--cols", d); }
else document.documentElement.style.setProperty("--cols", $("#density").value);
}
$("#sort").addEventListener("change", ()=>{ localStorage.setItem("gallery:sort", $("#sort").value); render(); });
$("#density").addEventListener("input", ()=>{ const v=$("#density").value; document.documentElement.style.setProperty("--cols", v); localStorage.setItem("gallery:density", v); });
})();
</script>
</body>
</html>