| <!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <meta name="robots" content="noindex"> |
| <title>Study Dashboard</title> |
| <style> |
| |
| :root{ |
| --bg:#ffffff; --ink:#1a1a1a; --muted:#6b6b6b; --line:#e3e3e3; --hover:#f4f4f4; |
| --accent:#2457a6; --accent-soft:#eef3fb; --warn:#b4530f; --bad:#b3261e; |
| --font:system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; |
| --mono:ui-monospace, Menlo, Consolas, monospace; |
| color-scheme:light; |
| } |
| @media (prefers-color-scheme: dark){:root{ |
| --bg:#161616; --ink:#ececec; --muted:#a0a0a0; --line:#333333; --hover:#212121; |
| --accent:#8ab4f8; --accent-soft:#1e2a3d; --warn:#f0a868; --bad:#f2b8b5; color-scheme:dark}} |
| [hidden]{display:none!important} |
| html,body{background:var(--bg);color:var(--ink)} |
| body{margin:0;font-family:var(--font);font-size:14px;line-height:1.5} |
| main{max-width:1080px;margin:0 auto;padding:32px 16px 64px;display:flex;flex-direction:column;gap:36px} |
| h1{font-size:22px;font-weight:600;line-height:1.3;margin:0} |
| h2{font-size:16px;font-weight:600;margin:0 0 2px} |
| .eyebrow{font-size:13px;color:var(--muted)} |
| .muted{color:var(--muted)} |
| .mono{font-family:var(--mono);font-size:12.5px} |
| header{display:flex;flex-wrap:wrap;gap:12px 24px;align-items:flex-end;justify-content:space-between} |
| header .meta{display:flex;flex-direction:column;gap:2px;font-size:12.5px;color:var(--muted)} |
| button{font:500 13px var(--font);color:var(--ink);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:6px 12px;cursor:pointer} |
| button:hover{background:var(--hover)} |
| button.primary{background:var(--accent);border-color:var(--accent);color:var(--bg)} |
| input[type=password]{font:14px var(--font);color:var(--ink);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:8px 10px;min-width:0;flex:1} |
| .card{display:flex;flex-direction:column} |
| main > section.card{border-top:1px solid var(--line);padding-top:20px} |
| section > .sub{margin:0 0 14px;color:var(--muted);font-size:13px} |
| .tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px 24px} |
| .tile b{display:block;font-size:28px;font-weight:600;line-height:1.1;margin:4px 0 2px;font-variant-numeric:tabular-nums} |
| .tile .eyebrow{font-size:13px} |
| .tile span.note{font-size:12px;color:var(--muted)} |
| .warnbox{color:var(--warn);border-left:3px solid var(--warn);padding:2px 0 2px 12px;font-size:13px} |
| .err{color:var(--bad)} |
| |
| .lists{display:flex;flex-direction:column;gap:6px} |
| .lrow{display:grid;grid-template-columns:56px 1fr 120px;gap:12px;align-items:center} |
| .track{height:8px;background:var(--hover);border-radius:4px;display:flex;gap:2px;overflow:hidden} |
| .track i{display:block;height:100%;background:var(--accent);border-radius:0 4px 4px 0} |
| .track i.pend{background:var(--accent-soft);border-radius:0 4px 4px 0} |
| .lrow .n{font-size:12.5px;color:var(--muted);text-align:right;font-variant-numeric:tabular-nums} |
| .lists details>summary{list-style:none;cursor:pointer;border-radius:4px} |
| .lists details>summary::-webkit-details-marker{display:none} |
| .lists details>summary:hover{background:var(--hover)} |
| .lists details[open]>summary .mono{font-weight:600} |
| .lcontent{display:flex;flex-direction:column;gap:12px;padding:10px 0 16px 68px} |
| .lcontent h3{font-size:12px;font-weight:500;color:var(--muted);margin:0} |
| .lstims{display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:10px} |
| .lstims figure,.lrank figure{margin:0;display:flex;flex-direction:column;gap:4px;font-size:11.5px} |
| .lstims img,.lrank img{width:100%;aspect-ratio:1;object-fit:cover;border-radius:4px;background:var(--hover)} |
| .lstims figure.check img{outline:2px solid var(--warn);outline-offset:-2px} |
| .lrank{display:flex;flex-direction:column;gap:6px} |
| .lrank .imgs{display:grid;grid-template-columns:repeat(auto-fill,minmax(80px,110px));gap:8px} |
| @media (max-width:600px){.lcontent{padding-left:0}} |
| |
| .tw{overflow-x:auto;margin:0 -4px} |
| table{border-collapse:collapse;width:100%;font-size:13px} |
| th{font-size:12px;font-weight:500;color:var(--muted);text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);white-space:nowrap} |
| th.sortable{cursor:pointer} |
| th.sortable:hover,th.sorted{color:var(--ink)} |
| td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:middle} |
| td.num,th.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap} |
| tr.stim{cursor:pointer} |
| tr.stim:hover td{background:var(--hover)} |
| tr.stim[aria-expanded=true] td{background:var(--accent-soft)} |
| .prompt{font-weight:500} |
| .tag{display:inline-block;font-size:11.5px;padding:1px 6px;border-radius:4px;background:var(--hover);color:var(--muted);white-space:nowrap} |
| .tag.ctrl{color:var(--warn)} |
| .meanbar{display:flex;align-items:center;gap:8px;justify-content:flex-end} |
| .meanbar .t{width:64px;height:6px;background:var(--hover);border-radius:3px;overflow:hidden} |
| .meanbar .t i{display:block;height:100%;background:var(--accent);border-radius:0 3px 3px 0} |
| .hist{display:inline-flex;align-items:flex-end;gap:2px;height:24px} |
| .hist i{display:block;width:8px;background:var(--accent);border-radius:2px 2px 0 0;min-height:1px} |
| .hist i.zero{background:var(--line)} |
| |
| .detail td{background:var(--hover);padding:16px} |
| .detail .wrap{display:flex;flex-wrap:wrap;gap:20px;align-items:flex-start} |
| .stimimg{position:relative;width:280px;max-width:100%;line-height:0;border-radius:4px;overflow:hidden} |
| .stimimg img{width:100%;height:auto} |
| .stimimg .mk{position:absolute;width:12px;height:12px;margin:-6px 0 0 -6px;border-radius:50%;background:#ff7a1a;opacity:.6;box-shadow:0 0 0 2px #ffffff} |
| .detail dl{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;margin:0;font-size:13px;max-width:520px} |
| .detail dt{color:var(--muted)} |
| .detail dd{margin:0} |
| |
| .pairs{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:20px 28px} |
| .pair{display:flex;flex-direction:column;gap:6px} |
| .pair .head{display:flex;justify-content:space-between;align-items:baseline;color:var(--muted);font-size:12.5px} |
| .pm{display:grid;grid-template-columns:1fr 64px 56px;gap:10px;align-items:center;font-size:13px} |
| .pm .t{height:6px;background:var(--hover);border-radius:3px;overflow:hidden} |
| .pm .t i{display:block;height:100%;background:var(--accent);border-radius:0 3px 3px 0} |
| .pm .v{text-align:right;font-size:12.5px;font-variant-numeric:tabular-nums} |
| .fail{color:var(--bad);font-weight:500} |
| .rankings{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:20px 28px} |
| .rset{display:flex;flex-direction:column;gap:8px} |
| .rimgs{display:flex;gap:8px} |
| .rimgs figure{margin:0;flex:1;min-width:0} |
| .rimgs img{width:100%;height:auto;display:block;border-radius:4px} |
| .rimgs figcaption{font-size:12px;margin-top:4px;line-height:1.35} |
| .warn{color:var(--warn)} |
| #login{max-width:400px;margin:14vh auto;padding:0 16px;display:flex;flex-direction:column;gap:12px} |
| #login form{display:flex;gap:8px} |
| @media (max-width:640px){ |
| .lrow{grid-template-columns:48px 1fr 92px} |
| } |
| </style> |
| </head> |
| <body> |
| <div id="login" class="card" hidden> |
| <div class="eyebrow">Partial prompt study</div> |
| <h1>Dashboard</h1> |
| <p class="muted" style="margin:0">Enter the <span class="mono">ADMIN_KEY</span> set on the Space.</p> |
| <form id="loginform"><input type="password" id="key" autocomplete="current-password" placeholder="Admin key" required><button class="primary">Open</button></form> |
| <p id="loginerr" class="err" hidden></p> |
| </div> |
| <main id="app" hidden></main> |
| <script> |
| const $ = (s, el = document) => el.querySelector(s); |
| const esc = v => String(v ?? "").replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); |
| const fmt = (v, d = 2) => v == null ? "–" : (+v).toFixed(d); |
| const pct = v => v == null ? "–" : Math.round(v * 100) + "%"; |
| const FLAG = {attention_check: "Attention check", too_fast: "Many answers under 1 s", same_answers: "Same rating everywhere"}; |
| // "Attention check, Attention check" -> "2 × Attention check" |
| const flagText = flags => Object.entries(flags.reduce((c, f) => (c[f] = (c[f] || 0) + 1, c), {})) |
| .map(([f, n]) => `${n > 1 ? n + " × " : ""}${FLAG[f] || f}`).join(", "); |
| const when = iso => iso ? new Date(iso).toLocaleString(undefined, {month: "short", day: "numeric", hour: "2-digit", minute: "2-digit"}) : "–"; |
| |
| const params = new URLSearchParams(location.search); |
| let key = params.get("key") || ""; |
| try { key = key || sessionStorage.getItem("adminKey") || ""; } catch (e) {} |
| let data = null, sort = {col: "sample_id", dir: 1}, open = new Set(); |
| |
| $("#loginform").onsubmit = e => { e.preventDefault(); key = $("#key").value.trim(); load(); }; |
| |
| async function load(refresh = false) { |
| if (!key) { $("#login").hidden = false; return; } |
| const r = await fetch(`/api/summary?key=${encodeURIComponent(key)}${refresh ? "&refresh=true" : ""}`); |
| if (!r.ok) { |
| const msg = (await r.json().catch(() => ({}))).detail || `HTTP ${r.status}`; |
| try { sessionStorage.removeItem("adminKey"); } catch (e) {} |
| $("#app").hidden = true; $("#login").hidden = false; |
| $("#loginerr").hidden = false; $("#loginerr").textContent = msg; |
| return; |
| } |
| try { sessionStorage.setItem("adminKey", key); } catch (e) {} |
| if (params.has("key")) history.replaceState(null, "", location.pathname); // keep the key out of the address bar |
| data = await r.json(); |
| $("#login").hidden = true; $("#app").hidden = false; |
| render(); |
| } |
| |
| function render() { |
| const d = data, t = d.totals; |
| const maxList = Math.max(1, ...d.lists.map(l => l.completed + l.in_progress)); |
| const spread = Math.max(...d.lists.map(l => l.completed)) - Math.min(...d.lists.map(l => l.completed)); |
| $("#app").innerHTML = ` |
| <header> |
| <div><div class="eyebrow">Partial prompt study · ${esc(d.study_version)}</div><h1>Dashboard</h1></div> |
| <div class="meta"> |
| <span>Stimuli <span class="mono">${esc(d.dataset)}</span></span> |
| <span>Saving to <span class="mono">${esc(d.saving_to)}</span></span> |
| <span>Updated ${when(d.generated_at)} · <button id="refresh">Refresh</button></span> |
| </div> |
| </header> |
| ${d.saving_to.startsWith("local") ? `<div class="warnbox">Responses are not being pushed to the Hub. They will be lost when the Space restarts. Set <span class="mono">HF_TOKEN</span> and <span class="mono">RESPONSES_REPO</span>.</div>` : ""} |
| |
| <div class="tiles"> |
| <div class="tile"><span class="eyebrow">Completed</span><b>${t.completed}</b><span class="note">sessions saved</span></div> |
| <div class="tile"><span class="eyebrow">In progress</span><b>${t.in_progress}</b><span class="note">started in the last 30 min</span></div> |
| <div class="tile"><span class="eyebrow">Excluded</span><b>${t.excluded}</b><span class="note">${t.excluded ? Object.entries(t.excluded_by).map(([f, n]) => `${FLAG[f] || f} ${n}`).join(" · ") : `failed ${t.exclude_at} checks or more`}${t.flagged ? ` · ${t.flagged} more with warnings` : ""}</span></div> |
| <div class="tile"><span class="eyebrow">Median time</span><b>${t.median_minutes == null ? "–" : fmt(t.median_minutes, 1) + " min"}</b><span class="note">per completed session</span></div> |
| </div> |
| |
| <section class="card"> |
| <h2>Progress per list</h2> |
| <p class="sub">${d.lists.length} counterbalanced lists. New participants get the least-filled list. Click a list to see its images and rankings. ${t.completed ? `Gap between the fullest and emptiest list: ${spread}.` : ""}</p> |
| <div class="lists">${d.lists.map(l => `<details> |
| <summary class="lrow" title="List ${l.list_id}: ${l.completed} completed, ${l.in_progress} in progress, ${l.items} items. Click to see its images and rankings."> |
| <span class="mono">List ${l.list_id}</span> |
| <div class="track">${l.completed ? `<i style="width:${l.completed / maxList * 100}%"></i>` : ""}${l.in_progress ? `<i class="pend" style="width:${l.in_progress / maxList * 100}%"></i>` : ""}</div> |
| <span class="n">${l.completed} done${l.in_progress ? ` · ${l.in_progress} live` : ""}</span> |
| </summary> |
| <div class="lcontent"> |
| <h3>${l.stimuli.length} images to rate${l.stimuli.some(s => s.check) ? ` · attention checks outlined` : ""}</h3> |
| <div class="lstims">${l.stimuli.map(s => `<figure class="${s.check ? "check" : ""}" title="${esc(s.prompt)}"> |
| <img src="/stimuli/${s.file_name.split("/").map(encodeURIComponent).join("/")}" alt="" loading="lazy"> |
| <figcaption><span class="mono">${esc(s.sample_id)}</span>${s.check ? ` <span class="tag">check</span>` : ""}<br>${esc(s.prompt)}</figcaption></figure>`).join("")}</div> |
| ${l.rankings.length ? `<h3>${l.rankings.length} rankings</h3>${l.rankings.map(r => `<div class="lrank"> |
| <span><span class="prompt">${esc(r.prompt)}</span> <span class="mono muted">${esc(r.set_id)}</span></span> |
| <div class="imgs">${r.images.map(f => `<figure><img src="/stimuli/${f.split("/").map(encodeURIComponent).join("/")}" alt="" loading="lazy"></figure>`).join("")}</div> |
| </div>`).join("")}` : ""} |
| </div></details>`).join("")} |
| </div> |
| </section> |
| |
| <section class="card"> |
| <h2>By category</h2> |
| <p class="sub">Attention check left out. Rating from 1 (does not match) to 5 (matches). Excluded sessions are left out of every statistic below.</p> |
| <div class="tw"><table> |
| <thead><tr><th>Category</th><th class="num">Ratings</th><th class="num">Mean rating</th><th class="num">“Problem” marked</th></tr></thead> |
| <tbody>${d.categories.map(c => `<tr> |
| <td><span class="tag">${esc(c.category)}</span> ${esc(c.name)}</td> |
| <td class="num">${c.n}</td><td class="num">${meanbar(c.mean)}</td><td class="num">${pct(c.problem_rate)}</td></tr>`).join("")}</tbody> |
| </table></div> |
| </section> |
| |
| <section class="card"> |
| <h2>Minimal pairs</h2> |
| <p class="sub">Members of a pair are seen by different participants. Compare their mean ratings.</p> |
| <div class="pairs">${d.pairs.map(p => `<div class="pair"> |
| <div class="head"><span class="mono">${esc(p.pair_id)}</span></div> |
| ${p.members.map(m => `<div class="pm" title="${esc(m.sample_id)}: n = ${m.n}"> |
| <span><span class="mono">${esc(m.sample_id)}</span> <span class="prompt">${esc(m.prompt)}</span>${m.pair_role ? ` <span class="tag">${esc(m.pair_role)}</span>` : ""}</span> |
| <div class="t">${m.mean != null ? `<i style="width:${(m.mean - 1) / 4 * 100}%"></i>` : ""}</div> |
| <span class="v">${fmt(m.mean)} <span class="muted">n${m.n}</span></span></div>`).join("")} |
| </div>`).join("")}</div> |
| </section> |
| |
| ${d.rankings.length ? `<section class="card"> |
| <h2>Rankings</h2> |
| <p class="sub">Images shown together for one text, ordered by mean rank (1 = favourite). “First” is how often each image was ranked first.</p> |
| <div class="rankings">${d.rankings.map(r => `<div class="rset"> |
| <div class="head"><span class="prompt">${esc(r.prompt)}</span> <span class="muted">${esc(r.set_id)} · n${r.n}</span></div> |
| <div class="rimgs">${r.images.map(im => `<figure title="${esc(im.image_id)}${im.sample_id ? " · " + esc(im.sample_id) : ""}${im.case_id ? " · " + esc(im.case_id) : ""}"> |
| <img src="/stimuli/${im.file_name.split("/").map(encodeURIComponent).join("/")}" alt="" loading="lazy"> |
| <figcaption>${im.n ? `<b>${fmt(im.mean_rank, 1)}</b> <span class="muted">${pct(im.first)} first</span><br>` : ""}<span class="mono muted">${esc(im.sample_id || im.image_id)}</span></figcaption> |
| </figure>`).join("")}</div> |
| </div>`).join("")}</div> |
| </section>` : ""} |
| |
| <section class="card"> |
| <h2>Stimuli</h2> |
| <p class="sub">Click a row to see the image with every click mark. Click a column header to sort.</p> |
| <div class="tw"><table id="stimtable"></table></div> |
| </section> |
| |
| <section class="card"> |
| <h2>Recent sessions</h2> |
| <p class="sub">The last ${d.sessions.length} sessions, newest first.</p> |
| <div class="tw"><table> |
| <thead><tr><th>Received</th><th>Session</th><th class="num">List</th><th class="num">Time</th><th>Source</th><th>English</th><th>T2I use</th><th>Quality</th></tr></thead> |
| <tbody>${d.sessions.map(s => `<tr> |
| <td class="num">${when(s.received_at)}</td><td class="mono">${esc(s.session)}</td><td class="num">${s.list_id}</td> |
| <td class="num">${s.minutes == null ? "–" : fmt(s.minutes, 1) + " min"}</td> |
| <td>${s.prolific ? "Prolific" : "Direct"}</td><td>${esc(s.english)}</td><td>${esc(s.t2i)}</td> |
| <td>${s.excluded ? `<span class="fail">✕ Excluded: ${flagText(s.flags)}</span>` |
| : s.flags.length ? `<span class="warn">⚠ ${flagText(s.flags)}</span>` : "✓ Passed"}</td></tr>`).join("") || `<tr><td colspan="8" class="muted">No sessions yet.</td></tr>`}</tbody> |
| </table></div> |
| </section>`; |
| $("#refresh").onclick = () => load(true); |
| renderStimuli(); |
| } |
| |
| function meanbar(m) { |
| return `<span class="meanbar">${fmt(m)}<span class="t">${m != null ? `<i style="width:${(m - 1) / 4 * 100}%"></i>` : ""}</span></span>`; |
| } |
| |
| const COLS = [ |
| ["sample_id", "ID"], ["prompt", "Prompt"], ["case_id", "Case"], |
| ["n", "n", 1], ["mean", "Mean rating", 1], ["dist", "Ratings 1–5"], ["problem_rate", "Problem", 1], |
| ["median_rt_s", "Median RT", 1], |
| ]; |
| |
| function renderStimuli() { |
| const rows = [...data.stimuli].sort((a, b) => { |
| const x = a[sort.col], y = b[sort.col]; |
| if (x == null) return 1; if (y == null) return -1; |
| return (x > y ? 1 : x < y ? -1 : 0) * sort.dir; |
| }); |
| const tbl = $("#stimtable"); |
| tbl.innerHTML = `<thead><tr>${COLS.map(([k, label, num]) => |
| `<th class="${num ? "num " : ""}${k !== "dist" ? "sortable" : ""}${sort.col === k ? " sorted" : ""}" data-k="${k}">${label}${sort.col === k ? (sort.dir > 0 ? " ↑" : " ↓") : ""}</th>`).join("")}</tr></thead> |
| <tbody>${rows.map(s => { |
| const peak = Math.max(1, ...s.dist); |
| return `<tr class="stim" data-id="${esc(s.sample_id)}" aria-expanded="${open.has(s.sample_id)}" tabindex="0"> |
| <td class="mono">${esc(s.sample_id)}</td> |
| <td><span class="prompt">${esc(s.prompt)}</span></td> |
| <td>${s.attention_check ? `<span class="tag ctrl">attention check</span>` : `<span class="tag">${esc(s.case_id)}</span>`}</td> |
| <td class="num">${s.n}</td> |
| <td class="num">${meanbar(s.mean)}</td> |
| <td><span class="hist" title="${s.dist.map((c, i) => `${i + 1}: ${c}`).join(" · ")}">${s.dist.map(c => `<i class="${c ? "" : "zero"}" style="height:${c ? Math.max(3, c / peak * 26) : 1}px"></i>`).join("")}</span></td> |
| <td class="num">${pct(s.problem_rate)}</td> |
| <td class="num">${s.median_rt_s == null ? "–" : fmt(s.median_rt_s, 1) + " s"}</td> |
| </tr>${open.has(s.sample_id) ? detail(s) : ""}`; |
| }).join("")}</tbody>`; |
| tbl.querySelectorAll("th.sortable").forEach(th => th.onclick = () => { |
| const k = th.dataset.k; |
| sort = {col: k, dir: sort.col === k ? -sort.dir : (["n", "mean", "problem_rate", "median_rt_s"].includes(k) ? -1 : 1)}; |
| renderStimuli(); |
| }); |
| tbl.querySelectorAll("tr.stim").forEach(tr => { |
| const toggle = () => { const id = tr.dataset.id; open.has(id) ? open.delete(id) : open.add(id); renderStimuli(); }; |
| tr.onclick = toggle; |
| tr.onkeydown = e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); } }; |
| }); |
| } |
| |
| function detail(s) { |
| return `<tr class="detail"><td colspan="${COLS.length}"><div class="wrap"> |
| <div class="stimimg"><img src="/stimuli/${encodeURIComponent(s.file_name)}" alt="${esc(s.prompt)}"> |
| ${s.marks.map(([x, y]) => `<div class="mk" style="left:${x * 100}%;top:${y * 100}%"></div>`).join("")}</div> |
| <dl> |
| <dt>Category</dt><dd><span class="tag">${esc(s.category)}</span> ${esc(s.category_name)} · ${esc(s.subcategory)}</dd> |
| <dt>Ratings</dt><dd>${s.dist.map((n, i) => `${i + 1}: <b>${n}</b>`).join(" · ")}</dd> |
| <dt>Click marks</dt><dd>${s.marks.length}</dd> |
| ${s.segments && s.segments.length ? `<dt>Parts selected</dt><dd>${s.segments.map(([c, n]) => `${esc(c)} ${n}`).join(", ")}</dd>` : ""} |
| </dl></div></td></tr>`; |
| } |
| |
| load(); |
| </script> |
| </body> |
| </html> |
|
|