Spaces:
Sleeping
Sleeping
deploy v2 study (40-runs baselines) at /v2/: dual-study backend + static/v2 rater UI; v1 study untouched
f80bbbc verified | <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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''})[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> | |