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)
This commit is contained in:
Michael Pilosov 2026-04-22 14:19:26 -06:00
parent b016dbdaee
commit e680867f8b
6 changed files with 192 additions and 3 deletions

View File

@ -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:

View File

@ -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();
})();

View File

@ -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;

View File

@ -8,6 +8,11 @@
<ul class="runs">
{% for r in runs %}
<li class="run {% if just_submitted is defined and r.id == just_submitted %}just-submitted{% endif %}">
{% if r.emb_exists %}
<input type="checkbox" class="compare-cb" data-stem="{{ r.emb_file[:-5] }}" aria-label="select run for comparison" />
{% else %}
<span class="compare-cb-slot" aria-hidden="true"></span>
{% endif %}
<div class="stamp">
{% if r.runtime %}<span class="rt">{{ r.runtime }}</span>{% endif %}
{% if r.start %}{{ r.start[:10] }}<br/>{{ r.start[11:19] }}{% else %}&nbsp;{% endif %}

View File

@ -0,0 +1,47 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>embedding notebook &middot; compare</title>
<link rel="stylesheet" href="/static/style.css?v=20" />
<script>
(function(){try{
var t=localStorage.getItem('theme');
if(!t)t=matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';
document.documentElement.setAttribute('data-theme',t);
}catch(e){}})();
</script>
</head>
<body>
<header class="masthead">
<div>
<h1 class="title">embedding notebook <em>&mdash; compare</em></h1>
</div>
<div class="meta">
<a href="/" class="masthead-link">&larr; runs</a>
<button type="button" class="theme-toggle" id="theme-toggle" aria-label="toggle theme">&#9680;</button>
</div>
</header>
<section style="padding: 2rem 1.2rem; max-width: 72ch; margin: 0 auto; font-family: var(--serif);">
<p style="color: var(--mute);">
Comparison page &mdash; scaffolding. Two runs selected:
</p>
<ul style="font-family: var(--mono); font-size: 0.85rem;">
<li><strong>A</strong> &middot; {{ stem_a }} &middot;
<a href="/api/runs/{{ stem_a }}/frames.json" target="_blank">frames.json</a> &middot;
<a href="/figs/{{ stem_a }}.html" target="_blank">original plotly</a></li>
<li><strong>B</strong> &middot; {{ stem_b }} &middot;
<a href="/api/runs/{{ stem_b }}/frames.json" target="_blank">frames.json</a> &middot;
<a href="/figs/{{ stem_b }}.html" target="_blank">original plotly</a></li>
</ul>
<p style="color: var(--faint); font-size: 0.85rem; font-style: italic;">
Three.js side-by-side animation UI will render here (next phase).
</p>
</section>
<script src="/static/theme.js?v=11"></script>
</body>
</html>

View File

@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>embedding notebook</title>
<link rel="stylesheet" href="/static/style.css?v=19" />
<link rel="stylesheet" href="/static/style.css?v=20" />
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script type="importmap">
{
@ -301,6 +301,13 @@
</span>
</div>
<div class="compare-bar">
<button type="button" id="compare-btn" disabled>
compare selected <span id="compare-count">(0/2)</span>
</button>
<span class="compare-hint muted">pick two embeddings &rarr; side-by-side animation in a new tab</span>
</div>
<div
id="runs-slot"
hx-get="/runs"
@ -400,6 +407,7 @@
<script src="/static/theme.js?v=11"></script>
<script type="module" src="/static/dataset-picker.js?v=11"></script>
<script type="module" src="/static/metrics.js?v=11"></script>
<script src="/static/compare-select.js?v=1"></script>
<script>
// Anchor-links alone don't expand <details>; force it.
document.querySelector('a[href="#metrics"]')?.addEventListener('click', () => {