From e680867f8b9fe00fff8819b93e93d1f2a15718d1 Mon Sep 17 00:00:00 2001 From: Michael Pilosov Date: Wed, 22 Apr 2026 14:19:26 -0600 Subject: [PATCH] compare: selection UX on runs list + /compare placeholder page - per-run checkbox when embedding HTML exists; cap at 2 selected - sticky 'compare selected' button opens /compare?a=&b= in new tab - selection state persists across the 3s htmx poll via a Set keyed by stem - /compare stub validates stems, renders scaffolding (three.js UI next) --- app/web/main.py | 12 ++++++ app/web/static/compare-select.js | 64 ++++++++++++++++++++++++++++++++ app/web/static/style.css | 57 +++++++++++++++++++++++++++- app/web/templates/_runs.html | 5 +++ app/web/templates/compare.html | 47 +++++++++++++++++++++++ app/web/templates/index.html | 10 ++++- 6 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 app/web/static/compare-select.js create mode 100644 app/web/templates/compare.html diff --git a/app/web/main.py b/app/web/main.py index c1aae6c..9b1c092 100644 --- a/app/web/main.py +++ b/app/web/main.py @@ -833,6 +833,18 @@ async def run_frames(stem: str) -> Response: return Response(content=payload, media_type="application/json") +@app.get("/compare", response_class=HTMLResponse) +async def compare_page(request: Request, a: str = "", b: str = "") -> HTMLResponse: + for label, stem in (("a", a), ("b", b)): + if not stem or not _STEM_RE.match(stem): + raise HTTPException(400, f"missing or malformed stem for {label!r}") + if not (FIGS_DIR / f"{stem}.html").is_file(): + raise HTTPException(404, f"no such run: {stem}") + return templates.TemplateResponse( + request, "compare.html", {"stem_a": a, "stem_b": b} + ) + + @app.get("/health") async def health() -> JSONResponse: async with httpx.AsyncClient(timeout=3.0) as client: diff --git a/app/web/static/compare-select.js b/app/web/static/compare-select.js new file mode 100644 index 0000000..31ea4b0 --- /dev/null +++ b/app/web/static/compare-select.js @@ -0,0 +1,64 @@ +// Manages run-comparison selection on the runs list. +// HTMX re-renders #runs-slot every 3s, so we keep state in a Set outside +// the polled region and re-apply checked state on every afterSwap. + +(function () { + const MAX = 2; + const selected = new Set(); + + const btn = document.getElementById('compare-btn'); + const countEl = document.getElementById('compare-count'); + const slot = document.getElementById('runs-slot'); + if (!btn || !countEl || !slot) return; + + function refreshButton() { + const n = selected.size; + countEl.textContent = `(${n}/${MAX})`; + btn.disabled = n !== MAX; + } + + function applyToDOM() { + const checkboxes = slot.querySelectorAll('.compare-cb'); + // Drop any selected stems that are no longer in the DOM (run aged out of list) + const present = new Set(); + checkboxes.forEach((cb) => present.add(cb.dataset.stem)); + for (const s of [...selected]) if (!present.has(s)) selected.delete(s); + + const atCap = selected.size >= MAX; + checkboxes.forEach((cb) => { + const stem = cb.dataset.stem; + cb.checked = selected.has(stem); + cb.disabled = atCap && !cb.checked; + }); + refreshButton(); + } + + slot.addEventListener('change', (e) => { + const cb = e.target; + if (!cb.matches || !cb.matches('.compare-cb')) return; + const stem = cb.dataset.stem; + if (cb.checked) { + if (selected.size >= MAX) { + cb.checked = false; + return; + } + selected.add(stem); + } else { + selected.delete(stem); + } + applyToDOM(); + }); + + document.body.addEventListener('htmx:afterSwap', (e) => { + if (e.target && e.target.id === 'runs-slot') applyToDOM(); + }); + + btn.addEventListener('click', () => { + if (selected.size !== MAX) return; + const [a, b] = [...selected]; + const url = `/compare?a=${encodeURIComponent(a)}&b=${encodeURIComponent(b)}`; + window.open(url, '_blank', 'noopener'); + }); + + applyToDOM(); +})(); diff --git a/app/web/static/style.css b/app/web/static/style.css index 9d8ef76..46226ae 100644 --- a/app/web/static/style.css +++ b/app/web/static/style.css @@ -456,10 +456,63 @@ button.submit:disabled { background: var(--faint); border-color: var(--faint); c border-bottom: 1px solid var(--rule); padding: 0.85rem 0 0.85rem; display: grid; - grid-template-columns: 5.5rem 1fr; - column-gap: 1rem; + grid-template-columns: 1.25rem 5.5rem 1fr; + column-gap: 0.6rem; align-items: start; } +.runs li.run > .compare-cb, +.runs li.run > .compare-cb-slot { + width: 1rem; + height: 1rem; + margin: 3px 0 0 0; + align-self: start; + cursor: pointer; + accent-color: var(--accent); +} +.runs li.run > .compare-cb-slot { + visibility: hidden; + cursor: default; +} +.runs li.run > .compare-cb:disabled { + cursor: not-allowed; + opacity: 0.35; +} + +.compare-bar { + display: flex; + align-items: center; + gap: 0.7rem; + margin-bottom: 0.5rem; + flex-wrap: wrap; +} +.compare-bar button { + font: inherit; + font-size: 0.82rem; + background: transparent; + border: 1px solid var(--rule); + color: var(--ink); + padding: 0.3rem 0.7rem; + cursor: pointer; + transition: background 100ms ease, border-color 100ms ease; +} +.compare-bar button:not(:disabled):hover { + background: var(--accent-tint); + border-color: var(--accent); +} +.compare-bar button:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.compare-bar #compare-count { + font-family: var(--mono); + font-size: 0.76rem; + color: var(--faint); + margin-left: 0.25rem; +} +.compare-hint { + font-size: 0.76rem; + font-style: italic; +} .runs li.run.just-submitted { background: linear-gradient(to right, var(--accent-tint), transparent 60%); padding-left: 0.55rem; diff --git a/app/web/templates/_runs.html b/app/web/templates/_runs.html index 689419c..bf6d426 100644 --- a/app/web/templates/_runs.html +++ b/app/web/templates/_runs.html @@ -8,6 +8,11 @@