dehezhang2's picture
deploy v2 study (40-runs baselines) at /v2/: dual-study backend + static/v2 rater UI; v1 study untouched
f80bbbc verified
Raw
History Blame Contribute Delete
138 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Video user study — side-by-side rating</title>
<style>
:root {
--bg: #0f1115;
--panel: #161922;
--panel-2: #1c2030;
--panel-3: #232839;
--border: #2a2f42;
--border-strong: #3a4060;
--fg: #e6e8ef;
--muted: #8b91a8;
--muted-2: #5e6480;
--accent: #6aa9ff;
--accent-strong: #8ec0ff;
--good: #28c76f;
--warn: #ff9f43;
--bad: #ea5455;
--tag: #232a3d;
--score-1: #ea5455;
--score-2: #ff7d52;
--score-3: #ffa84a;
--score-4: #8acf6d;
--score-5: #28c76f;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Helvetica Neue",
"PingFang SC", "Microsoft YaHei", sans-serif;
height: 100%; font-size: 14px; line-height: 1.5; }
#app { display: flex; flex-direction: column; height: 100vh; }
/* ---------- header ---------- */
header.top { padding: 10px 18px; border-bottom: 1px solid var(--border);
background: var(--panel); display: flex; gap: 16px; align-items: center;
flex-wrap: wrap; }
.title { font-weight: 600; font-size: 15px; }
.subtitle { color: var(--muted); font-size: 12px; }
.progress { margin-left: auto; display: flex; gap: 14px; align-items: center; }
.progress-bar { width: 200px; height: 6px; background: var(--panel-3);
border-radius: 3px; overflow: hidden; }
.progress-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--good));
width: 0%; transition: width 0.3s; }
.progress-text { font-size: 12px; color: var(--muted); white-space: nowrap; }
.progress-text .num { color: var(--fg); font-weight: 600; }
/* ---------- toolbar ---------- */
.toolbar { padding: 8px 18px; background: var(--panel); border-bottom: 1px solid var(--border);
display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.btn { background: var(--panel-3); border: 1px solid var(--border-strong);
color: var(--fg); padding: 5px 12px; border-radius: 4px; font-size: 12px;
cursor: pointer; font-family: inherit; transition: background 0.15s; }
.btn:hover { background: #2c3450; }
.btn.primary { background: var(--accent); color: #0a1a2a; border-color: var(--accent); font-weight: 600; }
.btn.primary:hover { background: var(--accent-strong); }
.btn.ghost { background: transparent; border-color: var(--border); color: var(--muted); }
.btn.ghost:hover { color: var(--fg); border-color: var(--border-strong); }
.toolbar .sep { width: 1px; height: 18px; background: var(--border); margin: 0 4px; }
.rater-box { display: flex; gap: 6px; align-items: center; font-size: 12px; color: var(--muted); }
.rater-box input { background: var(--panel-3); border: 1px solid var(--border);
color: var(--fg); padding: 4px 8px; border-radius: 3px; font-family: inherit;
font-size: 12px; width: 140px; }
/* ---------- main layout ---------- */
main { flex: 1; display: flex; min-height: 0; }
/* Sidebar is hidden by default; user clicks the reopen rail on the left
edge to show it. The hide button now lives inside the sidebar header. */
:root { --sidebar-w: 0px; }
body:not(.sidebar-expanded) aside.list { display: none; }
body.sidebar-expanded { --sidebar-w: 240px; }
aside.list { width: 240px; border-right: 1px solid var(--border); background: var(--panel);
overflow-y: auto; flex-shrink: 0; }
.list-header { padding: 10px 12px; font-size: 11px; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--border);
position: sticky; top: 0; background: var(--panel);
display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.list-header .hide-btn { background: var(--panel-3); border: 1px solid var(--border);
color: var(--muted); padding: 2px 8px; border-radius: 4px;
font-family: inherit; font-size: 11px; cursor: pointer;
text-transform: none; letter-spacing: 0; }
.list-header .hide-btn:hover { color: var(--fg); border-color: var(--border-strong); }
.case-row { padding: 9px 14px; border-bottom: 1px solid var(--border); cursor: pointer;
display: flex; align-items: center; gap: 8px; font-size: 13px; }
.case-row:hover { background: var(--panel-2); }
.case-row.active { background: var(--panel-2); border-left: 3px solid var(--accent);
padding-left: 11px; }
.case-row .id { font-family: ui-monospace, "SF Mono", Menlo, monospace; flex: 1; }
.case-row .status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
background: var(--muted-2); }
.case-row.partial .status-dot { background: var(--warn); }
.case-row.complete .status-dot { background: var(--good); }
/* Thin vertical rail on the left edge that re-opens the sidebar. Visible
only when the sidebar is collapsed (default). */
.sidebar-rail { display: none; position: fixed; left: 0;
top: 50%; transform: translateY(-50%);
width: 24px; padding: 14px 0; border: 1px solid var(--border); border-left: none;
border-radius: 0 6px 6px 0; background: var(--panel); color: var(--muted);
font-size: 14px; line-height: 1; cursor: pointer; z-index: 20;
writing-mode: vertical-rl; text-align: center; user-select: none; }
.sidebar-rail:hover { color: var(--fg); border-color: var(--border-strong); }
body:not(.sidebar-expanded) .sidebar-rail { display: block; }
/* section.detail becomes a non-scrolling flex column: instructions panel at
the top (also non-scrolling), then the two-pane wrap that fills the rest.
Inside the wrap the LEFT pane is fixed-position-like (it doesn't move,
since the parent has no scroll), and only the RIGHT pane has its own
overflow-y: auto. Net effect: scrolling rating cards on the right while
prompt + video + instructions remain pinned. */
section.detail { flex: 1; overflow: hidden; padding: 14px 24px 80px;
min-width: 0; min-height: 0;
display: flex; flex-direction: column; gap: 10px; }
section.detail > .instructions { flex: 0 0 auto; margin-bottom: 0; }
/* ---------- instructions panel ---------- */
.instructions { background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
padding: 14px 18px; margin-bottom: 18px; }
.instructions summary { font-weight: 600; cursor: pointer; user-select: none;
list-style: none; display: flex; align-items: center; gap: 8px; }
.instructions summary::before { content: '▸'; color: var(--muted); font-size: 11px; }
.instructions[open] summary::before { content: '▾'; }
.instructions h4 { font-size: 13px; color: var(--accent-strong);
margin: 14px 0 6px; text-transform: uppercase; letter-spacing: 0.4px; }
.instructions ul { margin: 4px 0 4px 18px; padding: 0; color: var(--muted); }
.instructions li { margin: 3px 0; }
.instructions p { color: var(--muted); margin: 6px 0; }
.instructions code { background: var(--panel-3); padding: 1px 5px; border-radius: 3px;
font-size: 12px; color: var(--accent-strong); }
.scale-row { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
.scale-row .scale-pill { padding: 3px 10px; border-radius: 12px; font-size: 11px;
font-weight: 600; color: #0a1a0a; }
/* ---------- prompt + ref ---------- */
.case-header { display: flex; gap: 16px; align-items: flex-start; margin-bottom: 14px;
flex-wrap: wrap; }
.case-header .case-id { font-family: ui-monospace, monospace; font-size: 18px;
font-weight: 600; }
.case-header .case-counter { color: var(--muted); font-size: 12px; }
.prompt-box { background: var(--panel); border: 1px solid var(--border); border-radius: 6px;
padding: 14px 16px; line-height: 1.7; font-size: 15px; flex: 1;
min-width: 280px; max-width: var(--left-w); align-self: start;
width: 100%; box-sizing: border-box; }
.prompt-box .label { font-size: 10px; text-transform: uppercase; color: var(--muted);
letter-spacing: 0.5px; margin-bottom: 8px; }
/* Highlight imperative verbs + control directives (Add / Use / Make ...) so
raters can spot what controllability they should be scoring against. */
.ctrl { color: var(--bad); font-weight: 700; }
/* Admin-curated highlights look identical in colour to the auto-detected
ones (also red) but get a subtle dotted underline so admins can tell
which marks are their own. */
.ctrl-admin { text-decoration: underline; text-decoration-style: dotted;
text-decoration-color: var(--bad); text-underline-offset: 2px; }
/* Admin highlight toolbar lives inside the prompt-box (admins only). */
.hi-toolbar { margin-top: 10px; padding-top: 8px;
border-top: 1px dashed var(--border); }
.hi-toolbar .hi-row { display: flex; align-items: center; gap: 8px;
flex-wrap: wrap; }
.hi-toolbar .hi-list { display: flex; flex-wrap: wrap; gap: 4px;
margin-top: 6px; }
.hi-toolbar .hi-tag { display: inline-flex; align-items: center; gap: 4px;
background: var(--panel-3); border: 1px solid var(--border-strong);
border-radius: 10px; padding: 2px 6px 2px 8px; font-size: 11px;
color: var(--fg); max-width: 100%; }
.hi-toolbar .hi-tag-text { color: var(--bad); font-weight: 600;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
max-width: 220px; }
.hi-toolbar .hi-tag-x { background: transparent; border: none;
color: var(--muted); padding: 0 2px; cursor: pointer; font-size: 12px; }
.hi-toolbar .hi-tag-x:hover { color: var(--bad); }
.ref-img { max-height: 140px; max-width: 220px; border-radius: 6px;
border: 1px solid var(--border); display: block; }
/* ---------- two-pane case layout ---------- */
.case-pane-wrap { flex: 1 1 auto; min-height: 0;
display: grid; gap: 12px;
/* Left pane is sized to fit the unified video / controls / prompt width
(--left-w + a bit of breathing room); right pane takes the remaining
space and packs its cards from the left edge. */
grid-template-columns: calc(var(--left-w) + 24px) minmax(0, 1fr);
align-items: stretch; }
@media (max-width: 900px) {
.case-pane-wrap { grid-template-columns: 1fr; }
}
/* Left pane fills its grid cell vertically. Doesn't scroll the page; if
its own content exceeds the cell height, the prompt-box scrolls
internally. */
.left-pane { display: flex; flex-direction: column; gap: 8px;
padding-top: 4px; padding-bottom: 8px;
min-height: 0; max-height: 100%; }
.left-pane > .video-panel, .left-pane > .video-controls,
.left-pane > .case-meta { flex: 0 0 auto; }
.left-pane > .prompt-box { flex: 1 1 auto; min-height: 5em;
max-height: none; overflow-y: auto; }
/* Right pane is the only scroll surface on this page. Grid of question
cards with FIXED-width columns that pack from the left, so the cards
visually align under the left pane's content instead of being stretched
to fill the column. */
.right-pane { min-width: 0; min-height: 0; max-height: 100%;
overflow-y: auto; padding-right: 4px;
display: grid; gap: 10px; align-content: start;
justify-content: start;
grid-template-columns: repeat(auto-fill, 230px); }
.right-pane > .dim-header { grid-column: 1 / -1; }
/* ---------- video tabs + single active video ---------- */
.video-tabs { display: flex; gap: 4px; margin: 8px 0 6px; flex-wrap: wrap; }
.tab-btn { background: var(--panel-3); border: 1px solid var(--border);
color: var(--muted); padding: 6px 14px; border-radius: 6px;
font-family: ui-monospace, monospace; font-size: 13px; cursor: pointer;
min-width: 44px; }
.tab-btn:hover { color: var(--fg); border-color: var(--border-strong); }
.tab-btn.active { background: var(--accent); color: #0a1a2a;
border-color: var(--accent); font-weight: 700; }
.video-tile { display: flex; flex-direction: column; gap: 6px; }
.video-tile.hidden { display: none; }
/* Video, control bar, and prompt-box all share --left-w as their max width
so they stack as a single coherent column inside the left pane. */
:root { --left-w: 620px; }
.video-frame { position: relative; background: #000; border: 1px solid var(--border);
border-radius: 6px; overflow: hidden; aspect-ratio: 16 / 9;
max-height: 34vh; max-width: var(--left-w); align-self: start; }
.video-frame video { width: 100%; height: 100%; display: block; background: #000; }
.video-frame .empty-state { position: absolute; inset: 0; display: flex;
flex-direction: column; align-items: center; justify-content: center;
background: repeating-linear-gradient(45deg, #1a1d28 0 10px, #16191f 10px 20px);
color: var(--muted); font-size: 12px; gap: 6px; padding: 12px; text-align: center; }
.video-frame .empty-state .icon { font-size: 28px; opacity: 0.3; }
.video-frame .empty-state .path { font-family: ui-monospace, monospace; font-size: 10px;
color: var(--muted-2); word-break: break-all; max-width: 90%; }
/* ---------- video controls (compact) ---------- */
.video-controls { background: var(--panel); border: 1px solid var(--border);
border-radius: 5px; padding: 4px 8px; display: flex; gap: 6px;
align-items: center; flex-wrap: wrap; margin: 6px 0 0; font-size: 11px;
color: var(--muted); max-width: var(--left-w); align-self: start; }
.video-controls .ctl-btn { background: var(--panel-3); border: 1px solid var(--border-strong);
color: var(--fg); padding: 2px 8px; border-radius: 3px; font-size: 11px;
line-height: 1.4; cursor: pointer; font-family: inherit; min-height: 24px; }
.video-controls .ctl-btn:hover { background: #2c3450; }
.video-controls .ctl-btn.on { background: var(--accent); color: #0a1a2a; border-color: var(--accent); }
.video-controls select { background: var(--panel-3); border: 1px solid var(--border);
color: var(--fg); padding: 1px 5px; border-radius: 3px; font-size: 11px;
font-family: inherit; min-height: 24px; }
/* ---------- right-pane question cards ---------- */
.dim-header { font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;
color: var(--accent-strong); font-weight: 700; margin: 18px 2px 8px;
display: flex; align-items: baseline; gap: 8px; }
.dim-header:first-child { margin-top: 0; }
.dim-header .dim-hint { font-weight: 400; color: var(--muted); font-size: 11px;
text-transform: none; letter-spacing: 0; }
.q-card { background: var(--panel); border: 1px solid var(--border);
border-radius: 8px; padding: 8px 10px; }
.q-card .q-name { font-size: 13px; font-weight: 600; color: var(--fg); }
.q-card .q-desc { font-size: 11px; color: var(--muted); margin: 2px 0 6px;
line-height: 1.4; }
.q-card .video-rate { display: flex; align-items: center; gap: 6px;
padding: 3px 0; }
.q-card .video-rate + .video-rate { border-top: 1px solid var(--border); }
.q-card .slot-chip { display: inline-flex; align-items: center;
justify-content: center; min-width: 24px; height: 24px; border-radius: 4px;
background: var(--panel-3); color: var(--fg); font-weight: 700;
font-size: 12px; font-family: ui-monospace, monospace;
border: 1px solid var(--border-strong); flex-shrink: 0; }
.q-card .likert { display: flex; gap: 3px; flex: 1; justify-content: flex-start; }
.q-card .likert button { flex: 1 1 0; min-width: 0; width: auto;
height: 30px; border: 1px solid var(--border);
background: var(--panel-3); color: var(--fg); border-radius: 4px;
cursor: pointer; font-family: inherit; font-size: 12px; font-weight: 500;
transition: transform 0.08s, border-color 0.15s; padding: 0; }
.q-card .likert button:hover { border-color: var(--accent); transform: translateY(-1px); }
.q-card .likert button.selected { color: #0a1a0a; font-weight: 700;
border-color: rgba(255,255,255,0.2); }
.q-card .likert button.selected[data-score="1"] { background: var(--score-1); }
.q-card .likert button.selected[data-score="2"] { background: var(--score-2); }
.q-card .likert button.selected[data-score="3"] { background: var(--score-3); }
.q-card .likert button.selected[data-score="4"] { background: var(--score-4); }
.q-card .likert button.selected[data-score="5"] { background: var(--score-5); }
/* ---------- footer / nav ---------- */
footer.bottom { position: fixed; bottom: 0; left: var(--sidebar-w); right: 0;
background: var(--panel); border-top: 1px solid var(--border);
padding: 10px 24px; display: flex; gap: 12px; align-items: center;
z-index: 10; }
footer.bottom .summary { color: var(--muted); font-size: 12px; flex: 1; }
footer.bottom .summary .num { color: var(--fg); font-weight: 600; }
footer.bottom .nav-btns { display: flex; gap: 8px; }
/* ---------- placeholder when no manifest ---------- */
.empty-app { padding: 40px; max-width: 760px; margin: 0 auto; }
.empty-app h2 { color: var(--fg); margin-bottom: 8px; }
.empty-app p { color: var(--muted); margin: 8px 0; line-height: 1.7; }
.empty-app pre { background: var(--panel); border: 1px solid var(--border);
border-radius: 6px; padding: 14px; font-size: 12px; color: var(--accent-strong);
overflow-x: auto; line-height: 1.5; }
.empty-app code { background: var(--panel-3); padding: 1px 5px; border-radius: 3px;
font-size: 12px; color: var(--accent-strong); }
/* ---------- toast ---------- */
.toast { position: fixed; bottom: 70px; right: 20px; background: var(--good);
color: #0a1a0a; padding: 10px 16px; border-radius: 6px; font-size: 13px;
font-weight: 600; opacity: 0; transition: opacity 0.2s; z-index: 100;
pointer-events: none; }
.sync-pill { padding: 4px 10px; border-radius: 12px; font-size: 11px;
font-family: ui-monospace, monospace; background: var(--panel-3); color: var(--muted);
border: 1px solid var(--border); white-space: nowrap; cursor: help; }
.sync-pill[data-state="idle"] { background: #1f2a44; color: var(--accent); border-color: #3a4a78; }
.sync-pill[data-state="submitting"] { background: #1f2a44; color: var(--accent); border-color: #3a4a78; }
.sync-pill[data-state="submitted"] { background: #1c3422; color: var(--good); border-color: #2a5a37; }
.sync-pill[data-state="local-only"] { background: #3a3520; color: var(--warn); border-color: #5a4a25; }
.sync-pill[data-state="error"] { background: #3a2020; color: var(--bad); border-color: #5a2828; }
.toast.show { opacity: 1; }
/* ---------- registration / login overlay ---------- */
.overlay { position: fixed; inset: 0; background: rgba(8, 10, 16, 0.82);
backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
display: flex; align-items: center; justify-content: center;
z-index: 200; padding: 20px; }
.overlay.hidden { display: none; }
.reg-card { background: var(--panel); border: 1px solid var(--border-strong);
border-radius: 10px; padding: 28px 30px; max-width: 460px; width: 100%;
box-shadow: 0 20px 60px rgba(0,0,0,0.5); }
.reg-card h2 { margin: 0 0 6px; font-size: 18px; }
.reg-card .lead { color: var(--muted); font-size: 13px; line-height: 1.6;
margin: 0 0 18px; }
.reg-card label { display: block; font-size: 11px; text-transform: uppercase;
letter-spacing: 0.5px; color: var(--muted); margin: 10px 0 4px; }
.reg-card input, .reg-card textarea { width: 100%; background: var(--panel-3);
border: 1px solid var(--border); color: var(--fg); padding: 8px 10px;
border-radius: 5px; font-family: inherit; font-size: 14px; }
.reg-card input:focus, .reg-card textarea:focus { outline: none;
border-color: var(--accent); }
.reg-card textarea { resize: vertical; min-height: 56px; }
.reg-card .row-actions { margin-top: 18px; display: flex; gap: 10px;
align-items: center; }
.reg-card .reg-err { color: var(--bad); font-size: 12px; min-height: 16px;
margin-top: 8px; }
.reg-card .reg-foot { color: var(--muted-2); font-size: 11px; margin-top: 14px;
line-height: 1.5; }
/* ---------- submitted celebration overlay ---------- */
.submitted-card { background: var(--panel); border: 1px solid var(--good);
border-radius: 10px; padding: 32px 34px; max-width: 460px; width: 100%;
text-align: center; box-shadow: 0 20px 60px rgba(0,0,0,0.5); }
.submitted-card .check { font-size: 48px; color: var(--good); }
.submitted-card h2 { margin: 10px 0 6px; font-size: 18px; }
.submitted-card p { color: var(--muted); margin: 6px 0; font-size: 13px; line-height: 1.6; }
.submitted-card code { background: var(--panel-3); padding: 2px 6px; border-radius: 3px;
font-size: 11px; color: var(--accent-strong); word-break: break-all; }
.submitted-card .row-actions { margin-top: 18px; display: flex; gap: 10px;
justify-content: center; }
/* ---------- submit button states ---------- */
.btn.submit-primary { background: var(--good); color: #0a1a0a;
border-color: var(--good); font-weight: 700; }
.btn.submit-primary:hover { filter: brightness(1.1); }
.btn.submit-primary:disabled { background: var(--panel-3); color: var(--muted-2);
border-color: var(--border); cursor: not-allowed; font-weight: 500; }
.submitted-badge { display: inline-flex; align-items: center; gap: 5px;
padding: 3px 9px; border-radius: 10px; background: #1c3422; color: var(--good);
font-size: 11px; font-weight: 600; border: 1px solid #2a5a37; }
kbd { background: var(--panel-3); border: 1px solid var(--border-strong);
border-radius: 3px; padding: 1px 5px; font-family: ui-monospace, monospace;
font-size: 11px; color: var(--fg); }
</style>
</head>
<body>
<div id="app"></div>
<div class="toast" id="toast"></div>
<!-- One-time registration: name + email. No password. Shown until both fields are
filled, then hidden until the user clears their state. Strings are filled
in by applyRegistrationOverlayI18n() based on LANG. -->
<div id="loginOverlay" class="overlay hidden" role="dialog" aria-modal="true">
<div class="reg-card">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:8px">
<h2 id="regHeader">Register to start rating</h2>
<div style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted)">
<span id="regLangLabel">Language</span>
<select id="regLangSelect"
onchange="LANG=this.value;document.documentElement.lang=LANG;saveState();applyRegistrationOverlayI18n();"></select>
</div>
</div>
<p class="lead" id="regLead"></p>
<label for="regName" id="regNameLabel">Your name</label>
<input id="regName" type="text" autocomplete="name" maxlength="80" />
<label for="regEmail" id="regEmailLabel">Email</label>
<input id="regEmail" type="email" autocomplete="email" maxlength="120" />
<label for="regNote" id="regNoteLabel">Affiliation / notes (optional)</label>
<textarea id="regNote" maxlength="280"></textarea>
<div class="reg-err" id="regErr"></div>
<div class="row-actions">
<button class="btn primary" id="regGoBtn"><span id="regContinueText">Continue →</span></button>
<span style="color:var(--muted-2);font-size:11px" id="regSideNote"></span>
</div>
<div class="reg-foot" id="regFootText"></div>
</div>
</div>
<!-- Shown after a successful POST /api/submit. -->
<div id="submittedOverlay" class="overlay hidden" role="dialog" aria-modal="true">
<div class="submitted-card">
<div class="check"></div>
<h2 id="submittedHeading">Submission recorded</h2>
<p id="submittedDetail"></p>
<p style="font-size:12px"><code id="submittedRef"></code></p>
<div class="row-actions">
<button class="btn ghost" id="submittedCloseBtn" onclick="hideSubmittedOverlay()">Close</button>
<button class="btn" id="submittedDownloadBtn" onclick="exportJSON()">↓ Also download backup JSON</button>
</div>
</div>
</div>
<script>
/* =========================================================================
* Auto-redirect to mobile.html on phone-sized / touch-first devices.
* Run before anything else so the wrong page never renders.
* Override with `?force=desktop` (sticky for the rest of the tab session).
* ========================================================================= */
(function autoRedirectIfMobile() {
try {
const url = new URL(location.href);
if (url.searchParams.has("force")) {
sessionStorage.setItem("user_study_view_force", url.searchParams.get("force"));
}
const forced = sessionStorage.getItem("user_study_view_force");
if (forced === "desktop") return;
const ua = navigator.userAgent || "";
const isMobileUA = /Mobi|Android|iPhone|iPod|BlackBerry|Windows Phone/i.test(ua);
const isSmall = window.innerWidth < 720;
const isCoarse = window.matchMedia && window.matchMedia("(pointer: coarse)").matches;
if (forced === "mobile" || isMobileUA || (isSmall && isCoarse)) {
location.replace("./mobile.html");
}
} catch (e) { /* ignore — fall through to desktop */ }
})();
/* =========================================================================
* Rubric — 4 dimensions × 13 sub-questions
* ========================================================================= */
const DIMENSIONS = [
{
key: "instruction_following",
name: "Instruction following",
hint: "Re-read the prompt — especially the red-highlighted phrases — before scoring.",
questions: [
{ key: "controllability",
name: "Controllability",
desc: "Does the video respond to control signals (BGM, subtitle etc)?" },
{ key: "prompt_adherence",
name: "Prompt adherence",
desc: "Does the content match what the prompt described (subjects, actions, setting, time, and story arc)? Focus primarily on the prompt's text content." },
{ key: "no_irrelevant_frames",
name: "No irrelevant frames",
desc: "Does the video stay on-prompt? Higher = fewer / no frames or content that weren't asked for by the prompt." },
],
},
{
key: "temporal_consistency",
name: "Temporal consistency",
hint: "Focus on how characters, objects, and the environment change across frames.",
questions: [
{ key: "person_stability",
name: "Person stability",
desc: "Faces, identities, and body proportions stay consistent across frames." },
{ key: "object_stability",
name: "Object stability",
desc: "Props, clothing, and objects keep their shape and position over time." },
{ key: "environment_stability",
name: "Environment stability",
desc: "Background, lighting, and scene layout stay coherent across frames (including absence of brightness / color / texture flicker)." },
],
},
{
// Key kept as `motion_realism` for backwards-compatibility with prior
// submissions; display name + hint broadened to "Realism" now that the
// `realism` sub-question (formerly under Visual quality) lives here.
key: "motion_realism",
name: "Realism",
hint: "Notice obvious out-of-place or unnatural moments in the video.",
questions: [
{ key: "realism",
name: "Static realism",
desc: "Photorealism or stylistic fidelity (per-frame appearance). When the prompt specifies a style, the video should match it. No uncanny or broken artifacts." },
{ key: "motion_logic",
name: "Motion logic",
desc: "Does body and object motion flow smoothly and follow physical / logical patterns (gravity, collisions, fluid / cloth dynamics)? No warping, teleporting, nonsensical, or impossible movements. Score low if there is little to no motion." },
{ key: "camera",
name: "Camera",
desc: "Camera movement (pan, dolly, focal change) feels natural and intentional. Score low if there is little to no camera movement." },
],
},
];
// v2 keys RATINGS by model name (per case) instead of by slot index, so
// adding/removing videos in a case doesn't misalign previously-rated cells.
// Old v1 storage is migrated on first load (using the v1 shuffle it saved
// alongside, to map slot_idx → original_index → model name).
const STORAGE_KEY = "user_study_video_ratings_v2";
const STORAGE_KEY_LEGACY = "user_study_video_ratings_v1";
const TOTAL_QUESTIONS = DIMENSIONS.reduce((n, d) => n + d.questions.length, 0);
/* =========================================================================
* Admins — these names see the "Reveal models" toggle.
* Matched case-insensitively against RATER.name. Spoofable by typing the
* name in the registration form, but cases.json on the wire contains the
* model names anyway — the gate is a UX nicety, not a security boundary.
* ========================================================================= */
const ADMIN_NAMES = new Set([
"deheng zhang",
"zhendong li",
]);
function isAdmin() {
const n = (RATER.name || "").toLowerCase().trim();
return ADMIN_NAMES.has(n);
}
/* =========================================================================
* i18n — English / 简体中文 / Български
* t(key, ...args) does positional {0}, {1} substitution.
* Rubric translations live in RUBRIC_I18N keyed by dim/question.
* ========================================================================= */
const LANGUAGES = [
{ code: "en", label: "English" },
{ code: "zh", label: "Chinese" },
{ code: "bg", label: "Bulgarian" },
];
let LANG = "en";
const STRINGS = {
en: {
"title": "Video user study — side-by-side rating",
"subtitle": "{0} cases · 3 dimensions · {1} sub-questions per video · 1–5 Likert",
"lang_label": "Language",
"rater": "Rater:",
"rater_placeholder": "your name",
"rater_edit_title": "click to edit name",
"anonymized": "🔒 Anonymized",
"reveal_models": "👁 Reveal models",
"submit_pending": "Submit study ({0} left)",
"submit_ready": "Submit study ({0} ratings)",
"submit_resubmit": "✓ Re-submit",
"submit_disabled_title": "rate every question to enable — {0} of {1} still missing",
"submit_ready_title": "post your full rating set to the dataset",
"submit_resubmit_title": "you've already submitted; click again to overwrite",
"submit_enable_hint": "rate every question to enable",
"save_progress": "💾 Save progress",
"save_progress_title": "upload your current ratings to the dataset under submissions/in_progress",
"export_json": "↓ Export JSON",
"export_title": "save a local backup of your ratings",
"admin_badge": "⚙ admin",
"admin_badge_title": "your name matches the admin list — submissions are flagged is_admin=true",
"reset_case": "↺ Reset case",
"reset_all": "✕ Reset all",
"submitted_badge": "✓ submitted",
"submitted_badge_title": "you've already submitted; further edits clear this flag",
"pill_idle": "● server ready",
"pill_submitting": "↻ submitting…",
"pill_submitted": "✓ submitted",
"pill_local_only": "💾 local-only",
"pill_error": "✕ submit error",
"pill_checking": "● checking…",
"pill_default_title": "click 'Submit study' when 100% complete",
"cases_n": "Cases ({0})",
"case_n_of_m": "Case {0} of {1}",
"prompt_label": "Prompt / instruction",
"no_prompt": "(no prompt provided)",
"play_all": "▶ Play all",
"pause": "⏸ Pause",
"restart": "⏮ Restart",
"loop": "↻ Loop",
"mute": "🔇 Mute",
"muted": "🔇 Muted",
"sound": "🔊 Sound",
"speed": "Speed:",
"sub_question_col": "Sub-question",
"video_label_anon": "Video {0}",
"video_label_revealed": "Video {0} · {1}",
"video_col_anon": "Video {0}",
"video_col_revealed": "{0} · {1}",
"no_video_file": "no video file at this path",
"drop_mp4_hint": "drop the .mp4 there, then refresh",
"footer_summary": "Case {0} / {1} · {2} / {3} ratings on this case",
"footer_complete_suffix": " · ✓ complete",
"prev": "← Prev",
"next": "Next →",
"instructions_toggle": "How to rate (click to expand) — 3 dimensions, {0} sub-questions, 1–5 each",
"instructions_dismiss": "Got it",
"instructions_intro": "For each case, watch all videos side by side, then rate <strong>each video independently</strong> on every sub-question. The rating table groups sub-questions by dimension; videos appear as columns.",
"scale_legend": "Likert scale (1 = worst, 5 = best)",
"dimensions_header": "Dimensions",
"dim_inst_short": "<strong>Instruction following</strong> — controllability, prompt adherence, no irrelevant frames.",
"dim_temporal_short": "<strong>Temporal consistency</strong> — person / object / environment stability (incl. anti-flicker).",
"dim_motion_short": "<strong>Realism</strong> — realism (visual + physical), motion logic, camera motion.",
"tips_header": "Tips",
"tip_keys": "Use <kbd>Space</kbd> to play / pause all videos in sync, <kbd>R</kbd> to restart, <kbd>L</kbd> to toggle loop.",
"tip_nav": "Use <kbd>←</kbd> / <kbd>→</kbd> (or <kbd>j</kbd> / <kbd>k</kbd>) to jump between cases.",
"tip_autosave": "Ratings autosave to your browser as you go — but they only count once you click <strong>Submit study</strong>.",
"tip_anon": "Videos are anonymized by default — model identity is hidden unless an admin toggles reveal.",
"tip_missing": "If a tile shows \"no video file\", the manifest path doesn't exist on disk yet — drop a real <code>.mp4</code> at that path and refresh.",
"tip_save_then_submit": "Click <code>💾</code> (Save progress) at any time — in-progress ratings stay safe. <strong>You must click <code>Submit study</code> once every question is filled in</strong>; otherwise your scores do not count.",
"tip_audio": "For BGM / soundtrack / subtitle questions, listen to the video — toggle the speaker button to unmute.",
"scale_1": "Very poor",
"scale_2": "Poor",
"scale_3": "Fair",
"scale_4": "Good",
"scale_5": "Excellent",
"reg_title": "Register to start rating",
"reg_lead": "We record who submitted each set of ratings so we can match results back to participants and resolve duplicates. No password — just your name and an email address that uniquely identifies you.",
"reg_name": "Your name",
"reg_name_ph": "e.g. Alex Chen",
"reg_email": "Email",
"reg_email_ph": "e.g. alex@example.com",
"reg_note": "Affiliation / notes (optional)",
"reg_note_ph": "e.g. INSAIT, internal pilot, etc.",
"reg_continue": "Continue →",
"reg_side_note": "Your ratings are saved locally as you go. Submission is a single final action when 100% complete.",
"reg_foot": "You can change your name later in the toolbar. Once you submit the study, your name + email + ratings are written to the <code>dehezhang2/Frameworker_User_Study</code> dataset on Hugging Face.",
"reg_err_name": "Please enter your name.",
"reg_err_email": "Please enter a valid email address.",
"submitted_title": "Submission recorded",
"submitted_thanks": "Thanks, {0}. We recorded {1} ratings across {2} videos to the dataset.",
"submitted_close": "Close",
"submitted_download": "↓ Also download backup JSON",
"toast_registered_as": "Registered as {0}",
"toast_pls_signin": "Please sign in before submitting",
"toast_missing_first": "{0} ratings still missing — first: {1}",
"toast_no_server": "No server reachable — use ↓ Export JSON to save your submission",
"toast_submit_failed_http": "Submit failed (HTTP {0})",
"toast_submit_failed": "Submit failed: {0}",
"toast_exported": "Exported JSON",
"toast_progress_saved": "Progress saved ({0} ratings)",
"toast_progress_save_failed": "Progress save failed: {0}",
"toast_registered_saved": "Registered as {0} — start time recorded on the server",
"toast_cleared_case": "Cleared ratings for {0}",
"toast_reset_all": "All ratings reset",
"toast_loaded_example": "Loaded cases.example.json — drop a real cases.json to override",
"toast_server_not_configured": "Server not configured — Export JSON instead",
"toast_save_failed": "⚠ failed to save: {0}",
"confirm_reset_case": "Reset all ratings for {0}?",
"confirm_reset_all": "Reset all ratings, registration, and rater id? This cannot be undone.",
"pill_server_ready_detail": "server ready — submissions go to {0}",
"pill_no_token_detail": "server reachable but HF_TOKEN secret is missing",
"pill_unhealthy": "server unhealthy",
"pill_health_http": "health probe HTTP {0}",
"pill_no_endpoint": "no /api/submit endpoint reachable",
"pill_uploading": "uploading to dataset…",
"pill_recorded_as": "recorded as {0} ({1} scores)",
"pill_changed_after_submit": "ratings changed after submission — submit again to update",
"empty_app_title": "Video user study",
"empty_app_intro_html": "This page expects a manifest at <code>./cases.json</code> describing the cases to rate. A working example lives at <code>./cases.example.json</code>.",
"empty_app_format_header": "cases.json format",
"empty_app_serve_header": "Then serve this directory and open the page:",
"empty_app_missing_tip_html": "Tip: missing video files render as a striped \"no video\" placeholder, so you can scaffold the rating UI before any real <code>.mp4</code>s exist.",
"empty_app_load_error": "Could not load ./cases.json or ./cases.example.json — either run via python -m http.server, or drop a manifest in this directory.",
"empty_app_empty_manifest": "Manifest is empty — add at least one case to cases.json.",
},
zh: {
"title": "视频用户研究 — 并排评分",
"subtitle": "{0} 个案例 · 3 个维度 · 每个视频 {1} 个子问题 · 1–5 分李克特量表",
"lang_label": "Language",
"rater": "评分人:",
"rater_placeholder": "您的姓名",
"rater_edit_title": "点击修改姓名",
"anonymized": "🔒 已匿名",
"reveal_models": "👁 显示模型名称",
"submit_pending": "提交研究(还剩 {0} 项)",
"submit_ready": "提交研究({0} 项评分)",
"submit_resubmit": "✓ 重新提交",
"submit_disabled_title": "请回答全部问题后再提交 — 共 {1} 项中尚有 {0} 项未完成",
"submit_ready_title": "将完整评分发送至数据集",
"submit_resubmit_title": "您已提交过;再次点击将覆盖",
"submit_enable_hint": "请完成全部评分后再启用",
"save_progress": "💾 保存进度",
"save_progress_title": "将当前评分上传到数据集的 submissions/in_progress 目录",
"export_json": "↓ 导出 JSON",
"export_title": "将评分另存为本地备份",
"admin_badge": "⚙ 管理员",
"admin_badge_title": "您的姓名在管理员列表中 — 提交将标记 is_admin=true",
"reset_case": "↺ 重置当前案例",
"reset_all": "✕ 全部重置",
"submitted_badge": "✓ 已提交",
"submitted_badge_title": "您已提交;继续编辑将清除此标记",
"pill_idle": "● 服务器就绪",
"pill_submitting": "↻ 正在提交…",
"pill_submitted": "✓ 已提交",
"pill_local_only": "💾 仅本地",
"pill_error": "✕ 提交出错",
"pill_checking": "● 检查中…",
"pill_default_title": "全部完成后点击「提交研究」",
"cases_n": "案例 ({0})",
"case_n_of_m": "第 {0} / {1} 个案例",
"prompt_label": "提示词 / 指令",
"no_prompt": "(未提供提示词)",
"play_all": "▶ 全部播放",
"pause": "⏸ 暂停",
"restart": "⏮ 重新开始",
"loop": "↻ 循环",
"mute": "🔇 静音",
"muted": "🔇 已静音",
"sound": "🔊 取消静音",
"speed": "速度:",
"sub_question_col": "子问题",
"video_label_anon": "视频 {0}",
"video_label_revealed": "视频 {0} · {1}",
"video_col_anon": "视频 {0}",
"video_col_revealed": "{0} · {1}",
"no_video_file": "该路径下没有视频文件",
"drop_mp4_hint": "请将 .mp4 文件放入该路径后刷新",
"footer_summary": "案例 {0} / {1} · 本案例已评 {2} / {3} 项",
"footer_complete_suffix": " · ✓ 已完成",
"prev": "← 上一个",
"next": "下一个 →",
"instructions_toggle": "评分说明(点击展开)— 共 3 个维度 / {0} 个子问题,每项 1–5 分",
"instructions_dismiss": "知道了",
"instructions_intro": "请并排观看所有视频,然后<strong>独立</strong>对每个视频的每个子问题打分。评分表按维度分组;视频以列方式呈现。",
"scale_legend": "李克特量表(1 = 最差, 5 = 最佳)",
"dimensions_header": "评估维度",
"dim_inst_short": "<strong>指令遵循</strong> — 可控性、提示一致性、无无关画面。",
"dim_temporal_short": "<strong>时间一致性</strong> — 人物 / 物体 / 环境稳定性(含防闪烁)。",
"dim_motion_short": "<strong>真实感</strong> — 真实感、物理规律、运动逻辑、相机运动。",
"tips_header": "操作提示",
"tip_keys": "<kbd>Space</kbd> 同步播放 / 暂停所有视频,<kbd>R</kbd> 重新开始,<kbd>L</kbd> 切换循环。",
"tip_nav": "使用 <kbd>←</kbd> / <kbd>→</kbd> (或 <kbd>j</kbd> / <kbd>k</kbd>) 在案例间切换。",
"tip_autosave": "评分过程中会自动保存在浏览器内 — 但只有在点击<strong>提交研究</strong>之后才会被记录。",
"tip_anon": "默认情况下视频已匿名 — 仅管理员可切换显示模型名称。",
"tip_missing": "若卡片显示「没有视频文件」,说明清单中的路径在磁盘上不存在 — 请将真实的 <code>.mp4</code> 放置在该路径后刷新。",
"tip_save_then_submit": "评分过程中可随时点击 <code>💾</code>(保存进度),已打分的内容会安全保留。<strong>全部完成后必须点击「提交研究」</strong>,否则您的评分不会被计入。",
"tip_audio": "涉及 BGM / 配乐 / 字幕等问题时,请打开视频声音聆听 — 点击扬声器按钮可取消静音。",
"scale_1": "很差",
"scale_2": "差",
"scale_3": "一般",
"scale_4": "好",
"scale_5": "极佳",
"reg_title": "注册以开始评分",
"reg_lead": "我们会记录每份评分的提交者,以便将结果对应到参与者并处理重复提交。无需密码 — 只需您的姓名和一个可唯一识别您的邮箱地址。",
"reg_name": "您的姓名",
"reg_name_ph": "例如:陈小明",
"reg_email": "邮箱",
"reg_email_ph": "例如:xiaoming@example.com",
"reg_note": "所属单位 / 备注(可选)",
"reg_note_ph": "例如:INSAIT、内部预测试 等",
"reg_continue": "继续 →",
"reg_side_note": "您的评分会在过程中自动保存到本地。提交是一次性最终操作,需在 100% 完成后执行。",
"reg_foot": "您之后可在工具栏修改姓名。提交后,姓名 + 邮箱 + 评分将写入 Hugging Face 上的 <code>dehezhang2/Frameworker_User_Study</code> 数据集。",
"reg_err_name": "请输入您的姓名。",
"reg_err_email": "请输入有效的邮箱地址。",
"submitted_title": "提交成功",
"submitted_thanks": "{0},感谢您的参与。我们已将 {2} 个视频 × {1} 项评分写入数据集。",
"submitted_close": "关闭",
"submitted_download": "↓ 同时下载备份 JSON",
"toast_registered_as": "已注册为 {0}",
"toast_pls_signin": "请先登录后再提交",
"toast_missing_first": "尚有 {0} 项未完成 — 例如:{1}",
"toast_no_server": "无法连接服务器 — 请使用 ↓ 导出 JSON 来保存",
"toast_submit_failed_http": "提交失败 (HTTP {0})",
"toast_submit_failed": "提交失败:{0}",
"toast_exported": "已导出 JSON",
"toast_progress_saved": "进度已保存({0} 项评分)",
"toast_progress_save_failed": "保存进度失败:{0}",
"toast_registered_saved": "已注册为 {0} — 起始时间已记录到服务器",
"toast_cleared_case": "已清空 {0} 的评分",
"toast_reset_all": "已重置全部评分",
"toast_loaded_example": "已加载 cases.example.json — 请放入真实的 cases.json 以覆盖",
"toast_server_not_configured": "服务器未配置 — 请改用「导出 JSON」",
"toast_save_failed": "⚠ 保存失败:{0}",
"confirm_reset_case": "确定要清空 {0} 的全部评分吗?",
"confirm_reset_all": "确定要重置全部评分、注册信息和评分人 ID 吗?该操作不可撤销。",
"pill_server_ready_detail": "服务器就绪 — 提交将写入 {0}",
"pill_no_token_detail": "服务器可达但缺少 HF_TOKEN 密钥",
"pill_unhealthy": "服务器不健康",
"pill_health_http": "健康探测 HTTP {0}",
"pill_no_endpoint": "无法访问 /api/submit",
"pill_uploading": "正在上传至数据集…",
"pill_recorded_as": "已记录为 {0}({1} 项评分)",
"pill_changed_after_submit": "提交后又有修改 — 请再次提交以更新",
"empty_app_title": "视频用户研究",
"empty_app_intro_html": "本页面需要在 <code>./cases.json</code> 中提供案例清单。可参考 <code>./cases.example.json</code>。",
"empty_app_format_header": "cases.json 格式",
"empty_app_serve_header": "随后从该目录启动服务并打开页面:",
"empty_app_missing_tip_html": "提示:缺失的视频文件会显示斜条「无视频」占位,因此可以先搭好评分界面,等真实的 <code>.mp4</code> 文件就位后再使用。",
"empty_app_load_error": "无法加载 ./cases.json 或 ./cases.example.json — 请使用 python -m http.server,或将清单文件放入当前目录。",
"empty_app_empty_manifest": "清单为空 — 请在 cases.json 中至少添加一个案例。",
},
bg: {
"title": "Видео потребителско проучване — паралелно оценяване",
"subtitle": "{0} случая · 3 измерения · {1} под-въпроса на видео · Ликерт 1–5",
"lang_label": "Language",
"rater": "Оценител:",
"rater_placeholder": "вашето име",
"rater_edit_title": "щракнете, за да редактирате името",
"anonymized": "🔒 Анонимизирано",
"reveal_models": "👁 Покажи моделите",
"submit_pending": "Изпрати проучването (остават {0})",
"submit_ready": "Изпрати проучването ({0} оценки)",
"submit_resubmit": "✓ Повторно изпращане",
"submit_disabled_title": "оценете всеки въпрос, за да активирате — липсват {0} от {1}",
"submit_ready_title": "изпратете пълния набор от оценки към набора от данни",
"submit_resubmit_title": "вече сте изпратили; щракнете отново, за да презапишете",
"submit_enable_hint": "оценете всеки въпрос, за да активирате",
"save_progress": "💾 Запази прогрес",
"save_progress_title": "качи текущите оценки в submissions/in_progress на набора от данни",
"export_json": "↓ Изтегли JSON",
"export_title": "запази локално копие на оценките",
"admin_badge": "⚙ администратор",
"admin_badge_title": "името ви е в списъка на администраторите — изпращанията се маркират с is_admin=true",
"reset_case": "↺ Изчисти случая",
"reset_all": "✕ Изчисти всичко",
"submitted_badge": "✓ изпратено",
"submitted_badge_title": "вече сте изпратили; следващи промени премахват този етикет",
"pill_idle": "● сървърът е готов",
"pill_submitting": "↻ изпращане…",
"pill_submitted": "✓ изпратено",
"pill_local_only": "💾 само локално",
"pill_error": "✕ грешка при изпращане",
"pill_checking": "● проверка…",
"pill_default_title": "щракнете „Изпрати проучването“, когато сте 100% готови",
"cases_n": "Случаи ({0})",
"case_n_of_m": "Случай {0} от {1}",
"prompt_label": "Подкана / инструкция",
"no_prompt": "(няма подкана)",
"play_all": "▶ Пусни всички",
"pause": "⏸ Пауза",
"restart": "⏮ Започни отначало",
"loop": "↻ Цикъл",
"mute": "🔇 Изключи звук",
"muted": "🔇 Без звук",
"sound": "🔊 Със звук",
"speed": "Скорост:",
"sub_question_col": "Под-въпрос",
"video_label_anon": "Видео {0}",
"video_label_revealed": "Видео {0} · {1}",
"video_col_anon": "Видео {0}",
"video_col_revealed": "{0} · {1}",
"no_video_file": "няма видео файл на този път",
"drop_mp4_hint": "поставете .mp4 файла там и презаредете",
"footer_summary": "Случай {0} / {1} · {2} / {3} оценки за този случай",
"footer_complete_suffix": " · ✓ завършен",
"prev": "← Назад",
"next": "Напред →",
"instructions_toggle": "Как се оценява (щракни за разгъване) — 3 измерения, {0} под-въпроса, по 1–5",
"instructions_dismiss": "Разбрах",
"instructions_intro": "За всеки случай гледайте всички видеа едно до друго и оценявайте <strong>всяко видео самостоятелно</strong> по всеки под-въпрос. Таблицата групира под-въпросите по измерения; видеата са колоните.",
"scale_legend": "Ликертова скала (1 = най-лошо, 5 = най-добро)",
"dimensions_header": "Измерения",
"dim_inst_short": "<strong>Следване на инструкции</strong> — контролируемост, съответствие с подканата, без неуместни кадри.",
"dim_temporal_short": "<strong>Времева съгласуваност</strong> — стабилност на хора / обекти / среда (вкл. анти-трептене).",
"dim_motion_short": "<strong>Реализъм</strong> — реализъм, физика, логика на движението, движение на камерата.",
"tips_header": "Съвети",
"tip_keys": "С <kbd>Space</kbd> пускате / спирате всички видеа синхронно, <kbd>R</kbd> рестартира, <kbd>L</kbd> превключва цикъл.",
"tip_nav": "С <kbd>←</kbd> / <kbd>→</kbd> (или <kbd>j</kbd> / <kbd>k</kbd>) минавате между случаите.",
"tip_autosave": "Оценките се запазват автоматично в браузъра ви — но влизат в сметка чак след като щракнете <strong>Изпрати проучването</strong>.",
"tip_anon": "По подразбиране видеата са анонимизирани — само администратор може да покаже имената на моделите.",
"tip_missing": "Ако плочка показва „няма видео файл“, пътят от манифеста не съществува — поставете истинско <code>.mp4</code> там и презаредете.",
"tip_save_then_submit": "Можете да щракнете <code>💾</code> (Запази прогрес) по всяко време — оценките ви остават запазени. <strong>Когато всички въпроси са попълнени, трябва да щракнете „Изпрати проучването“</strong>, иначе оценките ви не се броят.",
"tip_audio": "За въпроси относно BGM / музика / субтитри слушайте видео звука — превключете бутона за високоговорителя, за да включите звука.",
"scale_1": "Много слабо",
"scale_2": "Слабо",
"scale_3": "Средно",
"scale_4": "Добро",
"scale_5": "Отлично",
"reg_title": "Регистрирайте се, за да започнете оценяване",
"reg_lead": "Записваме кой е изпратил всеки набор от оценки, за да можем да съпоставяме резултатите с участниците и да разрешаваме дубликати. Без парола — само вашето име и имейл, който уникално ви идентифицира.",
"reg_name": "Вашето име",
"reg_name_ph": "напр. Алекс Иванов",
"reg_email": "Имейл",
"reg_email_ph": "напр. alex@example.com",
"reg_note": "Принадлежност / бележки (по избор)",
"reg_note_ph": "напр. INSAIT, вътрешен пилот и т.н.",
"reg_continue": "Продължи →",
"reg_side_note": "Оценките ви се запазват локално в процеса. Изпращането е еднократно финално действие при 100% готовност.",
"reg_foot": "Името можете да промените по-късно в лентата с инструменти. След като изпратите проучването, името + имейлът + оценките се записват в набора от данни <code>dehezhang2/Frameworker_User_Study</code> в Hugging Face.",
"reg_err_name": "Моля, въведете името си.",
"reg_err_email": "Моля, въведете валиден имейл адрес.",
"submitted_title": "Изпращането е записано",
"submitted_thanks": "Благодарим, {0}. Записахме {1} оценки за {2} видеа в набора от данни.",
"submitted_close": "Затвори",
"submitted_download": "↓ Изтегли резервно JSON копие",
"toast_registered_as": "Регистриран(а) като {0}",
"toast_pls_signin": "Моля, влезте преди изпращане",
"toast_missing_first": "Все още липсват {0} оценки — първите: {1}",
"toast_no_server": "Сървърът не е достъпен — използвайте ↓ Изтегли JSON, за да запазите",
"toast_submit_failed_http": "Изпращането се провали (HTTP {0})",
"toast_submit_failed": "Изпращането се провали: {0}",
"toast_exported": "JSON е изтеглен",
"toast_progress_saved": "Прогресът е запазен ({0} оценки)",
"toast_progress_save_failed": "Запазването на прогреса се провали: {0}",
"toast_registered_saved": "Регистриран(а) като {0} — началният час е записан на сървъра",
"toast_cleared_case": "Оценките за {0} са изчистени",
"toast_reset_all": "Всички оценки са изчистени",
"toast_loaded_example": "Заредено cases.example.json — поставете истински cases.json, за да го заместите",
"toast_server_not_configured": "Сървърът не е конфигуриран — използвайте „Изтегли JSON“",
"toast_save_failed": "⚠ грешка при запазване: {0}",
"confirm_reset_case": "Да се изчистят ли всички оценки за {0}?",
"confirm_reset_all": "Да се изчистят всички оценки, регистрация и rater id? Действието е необратимо.",
"pill_server_ready_detail": "сървърът е готов — изпращанията отиват в {0}",
"pill_no_token_detail": "сървърът е достъпен, но липсва тайната HF_TOKEN",
"pill_unhealthy": "сървърът не е здрав",
"pill_health_http": "проверка на здравето HTTP {0}",
"pill_no_endpoint": "няма достъпен /api/submit",
"pill_uploading": "качване в набора от данни…",
"pill_recorded_as": "записано като {0} ({1} оценки)",
"pill_changed_after_submit": "оценките са променени след изпращане — изпратете отново",
"empty_app_title": "Видео потребителско проучване",
"empty_app_intro_html": "Тази страница очаква манифест в <code>./cases.json</code> с описание на случаите за оценяване. Работещ пример има в <code>./cases.example.json</code>.",
"empty_app_format_header": "Формат на cases.json",
"empty_app_serve_header": "След това стартирайте директорията и отворете страницата:",
"empty_app_missing_tip_html": "Съвет: липсващите видеа се показват с раиран „без видео“ заместител, така че можете да подготвите интерфейса преди да съществуват реални <code>.mp4</code> файлове.",
"empty_app_load_error": "Не може да се зареди ./cases.json или ./cases.example.json — стартирайте python -m http.server или поставете манифест в тази директория.",
"empty_app_empty_manifest": "Манифестът е празен — добавете поне един случай в cases.json.",
},
};
// Per-dimension and per-sub-question translations. Keyed by dim.key / q.key.
const RUBRIC_I18N = {
zh: {
instruction_following: { name: "指令遵循", hint: "请先看清提示词,尤其是标红的部分,再作答。" },
temporal_consistency: { name: "时间一致性", hint: "请关注人物、物品和环境在帧间的变化。" },
motion_realism: { name: "真实感", hint: "请留意视频中不和谐或有明显问题的地方。" },
controllability: { name: "可控性",
desc: "视频是否响应控制信号(BGM、字幕等)?" },
prompt_adherence: { name: "提示一致性",
desc: "内容是否与提示的描述一致(主体、动作、场景、时间、故事走向)?以提示词的文本内容为主要依据。" },
no_irrelevant_frames: { name: "无无关画面",
desc: "视频是否仅呈现提示中要求的内容?分数越高 = 越少出现与提示无关的帧或内容。" },
reference_image_match: { name: "参考图一致性",
desc: "若提示词附带参考图(intake_img 案例),视频中的主角形象是否与参考图一致?仅在存在参考图时打分,无参考图自动跳过。" },
realism: { name: "静态真实感",
desc: "单帧的照片级真实感或风格忠实度。若提示词指定了风格,视频应符合该风格。无诡异感或失真伪影。" },
person_stability: { name: "人物稳定性",
desc: "面部、身份及身体比例在帧间保持一致。" },
object_stability: { name: "物体稳定性",
desc: "道具、服饰及物体随时间保持形状和位置。" },
environment_stability: { name: "环境稳定性",
desc: "背景、光照及场景布局在帧间保持连贯(包括无亮度、色彩或纹理闪烁)。" },
motion_logic: { name: "运动逻辑",
desc: "身体与物体的运动是否流畅且符合物理与常识规律(重力、碰撞、流体/布料动力学)?无形变、瞬移、不合常理或不可能的动作。若几乎没有运动,请打低分。" },
camera: { name: "相机运动",
desc: "相机运动(平移、推拉、焦距变化)自然且有意图。若几乎没有相机运动,请打低分。" },
},
bg: {
instruction_following: { name: "Следване на инструкции",
hint: "Прочетете внимателно подканата — особено маркираните в червено фрази — преди да оцените." },
temporal_consistency: { name: "Времева съгласуваност",
hint: "Съсредоточете се върху промените на хора, предмети и среда между кадрите." },
motion_realism: { name: "Реализъм",
hint: "Забележете очевидни проблеми или неестествени моменти във видеото." },
controllability: { name: "Контролируемост",
desc: "Реагира ли видеото на контролни сигнали (BGM, субтитри и т.н.)?" },
prompt_adherence: { name: "Съответствие с подканата",
desc: "Съответства ли съдържанието на описаното в подканата (обекти, действия, обстановка, време и развитие на историята)? Фокусирайте се основно върху текстовото съдържание на подканата." },
no_irrelevant_frames: { name: "Без неуместни кадри",
desc: "Остава ли видеото в рамките на подканата? По-висок = по-малко / без кадри или съдържание, които не са поискани." },
reference_image_match: { name: "Съответствие с референтното изображение",
desc: "Когато подканата включва референтна снимка (случаи intake_img), съответства ли главният герой / външен вид на видеото? Оценява се само при наличие на референтно изображение — иначе се прескача." },
realism: { name: "Статичен реализъм",
desc: "Фотореалистичност или стилова достоверност на отделен кадър. Ако подканата задава стил, видеото трябва да следва този стил. Без странни или счупени артефакти." },
person_stability: { name: "Стабилност на хората",
desc: "Лица, идентичности и пропорции на тялото остават постоянни между кадрите." },
object_stability: { name: "Стабилност на обектите",
desc: "Реквизити, дрехи и обекти запазват форма и позиция във времето." },
environment_stability: { name: "Стабилност на средата",
desc: "Фон, осветление и подредба на сцената остават последователни между кадрите (включително липса на трептене на яркост, цвят или текстура)." },
motion_logic: { name: "Логика на движението",
desc: "Текат ли движенията на тела и обекти плавно и логично? Без деформации, телепортация, нелогични или невъзможни движения. Дайте нисък резултат, ако има малко или никакво движение." },
camera: { name: "Камера",
desc: "Движението на камерата (панорама, дъли, промяна на фокус) изглежда естествено и целенасочено. Дайте нисък резултат, ако има малко или никакво движение на камерата." },
},
};
function t(key, ...args) {
let s = (STRINGS[LANG] && STRINGS[LANG][key]) ?? STRINGS.en[key] ?? key;
if (args.length) {
s = s.replace(/\{(\d+)\}/g, (_, i) => args[Number(i)] ?? "");
}
return s;
}
function dimName(dim) {
if (LANG === "en") return dim.name;
return RUBRIC_I18N[LANG]?.[dim.key]?.name ?? dim.name;
}
function dimHint(dim) {
if (LANG === "en") return dim.hint;
return RUBRIC_I18N[LANG]?.[dim.key]?.hint ?? dim.hint;
}
function qName(q) {
if (LANG === "en") return q.name;
return RUBRIC_I18N[LANG]?.[q.key]?.name ?? q.name;
}
function qDesc(q) {
if (LANG === "en") return q.desc;
return RUBRIC_I18N[LANG]?.[q.key]?.desc ?? q.desc;
}
function scaleLabel(score) {
return t(`scale_${score}`);
}
/* =========================================================================
* State
* ========================================================================= */
let CASES = []; // cases visible to this rater (subset of ALL)
let ALL_CASES = []; // full manifest as fetched from cases.json
const CASES_PER_RATER = 10; // each rater sees 10 of the full set
let VIEW_AS_MODE = false; // admin "view-as" — page is read-only
let VIEW_AS_META = null; // { rater_name, rater_email, ... } for the banner
let SHUFFLE = {}; // case_id -> [orig_idx, ...] (slot -> orig)
let RATINGS = {}; // case_id -> model_name -> { qkey: 1..5 }
let RATER = { id: "", name: "", email: "", note: "",
started_at: null, submitted_at: null };
let currentIdx = 0;
let anonymize = true;
// Per-case active video slot for the tabbed left pane. Defaults to 0 (= the
// first slot in the case-level shuffle order). Reset when the user navigates.
let ACTIVE_SLOT = {};
// Submission status — pill in the toolbar reflects this.
// idle : server reachable but nothing submitted yet
// submitting : POST /api/submit in flight
// submitted : server returned 200 (final write recorded)
// local-only : no /api/submit endpoint reachable (e.g. running locally)
// error : POST /api/submit failed
let SYNC_STATUS = "idle";
let SYNC_LAST_DETAIL = "";
const API_SUBMIT = "./api/submit";
const API_HEALTH = "./api/health";
const API_SAVE_PROGRESS = "./api/save_progress";
const API_ANNOTATIONS = "./api/annotations";
// Admin-curated highlight snippets, fetched at init from /api/annotations.
// Shape: { case_id: { lang: ["snippet 1", "snippet 2", ...] } }
let ADMIN_HIGHLIGHTS = {};
// Debounce window for auto-save after the latest rating change.
const PROGRESS_DEBOUNCE_MS = 60000;
let PROGRESS_TIMER = null;
let LAST_PROGRESS_SAVED_AT = null;
/* =========================================================================
* Storage
* ========================================================================= */
function loadState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch (e) { return null; }
}
function saveState() {
// View-as is read-only — never touch localStorage with the impersonated state.
if (VIEW_AS_MODE) return;
const blob = {
rater: RATER,
anonymize,
lang: LANG,
shuffle: SHUFFLE,
ratings: RATINGS,
saved_at: new Date().toISOString(),
};
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(blob));
} catch (e) {
showToast(t("toast_save_failed", e.message), true);
}
}
function genRaterId() {
return "rater_" + Math.random().toString(36).slice(2, 8) +
"_" + Date.now().toString(36).slice(-4);
}
/* =========================================================================
* Shuffling (anonymization)
* ========================================================================= */
// Per-rater 10-case subset of the manifest. Admins see everything.
//
// Once a (non-admin) rater registers, the 10 case ids they were assigned are
// locked into RATER.chosen_case_ids and written to localStorage + every
// server-side in-progress save. On subsequent visits — and on a fresh
// device, via GET /api/rater_state — the locked list is restored verbatim
// so the rater always sees the same 10 cases regardless of any future
// manifest changes (new cases, reordering, etc.).
function pickRandomSubsetIds(allCases) {
// Deterministic Fisher–Yates seeded by rater id, sorted back into manifest
// order. Returns CASES_PER_RATER ids.
const idx = allCases.map((_, i) => i);
let s = hashStr((RATER.id || "anon") + ":case-subset");
for (let k = 0; k < 6; k++) s = (s * 1664525 + 1013904223) >>> 0;
for (let i = idx.length - 1; i > 0; i--) {
s = (s * 1664525 + 1013904223) >>> 0;
const j = (s >>> 16) % (i + 1);
[idx[i], idx[j]] = [idx[j], idx[i]];
}
return idx.slice(0, CASES_PER_RATER).sort((a, b) => a - b)
.map(i => allCases[i].id);
}
function selectCasesForRater(allCases) {
if (isAdmin()) return allCases.slice();
if (allCases.length <= CASES_PER_RATER) return allCases.slice();
// Locked list wins if present and at least partially valid.
const locked = Array.isArray(RATER.chosen_case_ids) ? RATER.chosen_case_ids : null;
if (locked && locked.length) {
const byId = new Map(allCases.map(c => [c.id, c]));
const out = locked.map(id => byId.get(id)).filter(Boolean);
if (out.length >= Math.min(CASES_PER_RATER, allCases.length)) return out;
}
// First time for this rater: pick and lock.
const ids = pickRandomSubsetIds(allCases);
RATER.chosen_case_ids = ids;
// saveState() will be called by the caller; we don't call it here to keep
// this function pure-ish, but the locked list is now on the in-memory RATER.
const byId = new Map(allCases.map(c => [c.id, c]));
return ids.map(id => byId.get(id)).filter(Boolean);
}
function buildShuffle(cases) {
// Deterministic per (rater, case) and ALWAYS recomputed on load. The
// recomputation is idempotent (same seed → same order), so it costs nothing
// for raters whose cached SHUFFLE already matches; it also forces a refresh
// of any stale per-case order saved by older code paths (e.g. when the
// admin reveal toggle used to short-circuit the shuffle and lock ours into
// slot A). The shuffle is independent of the reveal toggle now — the
// toggle only controls whether the model name is rendered next to the
// slot letter.
for (const c of cases) {
const indices = c.videos.map((_, i) => i);
let s = hashStr(RATER.id + ":" + c.id);
for (let k = 0; k < 6; k++) s = (s * 1664525 + 1013904223) >>> 0;
for (let i = indices.length - 1; i > 0; i--) {
s = (s * 1664525 + 1013904223) >>> 0;
const j = (s >>> 16) % (i + 1);
[indices[i], indices[j]] = [indices[j], indices[i]];
}
SHUFFLE[c.id] = indices;
}
}
function hashStr(s) {
let h = 2166136261 >>> 0;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = (h * 16777619) >>> 0;
}
return h;
}
/* =========================================================================
* Rating helpers
* ========================================================================= */
function getCaseRatings(caseId) {
if (!RATINGS[caseId]) RATINGS[caseId] = {};
return RATINGS[caseId];
}
// Translate (caseId, slotIdx) → the model name in that slot under the
// current shuffle. Returns null if the case / slot isn't known yet.
function modelForSlot(caseId, slotIdx) {
const c = CASES.find(x => x.id === caseId);
const slots = SHUFFLE[caseId];
if (!c || !slots) return null;
const origIdx = slots[slotIdx];
return c.videos[origIdx]?.model ?? null;
}
function getModelRatings(caseId, model) {
const m = getCaseRatings(caseId);
if (!m[model]) m[model] = {};
return m[model];
}
// Back-compat wrapper for the per-slot path in render code; resolves slot →
// model and returns the matching ratings dict (auto-creates if absent).
function getSlotRatings(caseId, slotIdx) {
const model = modelForSlot(caseId, slotIdx);
if (!model) return {};
return getModelRatings(caseId, model);
}
function setRating(caseId, slotIdx, qkey, score) {
if (VIEW_AS_MODE) {
showToast("View-as is read-only — open the regular study to rate.", true);
return;
}
const model = modelForSlot(caseId, slotIdx);
if (!model) return;
getModelRatings(caseId, model)[qkey] = score;
saveState();
updateProgress();
updateCaseStatus(caseId);
updateSubmitButton();
// Editing after submitting clears the "submitted" badge so the user can
// re-submit a corrected payload.
if (RATER.submitted_at) {
RATER.submitted_at = null;
saveState();
setSyncStatus(SYNC_STATUS === "local-only" ? "local-only" : "idle",
t("pill_changed_after_submit"));
}
// Debounced server-side progress save (one file per rater, overwritten).
scheduleProgressSave();
}
/* =========================================================================
* Final submission — POST the whole study to /api/submit when complete.
* Per-case auto-sync was removed deliberately: writing partial files for
* every keystroke would flood the dataset with throwaway commits.
* ========================================================================= */
function buildFullSubmissionPayload() {
return {
rater: RATER,
anonymize_used: anonymize,
rubric: DIMENSIONS,
submitted_at: new Date().toISOString(),
cases: CASES.map(c => {
const slots = SHUFFLE[c.id] || c.videos.map((_, i) => i);
return {
id: c.id,
category: c.category || null,
prompt: c.prompt,
videos: slots.map((origIdx, slotIdx) => {
const v = c.videos[origIdx];
return {
slot: String.fromCharCode(65 + slotIdx),
slot_index: slotIdx,
original_index: origIdx,
model: v.model,
path: v.path,
ratings: (RATINGS[c.id] || {})[v.model] || {},
};
}),
};
}),
};
}
// Returns { done, expected, missing: [{case_id, slot, q}, ...] } for the entire study.
// Returns the rubric questions actually applicable to a given case. Skips
// `only_if_reference_image: true` questions when the case has no reference
// photo.
function questionsForCase(c) {
const hasRef = !!c.reference_image;
return DIMENSIONS.flatMap(d => d.questions.filter(q => !q.only_if_reference_image || hasRef));
}
function studyCompleteness() {
const missing = [];
let done = 0, expected = 0;
for (const c of CASES) {
const slots = SHUFFLE[c.id] || c.videos.map((_, i) => i);
const qs = questionsForCase(c);
for (let slotIdx = 0; slotIdx < slots.length; slotIdx++) {
const origIdx = slots[slotIdx];
const model = c.videos[origIdx].model;
const rs = (RATINGS[c.id] || {})[model] || {};
const slotLetter = String.fromCharCode(65 + slotIdx);
for (const q of qs) {
expected++;
const v = rs[q.key];
if (typeof v === "number" && v >= 1 && v <= 5) done++;
else missing.push({ case_id: c.id, slot: slotLetter, q: q.key, model });
}
}
}
return { done, expected, missing };
}
async function submitWholeStudy() {
if (VIEW_AS_MODE) {
showToast("View-as is read-only — submit is disabled.", true);
return;
}
if (!RATER.email || !RATER.name) {
showToast(t("toast_pls_signin"), true);
showLoginOverlay(true);
return;
}
const comp = studyCompleteness();
if (comp.missing.length > 0) {
const first = comp.missing.slice(0, 3)
.map(m => `${m.case_id}/${m.slot}/${m.q}`).join(", ");
showToast(t("toast_missing_first", comp.missing.length, first), true);
// Jump to the first incomplete case to make it easy to finish.
const firstCaseId = comp.missing[0].case_id;
const idx = CASES.findIndex(c => c.id === firstCaseId);
if (idx >= 0) goTo(idx);
return;
}
if (SYNC_STATUS === "local-only") {
showToast(t("toast_no_server"), true);
return;
}
setSyncStatus("submitting", t("pill_uploading"));
const payload = buildFullSubmissionPayload();
try {
const resp = await fetch(API_SUBMIT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.ok) {
const body = await resp.json().catch(() => ({}));
RATER.submitted_at = new Date().toISOString();
saveState();
setSyncStatus("submitted",
t("pill_recorded_as", body.stored_as || "submission", body.n_scores ?? "?"));
updateSubmitButton();
showSubmittedOverlay(body);
} else if (resp.status === 404 || resp.status === 503) {
setSyncStatus("local-only", `HTTP ${resp.status}`);
showToast(t("toast_server_not_configured"), true);
} else {
const text = await resp.text().catch(() => "");
setSyncStatus("error", `HTTP ${resp.status}: ${text.slice(0, 120)}`);
showToast(t("toast_submit_failed_http", resp.status), true);
}
} catch (e) {
setSyncStatus("local-only", "POST /api/submit failed: " + e.message);
showToast(t("toast_submit_failed", e.message), true);
}
}
/* ----- Mid-study progress save ---------------------------------------- */
// Returns the same shape as the final-submission payload but tolerates
// incomplete ratings. The server writes this to submissions/in_progress/<slug>.json
// (one file per rater, overwritten on each save) so progress survives across
// sessions even if localStorage is wiped.
function buildProgressPayload() {
const comp = studyCompleteness();
return {
rater: RATER,
anonymize_used: anonymize,
is_admin: isAdmin(),
rubric: DIMENSIONS,
progress_saved_at: new Date().toISOString(),
completeness: {
done: comp.done,
expected: comp.expected,
missing_count: comp.missing.length,
},
cases: CASES.map(c => {
const slots = SHUFFLE[c.id] || c.videos.map((_, i) => i);
return {
id: c.id,
category: c.category || null,
prompt: c.prompt,
videos: slots.map((origIdx, slotIdx) => {
const v = c.videos[origIdx];
return {
slot: String.fromCharCode(65 + slotIdx),
slot_index: slotIdx,
original_index: origIdx,
model: v.model,
path: v.path,
ratings: (RATINGS[c.id] || {})[v.model] || {},
};
}),
};
}),
};
}
async function saveProgress(opts = {}) {
if (VIEW_AS_MODE) return; // never write back when viewing as someone else
// opts: { silent: bool, source: "register" | "auto" | "manual" | "unload" }
const silent = !!opts.silent;
if (!RATER.email || !RATER.name) return; // no rater yet
if (SYNC_STATUS === "local-only" && opts.source !== "manual") return;
const payload = buildProgressPayload();
try {
const resp = await fetch(API_SAVE_PROGRESS, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.ok) {
const body = await resp.json().catch(() => ({}));
LAST_PROGRESS_SAVED_AT = new Date().toISOString();
if (!silent) showToast(t("toast_progress_saved", body.n_scores ?? 0));
} else if (resp.status === 404 || resp.status === 503) {
// Server not present (e.g. running against a static host); switch off
// auto-save so we don't keep retrying every minute.
setSyncStatus("local-only", `save_progress HTTP ${resp.status}`);
} else if (!silent) {
showToast(t("toast_progress_save_failed", "HTTP " + resp.status), true);
}
} catch (e) {
if (!silent) showToast(t("toast_progress_save_failed", e.message), true);
}
}
function scheduleProgressSave() {
if (!RATER.email) return;
if (SYNC_STATUS === "local-only") return;
if (PROGRESS_TIMER) clearTimeout(PROGRESS_TIMER);
PROGRESS_TIMER = setTimeout(() => {
PROGRESS_TIMER = null;
saveProgress({ silent: true, source: "auto" });
}, PROGRESS_DEBOUNCE_MS);
}
// Fire-and-forget best-effort save when the tab is closed or navigated away.
// `fetch` from beforeunload is unreliable; sendBeacon is designed for this.
function beaconProgress() {
if (!RATER.email || !RATER.name) return;
if (SYNC_STATUS === "local-only") return;
try {
const blob = new Blob(
[JSON.stringify(buildProgressPayload())],
{ type: "application/json" });
navigator.sendBeacon(API_SAVE_PROGRESS, blob);
} catch (e) { /* best-effort, ignore */ }
}
function setSyncStatus(state, detail) {
SYNC_STATUS = state;
if (detail) SYNC_LAST_DETAIL = detail;
const pill = document.getElementById("syncPill");
if (!pill) return;
const labelKey = {
idle: "pill_idle",
submitting: "pill_submitting",
submitted: "pill_submitted",
"local-only": "pill_local_only",
error: "pill_error",
}[state];
pill.textContent = labelKey ? t(labelKey) : state;
pill.dataset.state = state;
pill.title = SYNC_LAST_DETAIL || t("pill_default_title");
}
async function probeSyncEndpoint() {
try {
const resp = await fetch(API_HEALTH, { method: "GET", cache: "no-cache" });
if (resp.ok) {
const j = await resp.json().catch(() => ({}));
const ok = j.ok && j.has_token;
setSyncStatus(ok ? "idle" : "local-only",
ok ? t("pill_server_ready_detail", j.dataset || "dataset")
: (j.has_token === false ? t("pill_no_token_detail") : t("pill_unhealthy")));
} else {
setSyncStatus("local-only", t("pill_health_http", resp.status));
}
} catch (e) {
setSyncStatus("local-only", t("pill_no_endpoint"));
}
}
function caseProgress(caseCase) {
const slots = SHUFFLE[caseCase.id] || [];
const expected = slots.length * TOTAL_QUESTIONS;
let done = 0;
for (let s = 0; s < slots.length; s++) {
const origIdx = slots[s];
const model = caseCase.videos[origIdx]?.model;
const r = (RATINGS[caseCase.id] || {})[model] || {};
done += Object.keys(r).length;
}
return { done, expected };
}
function totalProgress() {
let done = 0, expected = 0;
for (const c of CASES) {
const p = caseProgress(c);
done += p.done; expected += p.expected;
}
return { done, expected };
}
/* =========================================================================
* DOM helpers
* ========================================================================= */
function el(tag, attrs = {}, ...children) {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === "class") e.className = v;
else if (k === "html") e.innerHTML = v;
else if (k.startsWith("on") && typeof v === "function") {
e.addEventListener(k.slice(2).toLowerCase(), v);
} else if (v !== false && v != null) e.setAttribute(k, v);
}
for (const c of children) {
if (c == null || c === false) continue;
e.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
}
return e;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]);
}
/* Patterns flagging controllability cues — imperative verbs and the
* directive phrases that follow. Returned matches are wrapped in a
* <span class="ctrl"> so the rater can immediately see what the prompt
* actually controls (subjects, identities, sound design, subtitles, BGM).
* Per-language because the imperative forms differ. */
const CTRL_PATTERNS = {
en: [
// "Add ambient sounds: ..." / "Add a XXX BGM." / "Add Chinese subtitles ..."
/\bAdd\b[^.]*\./g,
// "Use this uploaded photo as the look of the cultivation male lead."
/\bUse this[^.]*\./g,
// Leading "Make a <genre> animated short drama" up to the em-dash.
/Make a[^—–]*?animated short drama/g,
],
zh: [
/制作一部[^—–]*?短剧/g,
/请?添加[^。]*。/g,
/请使用上传的[^。]*。/g,
/配以[^。]*。/g,
/环境音[::][^。]*。/g,
],
bg: [
/Създайте[^—–]*?анимационна кратка драма/g,
/Добавете[^.]*\./g,
/Използвайте качената[^.]*\./g,
],
};
function highlightControllability(text, lang, caseId) {
// Operate on the escaped string so the wrap-spans we inject stay the only
// un-escaped HTML in the output.
let html = escapeHtml(text);
const patterns = CTRL_PATTERNS[lang] || CTRL_PATTERNS.en;
// Two source of ranges: auto-detected (CTRL_PATTERNS) + admin-curated
// snippets loaded from /api/annotations. We tag each range with its kind
// so the rendered span can carry a class telling them apart.
const ranges = []; // [start, end, kind]
for (const re of patterns) {
re.lastIndex = 0;
let m;
while ((m = re.exec(html)) !== null) {
ranges.push([m.index, m.index + m[0].length, "auto"]);
if (m[0].length === 0) re.lastIndex++; // safety vs zero-width
}
}
const adminSnippets = (caseId && ADMIN_HIGHLIGHTS[caseId]?.[lang]) || [];
for (const snippet of adminSnippets) {
if (!snippet) continue;
const esc = escapeHtml(snippet);
let idx = 0;
while ((idx = html.indexOf(esc, idx)) !== -1) {
ranges.push([idx, idx + esc.length, "admin"]);
idx += esc.length || 1;
}
}
if (!ranges.length) return html;
// Merge overlapping / adjacent ranges; if any chunk of the merged range
// was admin-tagged, the whole merged span gets the admin class.
ranges.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const merged = [ranges[0].slice()];
for (let i = 1; i < ranges.length; i++) {
const top = merged[merged.length - 1];
// Strict `<`: only merge ranges that actually overlap. Adjacent ranges
// (one ending exactly where the next begins) stay as separate spans,
// which keeps Chinese punctuation-flush sentences highlighted as two
// distinct red regions matching the en/bg layouts.
if (ranges[i][0] < top[1]) {
top[1] = Math.max(top[1], ranges[i][1]);
if (ranges[i][2] === "admin") top[2] = "admin";
} else merged.push(ranges[i].slice());
}
// Splice in spans from right to left so earlier indices stay valid.
let out = html;
for (let i = merged.length - 1; i >= 0; i--) {
const [s, e, kind] = merged[i];
const cls = kind === "admin" ? "ctrl ctrl-admin" : "ctrl";
out = out.slice(0, s)
+ `<span class="${cls}">` + out.slice(s, e) + '</span>'
+ out.slice(e);
}
return out;
}
function showToast(msg, isError = false) {
const t = document.getElementById("toast");
t.textContent = msg;
t.style.background = isError ? "var(--bad)" : "var(--good)";
t.style.color = isError ? "#fff" : "#0a1a0a";
t.classList.add("show");
setTimeout(() => t.classList.remove("show"), 1800);
}
/* =========================================================================
* Rendering — shell
* ========================================================================= */
function renderShell() {
const app = document.getElementById("app");
app.innerHTML = "";
app.appendChild(renderHeader());
app.appendChild(renderToolbar());
const main = el("main");
// Thin vertical rail on the left edge that re-opens the (default-hidden)
// sidebar. Visible via CSS only when body lacks `.sidebar-expanded`.
main.appendChild(el("div", {
class: "sidebar-rail",
title: "Show cases sidebar",
onclick: openSidebar,
}, "› Cases"));
main.appendChild(renderSidebar());
main.appendChild(el("section", { class: "detail", id: "detail" }));
app.appendChild(main);
app.appendChild(renderFooter());
renderDetail();
updateProgress();
updateSubmitButton();
// Refresh the sync pill text from whatever state we were last in.
setSyncStatus(SYNC_STATUS, SYNC_LAST_DETAIL);
// Re-attach the view-as banner — renderShell wipes #app on every re-render
// (e.g. when the admin clicks Prev/Next or switches case in the sidebar).
if (VIEW_AS_MODE) addViewAsBanner();
}
function addViewAsBanner() {
const app = document.getElementById("app");
if (!app || document.getElementById("viewAsBanner")) return;
const who = VIEW_AS_META?.rater_name || VIEW_AS_META?.rater_email || "(unknown)";
const bar = el("div", { id: "viewAsBanner",
style: "background:#3a2f60;color:#d9b6ff;border-bottom:1px solid #5a4080;"
+ "padding:8px 16px;display:flex;align-items:center;gap:12px;"
+ "font-size:13px;font-weight:600;" },
el("span", {}, `👁 Viewing as ${who} · read-only`),
el("a", { href: "./admin.html", target: "_self",
style: "margin-left:auto;color:#d9b6ff;text-decoration:underline;font-size:12px" },
"← back to admin"),
el("button", {
style: "background:#5a4080;border:1px solid #6a4f90;color:#fff;"
+ "padding:4px 10px;border-radius:4px;cursor:pointer;font-size:12px",
onclick: () => {
try { sessionStorage.removeItem("user_study_view_as"); } catch (e) {}
location.replace("./");
},
}, "✕ Exit view-as"),
);
app.insertBefore(bar, app.firstChild);
}
function renderHeader() {
// Title + subtitle removed per request — only the progress bar remains in
// the top strip. The sidebar toggle now lives inside the sidebar header,
// not here.
return el("header", { class: "top" },
el("div", { class: "progress" },
el("div", { class: "progress-bar" },
el("div", { class: "progress-fill", id: "progressFill" })),
el("span", { class: "progress-text", id: "progressText" }, "0 / 0"),
),
);
}
function openSidebar() {
document.body.classList.add("sidebar-expanded");
try { localStorage.setItem("user_study_sidebar_expanded", "1"); } catch (e) {}
renderShell();
}
function closeSidebar() {
document.body.classList.remove("sidebar-expanded");
try { localStorage.setItem("user_study_sidebar_expanded", "0"); } catch (e) {}
renderShell();
}
function renderLangSelect() {
const sel = el("select", {
title: t("lang_label"),
onchange: (e) => {
LANG = e.target.value;
document.documentElement.lang = LANG;
saveState();
renderShell();
applyRegistrationOverlayI18n();
},
});
for (const L of LANGUAGES) {
const opt = el("option", { value: L.code }, L.label);
if (L.code === LANG) opt.setAttribute("selected", "");
sel.appendChild(opt);
}
return sel;
}
function renderToolbar() {
const tb = el("div", { class: "toolbar" });
const nameInput = el("input", {
type: "text",
placeholder: t("rater_placeholder"),
value: RATER.name || "",
title: t("rater_edit_title"),
onchange: (e) => {
const newName = e.target.value.trim();
if (!newName) { e.target.value = RATER.name; return; }
RATER.name = newName;
// Admin status may have changed → re-pick the visible case subset.
CASES = selectCasesForRater(ALL_CASES);
SHUFFLE = {}; buildShuffle(CASES);
saveState();
renderShell();
},
});
tb.appendChild(el("div", { class: "rater-box" },
el("span", {}, t("rater")),
nameInput,
el("span", {
style: "color:var(--muted-2);font-size:11px;font-family:ui-monospace,monospace"
}, RATER.email || ""),
));
if (isAdmin()) {
tb.appendChild(el("span", { class: "submitted-badge",
style: "background:#3a2f60;color:#d9b6ff;border-color:#5a4080",
title: t("admin_badge_title") },
t("admin_badge")));
}
if (RATER.submitted_at) {
tb.appendChild(el("span", { class: "submitted-badge",
title: t("submitted_badge_title") },
t("submitted_badge")));
}
tb.appendChild(el("div", { class: "sep" }));
// Admin-only reveal toggle + a link to the stats dashboard. Non-admins
// always see anonymized view (and anonymize is force-set to true on first
// paint below).
if (isAdmin()) {
tb.appendChild(el("button", {
class: "btn ghost",
onclick: toggleAnon,
}, anonymize ? t("anonymized") : t("reveal_models")));
tb.appendChild(el("a", {
class: "btn ghost",
href: "./admin.html", target: "_blank", rel: "noopener",
style: "text-decoration:none",
title: "open the admin dashboard (password-gated)",
}, "🛠 Admin"));
tb.appendChild(el("div", { class: "sep" }));
}
tb.appendChild(el("span", {
id: "syncPill", class: "sync-pill",
"data-state": SYNC_STATUS,
title: SYNC_LAST_DETAIL || t("pill_default_title"),
}, t("pill_checking")));
tb.appendChild(el("div", { class: "sep" }));
tb.appendChild(el("button", {
id: "submitBtn",
class: "btn submit-primary",
onclick: submitWholeStudy,
disabled: "",
title: t("submit_enable_hint"),
}, t("submit_pending", 0)));
tb.appendChild(el("button", {
class: "btn ghost",
onclick: () => saveProgress({ silent: false, source: "manual" }),
title: t("save_progress_title"),
}, t("save_progress")));
tb.appendChild(el("button", {
class: "btn ghost",
onclick: exportJSON,
title: t("export_title"),
}, t("export_json")));
tb.appendChild(el("button", {
class: "btn ghost",
onclick: resetCurrentCase,
}, t("reset_case")));
tb.appendChild(el("button", {
class: "btn ghost",
onclick: resetAll,
}, t("reset_all")));
tb.appendChild(el("div", { class: "sep" }));
tb.appendChild(el("div", { class: "rater-box" },
el("span", {}, t("lang_label") + ":"),
renderLangSelect(),
));
return tb;
}
function updateSubmitButton() {
const btn = document.getElementById("submitBtn");
if (!btn) return;
const comp = studyCompleteness();
const remaining = comp.expected - comp.done;
if (remaining > 0) {
btn.disabled = true;
btn.textContent = t("submit_pending", remaining);
btn.title = t("submit_disabled_title", remaining, comp.expected);
} else if (SYNC_STATUS === "submitted") {
btn.disabled = false;
btn.textContent = t("submit_resubmit");
btn.title = t("submit_resubmit_title");
} else {
btn.disabled = false;
btn.textContent = t("submit_ready", comp.expected);
btn.title = t("submit_ready_title");
}
}
function renderSidebar() {
const aside = el("aside", { class: "list" });
aside.appendChild(el("div", { class: "list-header" },
el("span", {}, t("cases_n", CASES.length)),
el("button", {
class: "hide-btn",
title: "Hide cases sidebar",
onclick: closeSidebar,
}, "✕"),
));
CASES.forEach((c, i) => {
const p = caseProgress(c);
let cls = "case-row";
if (i === currentIdx) cls += " active";
if (p.done === 0) { /* untouched */ }
else if (p.done < p.expected) cls += " partial";
else cls += " complete";
aside.appendChild(el("div", {
class: cls,
"data-case-id": c.id,
onclick: () => goTo(i),
},
el("span", { class: "status-dot" }),
el("span", { class: "id" }, c.id),
el("span", { style: "font-size:11px;color:var(--muted)" },
`${p.done}/${p.expected}`),
));
});
return aside;
}
function renderFooter() {
return el("footer", { class: "bottom" },
el("div", { class: "summary", id: "footerSummary" }),
el("a", {
href: "./mobile.html?force=mobile",
style: "font-size:11px;color:var(--muted);text-decoration:none;"
+ "padding:4px 10px;border:1px solid var(--border);border-radius:12px;"
+ "background:var(--panel-3);margin-right:8px",
title: "switch to the phone-optimized layout",
}, "📱 Mobile"),
el("div", { class: "nav-btns" },
el("button", { class: "btn", onclick: () => step(-1) }, t("prev")),
el("button", { class: "btn", onclick: () => step(1) }, t("next")),
),
);
}
/* =========================================================================
* Rendering — detail (instructions + videos + ratings)
* ========================================================================= */
function renderDetail() {
const detail = document.getElementById("detail");
if (!detail) return;
detail.innerHTML = "";
if (!CASES.length) return;
detail.appendChild(renderInstructions(currentIdx === 0));
const c = CASES[currentIdx];
// Two-pane layout: prompt + tabbed video on the left (sticky); scrollable
// question cards on the right. The user picks A/B/C to switch which video
// plays on the left while the right pane lists every rubric question with
// one rating row per video.
const wrap = el("div", { class: "case-pane-wrap" });
wrap.appendChild(renderLeftPane(c));
wrap.appendChild(renderRightPane(c));
detail.appendChild(wrap);
bindAutoCollapseInstructions();
const right = detail.querySelector(".right-pane");
right?.__resetAutoCollapse?.();
if (right) right.scrollTop = 0;
// Update sidebar active row + footer
document.querySelectorAll(".case-row").forEach((row, i) => {
row.classList.toggle("active", i === currentIdx);
});
updateFooterSummary();
}
function getActiveSlot(caseId) {
return (ACTIVE_SLOT[caseId] ?? 0);
}
function setActiveSlot(caseId, slotIdx) {
pauseAll();
ACTIVE_SLOT[caseId] = slotIdx;
// Re-render only the left pane (prompt + tabs + active video + controls)
// so the right-pane question cards keep their scroll position.
const wrap = document.querySelector(".case-pane-wrap");
if (!wrap) return;
const newLeft = renderLeftPane(CASES[currentIdx]);
const oldLeft = wrap.querySelector(".left-pane");
if (oldLeft) wrap.replaceChild(newLeft, oldLeft);
}
function renderLeftPane(c) {
// Order: case meta (id + N-of-M) → video tabs + active video → controls
// → prompt-box (flex-grows to fill the rest of the pane so there's no
// empty space at the bottom). Reference image, when present, sits inside
// the prompt-box block as a small thumbnail.
const pane = el("div", { class: "left-pane" });
pane.appendChild(renderCaseMeta(c));
pane.appendChild(renderVideoPanel(c));
pane.appendChild(renderVideoControls(c));
pane.appendChild(renderPromptBox(c));
return pane;
}
function renderCaseMeta(c) {
return el("div", { class: "case-meta",
style: "display:flex;align-items:baseline;gap:10px;flex-wrap:wrap" },
el("span", { class: "case-id",
style: "font-family:ui-monospace,monospace;font-size:18px;font-weight:600" }, c.id),
el("span", { class: "case-counter",
style: "color:var(--muted);font-size:12px" },
t("case_n_of_m", currentIdx + 1, CASES.length)),
);
}
function renderPromptBox(c) {
const wrap = el("div", { class: "prompt-box" });
wrap.appendChild(el("div", { class: "label" }, t("prompt_label")));
const localized = (c.prompt_i18n && c.prompt_i18n[LANG]) || c.prompt;
const txt = el("div", { class: "prompt-text" });
txt.innerHTML = highlightControllability(localized || t("no_prompt"), LANG, c.id);
wrap.appendChild(txt);
if (isAdmin()) wrap.appendChild(renderAdminHighlightToolbar(c, localized));
if (c.reference_image) {
const img = el("img", {
class: "ref-img", src: c.reference_image, alt: "reference",
onerror: function () { this.style.display = "none"; },
});
wrap.appendChild(img);
}
return wrap;
}
function renderAdminHighlightToolbar(c, promptText) {
// Admin-only block inside the prompt-box. Lets an admin highlight key
// phrases in the prompt that all raters then see in bold red.
const wrap = el("div", { class: "hi-toolbar" });
const row = el("div", { class: "hi-row" });
row.appendChild(el("button", {
class: "btn ghost",
style: "padding:3px 10px;font-size:11px",
onclick: () => addCurrentSelectionHighlight(c),
title: "Select text in the prompt above, then click to mark it",
}, "+ Mark"));
row.appendChild(el("span", {
style: "color:var(--muted-2);font-size:11px;flex:1" },
"Select a phrase in the prompt above, then click + Mark to highlight it for every rater."));
wrap.appendChild(row);
const existing = (ADMIN_HIGHLIGHTS[c.id]?.[LANG]) || [];
if (existing.length) {
const list = el("div", { class: "hi-list" });
for (const snippet of existing) {
const tag = el("span", { class: "hi-tag", title: snippet },
el("span", { class: "hi-tag-text" }, snippet.length > 48 ? snippet.slice(0, 46) + "…" : snippet),
el("button", {
class: "hi-tag-x",
title: "Remove this highlight",
onclick: () => removeHighlight(c, snippet),
}, "✕"),
);
list.appendChild(tag);
}
wrap.appendChild(list);
}
return wrap;
}
async function postAnnotations(caseId, lang, ranges) {
try {
const r = await fetch(API_ANNOTATIONS, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
rater: RATER, case_id: caseId, lang, ranges,
}),
});
if (!r.ok) {
const text = await r.text().catch(() => "");
showToast(`Highlight save failed (HTTP ${r.status}): ${text.slice(0, 80)}`, true);
return false;
}
const j = await r.json().catch(() => ({}));
// Update local cache from the server's canonical response
if (!ADMIN_HIGHLIGHTS[caseId]) ADMIN_HIGHLIGHTS[caseId] = {};
ADMIN_HIGHLIGHTS[caseId][lang] = Array.isArray(j.ranges) ? j.ranges : ranges;
return true;
} catch (e) {
showToast("Highlight save failed: " + e.message, true);
return false;
}
}
function addCurrentSelectionHighlight(c) {
const sel = window.getSelection();
if (!sel || sel.isCollapsed) { showToast("Select text in the prompt first", true); return; }
const text = sel.toString().trim();
if (!text || text.length < 2) { showToast("Selection too short", true); return; }
// Make sure the selection is anchored inside the current prompt-text node.
const promptNode = document.querySelector(".prompt-box .prompt-text");
if (!promptNode || !promptNode.contains(sel.anchorNode)) {
showToast("Select inside the prompt only", true);
return;
}
const promptStr = (c.prompt_i18n && c.prompt_i18n[LANG]) || c.prompt || "";
if (!promptStr.includes(text)) {
// Selection may span a controllability <span> boundary — collapse
// whitespace and retry.
const norm = text.replace(/\s+/g, " ");
if (!promptStr.replace(/\s+/g, " ").includes(norm)) {
showToast("Selection does not match the prompt text", true);
return;
}
}
const cur = (ADMIN_HIGHLIGHTS[c.id]?.[LANG]) || [];
if (cur.includes(text)) { showToast("Already highlighted"); return; }
const next = [...cur, text];
postAnnotations(c.id, LANG, next).then(ok => {
if (ok) {
showToast(`Marked: ${text.slice(0, 40)}${text.length > 40 ? "…" : ""}`);
sel.removeAllRanges();
renderDetail();
}
});
}
function removeHighlight(c, snippet) {
const cur = (ADMIN_HIGHLIGHTS[c.id]?.[LANG]) || [];
const next = cur.filter(s => s !== snippet);
postAnnotations(c.id, LANG, next).then(ok => {
if (ok) { showToast("Removed"); renderDetail(); }
});
}
function renderRightPane(c) {
const pane = el("div", { class: "right-pane" });
const slots = SHUFFLE[c.id];
for (const dim of DIMENSIONS) {
const dh = el("div", { class: "dim-header" },
el("span", {}, dimName(dim)),
el("span", { class: "dim-hint" }, dimHint(dim)));
pane.appendChild(dh);
for (const q of dim.questions) {
// Skip "only_if_reference_image" questions for cases without one.
if (q.only_if_reference_image && !c.reference_image) continue;
pane.appendChild(renderQuestionCard(c, q, slots));
}
}
return pane;
}
function renderVideoPanel(c) {
const slots = SHUFFLE[c.id];
const active = getActiveSlot(c.id);
const wrap = el("div", { class: "video-panel" });
// Tab buttons (one per slot). Letters stay stable per case; admin mode
// also shows the model name after the letter.
const tabs = el("div", { class: "video-tabs" });
slots.forEach((origIdx, slotIdx) => {
const letter = String.fromCharCode(65 + slotIdx);
const v = c.videos[origIdx];
const label = anonymize ? letter : `${letter} · ${v.model}`;
tabs.appendChild(el("button", {
class: "tab-btn" + (slotIdx === active ? " active" : ""),
onclick: () => setActiveSlot(c.id, slotIdx),
title: anonymize ? t("video_label_anon", letter)
: t("video_label_revealed", letter, v.model),
}, label));
});
wrap.appendChild(tabs);
// All video tiles stay in the DOM so each <video> keeps its own playback
// state (currentTime, paused) across tab switches. Only the active one is
// visible via .hidden / display:none.
slots.forEach((origIdx, slotIdx) => {
const tile = renderSingleVideoTile(c, origIdx, slotIdx);
if (slotIdx !== active) tile.classList.add("hidden");
wrap.appendChild(tile);
});
return wrap;
}
function renderSingleVideoTile(c, origIdx, slotIdx) {
const v = c.videos[origIdx];
const tile = el("div", { class: "video-tile", "data-slot-idx": String(slotIdx) });
const frame = el("div", { class: "video-frame" });
const video = el("video", {
"data-slot": String(slotIdx),
"data-case": c.id,
preload: "metadata",
playsinline: "",
controls: "",
});
const source = el("source", { src: v.path, type: "video/mp4" });
video.appendChild(source);
const empty = el("div", { class: "empty-state", style: "display:none" },
el("div", { class: "icon" }, "▶"),
el("div", {}, t("no_video_file")),
el("div", { class: "path" }, v.path),
el("div", { style: "font-size:11px;color:var(--muted-2);margin-top:4px" },
t("drop_mp4_hint")),
);
const onErr = () => { empty.style.display = "flex"; video.style.display = "none"; };
video.addEventListener("error", onErr);
source.addEventListener("error", onErr);
frame.appendChild(video);
frame.appendChild(empty);
tile.appendChild(frame);
return tile;
}
function renderQuestionCard(c, q, slots) {
const card = el("div", { class: "q-card" });
card.appendChild(el("div", { class: "q-name" }, qName(q)));
card.appendChild(el("div", { class: "q-desc" }, qDesc(q)));
const order = rowSlotOrder(c.id, q.key, slots.length);
for (const slotIdx of order) {
card.appendChild(renderRatingRow(c, slotIdx, q.key));
}
return card;
}
function renderRatingRow(c, slotIdx, qkey) {
const slots = SHUFFLE[c.id];
const origIdx = slots[slotIdx];
const v = c.videos[origIdx];
const letter = String.fromCharCode(65 + slotIdx);
const row = el("div", { class: "video-rate" });
row.appendChild(el("span", {
class: "slot-chip",
title: anonymize ? t("video_label_anon", letter)
: t("video_label_revealed", letter, v.model),
onclick: () => setActiveSlot(c.id, slotIdx), // tap chip to focus that video
style: "cursor:pointer",
}, letter));
const group = el("div", { class: "likert" });
const cur = getSlotRatings(c.id, slotIdx)[qkey];
for (let s = 1; s <= 5; s++) {
const btn = el("button", {
"data-score": String(s),
title: `${s}${scaleLabel(s)}`,
onclick: () => {
setRating(c.id, slotIdx, qkey, s);
Array.from(group.children).forEach((b, i) =>
b.classList.toggle("selected", (i + 1) === s));
},
}, String(s));
if (cur === s) btn.classList.add("selected");
group.appendChild(btn);
}
row.appendChild(group);
return row;
}
// Track the height of the sticky-top region and publish it as a CSS variable
// so .dim-row inside the ratings table can stick just below it.
let _STICKY_RO = null;
function observeStickyTop(node) {
if (_STICKY_RO) { _STICKY_RO.disconnect(); _STICKY_RO = null; }
const update = () => {
document.documentElement.style.setProperty(
"--sticky-top-h", node.offsetHeight + "px");
};
update();
if (typeof ResizeObserver !== "undefined") {
_STICKY_RO = new ResizeObserver(update);
_STICKY_RO.observe(node);
} else {
window.addEventListener("resize", update, { passive: true });
}
}
// Once per case visit: auto-collapse the instructions panel as soon as the
// user scrolls past the top of the right pane. Keeps the rubric out of the
// way once they start scoring. We listen on the right pane because
// section.detail is no longer the scroll container (only .right-pane is).
function bindAutoCollapseInstructions() {
const detail = document.getElementById("detail");
const right = detail?.querySelector(".right-pane");
if (!right || right.__autoCollapseBound) return;
right.__autoCollapseBound = true;
let closed = false;
right.addEventListener("scroll", () => {
if (closed) return;
if (right.scrollTop > 80) {
const ins = detail.querySelector("details.instructions[open]");
if (ins) { ins.removeAttribute("open"); closed = true; }
}
}, { passive: true });
// Reset the once-per-case flag whenever a new case is rendered.
right.__resetAutoCollapse = () => { closed = false; right.scrollTop = 0; };
}
function renderInstructions(forceOpen) {
const det = el("details", { class: "instructions" });
if (forceOpen) det.setAttribute("open", "");
det.appendChild(el("summary", {}, t("instructions_toggle", TOTAL_QUESTIONS)));
const body = el("div");
body.innerHTML = `
<p>${t("instructions_intro")}</p>
<h4>${t("scale_legend")}</h4>
<div class="scale-row">
<span class="scale-pill" style="background:var(--score-1)">1 · ${t("scale_1")}</span>
<span class="scale-pill" style="background:var(--score-2)">2 · ${t("scale_2")}</span>
<span class="scale-pill" style="background:var(--score-3)">3 · ${t("scale_3")}</span>
<span class="scale-pill" style="background:var(--score-4)">4 · ${t("scale_4")}</span>
<span class="scale-pill" style="background:var(--score-5)">5 · ${t("scale_5")}</span>
</div>
<h4>${t("dimensions_header")}</h4>
<ul>
<li>${t("dim_inst_short")}</li>
<li>${t("dim_temporal_short")}</li>
<li>${t("dim_motion_short")}</li>
</ul>
<h4>${t("tips_header")}</h4>
<ul>
<li>${t("tip_save_then_submit")}</li>
<li>${t("tip_audio")}</li>
<li>${t("tip_keys")}</li>
<li>${t("tip_nav")}</li>
<li>${t("tip_autosave")}</li>
<li>${t("tip_anon")}</li>
<li>${t("tip_missing")}</li>
</ul>
<div style="margin-top:10px;display:flex;justify-content:flex-end">
<button type="button" class="btn primary" data-action="dismiss-instructions"
style="padding:6px 14px;font-size:12px">
${escapeHtml(t("instructions_dismiss"))}
</button>
</div>
`;
// Wire the dismiss button to collapse the <details>.
body.querySelector('[data-action="dismiss-instructions"]')?.addEventListener("click", () => {
det.removeAttribute("open");
});
det.appendChild(body);
return det;
}
function renderCaseHeader(c) {
const wrap = el("div", { class: "case-header" });
const left = el("div", { style: "flex:0 0 auto;display:flex;flex-direction:column;gap:4px" },
el("span", { class: "case-id" }, c.id),
el("span", { class: "case-counter" },
t("case_n_of_m", currentIdx + 1, CASES.length)),
);
wrap.appendChild(left);
const promptBox = el("div", { class: "prompt-box" });
promptBox.appendChild(el("div", { class: "label" }, t("prompt_label")));
const localized = (c.prompt_i18n && c.prompt_i18n[LANG]) || c.prompt;
// Highlight controllability cues (imperative verbs + directive phrases) so
// the rater knows what to evaluate against the videos.
const promptText = el("div", {});
promptText.innerHTML = highlightControllability(localized || t("no_prompt"), LANG);
promptBox.appendChild(promptText);
wrap.appendChild(promptBox);
if (c.reference_image) {
const img = el("img", {
class: "ref-img", src: c.reference_image,
alt: "reference",
onerror: function () { this.style.display = "none"; },
});
wrap.appendChild(img);
}
return wrap;
}
function renderVideoControls(c) {
// The active <video> already exposes the browser's native play/pause UI,
// so there's no separate "Play" button here. Pause / Restart stay because
// they act globally / sync-style.
const ctl = el("div", { class: "video-controls" });
ctl.appendChild(el("button", {
class: "ctl-btn", onclick: pauseAll,
}, t("pause")));
ctl.appendChild(el("button", {
class: "ctl-btn", onclick: restartActive,
}, t("restart")));
const loopBtn = el("button", {
class: "ctl-btn", id: "loopBtn",
onclick: () => toggleLoop(loopBtn),
}, t("loop"));
ctl.appendChild(loopBtn);
const muteBtn = el("button", {
class: "ctl-btn", id: "muteBtn",
onclick: () => toggleMute(muteBtn),
}, t("mute"));
ctl.appendChild(muteBtn);
ctl.appendChild(el("span", { style: "margin-left:auto" }, t("speed")));
const speedSel = el("select", {
onchange: (e) => setSpeed(parseFloat(e.target.value)),
});
["0.25", "0.5", "0.75", "1", "1.5", "2"].forEach(v => {
const opt = el("option", { value: v }, v + "×");
if (v === "1") opt.setAttribute("selected", "");
speedSel.appendChild(opt);
});
ctl.appendChild(speedSel);
return ctl;
}
function activeVideo() {
// The only video that's NOT inside a .hidden tile is the active one.
return document.querySelector(".video-tile:not(.hidden) video[data-slot]");
}
function restartActive() {
const v = activeVideo();
if (v) { v.currentTime = 0; try { v.play(); } catch(e){} }
}
// Within a case, every sub-question shows its rating rows in the same A → B
// → C order. (The case-level SHUFFLE still varies which video sits under
// each letter per-rater per-case, so positional bias across cases is still
// neutralised — but inside a single case the rater always sees the same
// layout, which is easier to scan and compare.)
function rowSlotOrder(caseId, qKey, slotCount) {
return Array.from({ length: slotCount }, (_, i) => i);
}
/* =========================================================================
* Video sync controls
* ========================================================================= */
function allVideos() {
return Array.from(document.querySelectorAll("video[data-slot]"));
}
function pauseAll() { allVideos().forEach(v => v.pause()); }
function restartAll() {
allVideos().forEach(v => { v.currentTime = 0; try { v.play(); } catch(e){} });
}
let LOOPING = false;
function toggleLoop(btn) {
LOOPING = !LOOPING;
allVideos().forEach(v => v.loop = LOOPING);
btn.classList.toggle("on", LOOPING);
}
let MUTED = false;
function toggleMute(btn) {
MUTED = !MUTED;
allVideos().forEach(v => v.muted = MUTED);
btn.classList.toggle("on", MUTED);
btn.textContent = MUTED ? "🔇 Muted" : "🔊 Sound";
}
function setSpeed(rate) { allVideos().forEach(v => v.playbackRate = rate); }
// Space-bar shortcut: play/pause whichever video is on the active tab.
function toggleActive() {
const v = activeVideo();
if (!v) return;
if (v.paused) { try { v.play(); } catch(e){} } else { v.pause(); }
}
/* =========================================================================
* Navigation + status updates
* ========================================================================= */
function goTo(idx) {
if (idx < 0 || idx >= CASES.length) return;
currentIdx = idx;
pauseAll();
renderDetail();
// Scroll detail to top
const det = document.getElementById("detail");
if (det) det.scrollTop = 0;
}
function step(delta) { goTo((currentIdx + delta + CASES.length) % CASES.length); }
function updateProgress() {
const { done, expected } = totalProgress();
const pct = expected > 0 ? (done / expected) * 100 : 0;
const fill = document.getElementById("progressFill");
const txt = document.getElementById("progressText");
if (fill) fill.style.width = pct.toFixed(1) + "%";
if (txt) txt.innerHTML =
`<span class="num">${done}</span> / ${expected} ratings (${pct.toFixed(0)}%)`;
}
function updateFooterSummary() {
const c = CASES[currentIdx];
if (!c) return;
const p = caseProgress(c);
const el = document.getElementById("footerSummary");
if (!el) return;
const summary = t("footer_summary",
`<span class="num">${currentIdx + 1}</span>`,
CASES.length,
`<span class="num">${p.done}</span>`,
p.expected);
el.innerHTML = summary +
(p.done === p.expected && p.expected > 0 ? t("footer_complete_suffix") : "");
}
function updateCaseStatus(caseId) {
const c = CASES.find(x => x.id === caseId);
if (!c) return;
const p = caseProgress(c);
const row = document.querySelector(`[data-case-id="${caseId}"]`);
if (!row) return;
row.classList.remove("partial", "complete");
if (p.done === p.expected && p.expected > 0) row.classList.add("complete");
else if (p.done > 0) row.classList.add("partial");
const counter = row.querySelector("span:last-child");
if (counter) counter.textContent = `${p.done}/${p.expected}`;
updateFooterSummary();
}
/* =========================================================================
* Toolbar actions
* ========================================================================= */
function toggleAnon() {
if (!isAdmin()) return; // gate: non-admins can't reveal models
anonymize = !anonymize;
// The shuffle is anonymize-independent now, so we don't wipe SHUFFLE here —
// only re-render so the slot labels switch between "Video A" and
// "Video A · ours".
saveState();
renderShell();
}
function resetCurrentCase() {
const c = CASES[currentIdx];
if (!confirm(t("confirm_reset_case", c.id))) return;
delete RATINGS[c.id];
saveState();
renderDetail();
document.querySelector(`[data-case-id="${c.id}"]`)?.classList.remove("partial", "complete");
updateProgress();
updateCaseStatus(c.id);
showToast(t("toast_cleared_case", c.id));
}
function resetAll() {
if (!confirm(t("confirm_reset_all"))) return;
RATINGS = {};
SHUFFLE = {};
RATER = { id: genRaterId(), name: "", email: "", note: "",
started_at: new Date().toISOString(), submitted_at: null };
buildShuffle(CASES);
saveState();
showLoginOverlay(true);
showToast(t("toast_reset_all"));
}
/* =========================================================================
* Registration overlay (login without password)
* ========================================================================= */
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
function showLoginOverlay(forceShow) {
const ov = document.getElementById("loginOverlay");
if (!ov) return;
if (!forceShow && RATER.name && RATER.email && EMAIL_RE.test(RATER.email)) {
ov.classList.add("hidden");
return;
}
ov.classList.remove("hidden");
// Prefill if known
const nameI = document.getElementById("regName");
const emailI = document.getElementById("regEmail");
const noteI = document.getElementById("regNote");
if (nameI) nameI.value = RATER.name || "";
if (emailI) emailI.value = RATER.email || "";
if (noteI) noteI.value = RATER.note || "";
(nameI || emailI)?.focus();
}
function hideLoginOverlay() {
document.getElementById("loginOverlay")?.classList.add("hidden");
}
function handleRegistrationSubmit() {
const name = document.getElementById("regName").value.trim();
const email = document.getElementById("regEmail").value.trim().toLowerCase();
const note = document.getElementById("regNote").value.trim();
const err = document.getElementById("regErr");
err.textContent = "";
if (!name) { err.textContent = t("reg_err_name"); return; }
if (!EMAIL_RE.test(email)) {
err.textContent = t("reg_err_email");
return;
}
RATER.name = name;
RATER.email = email;
RATER.note = note;
if (!RATER.id || RATER.id.startsWith("rater_")) {
// Derive a stable, human-readable id from the email so the shuffle seed
// is deterministic per participant across sessions / browsers.
RATER.id = email.replace(/[^a-z0-9]+/gi, "_").replace(/^_+|_+$/g, "");
}
if (!RATER.started_at) RATER.started_at = new Date().toISOString();
hideLoginOverlay();
// First: try to recover any prior server-side state for this email so
// their locked case subset + earlier ratings carry across devices.
(async () => {
await recoverRaterStateFromServer(email);
// Now pick (or restore) the case subset. If chosen_case_ids came back
// from the server, selectCasesForRater honours it; otherwise it picks
// a fresh deterministic 10 and locks them in.
CASES = selectCasesForRater(ALL_CASES);
SHUFFLE = {};
buildShuffle(CASES);
saveState();
renderShell();
})();
// Probe the server, then drop an empty in-progress entry so the rater
// appears in submissions/in_progress/ immediately on registration. Saving
// happens asynchronously and falls back silently if the server isn't
// reachable (local-only mode).
(async () => {
await probeSyncEndpoint();
if (SYNC_STATUS !== "local-only") {
await saveProgress({ silent: true, source: "register" });
showToast(t("toast_registered_saved", name));
} else {
showToast(t("toast_registered_as", name));
}
})();
}
// Sync the registration overlay's static markup to the current LANG so it
// reads correctly when the language switcher is changed before the user
// completes registration.
function applyRegistrationOverlayI18n() {
const map = {
regHeader: "reg_title",
regLead: "reg_lead",
regNameLabel: "reg_name",
regEmailLabel: "reg_email",
regNoteLabel: "reg_note",
regContinueText: "reg_continue",
regSideNote: "reg_side_note",
regFootText: "reg_foot",
submittedHeading: "submitted_title",
submittedCloseBtn: "submitted_close",
submittedDownloadBtn: "submitted_download",
regLangLabel: "lang_label",
};
for (const [domId, key] of Object.entries(map)) {
const el = document.getElementById(domId);
if (!el) continue;
if (key.endsWith("_foot") || key.endsWith("_lead")) el.innerHTML = t(key);
else el.textContent = t(key);
}
const name = document.getElementById("regName");
const email = document.getElementById("regEmail");
const note = document.getElementById("regNote");
if (name) name.placeholder = t("reg_name_ph");
if (email) email.placeholder = t("reg_email_ph");
if (note) note.placeholder = t("reg_note_ph");
// language selector on the registration overlay
const sel = document.getElementById("regLangSelect");
if (sel) {
sel.innerHTML = "";
for (const L of LANGUAGES) {
const opt = document.createElement("option");
opt.value = L.code; opt.textContent = L.label;
if (L.code === LANG) opt.selected = true;
sel.appendChild(opt);
}
}
}
/* =========================================================================
* Submitted celebration overlay
* ========================================================================= */
function showSubmittedOverlay(body) {
const ov = document.getElementById("submittedOverlay");
if (!ov) return;
const detail = document.getElementById("submittedDetail");
const ref = document.getElementById("submittedRef");
if (detail) {
detail.textContent = t("submitted_thanks",
RATER.name, body.n_scores ?? "?", body.n_videos ?? "?");
}
if (ref) ref.textContent = body.stored_as || "—";
ov.classList.remove("hidden");
}
function hideSubmittedOverlay() {
document.getElementById("submittedOverlay")?.classList.add("hidden");
}
function exportJSON() {
// De-anonymize for the export so downstream analysis maps slot → real model.
const out = {
rater: RATER,
anonymize_used: anonymize,
exported_at: new Date().toISOString(),
rubric: DIMENSIONS,
cases: CASES.map(c => {
const slots = SHUFFLE[c.id] || c.videos.map((_, i) => i);
return {
id: c.id,
prompt: c.prompt,
videos: slots.map((origIdx, slotIdx) => {
const v = c.videos[origIdx];
return {
slot: String.fromCharCode(65 + slotIdx),
slot_index: slotIdx,
original_index: origIdx,
model: v.model,
path: v.path,
ratings: (RATINGS[c.id] || {})[v.model] || {},
};
}),
};
}),
};
const blob = new Blob([JSON.stringify(out, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = el("a", {
href: url,
download: `user_study_${RATER.id || "anon"}_${new Date().toISOString().slice(0, 10)}.json`,
});
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
showToast(t("toast_exported"));
}
/* =========================================================================
* Empty / error state
* ========================================================================= */
function renderEmptyApp(reason) {
document.getElementById("app").innerHTML = `
<div class="empty-app">
<h2>${t("empty_app_title")}</h2>
<p style="color:var(--bad)">${escapeHtml(reason)}</p>
<p>${t("empty_app_intro_html")}</p>
<h3 style="color:var(--accent-strong);font-size:13px;text-transform:uppercase;letter-spacing:0.4px">${t("empty_app_format_header")}</h3>
<pre>[
{
"id": "case_001",
"prompt": "A cat in a chef's hat tossing pancakes...",
"reference_image": "refs/case_001.png", // optional
"videos": [
{ "model": "ours", "path": "videos/case_001_ours.mp4" },
{ "model": "baseline_a", "path": "videos/case_001_a.mp4" }
]
}
]</pre>
<p>${t("empty_app_serve_header")}</p>
<pre>cd evals/user_study
python -m http.server 8765
# open http://localhost:8765/</pre>
<p style="color:var(--muted-2)">${t("empty_app_missing_tip_html")}</p>
</div>`;
}
/* =========================================================================
* Keyboard
* ========================================================================= */
document.addEventListener("keydown", e => {
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
switch (e.key) {
case "ArrowLeft": case "k": e.preventDefault(); step(-1); break;
case "ArrowRight": case "j": e.preventDefault(); step(1); break;
case " ": e.preventDefault(); toggleActive(); break;
case "r": case "R": e.preventDefault(); restartAll(); break;
case "l": case "L":
e.preventDefault();
const lb = document.getElementById("loopBtn");
if (lb) toggleLoop(lb);
break;
}
});
// Best-effort save on tab close / navigation. `fetch` from beforeunload is
// unreliable; sendBeacon is built for fire-and-forget on unload.
window.addEventListener("beforeunload", beaconProgress);
// Some browsers throttle beforeunload but honour visibilitychange='hidden',
// which fires on tab close, switch-away, and mobile background.
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") beaconProgress();
});
/* =========================================================================
* Init
* ========================================================================= */
async function fetchManifest() {
for (const path of ["./cases.json", "./cases.example.json"]) {
try {
const resp = await fetch(path, { cache: "no-cache" });
if (resp.ok) {
const data = await resp.json();
return { data, path };
}
} catch (e) { /* try next */ }
}
return null;
}
// Wire up registration overlay events (the overlay markup is in the body
// directly, not rendered via JS, so we bind once after DOM ready).
document.getElementById("regGoBtn")?.addEventListener("click", handleRegistrationSubmit);
["regName", "regEmail"].forEach(id => {
document.getElementById(id)?.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); handleRegistrationSubmit(); }
});
});
// One-time migration from v1 (RATINGS keyed by slot_idx) to v2 (keyed by
// model name). Uses the v1 saved SHUFFLE to map slot → original_index →
// model. Cases whose v1 SHUFFLE is missing or stale are skipped; the
// rater would then see those cases as un-rated, but no existing rating
// is misattributed to a different video.
function migrateLegacyRatingsV1(legacy, allCases) {
const oldRatings = legacy?.ratings || {};
const oldShuffle = legacy?.shuffle || {};
const out = {};
let migrated = 0, skipped = 0;
for (const [caseId, slotMap] of Object.entries(oldRatings)) {
const caseObj = allCases.find(c => c.id === caseId);
const shuffle = oldShuffle[caseId];
if (!caseObj || !Array.isArray(shuffle)) {
skipped++;
continue;
}
out[caseId] = {};
for (const [slotStr, qMap] of Object.entries(slotMap || {})) {
const slotIdx = parseInt(slotStr, 10);
if (!Number.isFinite(slotIdx)) continue;
const origIdx = shuffle[slotIdx];
const video = caseObj.videos[origIdx];
if (!video || !video.model) continue;
// Merge in case the same model already has entries (shouldn't happen
// for the v1 format but defensive).
out[caseId][video.model] = Object.assign(
out[caseId][video.model] || {}, qMap);
migrated++;
}
}
return { ratings: out, migrated, skipped };
}
(async function init() {
// Restore LANG first so the empty-app + registration overlay show the right
// language even before the rest of the state is wired up.
try {
const prior0 = JSON.parse(localStorage.getItem(STORAGE_KEY) || "null");
if (prior0 && STRINGS[prior0.lang]) LANG = prior0.lang;
} catch (e) { /* ignore */ }
// Or pick browser preferred language on first visit.
if (LANG === "en") {
const nav = (navigator.language || "en").toLowerCase();
if (nav.startsWith("zh")) LANG = "zh";
else if (nav.startsWith("bg")) LANG = "bg";
}
document.documentElement.lang = LANG;
applyRegistrationOverlayI18n();
const found = await fetchManifest();
if (!found) {
renderEmptyApp(t("empty_app_load_error"));
return;
}
ALL_CASES = found.data || [];
if (!Array.isArray(ALL_CASES) || ALL_CASES.length === 0) {
renderEmptyApp(t("empty_app_empty_manifest"));
return;
}
// ===== view-as mode (admin "open the page as this rater") =====
// Triggered by admin.html → it puts the target user's submission in
// sessionStorage["user_study_view_as"] and opens this page with
// ?view_as=1. We treat the page as read-only: no localStorage writes,
// no /api/submit, no /api/save_progress.
try {
const urlVA = new URL(location.href);
if (urlVA.searchParams.get("view_as") === "1") {
const raw = sessionStorage.getItem("user_study_view_as");
if (raw) {
const va = JSON.parse(raw);
const p = va?.payload;
if (p && p.rater) {
VIEW_AS_MODE = true;
VIEW_AS_META = va.meta || { rater_name: p.rater.name, rater_email: p.rater.email };
RATER = Object.assign({
id:"", name:"", email:"", note:"",
started_at:null, submitted_at:null,
}, p.rater);
anonymize = p.anonymize_used !== false;
// Reconstruct RATINGS from the submission's cases array.
RATINGS = {};
for (const c of (p.cases || [])) {
if (!c || !c.id) continue;
RATINGS[c.id] = {};
for (const v of (c.videos || [])) {
if (!v.model) continue;
RATINGS[c.id][v.model] = v.ratings || {};
}
}
SHUFFLE = {};
CASES = selectCasesForRater(ALL_CASES);
buildShuffle(CASES);
renderShell();
addViewAsBanner();
// Still pull admin highlights so the prompt's red marks render.
// loadAnnotations() will re-render when annotations arrive.
loadAnnotations();
return;
}
}
}
} catch (e) { console.warn("view-as init failed:", e); }
// Restore prior state, if any
const prior = loadState();
const blankRater = () => ({
id: genRaterId(), name: "", email: "", note: "",
started_at: new Date().toISOString(), submitted_at: null,
});
if (prior) {
RATER = Object.assign(blankRater(), prior.rater || {});
anonymize = prior.anonymize !== false;
RATINGS = prior.ratings || {};
SHUFFLE = prior.shuffle || {};
// One-shot client-side migration: prior to the huobao→univa rename,
// RATINGS were v2-keyed by model name. If localStorage still has
// RATINGS[case_id]["huobao"], copy them under "univa" so the rater's
// previously-rated cells re-appear under the new column header.
let huobaoRenames = 0;
for (const cid of Object.keys(RATINGS)) {
const byModel = RATINGS[cid];
if (byModel && byModel.huobao && !byModel.univa) {
byModel.univa = byModel.huobao;
delete byModel.huobao;
huobaoRenames++;
}
}
if (huobaoRenames) {
console.info(`[user-study] renamed huobao→univa ratings on ${huobaoRenames} case(s)`);
}
} else {
// No v2 state. Try a one-time migration from v1.
const legacyRaw = localStorage.getItem(STORAGE_KEY_LEGACY);
if (legacyRaw) {
try {
const legacy = JSON.parse(legacyRaw);
RATER = Object.assign(blankRater(), legacy.rater || {});
anonymize = legacy.anonymize !== false;
const { ratings, migrated, skipped } = migrateLegacyRatingsV1(legacy, CASES);
RATINGS = ratings;
// SHUFFLE is recomputed every load anyway; don't carry the v1 order.
SHUFFLE = {};
console.info(
`[user-study] migrated v1 → v2: ${migrated} ratings carried over, ${skipped} cases skipped`);
localStorage.removeItem(STORAGE_KEY_LEGACY);
} catch (e) {
console.warn("[user-study] v1 migration failed:", e);
RATER = blankRater();
}
} else {
RATER = blankRater();
}
}
// Non-admins always see anonymized view — they can't toggle.
if (!isAdmin()) anonymize = true;
// Admins see all cases; regular raters see a deterministic random subset
// of size CASES_PER_RATER. Seeded by rater id so the same person always
// gets the same set across sessions / browsers.
CASES = selectCasesForRater(ALL_CASES);
// Sidebar is hidden by default; only auto-expand if the rater explicitly
// opened it in a prior session (sticky across reloads).
try {
if (localStorage.getItem("user_study_sidebar_expanded") === "1") {
document.body.classList.add("sidebar-expanded");
}
} catch (e) { /* ignore */ }
buildShuffle(CASES);
saveState();
// Gate the whole app on registration: render the shell only after the user
// has supplied a name + valid email. Returning users skip the overlay.
if (!RATER.name || !RATER.email || !EMAIL_RE.test(RATER.email)) {
showLoginOverlay(true);
} else {
renderShell();
}
if (found.path.endsWith("example.json")) {
showToast(t("toast_loaded_example"));
}
// Probe the /api/submit endpoint. Done after registration so the pill, which
// lives in the toolbar, exists in the DOM.
if (RATER.name && RATER.email) probeSyncEndpoint();
// Pull admin-curated prompt highlights. Fire-and-forget; if the endpoint
// isn't reachable the page falls back to auto-detected controllability
// cues only.
loadAnnotations();
})();
/* =========================================================================
* Cross-device recovery — read the rater's existing in-progress JSON
* from the server so their locked case subset + earlier ratings carry
* across devices / browsers. Best-effort; falls through silently when the
* server is unreachable.
* ========================================================================= */
async function recoverRaterStateFromServer(email) {
if (!email) return;
try {
const r = await fetch(`./api/rater_state?email=${encodeURIComponent(email)}`,
{ cache: "no-cache" });
if (!r.ok) return;
const j = await r.json().catch(() => ({}));
if (!j.found || !j.state) return;
const remote = j.state;
// Locked case subset wins over any local guess.
const remoteRater = remote.rater || {};
if (Array.isArray(remoteRater.chosen_case_ids) && remoteRater.chosen_case_ids.length) {
RATER.chosen_case_ids = remoteRater.chosen_case_ids;
}
// Pull in any non-empty ratings from the server side that we don't have
// locally yet — never overwrites an existing local score.
for (const c of (remote.cases || [])) {
if (!c || !c.id) continue;
RATINGS[c.id] = RATINGS[c.id] || {};
for (const v of (c.videos || [])) {
const model = v.model;
if (!model) continue;
RATINGS[c.id][model] = RATINGS[c.id][model] || {};
for (const [qk, score] of Object.entries(v.ratings || {})) {
if (typeof score === "number" && score >= 1 && score <= 5 &&
RATINGS[c.id][model][qk] === undefined) {
RATINGS[c.id][model][qk] = score;
}
}
}
}
} catch (e) { /* offline / local-only — silent */ }
}
async function loadAnnotations() {
try {
const r = await fetch(API_ANNOTATIONS, { cache: "no-cache" });
if (!r.ok) return;
const j = await r.json().catch(() => ({}));
ADMIN_HIGHLIGHTS = j.annotations || {};
// Re-render the prompt now that we have the highlights.
if (CASES.length && RATER.name && RATER.email) {
const detail = document.getElementById("detail");
if (detail && detail.querySelector(".prompt-text")) renderDetail();
}
} catch (e) { /* offline / local-only — auto-detected highlights only */ }
}
</script>
</body>
</html>