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
78.2 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#0f1115">
<title>Video user study v2 — mobile</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;
--score-1: #ea5455;
--score-2: #ff7d52;
--score-3: #ffa84a;
--score-4: #8acf6d;
--score-5: #28c76f;
--safe-bot: env(safe-area-inset-bottom, 0px);
--bar-h: 56px;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, "Inter", "PingFang SC",
"Microsoft YaHei", sans-serif;
font-size: 15px; line-height: 1.4;
-webkit-tap-highlight-color: transparent;
overscroll-behavior-y: contain; }
/* ---------- top header ---------- */
header.top { position: sticky; top: 0; z-index: 50;
background: var(--panel); border-bottom: 1px solid var(--border);
padding: 8px 12px; padding-top: calc(env(safe-area-inset-top, 0px) + 8px);
display: flex; align-items: center; gap: 8px; }
header.top .title { font-weight: 600; font-size: 14px; flex: 1;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
header.top .pill { font-size: 11px; padding: 3px 8px; border-radius: 10px;
background: var(--panel-3); color: var(--muted); white-space: nowrap;
font-family: ui-monospace, monospace; border: 1px solid var(--border); }
header.top .pill[data-state="idle"] { background:#1f2a44; color: var(--accent); border-color:#3a4a78; }
header.top .pill[data-state="submitting"] { background:#1f2a44; color: var(--accent); border-color:#3a4a78; }
header.top .pill[data-state="submitted"] { background:#1c3422; color: var(--good); border-color:#2a5a37; }
header.top .pill[data-state="local-only"] { background:#3a3520; color: var(--warn); border-color:#5a4a25; }
header.top .pill[data-state="error"] { background:#3a2020; color: var(--bad); border-color:#5a2828; }
header.top button.icon { background: transparent; border: none; color: var(--fg);
font-size: 22px; padding: 4px 8px; cursor: pointer; line-height: 1; }
header.top select { background: var(--panel-3); border: 1px solid var(--border);
color: var(--fg); padding: 4px 6px; border-radius: 4px; font-size: 12px;
font-family: inherit; }
/* ---------- progress strip ---------- */
.case-strip { position: sticky; top: 56px; z-index: 40;
background: var(--panel); border-bottom: 1px solid var(--border);
padding: 6px 12px; display: flex; align-items: center; gap: 8px;
font-size: 12px; color: var(--muted); }
.case-strip .case-id { color: var(--fg); font-weight: 600;
font-family: ui-monospace, monospace; }
.case-strip .bar { flex: 1; height: 4px; background: var(--panel-3); border-radius: 2px;
overflow: hidden; }
.case-strip .fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--good));
transition: width 0.2s; }
.case-strip button.jumper { background: var(--panel-3); border: 1px solid var(--border);
color: var(--fg); padding: 4px 10px; border-radius: 4px;
font-size: 12px; font-family: inherit; cursor: pointer; }
/* ---------- main scroll content ---------- */
main { padding: 8px 12px calc(var(--bar-h) + var(--safe-bot) + 24px); }
/* prompt */
.prompt-card { background: var(--panel); border: 1px solid var(--border);
border-radius: 8px; padding: 10px 12px; margin-bottom: 10px;
font-size: 14px; line-height: 1.6; }
.prompt-card .label { font-size: 10px; color: var(--muted); letter-spacing: 0.5px;
text-transform: uppercase; margin-bottom: 4px; }
.prompt-card details summary { cursor: pointer; user-select: none;
list-style: none; color: var(--muted); font-size: 13px; }
.prompt-card details summary::before { content: '▸ '; }
.prompt-card details[open] summary::before { content: '▾ '; }
.prompt-card details[open] summary { margin-bottom: 6px; }
.prompt-card .ref-img { max-width: 100%; max-height: 200px; border-radius: 6px;
border: 1px solid var(--border); display: block; margin-top: 8px; }
/* Bold-red highlight on controllability cues in the prompt. */
.ctrl { color: var(--bad); font-weight: 700; }
/* Admin-curated highlights get a subtle dotted underline so admins know
which marks are their own (raters see them the same red as auto cues). */
.ctrl-admin { text-decoration: underline; text-decoration-style: dotted;
text-decoration-color: var(--bad); text-underline-offset: 2px; }
/* videos — tab UI mirrors desktop: one active video at a time, A/B/C
buttons to switch. All tiles stay in DOM so each keeps its own state. */
.video-stack { display: flex; flex-direction: column; gap: 10px;
margin-bottom: 14px; }
.video-tabs { display: flex; gap: 6px; flex-wrap: wrap; }
.tab-btn { background: var(--panel-3); border: 1px solid var(--border);
color: var(--muted); padding: 8px 14px; border-radius: 6px;
font-family: ui-monospace, monospace; font-size: 13px; cursor: pointer;
min-height: 38px; min-width: 44px; }
.tab-btn.active { background: var(--accent); color: #0a1a2a;
border-color: var(--accent); font-weight: 700; }
.video-tile { position: relative; }
.video-tile.hidden { display: none; }
.video-frame { position: relative; background: #000; border: 1px solid var(--border);
border-radius: 8px; overflow: hidden; aspect-ratio: 16 / 9; }
.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: 4px; text-align: center;
padding: 12px; }
/* video controls */
.video-controls { display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
padding: 8px 10px; margin-bottom: 14px; font-size: 12px; }
.video-controls button { background: var(--panel-3); border: 1px solid var(--border-strong);
color: var(--fg); padding: 8px 12px; border-radius: 6px; font-size: 13px;
font-family: inherit; min-height: 36px; cursor: pointer; }
.video-controls button.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: 6px 8px; border-radius: 4px; font-size: 13px;
font-family: inherit; margin-left: auto; }
/* dimension + question cards. dim-header sits inline above its cards
(not sticky — only the prompt + videos at the top of the page float). */
/* Responsive grid of question cards. ~280 px min → 1 col on phones,
2-3 cols on tablets / landscape. Dim headers span all columns. */
.question-grid { display: grid; gap: 10px;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
align-content: start; }
.question-grid > .dim-header { grid-column: 1 / -1; }
.dim-header { margin: 14px 0 4px; padding: 0 2px;
font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;
color: var(--accent-strong); font-weight: 700;
display: flex; align-items: baseline; gap: 8px; }
.dim-header:first-child { margin-top: 0; }
.dim-header .dim-hint { font-size: 11px; color: var(--muted); font-weight: 400;
text-transform: none; letter-spacing: 0; }
.q-card { background: var(--panel); border: 1px solid var(--border);
border-radius: 8px; padding: 12px; margin-bottom: 10px; }
.q-card .q-name { font-size: 15px; font-weight: 600; color: var(--fg);
margin-bottom: 2px; }
.q-card .q-desc { font-size: 12px; color: var(--muted); margin-bottom: 10px;
line-height: 1.5; }
.q-card .video-rate { display: flex; align-items: center; gap: 8px;
padding: 6px 0; }
.q-card .video-rate + .video-rate { border-top: 1px solid var(--border); }
.q-card .video-rate .slot-letter { min-width: 28px; height: 28px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 4px; background: var(--panel-3); color: var(--fg);
font-weight: 700; font-size: 13px; font-family: ui-monospace, monospace;
border: 1px solid var(--border-strong); flex-shrink: 0; }
.q-card .likert { display: flex; gap: 4px; flex: 1; justify-content: space-between; }
.q-card .likert button { flex: 1; min-height: 42px; min-width: 0;
border: 1px solid var(--border); background: var(--panel-3); color: var(--fg);
border-radius: 6px; font-family: inherit; font-size: 14px; font-weight: 500;
cursor: pointer; }
.q-card .likert button.selected { font-weight: 700; color: #0a1a0a;
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); }
/* bottom fixed action bar */
footer.bottom { position: fixed; left: 0; right: 0; bottom: 0; z-index: 50;
background: var(--panel); border-top: 1px solid var(--border);
padding: 8px 10px calc(8px + var(--safe-bot));
display: grid; grid-template-columns: 1fr 1fr auto 1fr 1fr; gap: 6px;
height: calc(var(--bar-h) + var(--safe-bot)); align-items: center; }
footer.bottom button { background: var(--panel-3); border: 1px solid var(--border-strong);
color: var(--fg); padding: 0; height: 40px; border-radius: 6px;
font-family: inherit; font-size: 14px; font-weight: 500; cursor: pointer; }
footer.bottom .submit-btn { background: var(--good); color: #0a1a0a;
border-color: var(--good); font-weight: 700; }
footer.bottom .submit-btn:disabled { background: var(--panel-3); color: var(--muted-2);
border-color: var(--border); font-weight: 500; }
footer.bottom .top-btn { font-size: 18px; }
/* slide-in drawer (case list) */
.drawer-backdrop { position: fixed; inset: 0; background: rgba(8,10,16,0.7);
z-index: 60; opacity: 0; pointer-events: none; transition: opacity 0.2s; }
.drawer-backdrop.open { opacity: 1; pointer-events: auto; }
.drawer { position: fixed; top: 0; right: 0; bottom: 0; width: 86%;
max-width: 360px; background: var(--panel); border-left: 1px solid var(--border);
z-index: 61; transform: translateX(100%); transition: transform 0.22s ease;
display: flex; flex-direction: column; padding-top: env(safe-area-inset-top, 0px); }
.drawer.open { transform: translateX(0); }
.drawer header { padding: 12px 14px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between;
font-weight: 600; }
.drawer .list { flex: 1; overflow-y: auto; padding: 4px 0; }
.drawer .case-row { padding: 12px 14px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 10px; }
.drawer .case-row.active { background: var(--panel-2);
border-left: 3px solid var(--accent); padding-left: 11px; }
.drawer .case-row .dot { width: 9px; height: 9px; border-radius: 50%;
background: var(--muted-2); flex-shrink: 0; }
.drawer .case-row.partial .dot { background: var(--warn); }
.drawer .case-row.complete .dot { background: var(--good); }
.drawer .case-row .id { flex: 1; font-family: ui-monospace, monospace;
font-size: 13px; }
.drawer .case-row .pct { font-size: 11px; color: var(--muted); }
/* registration overlay (re-used from desktop) */
.overlay { position: fixed; inset: 0; background: rgba(8,10,16,0.84);
backdrop-filter: blur(4px); display: flex; align-items: center;
justify-content: center; z-index: 100; padding: 14px; }
.overlay.hidden { display: none; }
.reg-card { background: var(--panel); border: 1px solid var(--border-strong);
border-radius: 10px; padding: 18px 18px 16px; max-width: 420px; width: 100%; }
.reg-card h2 { margin: 0 0 4px; font-size: 17px; }
.reg-card .lead { color: var(--muted); font-size: 13px; line-height: 1.6;
margin: 0 0 14px; }
.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: 10px;
border-radius: 6px; font-family: inherit; font-size: 16px; /* 16 ≥ iOS no-zoom */ }
.reg-card textarea { min-height: 50px; resize: vertical; font-size: 14px; }
.reg-card .err { color: var(--bad); font-size: 12px; min-height: 14px;
margin-top: 6px; }
.reg-card .top-row { display: flex; align-items: center;
justify-content: space-between; margin-bottom: 8px; gap: 12px; }
.reg-card select { background: var(--panel-3); border: 1px solid var(--border);
color: var(--fg); padding: 4px 6px; border-radius: 4px; font-size: 12px;
font-family: inherit; }
.reg-card .go-btn { width: 100%; background: var(--accent); color: #0a1a2a;
border: 1px solid var(--accent); padding: 12px; border-radius: 8px;
font-weight: 700; font-size: 15px; cursor: pointer; margin-top: 14px; }
/* submitted overlay */
.submitted-card { background: var(--panel); border: 1px solid var(--good);
border-radius: 10px; padding: 22px; max-width: 420px; width: 100%;
text-align: center; }
.submitted-card .check { font-size: 44px; color: var(--good); }
.submitted-card h2 { margin: 8px 0 4px; font-size: 17px; }
.submitted-card p { color: var(--muted); font-size: 13px; margin: 4px 0;
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 .btn { background: var(--panel-3); border: 1px solid var(--border-strong);
color: var(--fg); padding: 10px 14px; border-radius: 6px; font-size: 14px;
font-family: inherit; margin-top: 12px; cursor: pointer; min-width: 100px; }
/* toast */
.toast { position: fixed; bottom: calc(var(--bar-h) + var(--safe-bot) + 14px);
left: 12px; right: 12px; background: var(--good); color: #0a1a0a;
padding: 10px 14px; border-radius: 8px; font-size: 13px; font-weight: 600;
text-align: center; opacity: 0; transition: opacity 0.2s;
z-index: 90; pointer-events: none; }
.toast.show { opacity: 1; }
.toast.error { background: var(--bad); color: #fff; }
/* desktop switch link */
.desktop-link { font-size: 11px; color: var(--muted); text-decoration: none;
padding: 4px 8px; border: 1px solid var(--border); border-radius: 12px;
background: var(--panel-3); }
.desktop-link:hover { color: var(--fg); }
/* admin / submitted badges */
.badge { display: inline-flex; align-items: center; padding: 2px 8px;
border-radius: 10px; font-size: 10px; font-weight: 700; }
.badge.admin { background: #3a2f60; color: #d9b6ff; border: 1px solid #5a4080; }
.badge.submitted { background: #1c3422; color: var(--good); border: 1px solid #2a5a37; }
/* shut down landscape if it gets weird */
@media (orientation: landscape) and (max-height: 460px) {
header.top { padding: 4px 12px; }
.case-strip { top: 44px; padding: 4px 12px; }
main { padding-top: 6px; }
}
kbd { background: var(--panel-3); border: 1px solid var(--border-strong);
border-radius: 3px; padding: 1px 4px; font-family: ui-monospace, monospace;
font-size: 11px; }
</style>
</head>
<body>
<!-- ============ Top bar ============ -->
<header class="top" id="topBar">
<button class="icon" id="drawerBtn" title="Cases"></button>
<span class="title" id="topTitle">Video user study</span>
<button class="icon" id="helpBtn" title="How to rate" style="font-size:18px">?</button>
<span class="pill" id="syncPill" data-state="idle"></span>
<select id="langSelect" title="Language"></select>
</header>
<div class="case-strip" id="caseStrip">
<span class="case-id" id="caseId"></span>
<span id="caseCounter"></span>
<div class="bar"><div class="fill" id="progressFill" style="width:0%"></div></div>
<span id="progressTxt">0/0</span>
</div>
<main id="main">
<!-- prompt + videos + controls + question cards inserted here -->
</main>
<footer class="bottom">
<button id="prevBtn">← Prev</button>
<button id="saveBtn">💾</button>
<button class="top-btn" id="scrollTopBtn" title="Back to videos"></button>
<button id="submitBtn" class="submit-btn" disabled>Submit</button>
<button id="nextBtn">Next →</button>
</footer>
<div class="toast" id="toast"></div>
<!-- Slide-in drawer with case list -->
<div class="drawer-backdrop" id="drawerBackdrop"></div>
<aside class="drawer" id="drawer" aria-hidden="true">
<header>
<span>Cases</span>
<button class="icon" id="drawerCloseBtn"
style="background:transparent;border:none;color:var(--fg);font-size:22px;cursor:pointer"></button>
</header>
<div class="list" id="drawerList"></div>
<div style="padding:10px 14px;border-top:1px solid var(--border);display:flex;gap:8px;align-items:center">
<a href="./?force=desktop" class="desktop-link" style="flex:1;text-align:center">🖥 Desktop view</a>
</div>
</aside>
<!-- Rating instructions overlay (mobile) — toggled via the "?" header button.
Auto-opened on first launch; user can close + reopen any time. -->
<div id="helpOverlay" class="overlay hidden" role="dialog" aria-modal="true">
<div class="reg-card" style="max-width:520px">
<div class="top-row">
<h2 id="helpHeader">How to rate</h2>
<button class="btn" id="helpCloseBtn"
style="background:transparent;border:none;color:var(--fg);font-size:22px;cursor:pointer"></button>
</div>
<div id="helpBody" style="font-size:13px;line-height:1.6;color:var(--fg);max-height:60vh;overflow-y:auto"></div>
<button class="go-btn" id="helpDismissBtn" style="margin-top:14px"><span id="helpDismissText">Got it</span></button>
</div>
</div>
<!-- Registration overlay -->
<div id="loginOverlay" class="overlay" role="dialog" aria-modal="true">
<div class="reg-card">
<div class="top-row">
<h2 id="regHeader">Register to start rating</h2>
<select id="regLangSelect"></select>
</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" inputmode="email" maxlength="120">
<label for="regNote" id="regNoteLabel">Affiliation / notes (optional)</label>
<textarea id="regNote" maxlength="280"></textarea>
<div class="err" id="regErr"></div>
<button class="go-btn" id="regGoBtn"><span id="regContinueText">Continue →</span></button>
<div style="color:var(--muted-2);font-size:11px;margin-top:8px;line-height:1.5"
id="regFootText"></div>
</div>
</div>
<!-- Submitted overlay -->
<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><code id="submittedRef"></code></p>
<div style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap">
<button class="btn" id="submittedCloseBtn">Close</button>
<button class="btn" id="submittedDownloadBtn">↓ Backup JSON</button>
</div>
</div>
</div>
<script>
/* =========================================================================
* Auto-redirect to the desktop page on large / pointer-fine devices.
* `?force=mobile` overrides (sticky in sessionStorage for the tab).
* ========================================================================= */
(function autoRedirectIfDesktop() {
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 === "mobile") return;
const ua = navigator.userAgent || "";
const isMobileUA = /Mobi|Android|iPhone|iPod|BlackBerry|Windows Phone/i.test(ua);
const isFine = window.matchMedia && window.matchMedia("(pointer: fine)").matches;
const isWide = window.innerWidth >= 900;
if (forced === "desktop" || (!isMobileUA && isFine && isWide)) {
location.replace("./");
}
} catch (e) { /* ignore — fall through to mobile */ }
})();
/* =========================================================================
* Same rubric + i18n + admin list + API contract as desktop index.html.
* Kept in sync manually for now (small enough). localStorage key is shared
* so progress made on desktop carries to mobile and vice-versa.
* ========================================================================= */
const DIMENSIONS = [
{ key: "instruction_following", name: "Instruction following",
hint: "Re-read the prompt — especially the red-highlighted phrases — before scoring.", questions: [
{ 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)." } ] },
// Dim key stays `motion_realism` for back-compat; display + hint broadened
// to "Realism", and the `realism` sub-question (formerly under Visual
// quality) lives here now.
{ 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." } ] },
];
const TOTAL_QUESTIONS = DIMENSIONS.reduce((n, d) => n + d.questions.length, 0);
const STORAGE_KEY = "user_study_v2_ratings_v1"; // shared with desktop
const ADMIN_NAMES = new Set(["deheng zhang", "zhendong li"]);
const LANGUAGES = [
{ code: "en", label: "English" },
{ code: "zh", label: "Chinese" },
{ code: "bg", label: "Bulgarian" },
];
const STRINGS = {
en: {
title:"Video user study",
prompt_label:"Prompt / instruction",
no_prompt:"(no prompt provided)",
case_n_of_m:"Case {0}/{1}",
play:"▶ Play", pause:"⏸", restart:"⏮", loop:"↻", mute:"🔇", sound:"🔊", speed:"Speed",
no_video_file:"no video file at this path",
prev:"← Prev", next:"Next →",
save_progress:"💾",
submit:"Submit ({0})",
submit_resubmit:"✓ Re-submit",
submit_disabled:"Submit ({0} left)",
reg_title:"Register to start rating",
reg_lead:"We record who submitted each set of ratings. No password — just your name and an email 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 →",
help_title:"How to rate",
help_dismiss:"Got it",
help_body_html:"<p>For each case watch all videos via the <b>A / B / C</b> tabs at the top, then rate each one on every sub-question below. Slot order is shuffled per rater but stable within a case.</p><h4 style='color:var(--accent);margin-top:14px;font-size:13px'>Likert (1 = worst, 5 = best)</h4><p>1 Very poor · 2 Poor · 3 Fair · 4 Good · 5 Excellent</p><h4 style='color:var(--accent);margin-top:14px;font-size:13px'>3 dimensions × 8 sub-questions</h4><ul style='padding-left:18px;margin:6px 0'><li><b>Instruction following</b> — re-read the prompt, especially the red-highlighted phrases.</li><li><b>Temporal consistency</b> — focus on how characters, objects, and environment change across frames.</li><li><b>Realism</b> — notice obvious out-of-place / unnatural moments.</li></ul><h4 style='color:var(--accent);margin-top:14px;font-size:13px'>Tips</h4><ul style='padding-left:18px;margin:6px 0'><li><b>Click 💾 to save progress at any time.</b> Click <b>Submit study</b> once everything is rated — otherwise your scores don't count.</li><li>For BGM / subtitle / sound questions, unmute the video first.</li><li>Score motion logic / camera low if the video has little to no movement.</li></ul>",
reg_foot:"Submissions 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.",
pill_idle:"●", pill_submitting:"↻", pill_submitted:"✓", pill_local_only:"💾", pill_error:"✕", pill_checking:"●",
toast_registered:"Registered as {0}",
toast_pls_signin:"Please sign in before submitting",
toast_missing:"{0} ratings still missing",
toast_no_server:"No server reachable — use Export instead",
toast_submit_failed:"Submit failed: {0}",
toast_progress_saved:"Progress saved ({0} ratings)",
toast_progress_save_failed:"Save failed: {0}",
submitted_title:"Submission recorded",
submitted_thanks:"Thanks, {0}. We recorded {1} ratings across {2} videos.",
submitted_close:"Close", submitted_download:"↓ Backup",
drawer_cases:"Cases",
scale_1:"1 Very poor", scale_5:"5 Excellent",
expand_prompt:"Read full prompt",
case_complete:"✓ complete",
confirm_reset:"Reset all ratings + registration on this device? This cannot be undone.",
admin:"⚙ admin", submitted_badge:"✓ submitted",
},
zh: {
title:"视频用户研究",
prompt_label:"提示词 / 指令",
no_prompt:"(未提供提示词)",
case_n_of_m:"案例 {0}/{1}",
play:"▶ 播放", pause:"⏸", restart:"⏮", loop:"↻", mute:"🔇", sound:"🔊", speed:"速度",
no_video_file:"该路径下没有视频文件",
prev:"← 上一个", next:"下一个 →",
save_progress:"💾",
submit:"提交 ({0})",
submit_resubmit:"✓ 重新提交",
submit_disabled:"提交 (还剩 {0})",
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:"继续 →",
help_title:"评分说明",
help_dismiss:"知道了",
help_body_html:"<p>每个案例,先在顶部的 <b>A / B / C</b> 视频标签切换观看每个视频,然后在下方对每个视频的每个子问题打分。视频顺序按评分人随机分配,但同一案例内保持一致。</p><h4 style='color:var(--accent);margin-top:14px;font-size:13px'>李克特量表(1 = 最差,5 = 最佳)</h4><p>1 很差 · 2 差 · 3 一般 · 4 好 · 5 极佳</p><h4 style='color:var(--accent);margin-top:14px;font-size:13px'>3 个维度 × 8 个子问题</h4><ul style='padding-left:18px;margin:6px 0'><li><b>指令遵循</b> — 请先看清提示词,尤其是标红的部分。</li><li><b>时间一致性</b> — 关注人物、物品和环境在帧间的变化。</li><li><b>真实感</b> — 留意视频中不和谐或有明显问题的地方。</li></ul><h4 style='color:var(--accent);margin-top:14px;font-size:13px'>操作提示</h4><ul style='padding-left:18px;margin:6px 0'><li><b>随时点击 💾 保存进度;</b>全部完成后必须点击「提交研究」,否则评分不会被计入。</li><li>BGM / 字幕 / 配乐 等问题请先打开视频声音聆听。</li><li>运动逻辑 / 相机运动 若几乎没有运动,请打低分。</li></ul>",
reg_foot:"提交将写入 Hugging Face 上的 <code>dehezhang2/Frameworker_User_Study</code> 数据集。",
reg_err_name:"请输入您的姓名。",
reg_err_email:"请输入有效的邮箱地址。",
pill_idle:"●", pill_submitting:"↻", pill_submitted:"✓", pill_local_only:"💾", pill_error:"✕", pill_checking:"●",
toast_registered:"已注册为 {0}",
toast_pls_signin:"请先登录后再提交",
toast_missing:"尚有 {0} 项未完成",
toast_no_server:"无法连接服务器 — 请使用导出功能",
toast_submit_failed:"提交失败: {0}",
toast_progress_saved:"进度已保存 ({0} 项评分)",
toast_progress_save_failed:"保存失败: {0}",
submitted_title:"提交成功",
submitted_thanks:"{0},感谢您的参与。我们已写入 {2} 个视频 × {1} 项评分。",
submitted_close:"关闭", submitted_download:"↓ 备份",
drawer_cases:"案例",
scale_1:"1 很差", scale_5:"5 极佳",
expand_prompt:"查看完整提示词",
case_complete:"✓ 已完成",
confirm_reset:"重置本设备的所有评分和注册信息吗?该操作不可撤销。",
admin:"⚙ 管理员", submitted_badge:"✓ 已提交",
},
bg: {
title:"Видео проучване",
prompt_label:"Подкана / инструкция",
no_prompt:"(няма подкана)",
case_n_of_m:"Случай {0}/{1}",
play:"▶ Пусни", pause:"⏸", restart:"⏮", loop:"↻", mute:"🔇", sound:"🔊", speed:"Скорост",
no_video_file:"няма видео файл на този път",
prev:"← Назад", next:"Напред →",
save_progress:"💾",
submit:"Изпрати ({0})",
submit_resubmit:"✓ Повторно",
submit_disabled:"Изпрати (остават {0})",
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:"Продължи →",
help_title:"Как се оценява",
help_dismiss:"Разбрах",
help_body_html:"<p>За всеки случай гледайте всички видеа чрез табовете <b>A / B / C</b> отгоре, след това оценете всяко по всеки под-въпрос отдолу.</p><h4 style='color:var(--accent);margin-top:14px;font-size:13px'>Ликерт (1 = най-лошо, 5 = най-добро)</h4><p>1 Много слабо · 2 Слабо · 3 Средно · 4 Добро · 5 Отлично</p><h4 style='color:var(--accent);margin-top:14px;font-size:13px'>3 измерения × 8 под-въпроса</h4><ul style='padding-left:18px;margin:6px 0'><li><b>Следване на инструкции</b> — прочетете подканата, особено маркираните в червено фрази.</li><li><b>Времева съгласуваност</b> — съсредоточете се върху промените на хора, обекти и среда между кадрите.</li><li><b>Реализъм</b> — забележете неестествени моменти.</li></ul><h4 style='color:var(--accent);margin-top:14px;font-size:13px'>Съвети</h4><ul style='padding-left:18px;margin:6px 0'><li><b>Щракнете 💾, за да запазите прогрес по всяко време;</b> щракнете <b>Изпрати проучването</b> накрая — иначе оценките не се броят.</li><li>За въпроси относно BGM / субтитри / звук включете звука първо.</li><li>Дайте нисък резултат за логика на движението / камера, ако има малко движение.</li></ul>",
reg_foot:"Изпращанията се записват в <code>dehezhang2/Frameworker_User_Study</code> на Hugging Face.",
reg_err_name:"Моля, въведете името си.",
reg_err_email:"Моля, въведете валиден имейл адрес.",
pill_idle:"●", pill_submitting:"↻", pill_submitted:"✓", pill_local_only:"💾", pill_error:"✕", pill_checking:"●",
toast_registered:"Регистриран(а) като {0}",
toast_pls_signin:"Моля, влезте преди изпращане",
toast_missing:"Все още липсват {0} оценки",
toast_no_server:"Сървърът не е достъпен — използвайте Изтегли",
toast_submit_failed:"Изпращането се провали: {0}",
toast_progress_saved:"Прогресът е запазен ({0} оценки)",
toast_progress_save_failed:"Запазването се провали: {0}",
submitted_title:"Записано",
submitted_thanks:"Благодарим, {0}. Записахме {1} оценки за {2} видеа.",
submitted_close:"Затвори", submitted_download:"↓ Копие",
drawer_cases:"Случаи",
scale_1:"1 Много слабо", scale_5:"5 Отлично",
expand_prompt:"Покажи цялата подкана",
case_complete:"✓ завършен",
confirm_reset:"Изчистване на всички оценки и регистрация на това устройство? Действието е необратимо.",
admin:"⚙ администратор", submitted_badge:"✓ изпратено",
},
};
const RUBRIC_I18N = {
zh: {
instruction_following:{name:"指令遵循",hint:"请先看清提示词,尤其是标红的部分,再作答。"},
temporal_consistency:{name:"时间一致性",hint:"请关注人物、物品和环境在帧间的变化。"},
motion_realism:{name:"真实感",hint:"请留意视频中不和谐或有明显问题的地方。"},
prompt_adherence:{name:"提示一致性",desc:"内容是否与提示的描述一致(主体、动作、场景、时间、故事走向)?以提示词的文本内容为主要依据。"},
no_irrelevant_frames:{name:"无无关画面",desc:"视频是否仅呈现提示中要求的内容?分数越高 = 越少出现与提示无关的帧或内容。"},
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:"Забележете очевидни проблеми или неестествени моменти във видеото."},
prompt_adherence:{name:"Съответствие с подканата",desc:"Съответства ли съдържанието на описаното в подканата (обекти, действия, обстановка, време и развитие на историята)? Фокусирайте се основно върху текстовото съдържание на подканата."},
no_irrelevant_frames:{name:"Без неуместни кадри",desc:"Остава ли видеото в рамките на подканата? По-висок = по-малко / без кадри или съдържание, които не са поискани."},
realism:{name:"Статичен реализъм",desc:"Фотореалистичност или стилова достоверност на отделен кадър. Ако подканата задава стил, видеото трябва да следва този стил. Без странни или счупени артефакти."},
person_stability:{name:"Стабилност на хората",desc:"Лица, идентичности и пропорции на тялото остават постоянни между кадрите."},
object_stability:{name:"Стабилност на обектите",desc:"Реквизити, дрехи и обекти запазват форма и позиция във времето."},
environment_stability:{name:"Стабилност на средата",desc:"Фон, осветление и подредба на сцената остават последователни между кадрите (включително липса на трептене на яркост, цвят или текстура)."},
motion_logic:{name:"Логика на движението",desc:"Текат ли движенията на тела и обекти плавно и в съответствие с физически / логически закони (гравитация, сблъсъци, динамика на флуиди / тъкани)? Без деформации, телепортация, нелогични или невъзможни движения. Дайте нисък резултат, ако има малко или никакво движение."},
camera:{name:"Камера",desc:"Движението на камерата (панорама, дъли, промяна на фокус) изглежда естествено и целенасочено. Дайте нисък резултат, ако има малко или никакво движение на камерата."},
},
};
/* ----- State (shared with desktop via localStorage) ----- */
let CASES = []; // cases visible to this rater (subset of ALL)
let ALL_CASES = []; // full manifest from cases.json
const CASES_PER_RATER = 10;
let SHUFFLE = {};
let RATINGS = {};
let RATER = { id:"", name:"", email:"", note:"",
started_at:null, submitted_at:null };
let LANG = "en";
let anonymize = true;
let currentIdx = 0;
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 PROGRESS_DEBOUNCE_MS = 60000;
let PROGRESS_TIMER = null;
/* ----- Helpers ----- */
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(d){ return LANG==="en"?d.name:(RUBRIC_I18N[LANG]?.[d.key]?.name??d.name); }
function dimHint(d){ return LANG==="en"?d.hint:(RUBRIC_I18N[LANG]?.[d.key]?.hint??d.hint); }
function qName(q){ return LANG==="en"?q.name:(RUBRIC_I18N[LANG]?.[q.key]?.name??q.name); }
function qDesc(q){ return LANG==="en"?q.desc:(RUBRIC_I18N[LANG]?.[q.key]?.desc??q.desc); }
function isAdmin(){ return ADMIN_NAMES.has((RATER.name||"").trim().toLowerCase()); }
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
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; }
function escapeHtml(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]);}
// Controllability highlighter — wraps imperative verbs + directive phrases
// in <span class="ctrl"> so raters see what the prompt actually controls.
const CTRL_PATTERNS = {
en: [
/\bAdd\b[^.]*\./g,
/\bUse this[^.]*\./g,
/Make a[^—–]*?animated short drama/g,
],
zh: [
/制作一部[^—–]*?短剧/g,
/请?添加[^。]*。/g,
/请使用上传的[^。]*。/g,
/配以[^。]*。/g,
/环境音[::][^。]*。/g,
],
bg: [
/Създайте[^—–]*?анимационна кратка драма/g,
/Добавете[^.]*\./g,
/Използвайте качената[^.]*\./g,
],
};
// Admin-curated highlight snippets, fetched at init from /api/annotations.
// Same shape as desktop: { case_id: { lang: [snippet, ...] } }
let ADMIN_HIGHLIGHTS = {};
const API_ANNOTATIONS = "./api/annotations";
function highlightControllability(text, lang, caseId) {
let html = escapeHtml(text);
const patterns = CTRL_PATTERNS[lang] || CTRL_PATTERNS.en;
const ranges = [];
// Auto-detected controllability cues
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++;
}
}
// Admin-curated snippets for this case + language
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;
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];
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());
}
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;
}
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 current case so the freshly-loaded admin highlights
// appear in the prompt.
if (CASES.length && RATER.name) renderCase();
} catch (e) { /* offline: auto-detected highlights only */ }
}
function showToast(msg, isErr=false){
const tt = document.getElementById("toast");
tt.textContent = msg;
tt.classList.toggle("error", !!isErr);
tt.classList.add("show");
setTimeout(()=>tt.classList.remove("show"), 1800);
}
function genRaterId(){ return "rater_"+Math.random().toString(36).slice(2,8)+"_"+Date.now().toString(36).slice(-4); }
/* ----- LocalStorage (shared key) ----- */
function loadState(){ try{ return JSON.parse(localStorage.getItem(STORAGE_KEY)||"null"); }catch(e){return null;} }
function saveState(){
try{ localStorage.setItem(STORAGE_KEY, JSON.stringify({
rater: RATER, anonymize, lang: LANG, shuffle: SHUFFLE, ratings: RATINGS,
saved_at: new Date().toISOString(),
})); }catch(e){ showToast("Save failed: "+e.message, true); }
}
/* ----- Shuffle ----- */
// Per-rater 10-case subset of the manifest. Admins see all. The chosen ids
// are locked into RATER.chosen_case_ids on first pick so the rater always
// sees the same set across sessions / devices regardless of future
// manifest changes.
function pickRandomSubsetIds(allCases){
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);
}
// Coverage-first assignment: ask the server which cases have the fewest
// raters so far. Advisory — no-op if a locked list exists; any failure falls
// back to the seeded random pick in selectCasesForRater.
async function assignCasesFromServer(){
if (isAdmin()) return;
if (Array.isArray(RATER.chosen_case_ids) && RATER.chosen_case_ids.length) return;
if (!Array.isArray(ALL_CASES) || ALL_CASES.length <= CASES_PER_RATER) return;
try {
const r = await fetch(
`./api/assign_cases?email=${encodeURIComponent(RATER.email||"")}&n=${CASES_PER_RATER}`,
{ cache: "no-cache" });
if (!r.ok) return;
const j = await r.json();
const valid = new Set(ALL_CASES.map(c => c.id));
const ids = (Array.isArray(j.case_ids) ? j.case_ids : []).filter(id => valid.has(id));
if (ids.length >= Math.min(CASES_PER_RATER, ALL_CASES.length)){
RATER.chosen_case_ids = ids;
}
} catch(e){ /* fall back to seeded random */ }
}
function selectCasesForRater(allCases){
if (isAdmin()) return allCases.slice();
if (allCases.length <= CASES_PER_RATER) return allCases.slice();
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;
}
// Only LOCK once registered (email set) — pre-registration renders must not
// freeze a random subset before assignCasesFromServer's coverage-first pick.
const ids = pickRandomSubsetIds(allCases);
if (RATER.email) RATER.chosen_case_ids = ids;
const byId = new Map(allCases.map(c => [c.id, c]));
return ids.map(id => byId.get(id)).filter(Boolean);
}
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 remoteRater = j.state.rater || {};
if (Array.isArray(remoteRater.chosen_case_ids) && remoteRater.chosen_case_ids.length) {
RATER.chosen_case_ids = remoteRater.chosen_case_ids;
}
for (const c of (j.state.cases || [])) {
if (!c || !c.id) continue;
RATINGS[c.id] = RATINGS[c.id] || {};
for (const v of (c.videos || [])) {
const m = v.model;
if (!m) continue;
RATINGS[c.id][m] = RATINGS[c.id][m] || {};
for (const [qk, score] of Object.entries(v.ratings || {})) {
if (typeof score === "number" && score>=1 && score<=5 &&
RATINGS[c.id][m][qk] === undefined) {
RATINGS[c.id][m][qk] = score;
}
}
}
}
} catch (e) { /* silent */ }
}
function buildShuffle(cases){
for (const c of cases){
if (SHUFFLE[c.id] && SHUFFLE[c.id].length===c.videos.length) continue;
const idx = c.videos.map((_,i)=>i);
if (anonymize){
let s = hashStr(RATER.id+":"+c.id);
for (let i=idx.length-1;i>0;i--){
s = (s*1664525+1013904223)>>>0;
const j = s%(i+1);
[idx[i],idx[j]] = [idx[j],idx[i]];
}
}
SHUFFLE[c.id] = idx;
}
}
// Within a case, every sub-question shows its rating rows in A → B → C
// order. (Per-rater case-level shuffle still varies which video sits under
// each letter across cases, but the order inside a case is now stable so
// the rater always sees a consistent layout.)
function rowSlotOrder(caseId, qKey, slotCount){
return Array.from({length:slotCount},(_,i)=>i);
}
/* ----- Ratings -----
* RATINGS is keyed RATINGS[case_id][model][q_key] — by MODEL NAME, exactly
* like desktop index.html, so the shared localStorage blob and the
* server-recovered state read identically on both pages. (Older mobile code
* keyed by slot index; see migrateSlotKeyedRatings for the one-shot fix-up.)
*/
function getCaseR(cid){ if(!RATINGS[cid]) RATINGS[cid]={}; return RATINGS[cid]; }
function getModelR(cid, model){ const m = getCaseR(cid); if(!m[model]) m[model]={}; return m[model]; }
function setRating(cid, model, qk, score){
getModelR(cid, model)[qk] = score;
saveState();
updateProgress();
if (RATER.submitted_at){
RATER.submitted_at = null; saveState();
setSyncStatus(SYNC_STATUS==="local-only"?"local-only":"idle");
}
updateSubmitBtn();
scheduleProgressSave();
}
function studyCompleteness(){
const qkeys = DIMENSIONS.flatMap(d=>d.questions.map(q=>q.key));
let done=0, expected=0; const missing=[];
for (const c of CASES){
const slots = SHUFFLE[c.id] || c.videos.map((_,i)=>i);
for (let s=0; s<slots.length; s++){
const r = (RATINGS[c.id]||{})[c.videos[slots[s]].model] || {};
for (const qk of qkeys){
expected++;
if (typeof r[qk]==="number" && r[qk]>=1 && r[qk]<=5) done++;
else missing.push({case_id:c.id, slot:String.fromCharCode(65+s), q:qk});
}
}
}
return { done, expected, missing };
}
function caseProgress(c){
const slots = SHUFFLE[c.id] || [];
const expected = slots.length * TOTAL_QUESTIONS;
let done=0;
for (let s=0;s<slots.length;s++){
const r = (RATINGS[c.id]||{})[c.videos[slots[s]].model] || {};
done += Object.keys(r).filter(k=>typeof r[k]==="number"&&r[k]>=1&&r[k]<=5).length;
}
return { done, expected };
}
/* ----- Sync pill + server status ----- */
function setSyncStatus(state, detail){
SYNC_STATUS = state;
if (detail!==undefined) SYNC_LAST_DETAIL = detail;
const pill = document.getElementById("syncPill");
if (!pill) return;
pill.dataset.state = state;
pill.textContent = t({
idle:"pill_idle", submitting:"pill_submitting", submitted:"pill_submitted",
"local-only":"pill_local_only", error:"pill_error",
}[state] || "pill_checking");
pill.title = SYNC_LAST_DETAIL || "";
}
async function probeSync(){
try{
const r = await fetch(API_HEALTH, {cache:"no-cache"});
if (r.ok){
const j = await r.json().catch(()=>({}));
setSyncStatus(j.ok && j.has_token ? "idle" : "local-only");
} else setSyncStatus("local-only");
}catch(e){ setSyncStatus("local-only"); }
}
/* ----- Payloads + API calls ----- */
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) => ({
slot: String.fromCharCode(65+slotIdx), slot_index: slotIdx,
original_index: origIdx, model: c.videos[origIdx].model,
path: c.videos[origIdx].path,
ratings: (RATINGS[c.id]||{})[c.videos[origIdx].model] || {},
})),
};
}),
};
}
function buildFullPayload(){
const p = buildProgressPayload();
p.submitted_at = new Date().toISOString();
delete p.progress_saved_at; delete p.completeness;
return p;
}
async function saveProgress(opts={}){
if (!RATER.email || !RATER.name) return;
if (SYNC_STATUS==="local-only" && opts.source!=="manual") return;
try{
const r = await fetch(API_SAVE_PROGRESS, {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify(buildProgressPayload()),
});
if (r.ok){
const j = await r.json().catch(()=>({}));
if (!opts.silent) showToast(t("toast_progress_saved", j.n_scores ?? 0));
} else if (r.status===404||r.status===503){
setSyncStatus("local-only");
} else if (!opts.silent){
showToast(t("toast_progress_save_failed", "HTTP "+r.status), true);
}
}catch(e){
if (!opts.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);
}
function beaconProgress(){
if (!RATER.email || SYNC_STATUS==="local-only") return;
try{ navigator.sendBeacon(API_SAVE_PROGRESS,
new Blob([JSON.stringify(buildProgressPayload())], {type:"application/json"})); }catch(e){}
}
async function submitWholeStudy(){
if (!RATER.email||!RATER.name){ showToast(t("toast_pls_signin"), true); openRegister(); return; }
const comp = studyCompleteness();
if (comp.missing.length>0){
showToast(t("toast_missing", comp.missing.length), true);
const firstId = comp.missing[0].case_id;
const idx = CASES.findIndex(c=>c.id===firstId);
if (idx>=0) goTo(idx);
return;
}
if (SYNC_STATUS==="local-only"){ showToast(t("toast_no_server"), true); return; }
setSyncStatus("submitting");
try{
const r = await fetch(API_SUBMIT, {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify(buildFullPayload()),
});
if (r.ok){
const body = await r.json().catch(()=>({}));
RATER.submitted_at = new Date().toISOString();
saveState();
setSyncStatus("submitted");
updateSubmitBtn();
showSubmittedOverlay(body);
} else if (r.status===404||r.status===503){
setSyncStatus("local-only"); showToast(t("toast_no_server"), true);
} else {
setSyncStatus("error");
showToast(t("toast_submit_failed", "HTTP "+r.status), true);
}
}catch(e){
setSyncStatus("local-only"); showToast(t("toast_submit_failed", e.message), true);
}
}
function exportJSON(){
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((oi,si)=>({
slot:String.fromCharCode(65+si), slot_index:si, original_index:oi,
model:c.videos[oi].model, path:c.videos[oi].path,
ratings:(RATINGS[c.id]||{})[c.videos[oi].model]||{},
})),
};
}),
};
const blob = new Blob([JSON.stringify(out,null,2)], {type:"application/json"});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = `user_study_${RATER.id||"anon"}_${new Date().toISOString().slice(0,10)}.json`;
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
}
/* =========================================================================
* Mobile rendering
* ========================================================================= */
function renderShell(){
// language selectors
for (const id of ["langSelect","regLangSelect"]){
const sel = document.getElementById(id);
sel.innerHTML = "";
for (const L of LANGUAGES){
const o = document.createElement("option");
o.value = L.code; o.textContent = L.label;
if (L.code===LANG) o.selected = true;
sel.appendChild(o);
}
}
document.getElementById("topTitle").textContent = t("title");
// bottom-bar labels (kept short on mobile)
document.getElementById("prevBtn").textContent = t("prev");
document.getElementById("nextBtn").textContent = t("next");
document.getElementById("saveBtn").textContent = t("save_progress");
document.getElementById("submittedCloseBtn").textContent = t("submitted_close");
document.getElementById("submittedDownloadBtn").textContent = t("submitted_download");
applyRegistrationI18n();
updateSubmitBtn();
renderCase();
renderDrawer();
}
function renderCase(){
const main = document.getElementById("main");
main.innerHTML = "";
if (!CASES.length) return;
const c = CASES[currentIdx];
document.getElementById("caseId").textContent = c.id;
document.getElementById("caseCounter").textContent = t("case_n_of_m", currentIdx+1, CASES.length);
// prompt card
main.appendChild(renderPromptCard(c));
// video stack
main.appendChild(renderVideoStack(c));
// controls
main.appendChild(renderVideoControls());
// rubric — laid out as a responsive grid (1 col on phones, 2-3 on tablets).
const grid = document.createElement("div");
grid.className = "question-grid";
for (const dim of DIMENSIONS){
grid.appendChild(renderDimHeader(dim));
for (const q of dim.questions) grid.appendChild(renderQuestionCard(c, q));
}
main.appendChild(grid);
updateProgress();
refreshDrawerActive();
}
function renderPromptCard(c){
const wrap = document.createElement("div");
wrap.className = "prompt-card";
const localized = (c.prompt_i18n && c.prompt_i18n[LANG]) || c.prompt || t("no_prompt");
const isLong = (localized||"").length > 160;
const highlightedFull = highlightControllability(localized, LANG, c.id);
// For the collapsed teaser we still highlight, but on the truncated text.
const teaser = highlightControllability(localized.slice(0,150)+"…", LANG, c.id);
wrap.innerHTML = isLong
? `<div class="label">${escapeHtml(t("prompt_label"))}</div>
<details><summary>${teaser} <span style="color:var(--accent)">(${escapeHtml(t("expand_prompt"))})</span></summary>
<div style="margin-top:6px">${highlightedFull}</div></details>`
: `<div class="label">${escapeHtml(t("prompt_label"))}</div>
<div>${highlightedFull}</div>`;
if (c.reference_image){
const img = document.createElement("img");
img.src = c.reference_image; img.className = "ref-img";
img.onerror = function(){ this.style.display="none"; };
wrap.appendChild(img);
}
return wrap;
}
// Mobile keeps the same A/B/C tab UX as desktop, but stacked top-to-bottom.
// Only the active video is visible; others remain in the DOM so they keep
// their own playback state.
function renderVideoStack(c){
const slots = SHUFFLE[c.id];
const active = getActiveSlot(c.id);
const wrap = document.createElement("div");
wrap.className = "video-stack";
wrap.id = "videoStack";
// Tab bar
const tabs = document.createElement("div");
tabs.className = "video-tabs";
slots.forEach((origIdx, slotIdx) => {
const v = c.videos[origIdx];
const letter = String.fromCharCode(65+slotIdx);
const label = anonymize ? letter : `${letter} · ${escapeHtml(v.model)}`;
const btn = document.createElement("button");
btn.className = "tab-btn" + (slotIdx === active ? " active" : "");
btn.innerHTML = label;
btn.onclick = () => setActiveSlot(c.id, slotIdx);
tabs.appendChild(btn);
});
wrap.appendChild(tabs);
// Video tiles
slots.forEach((origIdx, slotIdx) => {
const v = c.videos[origIdx];
const tile = document.createElement("div");
tile.className = "video-tile" + (slotIdx === active ? "" : " hidden");
tile.innerHTML = `
<div class="video-frame">
<video data-slot="${slotIdx}" preload="metadata" playsinline controls
style="width:100%;height:100%">
<source src="${escapeHtml(v.path)}" type="video/mp4">
</video>
<div class="empty-state" style="display:none">
<div style="font-size:24px;opacity:0.3">▶</div>
<div>${escapeHtml(t("no_video_file"))}</div>
</div>
</div>`;
const video = tile.querySelector("video");
const empty = tile.querySelector(".empty-state");
const onErr = () => { empty.style.display="flex"; video.style.display="none"; };
video.addEventListener("error", onErr);
video.querySelector("source").addEventListener("error", onErr);
wrap.appendChild(tile);
});
return wrap;
}
// Active slot bookkeeping (per case) for the tab UI. Stays in memory only —
// not persisted across reloads, which is fine since the tabs are just a view
// helper and ratings persist per-slot in the global RATINGS map.
let ACTIVE_SLOT = {};
function getActiveSlot(caseId){ return ACTIVE_SLOT[caseId] ?? 0; }
function setActiveSlot(caseId, slotIdx){
pauseAll();
ACTIVE_SLOT[caseId] = slotIdx;
// Re-render only the video stack and tab bar (cheap), leaving question
// cards / scroll position intact.
const old = document.getElementById("videoStack");
if (!old) return;
const fresh = renderVideoStack(CASES[currentIdx]);
old.parentElement.replaceChild(fresh, old);
}
function renderVideoControls(){
const wrap = document.createElement("div");
wrap.className = "video-controls";
const mkBtn = (label, fn, id) => {
const b = document.createElement("button");
b.textContent = label; b.onclick = fn; if (id) b.id = id;
return b;
};
// Play button removed — the native <video controls> on the active tab
// provides play/pause already.
wrap.appendChild(mkBtn(t("pause"), pauseAll));
wrap.appendChild(mkBtn(t("restart"), restartAll));
const loop = mkBtn(t("loop"), () => toggleLoop(loop));
wrap.appendChild(loop);
const mute = mkBtn(t("mute"), () => toggleMute(mute));
wrap.appendChild(mute);
const sel = document.createElement("select");
sel.onchange = (e)=>setSpeed(parseFloat(e.target.value));
for (const v of ["0.5","0.75","1","1.5","2"]){
const o = document.createElement("option"); o.value=v; o.textContent=v+"×";
if (v==="1") o.selected = true; sel.appendChild(o);
}
wrap.appendChild(sel);
return wrap;
}
function renderDimHeader(dim){
const d = document.createElement("div");
d.className = "dim-header";
d.innerHTML = `<span>${escapeHtml(dimName(dim))}</span><span class="dim-hint">${escapeHtml(dimHint(dim))}</span>`;
return d;
}
function renderQuestionCard(c, q){
const card = document.createElement("div");
card.className = "q-card";
card.innerHTML = `<div class="q-name">${escapeHtml(qName(q))}</div>
<div class="q-desc">${escapeHtml(qDesc(q))}</div>`;
const slots = SHUFFLE[c.id];
const order = rowSlotOrder(c.id, q.key, slots.length);
for (const slotIdx of order){
const origIdx = slots[slotIdx];
const v = c.videos[origIdx];
const letter = String.fromCharCode(65+slotIdx);
const row = document.createElement("div");
row.className = "video-rate";
const chip = document.createElement("span");
chip.className = "slot-letter";
chip.textContent = anonymize ? letter : `${letter}`;
chip.title = anonymize ? `Video ${letter}` : `Video ${letter} · ${v.model}`;
row.appendChild(chip);
const group = document.createElement("div");
group.className = "likert";
const cur = getModelR(c.id, v.model)[q.key];
for (let s=1; s<=5; s++){
const b = document.createElement("button");
b.dataset.score = String(s);
b.textContent = String(s);
if (cur===s) b.classList.add("selected");
b.onclick = () => {
setRating(c.id, v.model, q.key, s);
Array.from(group.children).forEach((bb,i)=>bb.classList.toggle("selected", (i+1)===s));
};
group.appendChild(b);
}
row.appendChild(group);
card.appendChild(row);
}
return card;
}
/* ----- Drawer ----- */
function renderDrawer(){
const list = document.getElementById("drawerList");
list.innerHTML = "";
CASES.forEach((c, i) => {
const p = caseProgress(c);
let cls = "case-row";
if (i===currentIdx) cls += " active";
if (p.done === p.expected && p.expected>0) cls += " complete";
else if (p.done > 0) cls += " partial";
const row = document.createElement("div");
row.className = cls;
row.innerHTML = `<span class="dot"></span>
<span class="id">${c.id}</span>
<span class="pct">${p.done}/${p.expected}</span>`;
row.onclick = () => { closeDrawer(); goTo(i); };
list.appendChild(row);
});
}
function refreshDrawerActive(){
document.querySelectorAll("#drawerList .case-row").forEach((r,i)=>{
r.classList.toggle("active", i===currentIdx);
});
}
function openDrawer(){
document.getElementById("drawer").classList.add("open");
document.getElementById("drawerBackdrop").classList.add("open");
}
function closeDrawer(){
document.getElementById("drawer").classList.remove("open");
document.getElementById("drawerBackdrop").classList.remove("open");
}
/* ----- Video 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?t("muted"||"sound"):t("mute"); }
function setSpeed(rate){ allVideos().forEach(v=>v.playbackRate=rate); }
/* ----- Navigation ----- */
function goTo(idx){
if (idx<0 || idx>=CASES.length) return;
pauseAll();
currentIdx = idx;
renderCase();
// scroll to top of main on case switch
window.scrollTo({top:0, behavior:"instant"});
}
function step(d){ goTo((currentIdx + d + CASES.length) % CASES.length); }
/* ----- Submit button + progress ----- */
function updateSubmitBtn(){
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_disabled", remaining);
} else if (SYNC_STATUS === "submitted"){
btn.disabled = false; btn.textContent = t("submit_resubmit");
} else {
btn.disabled = false; btn.textContent = t("submit", comp.expected);
}
}
function updateProgress(){
const { done, expected } = studyCompleteness();
document.getElementById("progressFill").style.width =
expected>0 ? (done/expected*100).toFixed(1)+"%" : "0%";
document.getElementById("progressTxt").textContent = `${done}/${expected}`;
updateSubmitBtn();
// also refresh per-case status in drawer (cheap)
refreshDrawerCounts();
}
function refreshDrawerCounts(){
document.querySelectorAll("#drawerList .case-row").forEach((row,i)=>{
const c = CASES[i]; if (!c) return;
const p = caseProgress(c);
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 pct = row.querySelector(".pct");
if (pct) pct.textContent = `${p.done}/${p.expected}`;
});
}
/* ----- Registration overlay ----- */
function openRegister(){
document.getElementById("loginOverlay").classList.remove("hidden");
document.getElementById("regName").value = RATER.name || "";
document.getElementById("regEmail").value = RATER.email || "";
document.getElementById("regNote").value = RATER.note || "";
applyRegistrationI18n();
}
function closeRegister(){ document.getElementById("loginOverlay").classList.add("hidden"); }
/* ----- help / 评分说明 overlay (mobile only) ----- */
const HELP_SEEN_KEY = "user_study_help_seen_v1";
function openHelp(){
applyHelpI18n();
document.getElementById("helpOverlay").classList.remove("hidden");
}
function closeHelp(){
document.getElementById("helpOverlay").classList.add("hidden");
try { localStorage.setItem(HELP_SEEN_KEY, "1"); } catch(e){}
}
function applyHelpI18n(){
const h = document.getElementById("helpHeader");
if (h) h.textContent = t("help_title");
const body = document.getElementById("helpBody");
if (body) body.innerHTML = t("help_body_html");
const btn = document.getElementById("helpDismissText");
if (btn) btn.textContent = t("help_dismiss");
}
function applyRegistrationI18n(){
document.getElementById("regHeader").textContent = t("reg_title");
document.getElementById("regLead").textContent = t("reg_lead");
document.getElementById("regNameLabel").textContent = t("reg_name");
document.getElementById("regEmailLabel").textContent = t("reg_email");
document.getElementById("regNoteLabel").textContent = t("reg_note");
document.getElementById("regContinueText").textContent = t("reg_continue");
document.getElementById("regFootText").innerHTML = t("reg_foot");
document.getElementById("regName").placeholder = t("reg_name_ph");
document.getElementById("regEmail").placeholder = t("reg_email_ph");
document.getElementById("regNote").placeholder = t("reg_note_ph");
document.getElementById("submittedHeading").textContent = t("submitted_title");
}
function handleRegister(){
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_")){
RATER.id = email.replace(/[^a-z0-9]+/gi,"_").replace(/^_+|_+$/g,"");
}
if (!RATER.started_at) RATER.started_at = new Date().toISOString();
// Non-admins always anonymized
if (!isAdmin()) anonymize = true;
closeRegister();
// Recover any prior locked case subset + ratings from the server first,
// then resolve the subset (locked > coverage-first > deterministic pick).
(async () => {
// Drop any pre-registration (anonymous) lock so the subset is re-resolved
// in priority order: server-recovered > coverage-first > seeded random.
RATER.chosen_case_ids = null;
await recoverRaterStateFromServer(email);
await assignCasesFromServer();
CASES = selectCasesForRater(ALL_CASES);
SHUFFLE = {}; buildShuffle(CASES); saveState();
renderShell();
})();
(async()=>{
await probeSync();
if (SYNC_STATUS!=="local-only"){
await saveProgress({silent:true, source:"register"});
}
showToast(t("toast_registered", name));
})();
}
/* ----- Submitted overlay ----- */
function showSubmittedOverlay(body){
const ov = document.getElementById("submittedOverlay");
document.getElementById("submittedDetail").textContent =
t("submitted_thanks", RATER.name, body.n_scores ?? "?", body.n_videos ?? "?");
document.getElementById("submittedRef").textContent = body.stored_as || "—";
ov.classList.remove("hidden");
}
function hideSubmittedOverlay(){ document.getElementById("submittedOverlay").classList.add("hidden"); }
/* =========================================================================
* Init
* ========================================================================= */
(async function init(){
// restore lang first
try{
const prior0 = JSON.parse(localStorage.getItem(STORAGE_KEY)||"null");
if (prior0 && STRINGS[prior0.lang]) LANG = prior0.lang;
}catch(e){}
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;
// fetch manifest
let manifest = null;
for (const p of ["./cases.json", "./cases.example.json"]){
try{ const r = await fetch(p, {cache:"no-cache"}); if (r.ok){ manifest = await r.json(); break; } }catch(e){}
}
if (!manifest || !Array.isArray(manifest) || manifest.length===0){
document.getElementById("main").innerHTML =
'<div style="padding:40px;color:var(--bad);text-align:center">Could not load cases.json</div>';
return;
}
ALL_CASES = manifest;
// restore state
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 huobao→univa rename (see desktop for context).
for (const cid of Object.keys(RATINGS)) {
const m = RATINGS[cid];
if (m && m.huobao && !m.univa) { m.univa = m.huobao; delete m.huobao; }
}
} else { RATER = blankRater(); }
if (!isAdmin()) anonymize = true;
// Admins see all cases; everyone else gets a deterministic 10-case subset.
CASES = selectCasesForRater(ALL_CASES);
buildShuffle(CASES);
// One-shot migration: older mobile builds keyed RATINGS by slot index
// instead of model name. SHUFFLE is deterministic per (rater, case), so a
// numeric slot key maps back to the exact model the rater saw and scored.
{
let moved = 0;
for (const c of CASES) {
const m = RATINGS[c.id];
if (!m) continue;
const slots = SHUFFLE[c.id] || c.videos.map((_,i)=>i);
for (const k of Object.keys(m)) {
if (!/^\d+$/.test(k)) continue;
const model = (c.videos[slots[Number(k)]] || {}).model;
if (model && !m[model]) { m[model] = m[k]; moved++; }
delete m[k];
}
}
if (moved) console.info(`[user-study] migrated ${moved} slot-keyed rating group(s) to model keys`);
}
saveState();
// wire DOM events
document.getElementById("drawerBtn").onclick = openDrawer;
document.getElementById("helpBtn").onclick = openHelp;
document.getElementById("helpCloseBtn").onclick = closeHelp;
document.getElementById("helpDismissBtn").onclick = closeHelp;
document.getElementById("drawerCloseBtn").onclick = closeDrawer;
document.getElementById("drawerBackdrop").onclick = closeDrawer;
document.getElementById("prevBtn").onclick = () => step(-1);
document.getElementById("nextBtn").onclick = () => step(1);
document.getElementById("submitBtn").onclick = submitWholeStudy;
document.getElementById("saveBtn").onclick = () => saveProgress({silent:false, source:"manual"});
document.getElementById("scrollTopBtn").onclick = () => {
document.getElementById("videoStack")?.scrollIntoView({behavior:"smooth", block:"start"});
};
document.getElementById("regGoBtn").onclick = handleRegister;
document.getElementById("submittedCloseBtn").onclick = hideSubmittedOverlay;
document.getElementById("submittedDownloadBtn").onclick = exportJSON;
document.getElementById("langSelect").onchange = (e) => {
LANG = e.target.value; document.documentElement.lang = LANG;
saveState(); renderShell();
};
document.getElementById("regLangSelect").onchange = (e) => {
LANG = e.target.value; document.documentElement.lang = LANG;
saveState(); applyRegistrationI18n();
};
// best-effort save on close
window.addEventListener("beforeunload", beaconProgress);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState==="hidden") beaconProgress();
});
// gate on registration
if (!RATER.name || !RATER.email || !EMAIL_RE.test(RATER.email)){
openRegister();
} else {
renderShell();
closeRegister();
probeSync();
// Auto-open the rubric popup once per device. The user can manually
// reopen it any time via the "?" header button.
try {
if (localStorage.getItem(HELP_SEEN_KEY) !== "1") openHelp();
} catch (e) {}
}
// Always fetch admin-curated highlights; available even before registration
// gate clears so the prompt shows highlights as soon as the rater enters.
loadAnnotations();
})();
</script>
</body>
</html>