dehezhang2 commited on
Commit
f80bbbc
·
verified ·
1 Parent(s): f92a088

deploy v2 study (40-runs baselines) at /v2/: dual-study backend + static/v2 rater UI; v1 study untouched

Browse files
app.py CHANGED
@@ -605,7 +605,17 @@ app.include_router(build_study_router(
605
  cases_file="static/cases.json",
606
  ))
607
 
 
 
 
 
 
 
 
 
 
608
  # v2 — the 40-runs baseline study; everything lives under v2/ in the dataset.
 
609
  app.include_router(build_study_router(
610
  prefix="/v2/api", study="v2",
611
  submissions_root="v2/submissions",
 
605
  cases_file="static/cases.json",
606
  ))
607
 
608
+ # v1 archived UI — same v1 study backend, for the pages preserved at /v1/.
609
+ app.include_router(build_study_router(
610
+ prefix="/v1/api", study="v1",
611
+ submissions_root="submissions",
612
+ annotations_path="annotations.json",
613
+ normalize_legacy=True,
614
+ cases_file="static/v1/cases.json",
615
+ ))
616
+
617
  # v2 — the 40-runs baseline study; everything lives under v2/ in the dataset.
618
+ # The Space root (static/index.html) redirects here: v2 is the CURRENT study.
619
  app.include_router(build_study_router(
620
  prefix="/v2/api", study="v2",
621
  submissions_root="v2/submissions",
static/index.html CHANGED
The diff for this file is too large to render. See raw diff
 
static/mobile.html CHANGED
@@ -1,1492 +1,8 @@
1
  <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport"
6
- content="width=device-width, initial-scale=1.0, viewport-fit=cover">
7
- <meta name="theme-color" content="#0f1115">
8
- <title>Video user study — mobile</title>
9
- <style>
10
- :root {
11
- --bg: #0f1115;
12
- --panel: #161922;
13
- --panel-2: #1c2030;
14
- --panel-3: #232839;
15
- --border: #2a2f42;
16
- --border-strong: #3a4060;
17
- --fg: #e6e8ef;
18
- --muted: #8b91a8;
19
- --muted-2: #5e6480;
20
- --accent: #6aa9ff;
21
- --accent-strong: #8ec0ff;
22
- --good: #28c76f;
23
- --warn: #ff9f43;
24
- --bad: #ea5455;
25
- --score-1: #ea5455;
26
- --score-2: #ff7d52;
27
- --score-3: #ffa84a;
28
- --score-4: #8acf6d;
29
- --score-5: #28c76f;
30
- --safe-bot: env(safe-area-inset-bottom, 0px);
31
- --bar-h: 56px;
32
- }
33
- * { box-sizing: border-box; }
34
- html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg);
35
- font-family: -apple-system, BlinkMacSystemFont, "Inter", "PingFang SC",
36
- "Microsoft YaHei", sans-serif;
37
- font-size: 15px; line-height: 1.4;
38
- -webkit-tap-highlight-color: transparent;
39
- overscroll-behavior-y: contain; }
40
-
41
- /* ---------- top header ---------- */
42
- header.top { position: sticky; top: 0; z-index: 50;
43
- background: var(--panel); border-bottom: 1px solid var(--border);
44
- padding: 8px 12px; padding-top: calc(env(safe-area-inset-top, 0px) + 8px);
45
- display: flex; align-items: center; gap: 8px; }
46
- header.top .title { font-weight: 600; font-size: 14px; flex: 1;
47
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
48
- header.top .pill { font-size: 11px; padding: 3px 8px; border-radius: 10px;
49
- background: var(--panel-3); color: var(--muted); white-space: nowrap;
50
- font-family: ui-monospace, monospace; border: 1px solid var(--border); }
51
- header.top .pill[data-state="idle"] { background:#1f2a44; color: var(--accent); border-color:#3a4a78; }
52
- header.top .pill[data-state="submitting"] { background:#1f2a44; color: var(--accent); border-color:#3a4a78; }
53
- header.top .pill[data-state="submitted"] { background:#1c3422; color: var(--good); border-color:#2a5a37; }
54
- header.top .pill[data-state="local-only"] { background:#3a3520; color: var(--warn); border-color:#5a4a25; }
55
- header.top .pill[data-state="error"] { background:#3a2020; color: var(--bad); border-color:#5a2828; }
56
- header.top button.icon { background: transparent; border: none; color: var(--fg);
57
- font-size: 22px; padding: 4px 8px; cursor: pointer; line-height: 1; }
58
- header.top select { background: var(--panel-3); border: 1px solid var(--border);
59
- color: var(--fg); padding: 4px 6px; border-radius: 4px; font-size: 12px;
60
- font-family: inherit; }
61
-
62
- /* ---------- progress strip ---------- */
63
- .case-strip { position: sticky; top: 56px; z-index: 40;
64
- background: var(--panel); border-bottom: 1px solid var(--border);
65
- padding: 6px 12px; display: flex; align-items: center; gap: 8px;
66
- font-size: 12px; color: var(--muted); }
67
- .case-strip .case-id { color: var(--fg); font-weight: 600;
68
- font-family: ui-monospace, monospace; }
69
- .case-strip .bar { flex: 1; height: 4px; background: var(--panel-3); border-radius: 2px;
70
- overflow: hidden; }
71
- .case-strip .fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--good));
72
- transition: width 0.2s; }
73
- .case-strip button.jumper { background: var(--panel-3); border: 1px solid var(--border);
74
- color: var(--fg); padding: 4px 10px; border-radius: 4px;
75
- font-size: 12px; font-family: inherit; cursor: pointer; }
76
-
77
- /* ---------- main scroll content ---------- */
78
- main { padding: 8px 12px calc(var(--bar-h) + var(--safe-bot) + 24px); }
79
-
80
- /* prompt */
81
- .prompt-card { background: var(--panel); border: 1px solid var(--border);
82
- border-radius: 8px; padding: 10px 12px; margin-bottom: 10px;
83
- font-size: 14px; line-height: 1.6; }
84
- .prompt-card .label { font-size: 10px; color: var(--muted); letter-spacing: 0.5px;
85
- text-transform: uppercase; margin-bottom: 4px; }
86
- .prompt-card details summary { cursor: pointer; user-select: none;
87
- list-style: none; color: var(--muted); font-size: 13px; }
88
- .prompt-card details summary::before { content: '▸ '; }
89
- .prompt-card details[open] summary::before { content: '▾ '; }
90
- .prompt-card details[open] summary { margin-bottom: 6px; }
91
- .prompt-card .ref-img { max-width: 100%; max-height: 200px; border-radius: 6px;
92
- border: 1px solid var(--border); display: block; margin-top: 8px; }
93
- /* Bold-red highlight on controllability cues in the prompt. */
94
- .ctrl { color: var(--bad); font-weight: 700; }
95
- /* Admin-curated highlights get a subtle dotted underline so admins know
96
- which marks are their own (raters see them the same red as auto cues). */
97
- .ctrl-admin { text-decoration: underline; text-decoration-style: dotted;
98
- text-decoration-color: var(--bad); text-underline-offset: 2px; }
99
-
100
- /* videos — tab UI mirrors desktop: one active video at a time, A/B/C
101
- buttons to switch. All tiles stay in DOM so each keeps its own state. */
102
- .video-stack { display: flex; flex-direction: column; gap: 10px;
103
- margin-bottom: 14px; }
104
- .video-tabs { display: flex; gap: 6px; flex-wrap: wrap; }
105
- .tab-btn { background: var(--panel-3); border: 1px solid var(--border);
106
- color: var(--muted); padding: 8px 14px; border-radius: 6px;
107
- font-family: ui-monospace, monospace; font-size: 13px; cursor: pointer;
108
- min-height: 38px; min-width: 44px; }
109
- .tab-btn.active { background: var(--accent); color: #0a1a2a;
110
- border-color: var(--accent); font-weight: 700; }
111
- .video-tile { position: relative; }
112
- .video-tile.hidden { display: none; }
113
- .video-frame { position: relative; background: #000; border: 1px solid var(--border);
114
- border-radius: 8px; overflow: hidden; aspect-ratio: 16 / 9; }
115
- .video-frame video { width: 100%; height: 100%; display: block; background: #000; }
116
- .video-frame .empty-state { position: absolute; inset: 0; display: flex;
117
- flex-direction: column; align-items: center; justify-content: center;
118
- background: repeating-linear-gradient(45deg, #1a1d28 0 10px, #16191f 10px 20px);
119
- color: var(--muted); font-size: 12px; gap: 4px; text-align: center;
120
- padding: 12px; }
121
-
122
- /* video controls */
123
- .video-controls { display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
124
- background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
125
- padding: 8px 10px; margin-bottom: 14px; font-size: 12px; }
126
- .video-controls button { background: var(--panel-3); border: 1px solid var(--border-strong);
127
- color: var(--fg); padding: 8px 12px; border-radius: 6px; font-size: 13px;
128
- font-family: inherit; min-height: 36px; cursor: pointer; }
129
- .video-controls button.on { background: var(--accent); color: #0a1a2a; border-color: var(--accent); }
130
- .video-controls select { background: var(--panel-3); border: 1px solid var(--border);
131
- color: var(--fg); padding: 6px 8px; border-radius: 4px; font-size: 13px;
132
- font-family: inherit; margin-left: auto; }
133
-
134
- /* dimension + question cards. dim-header sits inline above its cards
135
- (not sticky — only the prompt + videos at the top of the page float). */
136
- /* Responsive grid of question cards. ~280 px min → 1 col on phones,
137
- 2-3 cols on tablets / landscape. Dim headers span all columns. */
138
- .question-grid { display: grid; gap: 10px;
139
- grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
140
- align-content: start; }
141
- .question-grid > .dim-header { grid-column: 1 / -1; }
142
- .dim-header { margin: 14px 0 4px; padding: 0 2px;
143
- font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;
144
- color: var(--accent-strong); font-weight: 700;
145
- display: flex; align-items: baseline; gap: 8px; }
146
- .dim-header:first-child { margin-top: 0; }
147
- .dim-header .dim-hint { font-size: 11px; color: var(--muted); font-weight: 400;
148
- text-transform: none; letter-spacing: 0; }
149
- .q-card { background: var(--panel); border: 1px solid var(--border);
150
- border-radius: 8px; padding: 12px; margin-bottom: 10px; }
151
- .q-card .q-name { font-size: 15px; font-weight: 600; color: var(--fg);
152
- margin-bottom: 2px; }
153
- .q-card .q-desc { font-size: 12px; color: var(--muted); margin-bottom: 10px;
154
- line-height: 1.5; }
155
- .q-card .video-rate { display: flex; align-items: center; gap: 8px;
156
- padding: 6px 0; }
157
- .q-card .video-rate + .video-rate { border-top: 1px solid var(--border); }
158
- .q-card .video-rate .slot-letter { min-width: 28px; height: 28px;
159
- display: inline-flex; align-items: center; justify-content: center;
160
- border-radius: 4px; background: var(--panel-3); color: var(--fg);
161
- font-weight: 700; font-size: 13px; font-family: ui-monospace, monospace;
162
- border: 1px solid var(--border-strong); flex-shrink: 0; }
163
- .q-card .likert { display: flex; gap: 4px; flex: 1; justify-content: space-between; }
164
- .q-card .likert button { flex: 1; min-height: 42px; min-width: 0;
165
- border: 1px solid var(--border); background: var(--panel-3); color: var(--fg);
166
- border-radius: 6px; font-family: inherit; font-size: 14px; font-weight: 500;
167
- cursor: pointer; }
168
- .q-card .likert button.selected { font-weight: 700; color: #0a1a0a;
169
- border-color: rgba(255,255,255,0.2); }
170
- .q-card .likert button.selected[data-score="1"] { background: var(--score-1); }
171
- .q-card .likert button.selected[data-score="2"] { background: var(--score-2); }
172
- .q-card .likert button.selected[data-score="3"] { background: var(--score-3); }
173
- .q-card .likert button.selected[data-score="4"] { background: var(--score-4); }
174
- .q-card .likert button.selected[data-score="5"] { background: var(--score-5); }
175
-
176
- /* bottom fixed action bar */
177
- footer.bottom { position: fixed; left: 0; right: 0; bottom: 0; z-index: 50;
178
- background: var(--panel); border-top: 1px solid var(--border);
179
- padding: 8px 10px calc(8px + var(--safe-bot));
180
- display: grid; grid-template-columns: 1fr 1fr auto 1fr 1fr; gap: 6px;
181
- height: calc(var(--bar-h) + var(--safe-bot)); align-items: center; }
182
- footer.bottom button { background: var(--panel-3); border: 1px solid var(--border-strong);
183
- color: var(--fg); padding: 0; height: 40px; border-radius: 6px;
184
- font-family: inherit; font-size: 14px; font-weight: 500; cursor: pointer; }
185
- footer.bottom .submit-btn { background: var(--good); color: #0a1a0a;
186
- border-color: var(--good); font-weight: 700; }
187
- footer.bottom .submit-btn:disabled { background: var(--panel-3); color: var(--muted-2);
188
- border-color: var(--border); font-weight: 500; }
189
- footer.bottom .top-btn { font-size: 18px; }
190
-
191
- /* slide-in drawer (case list) */
192
- .drawer-backdrop { position: fixed; inset: 0; background: rgba(8,10,16,0.7);
193
- z-index: 60; opacity: 0; pointer-events: none; transition: opacity 0.2s; }
194
- .drawer-backdrop.open { opacity: 1; pointer-events: auto; }
195
- .drawer { position: fixed; top: 0; right: 0; bottom: 0; width: 86%;
196
- max-width: 360px; background: var(--panel); border-left: 1px solid var(--border);
197
- z-index: 61; transform: translateX(100%); transition: transform 0.22s ease;
198
- display: flex; flex-direction: column; padding-top: env(safe-area-inset-top, 0px); }
199
- .drawer.open { transform: translateX(0); }
200
- .drawer header { padding: 12px 14px; border-bottom: 1px solid var(--border);
201
- display: flex; align-items: center; justify-content: space-between;
202
- font-weight: 600; }
203
- .drawer .list { flex: 1; overflow-y: auto; padding: 4px 0; }
204
- .drawer .case-row { padding: 12px 14px; border-bottom: 1px solid var(--border);
205
- display: flex; align-items: center; gap: 10px; }
206
- .drawer .case-row.active { background: var(--panel-2);
207
- border-left: 3px solid var(--accent); padding-left: 11px; }
208
- .drawer .case-row .dot { width: 9px; height: 9px; border-radius: 50%;
209
- background: var(--muted-2); flex-shrink: 0; }
210
- .drawer .case-row.partial .dot { background: var(--warn); }
211
- .drawer .case-row.complete .dot { background: var(--good); }
212
- .drawer .case-row .id { flex: 1; font-family: ui-monospace, monospace;
213
- font-size: 13px; }
214
- .drawer .case-row .pct { font-size: 11px; color: var(--muted); }
215
-
216
- /* registration overlay (re-used from desktop) */
217
- .overlay { position: fixed; inset: 0; background: rgba(8,10,16,0.84);
218
- backdrop-filter: blur(4px); display: flex; align-items: center;
219
- justify-content: center; z-index: 100; padding: 14px; }
220
- .overlay.hidden { display: none; }
221
- .reg-card { background: var(--panel); border: 1px solid var(--border-strong);
222
- border-radius: 10px; padding: 18px 18px 16px; max-width: 420px; width: 100%; }
223
- .reg-card h2 { margin: 0 0 4px; font-size: 17px; }
224
- .reg-card .lead { color: var(--muted); font-size: 13px; line-height: 1.6;
225
- margin: 0 0 14px; }
226
- .reg-card label { display: block; font-size: 11px; text-transform: uppercase;
227
- letter-spacing: 0.5px; color: var(--muted); margin: 10px 0 4px; }
228
- .reg-card input, .reg-card textarea { width: 100%; background: var(--panel-3);
229
- border: 1px solid var(--border); color: var(--fg); padding: 10px;
230
- border-radius: 6px; font-family: inherit; font-size: 16px; /* 16 ≥ iOS no-zoom */ }
231
- .reg-card textarea { min-height: 50px; resize: vertical; font-size: 14px; }
232
- .reg-card .err { color: var(--bad); font-size: 12px; min-height: 14px;
233
- margin-top: 6px; }
234
- .reg-card .top-row { display: flex; align-items: center;
235
- justify-content: space-between; margin-bottom: 8px; gap: 12px; }
236
- .reg-card select { background: var(--panel-3); border: 1px solid var(--border);
237
- color: var(--fg); padding: 4px 6px; border-radius: 4px; font-size: 12px;
238
- font-family: inherit; }
239
- .reg-card .go-btn { width: 100%; background: var(--accent); color: #0a1a2a;
240
- border: 1px solid var(--accent); padding: 12px; border-radius: 8px;
241
- font-weight: 700; font-size: 15px; cursor: pointer; margin-top: 14px; }
242
-
243
- /* submitted overlay */
244
- .submitted-card { background: var(--panel); border: 1px solid var(--good);
245
- border-radius: 10px; padding: 22px; max-width: 420px; width: 100%;
246
- text-align: center; }
247
- .submitted-card .check { font-size: 44px; color: var(--good); }
248
- .submitted-card h2 { margin: 8px 0 4px; font-size: 17px; }
249
- .submitted-card p { color: var(--muted); font-size: 13px; margin: 4px 0;
250
- line-height: 1.6; }
251
- .submitted-card code { background: var(--panel-3); padding: 2px 6px;
252
- border-radius: 3px; font-size: 11px; color: var(--accent-strong);
253
- word-break: break-all; }
254
- .submitted-card .btn { background: var(--panel-3); border: 1px solid var(--border-strong);
255
- color: var(--fg); padding: 10px 14px; border-radius: 6px; font-size: 14px;
256
- font-family: inherit; margin-top: 12px; cursor: pointer; min-width: 100px; }
257
-
258
- /* toast */
259
- .toast { position: fixed; bottom: calc(var(--bar-h) + var(--safe-bot) + 14px);
260
- left: 12px; right: 12px; background: var(--good); color: #0a1a0a;
261
- padding: 10px 14px; border-radius: 8px; font-size: 13px; font-weight: 600;
262
- text-align: center; opacity: 0; transition: opacity 0.2s;
263
- z-index: 90; pointer-events: none; }
264
- .toast.show { opacity: 1; }
265
- .toast.error { background: var(--bad); color: #fff; }
266
-
267
- /* desktop switch link */
268
- .desktop-link { font-size: 11px; color: var(--muted); text-decoration: none;
269
- padding: 4px 8px; border: 1px solid var(--border); border-radius: 12px;
270
- background: var(--panel-3); }
271
- .desktop-link:hover { color: var(--fg); }
272
-
273
- /* admin / submitted badges */
274
- .badge { display: inline-flex; align-items: center; padding: 2px 8px;
275
- border-radius: 10px; font-size: 10px; font-weight: 700; }
276
- .badge.admin { background: #3a2f60; color: #d9b6ff; border: 1px solid #5a4080; }
277
- .badge.submitted { background: #1c3422; color: var(--good); border: 1px solid #2a5a37; }
278
-
279
- /* shut down landscape if it gets weird */
280
- @media (orientation: landscape) and (max-height: 460px) {
281
- header.top { padding: 4px 12px; }
282
- .case-strip { top: 44px; padding: 4px 12px; }
283
- main { padding-top: 6px; }
284
- }
285
-
286
- kbd { background: var(--panel-3); border: 1px solid var(--border-strong);
287
- border-radius: 3px; padding: 1px 4px; font-family: ui-monospace, monospace;
288
- font-size: 11px; }
289
- </style>
290
- </head>
291
- <body>
292
-
293
- <!-- ============ Top bar ============ -->
294
- <header class="top" id="topBar">
295
- <button class="icon" id="drawerBtn" title="Cases">☰</button>
296
- <span class="title" id="topTitle">Video user study</span>
297
- <button class="icon" id="helpBtn" title="How to rate" style="font-size:18px">?</button>
298
- <span class="pill" id="syncPill" data-state="idle">●</span>
299
- <select id="langSelect" title="Language"></select>
300
- </header>
301
-
302
- <div class="case-strip" id="caseStrip">
303
- <span class="case-id" id="caseId">—</span>
304
- <span id="caseCounter">—</span>
305
- <div class="bar"><div class="fill" id="progressFill" style="width:0%"></div></div>
306
- <span id="progressTxt">0/0</span>
307
- </div>
308
-
309
- <main id="main">
310
- <!-- prompt + videos + controls + question cards inserted here -->
311
- </main>
312
-
313
- <footer class="bottom">
314
- <button id="prevBtn">← Prev</button>
315
- <button id="saveBtn">💾</button>
316
- <button class="top-btn" id="scrollTopBtn" title="Back to videos">↑</button>
317
- <button id="submitBtn" class="submit-btn" disabled>Submit</button>
318
- <button id="nextBtn">Next →</button>
319
- </footer>
320
-
321
- <div class="toast" id="toast"></div>
322
-
323
- <!-- Slide-in drawer with case list -->
324
- <div class="drawer-backdrop" id="drawerBackdrop"></div>
325
- <aside class="drawer" id="drawer" aria-hidden="true">
326
- <header>
327
- <span>Cases</span>
328
- <button class="icon" id="drawerCloseBtn"
329
- style="background:transparent;border:none;color:var(--fg);font-size:22px;cursor:pointer">✕</button>
330
- </header>
331
- <div class="list" id="drawerList"></div>
332
- <div style="padding:10px 14px;border-top:1px solid var(--border);display:flex;gap:8px;align-items:center">
333
- <a href="./?force=desktop" class="desktop-link" style="flex:1;text-align:center">🖥 Desktop view</a>
334
- </div>
335
- </aside>
336
-
337
- <!-- Rating instructions overlay (mobile) — toggled via the "?" header button.
338
- Auto-opened on first launch; user can close + reopen any time. -->
339
- <div id="helpOverlay" class="overlay hidden" role="dialog" aria-modal="true">
340
- <div class="reg-card" style="max-width:520px">
341
- <div class="top-row">
342
- <h2 id="helpHeader">How to rate</h2>
343
- <button class="btn" id="helpCloseBtn"
344
- style="background:transparent;border:none;color:var(--fg);font-size:22px;cursor:pointer">✕</button>
345
- </div>
346
- <div id="helpBody" style="font-size:13px;line-height:1.6;color:var(--fg);max-height:60vh;overflow-y:auto"></div>
347
- <button class="go-btn" id="helpDismissBtn" style="margin-top:14px"><span id="helpDismissText">Got it</span></button>
348
- </div>
349
- </div>
350
-
351
- <!-- Registration overlay -->
352
- <div id="loginOverlay" class="overlay" role="dialog" aria-modal="true">
353
- <div class="reg-card">
354
- <div class="top-row">
355
- <h2 id="regHeader">Register to start rating</h2>
356
- <select id="regLangSelect"></select>
357
- </div>
358
- <p class="lead" id="regLead"></p>
359
- <label for="regName" id="regNameLabel">Your name</label>
360
- <input id="regName" type="text" autocomplete="name" maxlength="80">
361
- <label for="regEmail" id="regEmailLabel">Email</label>
362
- <input id="regEmail" type="email" autocomplete="email" inputmode="email" maxlength="120">
363
- <label for="regNote" id="regNoteLabel">Affiliation / notes (optional)</label>
364
- <textarea id="regNote" maxlength="280"></textarea>
365
- <div class="err" id="regErr"></div>
366
- <button class="go-btn" id="regGoBtn"><span id="regContinueText">Continue →</span></button>
367
- <div style="color:var(--muted-2);font-size:11px;margin-top:8px;line-height:1.5"
368
- id="regFootText"></div>
369
- </div>
370
- </div>
371
-
372
- <!-- Submitted overlay -->
373
- <div id="submittedOverlay" class="overlay hidden" role="dialog" aria-modal="true">
374
- <div class="submitted-card">
375
- <div class="check">✓</div>
376
- <h2 id="submittedHeading">Submission recorded</h2>
377
- <p id="submittedDetail"></p>
378
- <p><code id="submittedRef">—</code></p>
379
- <div style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap">
380
- <button class="btn" id="submittedCloseBtn">Close</button>
381
- <button class="btn" id="submittedDownloadBtn">↓ Backup JSON</button>
382
- </div>
383
- </div>
384
- </div>
385
-
386
- <script>
387
- /* =========================================================================
388
- * Auto-redirect to the desktop page on large / pointer-fine devices.
389
- * `?force=mobile` overrides (sticky in sessionStorage for the tab).
390
- * ========================================================================= */
391
- (function autoRedirectIfDesktop() {
392
- try {
393
- const url = new URL(location.href);
394
- if (url.searchParams.has("force")) {
395
- sessionStorage.setItem("user_study_view_force", url.searchParams.get("force"));
396
- }
397
- const forced = sessionStorage.getItem("user_study_view_force");
398
- if (forced === "mobile") return;
399
- const ua = navigator.userAgent || "";
400
- const isMobileUA = /Mobi|Android|iPhone|iPod|BlackBerry|Windows Phone/i.test(ua);
401
- const isFine = window.matchMedia && window.matchMedia("(pointer: fine)").matches;
402
- const isWide = window.innerWidth >= 900;
403
- if (forced === "desktop" || (!isMobileUA && isFine && isWide)) {
404
- location.replace("./");
405
- }
406
- } catch (e) { /* ignore — fall through to mobile */ }
407
- })();
408
-
409
- /* =========================================================================
410
- * Same rubric + i18n + admin list + API contract as desktop index.html.
411
- * Kept in sync manually for now (small enough). localStorage key is shared
412
- * so progress made on desktop carries to mobile and vice-versa.
413
- * ========================================================================= */
414
- const DIMENSIONS = [
415
- { key: "instruction_following", name: "Instruction following",
416
- hint: "Re-read the prompt — especially the red-highlighted phrases — before scoring.", questions: [
417
- { key: "controllability", name: "Controllability",
418
- desc: "Does the video respond to control signals (BGM, subtitle etc)?" },
419
- { key: "prompt_adherence", name: "Prompt adherence",
420
- desc: "Does the content match what the prompt described (subjects, actions, setting, time, and story arc)? Focus primarily on the prompt's text content." },
421
- { key: "no_irrelevant_frames", name: "No irrelevant frames",
422
- desc: "Does the video stay on-prompt? Higher = fewer / no frames or content that weren't asked for by the prompt." } ] },
423
- { key: "temporal_consistency", name: "Temporal consistency",
424
- hint: "Focus on how characters, objects, and the environment change across frames.", questions: [
425
- { key: "person_stability", name: "Person stability",
426
- desc: "Faces, identities, and body proportions stay consistent across frames." },
427
- { key: "object_stability", name: "Object stability",
428
- desc: "Props, clothing, and objects keep their shape and position over time." },
429
- { key: "environment_stability", name: "Environment stability",
430
- desc: "Background, lighting, and scene layout stay coherent across frames (including absence of brightness / color / texture flicker)." } ] },
431
- // Dim key stays `motion_realism` for back-compat; display + hint broadened
432
- // to "Realism", and the `realism` sub-question (formerly under Visual
433
- // quality) lives here now.
434
- { key: "motion_realism", name: "Realism",
435
- hint: "Notice obvious out-of-place or unnatural moments in the video.", questions: [
436
- { key: "realism", name: "Static realism",
437
- desc: "Photorealism or stylistic fidelity (per-frame appearance). When the prompt specifies a style, the video should match it. No uncanny or broken artifacts." },
438
- { key: "motion_logic", name: "Motion logic",
439
- 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." },
440
- { key: "camera", name: "Camera",
441
- desc: "Camera movement (pan, dolly, focal change) feels natural and intentional. Score low if there is little to no camera movement." } ] },
442
- ];
443
-
444
- const TOTAL_QUESTIONS = DIMENSIONS.reduce((n, d) => n + d.questions.length, 0);
445
- const STORAGE_KEY = "user_study_video_ratings_v1"; // shared with desktop
446
- const ADMIN_NAMES = new Set(["deheng zhang", "zhendong li"]);
447
-
448
- const LANGUAGES = [
449
- { code: "en", label: "English" },
450
- { code: "zh", label: "Chinese" },
451
- { code: "bg", label: "Bulgarian" },
452
- ];
453
-
454
- const STRINGS = {
455
- en: {
456
- title:"Video user study",
457
- prompt_label:"Prompt / instruction",
458
- no_prompt:"(no prompt provided)",
459
- case_n_of_m:"Case {0}/{1}",
460
- play:"▶ Play", pause:"⏸", restart:"⏮", loop:"↻", mute:"🔇", sound:"🔊", speed:"Speed",
461
- no_video_file:"no video file at this path",
462
- prev:"← Prev", next:"Next →",
463
- save_progress:"💾",
464
- submit:"Submit ({0})",
465
- submit_resubmit:"✓ Re-submit",
466
- submit_disabled:"Submit ({0} left)",
467
- reg_title:"Register to start rating",
468
- reg_lead:"We record who submitted each set of ratings. No password — just your name and an email that uniquely identifies you.",
469
- reg_name:"Your name", reg_name_ph:"e.g. Alex Chen",
470
- reg_email:"Email", reg_email_ph:"e.g. alex@example.com",
471
- reg_note:"Affiliation / notes (optional)", reg_note_ph:"e.g. INSAIT, internal pilot, etc.",
472
- reg_continue:"Continue →",
473
- help_title:"How to rate",
474
- help_dismiss:"Got it",
475
- 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 × 9 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>",
476
- reg_foot:"Submissions are written to the <code>dehezhang2/Frameworker_User_Study</code> dataset on Hugging Face.",
477
- reg_err_name:"Please enter your name.",
478
- reg_err_email:"Please enter a valid email address.",
479
- pill_idle:"●", pill_submitting:"↻", pill_submitted:"✓", pill_local_only:"💾", pill_error:"✕", pill_checking:"●",
480
- toast_registered:"Registered as {0}",
481
- toast_pls_signin:"Please sign in before submitting",
482
- toast_missing:"{0} ratings still missing",
483
- toast_no_server:"No server reachable — use Export instead",
484
- toast_submit_failed:"Submit failed: {0}",
485
- toast_progress_saved:"Progress saved ({0} ratings)",
486
- toast_progress_save_failed:"Save failed: {0}",
487
- submitted_title:"Submission recorded",
488
- submitted_thanks:"Thanks, {0}. We recorded {1} ratings across {2} videos.",
489
- submitted_close:"Close", submitted_download:"↓ Backup",
490
- drawer_cases:"Cases",
491
- scale_1:"1 Very poor", scale_5:"5 Excellent",
492
- expand_prompt:"Read full prompt",
493
- case_complete:"✓ complete",
494
- confirm_reset:"Reset all ratings + registration on this device? This cannot be undone.",
495
- admin:"⚙ admin", submitted_badge:"✓ submitted",
496
- },
497
- zh: {
498
- title:"视频用户研究",
499
- prompt_label:"提示词 / 指令",
500
- no_prompt:"(未提供提示词)",
501
- case_n_of_m:"案例 {0}/{1}",
502
- play:"▶ 播放", pause:"⏸", restart:"⏮", loop:"↻", mute:"🔇", sound:"🔊", speed:"速度",
503
- no_video_file:"该路径下没有视频文件",
504
- prev:"← 上一个", next:"下一个 →",
505
- save_progress:"💾",
506
- submit:"提交 ({0})",
507
- submit_resubmit:"✓ 重新提交",
508
- submit_disabled:"提交 (还剩 {0})",
509
- reg_title:"注册以开始评分",
510
- reg_lead:"我们会记录每份评分的提交者。无需密码 — 只需姓名和一个唯一识别您的邮箱。",
511
- reg_name:"您的姓名", reg_name_ph:"例如:陈小明",
512
- reg_email:"邮箱", reg_email_ph:"例如:xiaoming@example.com",
513
- reg_note:"所属单位 / 备注(可选)", reg_note_ph:"例如:INSAIT、内部预测试 等",
514
- reg_continue:"继续 →",
515
- help_title:"评分说明",
516
- help_dismiss:"知道了",
517
- 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 个维度 × 9 个子问题</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>",
518
- reg_foot:"提交将写入 Hugging Face 上的 <code>dehezhang2/Frameworker_User_Study</code> 数据集。",
519
- reg_err_name:"请输入您的姓名。",
520
- reg_err_email:"请输入有效的邮箱地址���",
521
- pill_idle:"●", pill_submitting:"↻", pill_submitted:"✓", pill_local_only:"💾", pill_error:"✕", pill_checking:"●",
522
- toast_registered:"已注册为 {0}",
523
- toast_pls_signin:"请先登录后再提交",
524
- toast_missing:"尚有 {0} 项未完成",
525
- toast_no_server:"无法连接服务器 — 请使用导出功能",
526
- toast_submit_failed:"提交失败: {0}",
527
- toast_progress_saved:"进度已保存 ({0} 项评分)",
528
- toast_progress_save_failed:"保存失败: {0}",
529
- submitted_title:"提交成功",
530
- submitted_thanks:"{0},感谢您的参与。我们已写入 {2} 个视频 × {1} 项评分。",
531
- submitted_close:"关闭", submitted_download:"↓ 备份",
532
- drawer_cases:"案例",
533
- scale_1:"1 很差", scale_5:"5 极佳",
534
- expand_prompt:"查看完整提示词",
535
- case_complete:"✓ 已完成",
536
- confirm_reset:"重置本设备的所有评分和注册信息吗?该操作不可撤销。",
537
- admin:"⚙ 管理员", submitted_badge:"✓ 已提交",
538
- },
539
- bg: {
540
- title:"Видео проучване",
541
- prompt_label:"Подкана / инструкция",
542
- no_prompt:"(няма подкана)",
543
- case_n_of_m:"Случай {0}/{1}",
544
- play:"▶ Пусни", pause:"⏸", restart:"⏮", loop:"↻", mute:"🔇", sound:"🔊", speed:"Скорост",
545
- no_video_file:"няма видео файл на този път",
546
- prev:"← Назад", next:"Напред →",
547
- save_progress:"💾",
548
- submit:"Изпрати ({0})",
549
- submit_resubmit:"✓ Повторно",
550
- submit_disabled:"Изпрати (остават {0})",
551
- reg_title:"Регистрирайте се, за да започнете",
552
- reg_lead:"Записваме кой е изпратил всеки набор от оценки. Без парола — само името и имейл, които ви идентифицират уникално.",
553
- reg_name:"Вашето име", reg_name_ph:"напр. Алекс Иванов",
554
- reg_email:"Имейл", reg_email_ph:"напр. alex@example.com",
555
- reg_note:"Принадлежност / бележки (по избор)", reg_note_ph:"напр. INSAIT, вътрешен пилот",
556
- reg_continue:"Продължи →",
557
- help_title:"Как се оценява",
558
- help_dismiss:"Разбрах",
559
- 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 измерения × 9 под-въпроса</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>",
560
- reg_foot:"Изпращанията се записват в <code>dehezhang2/Frameworker_User_Study</code> на Hugging Face.",
561
- reg_err_name:"Моля, въведете името си.",
562
- reg_err_email:"Моля, въведете валиден имейл адрес.",
563
- pill_idle:"●", pill_submitting:"↻", pill_submitted:"✓", pill_local_only:"💾", pill_error:"✕", pill_checking:"●",
564
- toast_registered:"Регистриран(а) като {0}",
565
- toast_pls_signin:"Моля, влезте преди изпращане",
566
- toast_missing:"Все още липсват {0} оценки",
567
- toast_no_server:"Сървърът не е достъпен — използвайте Изтегли",
568
- toast_submit_failed:"Изпращането се провали: {0}",
569
- toast_progress_saved:"Прогресът е запазен ({0} оценки)",
570
- toast_progress_save_failed:"Запазването се провали: {0}",
571
- submitted_title:"Записано",
572
- submitted_thanks:"Благодарим, {0}. Записахме {1} оценки за {2} видеа.",
573
- submitted_close:"Затвори", submitted_download:"↓ Копие",
574
- drawer_cases:"Случаи",
575
- scale_1:"1 Много слабо", scale_5:"5 Отлично",
576
- expand_prompt:"Покажи цялата подкана",
577
- case_complete:"✓ завършен",
578
- confirm_reset:"Изчистване на всички оценки и регистрация на това устройство? Действието е необратимо.",
579
- admin:"⚙ администратор", submitted_badge:"✓ изпратено",
580
- },
581
- };
582
-
583
- const RUBRIC_I18N = {
584
- zh: {
585
- instruction_following:{name:"指令遵循",hint:"请先看清提示词,尤其是标红的部分,再作答。"},
586
- temporal_consistency:{name:"时间一致性",hint:"请关注人物、物品和环境在帧间的变化。"},
587
- motion_realism:{name:"真实感",hint:"请留意视频中不和谐或有明显问题的地方。"},
588
- controllability:{name:"可控性",desc:"视频是否响应控制信号(BGM、字幕等)?"},
589
- prompt_adherence:{name:"提示一致性",desc:"内容是否与提示的描述一致(主体、动作、场景、时间、故事走向)?以提示词的文本内容为主要依据。"},
590
- no_irrelevant_frames:{name:"无无关画面",desc:"视频是否仅呈现提示中要求的内容?分数越高 = 越少出现与提示无关的帧或内容。"},
591
- realism:{name:"静态真实感",desc:"单帧的照片级真实感或风格忠实度。若提示词指定了风格,视频应符合该风格。无诡异感或失真伪影。"},
592
- person_stability:{name:"人物稳定性",desc:"面部、身份及身体比例在帧间保持一致。"},
593
- object_stability:{name:"物体稳定性",desc:"道具、服饰及物体随时间保持形状和位置。"},
594
- environment_stability:{name:"环境稳定性",desc:"背景、光照及场景布局在帧间保持连贯(包括无亮度、色彩或纹理闪烁)。"},
595
- motion_logic:{name:"运动逻辑",desc:"身体与物体的运动是否流畅且符合物理与常识规律(重力、碰撞、流体/布料动力学)?无形变、瞬移、不合常理或不可能的动作。若几乎没有运动,请打低分。"},
596
- camera:{name:"相机运动",desc:"相机运动(平移、推拉、焦距变化)自然且有意图。若几乎没有相机运动,请打低分。"},
597
- },
598
- bg: {
599
- instruction_following:{name:"Следване на инструкции",hint:"Прочетете внимателно подканата — особено маркираните в червено фрази — преди да оцените."},
600
- temporal_consistency:{name:"Времева съгласуваност",hint:"Съсредоточете се върху промените на хора, предмети и среда между кадрите."},
601
- motion_realism:{name:"Реализъм",hint:"Забележете очевидни проблеми или неестествени моменти във видеото."},
602
- controllability:{name:"Контролируемост",desc:"Реагира ли видеото на контролни сигнали (BGM, субтитри и т.н.)?"},
603
- prompt_adherence:{name:"Съответствие с подканата",desc:"Съответства ли съдържанието на описаното в подканата (обекти, действия, обстановка, време и развитие на историята)? Фокусирайте се основно върху текстовото съдържание на подканата."},
604
- no_irrelevant_frames:{name:"Без неуместни кадри",desc:"Остава ли видеото в рамките на подканата? По-висок = по-малко / без кадри или съдържание, които не са поискани."},
605
- realism:{name:"Статичен реализъм",desc:"Фотореалистичност или стилова достоверност на отделен кадър. Ако подканата задава стил, видеото трябва да следва този стил. Без странни или счупени артефакти."},
606
- person_stability:{name:"Стабилност на хората",desc:"Лица, идентичности и пропорции на тялото остават постоянни между кадрите."},
607
- object_stability:{name:"Стабилност на обектите",desc:"Реквизити, дрехи и обекти запазват форма и позиция във времето."},
608
- environment_stability:{name:"Стабилност на средата",desc:"Фон, осветление и подредба на сцената остават последователни между кадрите (включително липса на трептене на яркост, цвят или текстура)."},
609
- motion_logic:{name:"Логика на движението",desc:"Текат ли движенията на тела и обекти плавно и в съответствие с физически / логически закони (гравитация, сблъсъци, динамика на флуиди / тъкани)? Без деформации, телепортация, нелогични или невъзможни движения. Дайте нисък резултат, ако има малко или никакво движение."},
610
- camera:{name:"Камера",desc:"Движението на камерата (панорама, дъли, промяна на фокус) изглежда естествено и целенасочено. Дайте нисък резултат, ако има малко или никакво движение на камерата."},
611
- },
612
- };
613
-
614
- /* ----- State (shared with desktop via localStorage) ----- */
615
- let CASES = []; // cases visible to this rater (subset of ALL)
616
- let ALL_CASES = []; // full manifest from cases.json
617
- const CASES_PER_RATER = 10;
618
- let SHUFFLE = {};
619
- let RATINGS = {};
620
- let RATER = { id:"", name:"", email:"", note:"",
621
- started_at:null, submitted_at:null };
622
- let LANG = "en";
623
- let anonymize = true;
624
- let currentIdx = 0;
625
- let SYNC_STATUS = "idle";
626
- let SYNC_LAST_DETAIL = "";
627
- const API_SUBMIT = "./api/submit";
628
- const API_HEALTH = "./api/health";
629
- const API_SAVE_PROGRESS = "./api/save_progress";
630
- const PROGRESS_DEBOUNCE_MS = 60000;
631
- let PROGRESS_TIMER = null;
632
-
633
- /* ----- Helpers ----- */
634
- function t(key, ...args){
635
- let s = (STRINGS[LANG]&&STRINGS[LANG][key])??STRINGS.en[key]??key;
636
- if(args.length) s = s.replace(/\{(\d+)\}/g,(_,i)=>args[Number(i)]??"");
637
- return s;
638
- }
639
- function dimName(d){ return LANG==="en"?d.name:(RUBRIC_I18N[LANG]?.[d.key]?.name??d.name); }
640
- function dimHint(d){ return LANG==="en"?d.hint:(RUBRIC_I18N[LANG]?.[d.key]?.hint??d.hint); }
641
- function qName(q){ return LANG==="en"?q.name:(RUBRIC_I18N[LANG]?.[q.key]?.name??q.name); }
642
- function qDesc(q){ return LANG==="en"?q.desc:(RUBRIC_I18N[LANG]?.[q.key]?.desc??q.desc); }
643
- function isAdmin(){ return ADMIN_NAMES.has((RATER.name||"").trim().toLowerCase()); }
644
- const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
645
- 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; }
646
- function escapeHtml(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]);}
647
-
648
- // Controllability highlighter — wraps imperative verbs + directive phrases
649
- // in <span class="ctrl"> so raters see what the prompt actually controls.
650
- const CTRL_PATTERNS = {
651
- en: [
652
- /\bAdd\b[^.]*\./g,
653
- /\bUse this[^.]*\./g,
654
- /Make a[^—–]*?animated short drama/g,
655
- ],
656
- zh: [
657
- /制作一部[^—–]*?短剧/g,
658
- /请?添加[^。]*。/g,
659
- /请使用上传的[^。]*。/g,
660
- /配以[^。]*。/g,
661
- /环境音[::][^。]*。/g,
662
- ],
663
- bg: [
664
- /Създайте[^—–]*?анимационна кратка драма/g,
665
- /Добавете[^.]*\./g,
666
- /Използвайте качената[^.]*\./g,
667
- ],
668
- };
669
- // Admin-curated highlight snippets, fetched at init from /api/annotations.
670
- // Same shape as desktop: { case_id: { lang: [snippet, ...] } }
671
- let ADMIN_HIGHLIGHTS = {};
672
- const API_ANNOTATIONS = "./api/annotations";
673
-
674
- function highlightControllability(text, lang, caseId) {
675
- let html = escapeHtml(text);
676
- const patterns = CTRL_PATTERNS[lang] || CTRL_PATTERNS.en;
677
- const ranges = [];
678
- // Auto-detected controllability cues
679
- for (const re of patterns) {
680
- re.lastIndex = 0; let m;
681
- while ((m = re.exec(html)) !== null) {
682
- ranges.push([m.index, m.index + m[0].length, "auto"]);
683
- if (m[0].length === 0) re.lastIndex++;
684
- }
685
- }
686
- // Admin-curated snippets for this case + language
687
- const adminSnippets = (caseId && ADMIN_HIGHLIGHTS[caseId]?.[lang]) || [];
688
- for (const snippet of adminSnippets) {
689
- if (!snippet) continue;
690
- const esc = escapeHtml(snippet);
691
- let idx = 0;
692
- while ((idx = html.indexOf(esc, idx)) !== -1) {
693
- ranges.push([idx, idx + esc.length, "admin"]);
694
- idx += esc.length || 1;
695
- }
696
- }
697
- if (!ranges.length) return html;
698
- ranges.sort((a,b)=>a[0]-b[0]||a[1]-b[1]);
699
- const merged = [ranges[0].slice()];
700
- for (let i=1;i<ranges.length;i++){
701
- const top = merged[merged.length-1];
702
- if (ranges[i][0]<top[1]) {
703
- top[1] = Math.max(top[1], ranges[i][1]);
704
- if (ranges[i][2] === "admin") top[2] = "admin";
705
- } else merged.push(ranges[i].slice());
706
- }
707
- let out = html;
708
- for (let i=merged.length-1;i>=0;i--){
709
- const [s,e,kind] = merged[i];
710
- const cls = kind === "admin" ? "ctrl ctrl-admin" : "ctrl";
711
- out = out.slice(0,s) + `<span class="${cls}">` + out.slice(s,e) + '</span>' + out.slice(e);
712
- }
713
- return out;
714
- }
715
-
716
- async function loadAnnotations() {
717
- try {
718
- const r = await fetch(API_ANNOTATIONS, { cache: "no-cache" });
719
- if (!r.ok) return;
720
- const j = await r.json().catch(() => ({}));
721
- ADMIN_HIGHLIGHTS = j.annotations || {};
722
- // Re-render the current case so the freshly-loaded admin highlights
723
- // appear in the prompt.
724
- if (CASES.length && RATER.name) renderCase();
725
- } catch (e) { /* offline: auto-detected highlights only */ }
726
- }
727
- function showToast(msg, isErr=false){
728
- const tt = document.getElementById("toast");
729
- tt.textContent = msg;
730
- tt.classList.toggle("error", !!isErr);
731
- tt.classList.add("show");
732
- setTimeout(()=>tt.classList.remove("show"), 1800);
733
- }
734
- function genRaterId(){ return "rater_"+Math.random().toString(36).slice(2,8)+"_"+Date.now().toString(36).slice(-4); }
735
-
736
- /* ----- LocalStorage (shared key) ----- */
737
- function loadState(){ try{ return JSON.parse(localStorage.getItem(STORAGE_KEY)||"null"); }catch(e){return null;} }
738
- function saveState(){
739
- try{ localStorage.setItem(STORAGE_KEY, JSON.stringify({
740
- rater: RATER, anonymize, lang: LANG, shuffle: SHUFFLE, ratings: RATINGS,
741
- saved_at: new Date().toISOString(),
742
- })); }catch(e){ showToast("Save failed: "+e.message, true); }
743
- }
744
-
745
- /* ----- Shuffle ----- */
746
- // Per-rater 10-case subset of the manifest. Admins see all. The chosen ids
747
- // are locked into RATER.chosen_case_ids on first pick so the rater always
748
- // sees the same set across sessions / devices regardless of future
749
- // manifest changes.
750
- function pickRandomSubsetIds(allCases){
751
- const idx = allCases.map((_,i)=>i);
752
- let s = hashStr((RATER.id||"anon")+":case-subset");
753
- for (let k=0;k<6;k++) s = (s*1664525+1013904223)>>>0;
754
- for (let i=idx.length-1;i>0;i--){
755
- s = (s*1664525+1013904223)>>>0;
756
- const j = (s>>>16)%(i+1);
757
- [idx[i],idx[j]] = [idx[j],idx[i]];
758
- }
759
- return idx.slice(0,CASES_PER_RATER).sort((a,b)=>a-b).map(i => allCases[i].id);
760
- }
761
- function selectCasesForRater(allCases){
762
- if (isAdmin()) return allCases.slice();
763
- if (allCases.length <= CASES_PER_RATER) return allCases.slice();
764
- const locked = Array.isArray(RATER.chosen_case_ids) ? RATER.chosen_case_ids : null;
765
- if (locked && locked.length){
766
- const byId = new Map(allCases.map(c => [c.id, c]));
767
- const out = locked.map(id => byId.get(id)).filter(Boolean);
768
- if (out.length >= Math.min(CASES_PER_RATER, allCases.length)) return out;
769
- }
770
- const ids = pickRandomSubsetIds(allCases);
771
- RATER.chosen_case_ids = ids;
772
- const byId = new Map(allCases.map(c => [c.id, c]));
773
- return ids.map(id => byId.get(id)).filter(Boolean);
774
- }
775
- async function recoverRaterStateFromServer(email){
776
- if (!email) return;
777
- try {
778
- const r = await fetch(`./api/rater_state?email=${encodeURIComponent(email)}`,
779
- { cache: "no-cache" });
780
- if (!r.ok) return;
781
- const j = await r.json().catch(()=>({}));
782
- if (!j.found || !j.state) return;
783
- const remoteRater = j.state.rater || {};
784
- if (Array.isArray(remoteRater.chosen_case_ids) && remoteRater.chosen_case_ids.length) {
785
- RATER.chosen_case_ids = remoteRater.chosen_case_ids;
786
- }
787
- for (const c of (j.state.cases || [])) {
788
- if (!c || !c.id) continue;
789
- RATINGS[c.id] = RATINGS[c.id] || {};
790
- for (const v of (c.videos || [])) {
791
- const m = v.model;
792
- if (!m) continue;
793
- RATINGS[c.id][m] = RATINGS[c.id][m] || {};
794
- for (const [qk, score] of Object.entries(v.ratings || {})) {
795
- if (typeof score === "number" && score>=1 && score<=5 &&
796
- RATINGS[c.id][m][qk] === undefined) {
797
- RATINGS[c.id][m][qk] = score;
798
- }
799
- }
800
- }
801
- }
802
- } catch (e) { /* silent */ }
803
- }
804
-
805
- function buildShuffle(cases){
806
- for (const c of cases){
807
- if (SHUFFLE[c.id] && SHUFFLE[c.id].length===c.videos.length) continue;
808
- const idx = c.videos.map((_,i)=>i);
809
- if (anonymize){
810
- let s = hashStr(RATER.id+":"+c.id);
811
- for (let i=idx.length-1;i>0;i--){
812
- s = (s*1664525+1013904223)>>>0;
813
- const j = s%(i+1);
814
- [idx[i],idx[j]] = [idx[j],idx[i]];
815
- }
816
- }
817
- SHUFFLE[c.id] = idx;
818
- }
819
- }
820
- // Within a case, every sub-question shows its rating rows in A → B → C
821
- // order. (Per-rater case-level shuffle still varies which video sits under
822
- // each letter across cases, but the order inside a case is now stable so
823
- // the rater always sees a consistent layout.)
824
- function rowSlotOrder(caseId, qKey, slotCount){
825
- return Array.from({length:slotCount},(_,i)=>i);
826
- }
827
-
828
- /* ----- Ratings ----- */
829
- function getCaseR(cid){ if(!RATINGS[cid]) RATINGS[cid]={}; return RATINGS[cid]; }
830
- function getSlotR(cid, s){ const m = getCaseR(cid); if(!m[s]) m[s]={}; return m[s]; }
831
- function setRating(cid, sIdx, qk, score){
832
- getSlotR(cid, sIdx)[qk] = score;
833
- saveState();
834
- updateProgress();
835
- if (RATER.submitted_at){
836
- RATER.submitted_at = null; saveState();
837
- setSyncStatus(SYNC_STATUS==="local-only"?"local-only":"idle");
838
- }
839
- updateSubmitBtn();
840
- scheduleProgressSave();
841
- }
842
- function studyCompleteness(){
843
- const qkeys = DIMENSIONS.flatMap(d=>d.questions.map(q=>q.key));
844
- let done=0, expected=0; const missing=[];
845
- for (const c of CASES){
846
- const slots = SHUFFLE[c.id] || c.videos.map((_,i)=>i);
847
- for (let s=0; s<slots.length; s++){
848
- const r = (RATINGS[c.id]||{})[s] || {};
849
- for (const qk of qkeys){
850
- expected++;
851
- if (typeof r[qk]==="number" && r[qk]>=1 && r[qk]<=5) done++;
852
- else missing.push({case_id:c.id, slot:String.fromCharCode(65+s), q:qk});
853
- }
854
- }
855
- }
856
- return { done, expected, missing };
857
- }
858
- function caseProgress(c){
859
- const slots = SHUFFLE[c.id] || [];
860
- const expected = slots.length * TOTAL_QUESTIONS;
861
- let done=0;
862
- for (let s=0;s<slots.length;s++){
863
- const r = (RATINGS[c.id]||{})[s] || {};
864
- done += Object.keys(r).filter(k=>typeof r[k]==="number"&&r[k]>=1&&r[k]<=5).length;
865
- }
866
- return { done, expected };
867
- }
868
-
869
- /* ----- Sync pill + server status ----- */
870
- function setSyncStatus(state, detail){
871
- SYNC_STATUS = state;
872
- if (detail!==undefined) SYNC_LAST_DETAIL = detail;
873
- const pill = document.getElementById("syncPill");
874
- if (!pill) return;
875
- pill.dataset.state = state;
876
- pill.textContent = t({
877
- idle:"pill_idle", submitting:"pill_submitting", submitted:"pill_submitted",
878
- "local-only":"pill_local_only", error:"pill_error",
879
- }[state] || "pill_checking");
880
- pill.title = SYNC_LAST_DETAIL || "";
881
- }
882
- async function probeSync(){
883
- try{
884
- const r = await fetch(API_HEALTH, {cache:"no-cache"});
885
- if (r.ok){
886
- const j = await r.json().catch(()=>({}));
887
- setSyncStatus(j.ok && j.has_token ? "idle" : "local-only");
888
- } else setSyncStatus("local-only");
889
- }catch(e){ setSyncStatus("local-only"); }
890
- }
891
-
892
- /* ----- Payloads + API calls ----- */
893
- function buildProgressPayload(){
894
- const comp = studyCompleteness();
895
- return {
896
- rater: RATER, anonymize_used: anonymize, is_admin: isAdmin(),
897
- rubric: DIMENSIONS, progress_saved_at: new Date().toISOString(),
898
- completeness: { done: comp.done, expected: comp.expected, missing_count: comp.missing.length },
899
- cases: CASES.map(c => {
900
- const slots = SHUFFLE[c.id] || c.videos.map((_,i)=>i);
901
- return {
902
- id: c.id, category: c.category||null, prompt: c.prompt,
903
- videos: slots.map((origIdx, slotIdx) => ({
904
- slot: String.fromCharCode(65+slotIdx), slot_index: slotIdx,
905
- original_index: origIdx, model: c.videos[origIdx].model,
906
- path: c.videos[origIdx].path,
907
- ratings: (RATINGS[c.id]||{})[slotIdx] || {},
908
- })),
909
- };
910
- }),
911
- };
912
- }
913
- function buildFullPayload(){
914
- const p = buildProgressPayload();
915
- p.submitted_at = new Date().toISOString();
916
- delete p.progress_saved_at; delete p.completeness;
917
- return p;
918
- }
919
- async function saveProgress(opts={}){
920
- if (!RATER.email || !RATER.name) return;
921
- if (SYNC_STATUS==="local-only" && opts.source!=="manual") return;
922
- try{
923
- const r = await fetch(API_SAVE_PROGRESS, {
924
- method:"POST", headers:{"Content-Type":"application/json"},
925
- body: JSON.stringify(buildProgressPayload()),
926
- });
927
- if (r.ok){
928
- const j = await r.json().catch(()=>({}));
929
- if (!opts.silent) showToast(t("toast_progress_saved", j.n_scores ?? 0));
930
- } else if (r.status===404||r.status===503){
931
- setSyncStatus("local-only");
932
- } else if (!opts.silent){
933
- showToast(t("toast_progress_save_failed", "HTTP "+r.status), true);
934
- }
935
- }catch(e){
936
- if (!opts.silent) showToast(t("toast_progress_save_failed", e.message), true);
937
- }
938
- }
939
- function scheduleProgressSave(){
940
- if (!RATER.email) return;
941
- if (SYNC_STATUS==="local-only") return;
942
- if (PROGRESS_TIMER) clearTimeout(PROGRESS_TIMER);
943
- PROGRESS_TIMER = setTimeout(()=>{ PROGRESS_TIMER=null; saveProgress({silent:true,source:"auto"}); }, PROGRESS_DEBOUNCE_MS);
944
- }
945
- function beaconProgress(){
946
- if (!RATER.email || SYNC_STATUS==="local-only") return;
947
- try{ navigator.sendBeacon(API_SAVE_PROGRESS,
948
- new Blob([JSON.stringify(buildProgressPayload())], {type:"application/json"})); }catch(e){}
949
- }
950
- async function submitWholeStudy(){
951
- if (!RATER.email||!RATER.name){ showToast(t("toast_pls_signin"), true); openRegister(); return; }
952
- const comp = studyCompleteness();
953
- if (comp.missing.length>0){
954
- showToast(t("toast_missing", comp.missing.length), true);
955
- const firstId = comp.missing[0].case_id;
956
- const idx = CASES.findIndex(c=>c.id===firstId);
957
- if (idx>=0) goTo(idx);
958
- return;
959
- }
960
- if (SYNC_STATUS==="local-only"){ showToast(t("toast_no_server"), true); return; }
961
- setSyncStatus("submitting");
962
- try{
963
- const r = await fetch(API_SUBMIT, {
964
- method:"POST", headers:{"Content-Type":"application/json"},
965
- body: JSON.stringify(buildFullPayload()),
966
- });
967
- if (r.ok){
968
- const body = await r.json().catch(()=>({}));
969
- RATER.submitted_at = new Date().toISOString();
970
- saveState();
971
- setSyncStatus("submitted");
972
- updateSubmitBtn();
973
- showSubmittedOverlay(body);
974
- } else if (r.status===404||r.status===503){
975
- setSyncStatus("local-only"); showToast(t("toast_no_server"), true);
976
- } else {
977
- setSyncStatus("error");
978
- showToast(t("toast_submit_failed", "HTTP "+r.status), true);
979
- }
980
- }catch(e){
981
- setSyncStatus("local-only"); showToast(t("toast_submit_failed", e.message), true);
982
- }
983
- }
984
- function exportJSON(){
985
- const out = {
986
- rater: RATER, anonymize_used: anonymize, exported_at: new Date().toISOString(),
987
- rubric: DIMENSIONS,
988
- cases: CASES.map(c => {
989
- const slots = SHUFFLE[c.id] || c.videos.map((_,i)=>i);
990
- return { id:c.id, prompt:c.prompt,
991
- videos: slots.map((oi,si)=>({
992
- slot:String.fromCharCode(65+si), slot_index:si, original_index:oi,
993
- model:c.videos[oi].model, path:c.videos[oi].path,
994
- ratings:(RATINGS[c.id]||{})[si]||{},
995
- })),
996
- };
997
- }),
998
- };
999
- const blob = new Blob([JSON.stringify(out,null,2)], {type:"application/json"});
1000
- const url = URL.createObjectURL(blob);
1001
- const a = document.createElement("a");
1002
- a.href = url; a.download = `user_study_${RATER.id||"anon"}_${new Date().toISOString().slice(0,10)}.json`;
1003
- document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
1004
- }
1005
-
1006
- /* =========================================================================
1007
- * Mobile rendering
1008
- * ========================================================================= */
1009
- function renderShell(){
1010
- // language selectors
1011
- for (const id of ["langSelect","regLangSelect"]){
1012
- const sel = document.getElementById(id);
1013
- sel.innerHTML = "";
1014
- for (const L of LANGUAGES){
1015
- const o = document.createElement("option");
1016
- o.value = L.code; o.textContent = L.label;
1017
- if (L.code===LANG) o.selected = true;
1018
- sel.appendChild(o);
1019
- }
1020
- }
1021
- document.getElementById("topTitle").textContent = t("title");
1022
- // bottom-bar labels (kept short on mobile)
1023
- document.getElementById("prevBtn").textContent = t("prev");
1024
- document.getElementById("nextBtn").textContent = t("next");
1025
- document.getElementById("saveBtn").textContent = t("save_progress");
1026
- document.getElementById("submittedCloseBtn").textContent = t("submitted_close");
1027
- document.getElementById("submittedDownloadBtn").textContent = t("submitted_download");
1028
- applyRegistrationI18n();
1029
- updateSubmitBtn();
1030
- renderCase();
1031
- renderDrawer();
1032
- }
1033
-
1034
- function renderCase(){
1035
- const main = document.getElementById("main");
1036
- main.innerHTML = "";
1037
- if (!CASES.length) return;
1038
- const c = CASES[currentIdx];
1039
- document.getElementById("caseId").textContent = c.id;
1040
- document.getElementById("caseCounter").textContent = t("case_n_of_m", currentIdx+1, CASES.length);
1041
-
1042
- // prompt card
1043
- main.appendChild(renderPromptCard(c));
1044
- // video stack
1045
- main.appendChild(renderVideoStack(c));
1046
- // controls
1047
- main.appendChild(renderVideoControls());
1048
- // rubric — laid out as a responsive grid (1 col on phones, 2-3 on tablets).
1049
- const grid = document.createElement("div");
1050
- grid.className = "question-grid";
1051
- for (const dim of DIMENSIONS){
1052
- grid.appendChild(renderDimHeader(dim));
1053
- for (const q of dim.questions) grid.appendChild(renderQuestionCard(c, q));
1054
- }
1055
- main.appendChild(grid);
1056
- updateProgress();
1057
- refreshDrawerActive();
1058
- }
1059
-
1060
- function renderPromptCard(c){
1061
- const wrap = document.createElement("div");
1062
- wrap.className = "prompt-card";
1063
- const localized = (c.prompt_i18n && c.prompt_i18n[LANG]) || c.prompt || t("no_prompt");
1064
- const isLong = (localized||"").length > 160;
1065
- const highlightedFull = highlightControllability(localized, LANG, c.id);
1066
- // For the collapsed teaser we still highlight, but on the truncated text.
1067
- const teaser = highlightControllability(localized.slice(0,150)+"…", LANG, c.id);
1068
- wrap.innerHTML = isLong
1069
- ? `<div class="label">${escapeHtml(t("prompt_label"))}</div>
1070
- <details><summary>${teaser} <span style="color:var(--accent)">(${escapeHtml(t("expand_prompt"))})</span></summary>
1071
- <div style="margin-top:6px">${highlightedFull}</div></details>`
1072
- : `<div class="label">${escapeHtml(t("prompt_label"))}</div>
1073
- <div>${highlightedFull}</div>`;
1074
- if (c.reference_image){
1075
- const img = document.createElement("img");
1076
- img.src = c.reference_image; img.className = "ref-img";
1077
- img.onerror = function(){ this.style.display="none"; };
1078
- wrap.appendChild(img);
1079
- }
1080
- return wrap;
1081
- }
1082
-
1083
- // Mobile keeps the same A/B/C tab UX as desktop, but stacked top-to-bottom.
1084
- // Only the active video is visible; others remain in the DOM so they keep
1085
- // their own playback state.
1086
- function renderVideoStack(c){
1087
- const slots = SHUFFLE[c.id];
1088
- const active = getActiveSlot(c.id);
1089
- const wrap = document.createElement("div");
1090
- wrap.className = "video-stack";
1091
- wrap.id = "videoStack";
1092
-
1093
- // Tab bar
1094
- const tabs = document.createElement("div");
1095
- tabs.className = "video-tabs";
1096
- slots.forEach((origIdx, slotIdx) => {
1097
- const v = c.videos[origIdx];
1098
- const letter = String.fromCharCode(65+slotIdx);
1099
- const label = anonymize ? letter : `${letter} · ${escapeHtml(v.model)}`;
1100
- const btn = document.createElement("button");
1101
- btn.className = "tab-btn" + (slotIdx === active ? " active" : "");
1102
- btn.innerHTML = label;
1103
- btn.onclick = () => setActiveSlot(c.id, slotIdx);
1104
- tabs.appendChild(btn);
1105
- });
1106
- wrap.appendChild(tabs);
1107
-
1108
- // Video tiles
1109
- slots.forEach((origIdx, slotIdx) => {
1110
- const v = c.videos[origIdx];
1111
- const tile = document.createElement("div");
1112
- tile.className = "video-tile" + (slotIdx === active ? "" : " hidden");
1113
- tile.innerHTML = `
1114
- <div class="video-frame">
1115
- <video data-slot="${slotIdx}" preload="metadata" playsinline controls
1116
- style="width:100%;height:100%">
1117
- <source src="${escapeHtml(v.path)}" type="video/mp4">
1118
- </video>
1119
- <div class="empty-state" style="display:none">
1120
- <div style="font-size:24px;opacity:0.3">▶</div>
1121
- <div>${escapeHtml(t("no_video_file"))}</div>
1122
- </div>
1123
- </div>`;
1124
- const video = tile.querySelector("video");
1125
- const empty = tile.querySelector(".empty-state");
1126
- const onErr = () => { empty.style.display="flex"; video.style.display="none"; };
1127
- video.addEventListener("error", onErr);
1128
- video.querySelector("source").addEventListener("error", onErr);
1129
- wrap.appendChild(tile);
1130
- });
1131
- return wrap;
1132
- }
1133
-
1134
- // Active slot bookkeeping (per case) for the tab UI. Stays in memory only —
1135
- // not persisted across reloads, which is fine since the tabs are just a view
1136
- // helper and ratings persist per-slot in the global RATINGS map.
1137
- let ACTIVE_SLOT = {};
1138
- function getActiveSlot(caseId){ return ACTIVE_SLOT[caseId] ?? 0; }
1139
- function setActiveSlot(caseId, slotIdx){
1140
- pauseAll();
1141
- ACTIVE_SLOT[caseId] = slotIdx;
1142
- // Re-render only the video stack and tab bar (cheap), leaving question
1143
- // cards / scroll position intact.
1144
- const old = document.getElementById("videoStack");
1145
- if (!old) return;
1146
- const fresh = renderVideoStack(CASES[currentIdx]);
1147
- old.parentElement.replaceChild(fresh, old);
1148
- }
1149
-
1150
- function renderVideoControls(){
1151
- const wrap = document.createElement("div");
1152
- wrap.className = "video-controls";
1153
- const mkBtn = (label, fn, id) => {
1154
- const b = document.createElement("button");
1155
- b.textContent = label; b.onclick = fn; if (id) b.id = id;
1156
- return b;
1157
- };
1158
- // Play button removed — the native <video controls> on the active tab
1159
- // provides play/pause already.
1160
- wrap.appendChild(mkBtn(t("pause"), pauseAll));
1161
- wrap.appendChild(mkBtn(t("restart"), restartAll));
1162
- const loop = mkBtn(t("loop"), () => toggleLoop(loop));
1163
- wrap.appendChild(loop);
1164
- const mute = mkBtn(t("mute"), () => toggleMute(mute));
1165
- wrap.appendChild(mute);
1166
- const sel = document.createElement("select");
1167
- sel.onchange = (e)=>setSpeed(parseFloat(e.target.value));
1168
- for (const v of ["0.5","0.75","1","1.5","2"]){
1169
- const o = document.createElement("option"); o.value=v; o.textContent=v+"×";
1170
- if (v==="1") o.selected = true; sel.appendChild(o);
1171
- }
1172
- wrap.appendChild(sel);
1173
- return wrap;
1174
- }
1175
-
1176
- function renderDimHeader(dim){
1177
- const d = document.createElement("div");
1178
- d.className = "dim-header";
1179
- d.innerHTML = `<span>${escapeHtml(dimName(dim))}</span><span class="dim-hint">${escapeHtml(dimHint(dim))}</span>`;
1180
- return d;
1181
- }
1182
-
1183
- function renderQuestionCard(c, q){
1184
- const card = document.createElement("div");
1185
- card.className = "q-card";
1186
- card.innerHTML = `<div class="q-name">${escapeHtml(qName(q))}</div>
1187
- <div class="q-desc">${escapeHtml(qDesc(q))}</div>`;
1188
- const slots = SHUFFLE[c.id];
1189
- const order = rowSlotOrder(c.id, q.key, slots.length);
1190
- for (const slotIdx of order){
1191
- const origIdx = slots[slotIdx];
1192
- const v = c.videos[origIdx];
1193
- const letter = String.fromCharCode(65+slotIdx);
1194
- const row = document.createElement("div");
1195
- row.className = "video-rate";
1196
- const chip = document.createElement("span");
1197
- chip.className = "slot-letter";
1198
- chip.textContent = anonymize ? letter : `${letter}`;
1199
- chip.title = anonymize ? `Video ${letter}` : `Video ${letter} · ${v.model}`;
1200
- row.appendChild(chip);
1201
- const group = document.createElement("div");
1202
- group.className = "likert";
1203
- const cur = getSlotR(c.id, slotIdx)[q.key];
1204
- for (let s=1; s<=5; s++){
1205
- const b = document.createElement("button");
1206
- b.dataset.score = String(s);
1207
- b.textContent = String(s);
1208
- if (cur===s) b.classList.add("selected");
1209
- b.onclick = () => {
1210
- setRating(c.id, slotIdx, q.key, s);
1211
- Array.from(group.children).forEach((bb,i)=>bb.classList.toggle("selected", (i+1)===s));
1212
- };
1213
- group.appendChild(b);
1214
- }
1215
- row.appendChild(group);
1216
- card.appendChild(row);
1217
- }
1218
- return card;
1219
- }
1220
-
1221
- /* ----- Drawer ----- */
1222
- function renderDrawer(){
1223
- const list = document.getElementById("drawerList");
1224
- list.innerHTML = "";
1225
- CASES.forEach((c, i) => {
1226
- const p = caseProgress(c);
1227
- let cls = "case-row";
1228
- if (i===currentIdx) cls += " active";
1229
- if (p.done === p.expected && p.expected>0) cls += " complete";
1230
- else if (p.done > 0) cls += " partial";
1231
- const row = document.createElement("div");
1232
- row.className = cls;
1233
- row.innerHTML = `<span class="dot"></span>
1234
- <span class="id">${c.id}</span>
1235
- <span class="pct">${p.done}/${p.expected}</span>`;
1236
- row.onclick = () => { closeDrawer(); goTo(i); };
1237
- list.appendChild(row);
1238
- });
1239
- }
1240
- function refreshDrawerActive(){
1241
- document.querySelectorAll("#drawerList .case-row").forEach((r,i)=>{
1242
- r.classList.toggle("active", i===currentIdx);
1243
- });
1244
- }
1245
- function openDrawer(){
1246
- document.getElementById("drawer").classList.add("open");
1247
- document.getElementById("drawerBackdrop").classList.add("open");
1248
- }
1249
- function closeDrawer(){
1250
- document.getElementById("drawer").classList.remove("open");
1251
- document.getElementById("drawerBackdrop").classList.remove("open");
1252
- }
1253
-
1254
- /* ----- Video controls ----- */
1255
- function allVideos(){ return Array.from(document.querySelectorAll("video[data-slot]")); }
1256
- function pauseAll(){ allVideos().forEach(v=>v.pause()); }
1257
- function restartAll(){ allVideos().forEach(v=>{ v.currentTime=0; try{ v.play(); }catch(e){} }); }
1258
- let LOOPING=false;
1259
- function toggleLoop(btn){ LOOPING=!LOOPING; allVideos().forEach(v=>v.loop=LOOPING); btn.classList.toggle("on",LOOPING); }
1260
- let MUTED=false;
1261
- function toggleMute(btn){ MUTED=!MUTED; allVideos().forEach(v=>v.muted=MUTED); btn.classList.toggle("on",MUTED); btn.textContent=MUTED?t("muted"||"sound"):t("mute"); }
1262
- function setSpeed(rate){ allVideos().forEach(v=>v.playbackRate=rate); }
1263
-
1264
- /* ----- Navigation ----- */
1265
- function goTo(idx){
1266
- if (idx<0 || idx>=CASES.length) return;
1267
- pauseAll();
1268
- currentIdx = idx;
1269
- renderCase();
1270
- // scroll to top of main on case switch
1271
- window.scrollTo({top:0, behavior:"instant"});
1272
- }
1273
- function step(d){ goTo((currentIdx + d + CASES.length) % CASES.length); }
1274
-
1275
- /* ----- Submit button + progress ----- */
1276
- function updateSubmitBtn(){
1277
- const btn = document.getElementById("submitBtn");
1278
- if (!btn) return;
1279
- const comp = studyCompleteness();
1280
- const remaining = comp.expected - comp.done;
1281
- if (remaining > 0){
1282
- btn.disabled = true; btn.textContent = t("submit_disabled", remaining);
1283
- } else if (SYNC_STATUS === "submitted"){
1284
- btn.disabled = false; btn.textContent = t("submit_resubmit");
1285
- } else {
1286
- btn.disabled = false; btn.textContent = t("submit", comp.expected);
1287
- }
1288
- }
1289
- function updateProgress(){
1290
- const { done, expected } = studyCompleteness();
1291
- document.getElementById("progressFill").style.width =
1292
- expected>0 ? (done/expected*100).toFixed(1)+"%" : "0%";
1293
- document.getElementById("progressTxt").textContent = `${done}/${expected}`;
1294
- updateSubmitBtn();
1295
- // also refresh per-case status in drawer (cheap)
1296
- refreshDrawerCounts();
1297
- }
1298
- function refreshDrawerCounts(){
1299
- document.querySelectorAll("#drawerList .case-row").forEach((row,i)=>{
1300
- const c = CASES[i]; if (!c) return;
1301
- const p = caseProgress(c);
1302
- row.classList.remove("partial","complete");
1303
- if (p.done===p.expected && p.expected>0) row.classList.add("complete");
1304
- else if (p.done>0) row.classList.add("partial");
1305
- const pct = row.querySelector(".pct");
1306
- if (pct) pct.textContent = `${p.done}/${p.expected}`;
1307
- });
1308
- }
1309
-
1310
- /* ----- Registration overlay ----- */
1311
- function openRegister(){
1312
- document.getElementById("loginOverlay").classList.remove("hidden");
1313
- document.getElementById("regName").value = RATER.name || "";
1314
- document.getElementById("regEmail").value = RATER.email || "";
1315
- document.getElementById("regNote").value = RATER.note || "";
1316
- applyRegistrationI18n();
1317
- }
1318
- function closeRegister(){ document.getElementById("loginOverlay").classList.add("hidden"); }
1319
-
1320
- /* ----- help / 评分说明 overlay (mobile only) ----- */
1321
- const HELP_SEEN_KEY = "user_study_help_seen_v1";
1322
- function openHelp(){
1323
- applyHelpI18n();
1324
- document.getElementById("helpOverlay").classList.remove("hidden");
1325
- }
1326
- function closeHelp(){
1327
- document.getElementById("helpOverlay").classList.add("hidden");
1328
- try { localStorage.setItem(HELP_SEEN_KEY, "1"); } catch(e){}
1329
- }
1330
- function applyHelpI18n(){
1331
- const h = document.getElementById("helpHeader");
1332
- if (h) h.textContent = t("help_title");
1333
- const body = document.getElementById("helpBody");
1334
- if (body) body.innerHTML = t("help_body_html");
1335
- const btn = document.getElementById("helpDismissText");
1336
- if (btn) btn.textContent = t("help_dismiss");
1337
- }
1338
- function applyRegistrationI18n(){
1339
- document.getElementById("regHeader").textContent = t("reg_title");
1340
- document.getElementById("regLead").textContent = t("reg_lead");
1341
- document.getElementById("regNameLabel").textContent = t("reg_name");
1342
- document.getElementById("regEmailLabel").textContent = t("reg_email");
1343
- document.getElementById("regNoteLabel").textContent = t("reg_note");
1344
- document.getElementById("regContinueText").textContent = t("reg_continue");
1345
- document.getElementById("regFootText").innerHTML = t("reg_foot");
1346
- document.getElementById("regName").placeholder = t("reg_name_ph");
1347
- document.getElementById("regEmail").placeholder = t("reg_email_ph");
1348
- document.getElementById("regNote").placeholder = t("reg_note_ph");
1349
- document.getElementById("submittedHeading").textContent = t("submitted_title");
1350
- }
1351
- function handleRegister(){
1352
- const name = document.getElementById("regName").value.trim();
1353
- const email = document.getElementById("regEmail").value.trim().toLowerCase();
1354
- const note = document.getElementById("regNote").value.trim();
1355
- const err = document.getElementById("regErr");
1356
- err.textContent = "";
1357
- if (!name){ err.textContent = t("reg_err_name"); return; }
1358
- if (!EMAIL_RE.test(email)){ err.textContent = t("reg_err_email"); return; }
1359
- RATER.name = name; RATER.email = email; RATER.note = note;
1360
- if (!RATER.id || RATER.id.startsWith("rater_")){
1361
- RATER.id = email.replace(/[^a-z0-9]+/gi,"_").replace(/^_+|_+$/g,"");
1362
- }
1363
- if (!RATER.started_at) RATER.started_at = new Date().toISOString();
1364
- // Non-admins always anonymized
1365
- if (!isAdmin()) anonymize = true;
1366
- closeRegister();
1367
- // Recover any prior locked case subset + ratings from the server first,
1368
- // then resolve the subset (locked > deterministic pick).
1369
- (async () => {
1370
- await recoverRaterStateFromServer(email);
1371
- CASES = selectCasesForRater(ALL_CASES);
1372
- SHUFFLE = {}; buildShuffle(CASES); saveState();
1373
- renderShell();
1374
- })();
1375
- (async()=>{
1376
- await probeSync();
1377
- if (SYNC_STATUS!=="local-only"){
1378
- await saveProgress({silent:true, source:"register"});
1379
- }
1380
- showToast(t("toast_registered", name));
1381
- })();
1382
- }
1383
-
1384
- /* ----- Submitted overlay ----- */
1385
- function showSubmittedOverlay(body){
1386
- const ov = document.getElementById("submittedOverlay");
1387
- document.getElementById("submittedDetail").textContent =
1388
- t("submitted_thanks", RATER.name, body.n_scores ?? "?", body.n_videos ?? "?");
1389
- document.getElementById("submittedRef").textContent = body.stored_as || "—";
1390
- ov.classList.remove("hidden");
1391
- }
1392
- function hideSubmittedOverlay(){ document.getElementById("submittedOverlay").classList.add("hidden"); }
1393
-
1394
- /* =========================================================================
1395
- * Init
1396
- * ========================================================================= */
1397
- (async function init(){
1398
- // restore lang first
1399
- try{
1400
- const prior0 = JSON.parse(localStorage.getItem(STORAGE_KEY)||"null");
1401
- if (prior0 && STRINGS[prior0.lang]) LANG = prior0.lang;
1402
- }catch(e){}
1403
- if (LANG === "en"){
1404
- const nav = (navigator.language||"en").toLowerCase();
1405
- if (nav.startsWith("zh")) LANG = "zh";
1406
- else if (nav.startsWith("bg")) LANG = "bg";
1407
- }
1408
- document.documentElement.lang = LANG;
1409
-
1410
- // fetch manifest
1411
- let manifest = null;
1412
- for (const p of ["./cases.json", "./cases.example.json"]){
1413
- try{ const r = await fetch(p, {cache:"no-cache"}); if (r.ok){ manifest = await r.json(); break; } }catch(e){}
1414
- }
1415
- if (!manifest || !Array.isArray(manifest) || manifest.length===0){
1416
- document.getElementById("main").innerHTML =
1417
- '<div style="padding:40px;color:var(--bad);text-align:center">Could not load cases.json</div>';
1418
- return;
1419
- }
1420
- ALL_CASES = manifest;
1421
-
1422
- // restore state
1423
- const prior = loadState();
1424
- const blankRater = () => ({ id:genRaterId(), name:"", email:"", note:"",
1425
- started_at:new Date().toISOString(), submitted_at:null });
1426
- if (prior){
1427
- RATER = Object.assign(blankRater(), prior.rater||{});
1428
- anonymize = prior.anonymize !== false;
1429
- RATINGS = prior.ratings || {};
1430
- SHUFFLE = prior.shuffle || {};
1431
- // One-shot client-side huobao→univa rename (see desktop for context).
1432
- for (const cid of Object.keys(RATINGS)) {
1433
- const m = RATINGS[cid];
1434
- if (m && m.huobao && !m.univa) { m.univa = m.huobao; delete m.huobao; }
1435
- }
1436
- } else { RATER = blankRater(); }
1437
- if (!isAdmin()) anonymize = true;
1438
- // Admins see all cases; everyone else gets a deterministic 10-case subset.
1439
- CASES = selectCasesForRater(ALL_CASES);
1440
- buildShuffle(CASES); saveState();
1441
-
1442
- // wire DOM events
1443
- document.getElementById("drawerBtn").onclick = openDrawer;
1444
- document.getElementById("helpBtn").onclick = openHelp;
1445
- document.getElementById("helpCloseBtn").onclick = closeHelp;
1446
- document.getElementById("helpDismissBtn").onclick = closeHelp;
1447
- document.getElementById("drawerCloseBtn").onclick = closeDrawer;
1448
- document.getElementById("drawerBackdrop").onclick = closeDrawer;
1449
- document.getElementById("prevBtn").onclick = () => step(-1);
1450
- document.getElementById("nextBtn").onclick = () => step(1);
1451
- document.getElementById("submitBtn").onclick = submitWholeStudy;
1452
- document.getElementById("saveBtn").onclick = () => saveProgress({silent:false, source:"manual"});
1453
- document.getElementById("scrollTopBtn").onclick = () => {
1454
- document.getElementById("videoStack")?.scrollIntoView({behavior:"smooth", block:"start"});
1455
- };
1456
- document.getElementById("regGoBtn").onclick = handleRegister;
1457
- document.getElementById("submittedCloseBtn").onclick = hideSubmittedOverlay;
1458
- document.getElementById("submittedDownloadBtn").onclick = exportJSON;
1459
- document.getElementById("langSelect").onchange = (e) => {
1460
- LANG = e.target.value; document.documentElement.lang = LANG;
1461
- saveState(); renderShell();
1462
- };
1463
- document.getElementById("regLangSelect").onchange = (e) => {
1464
- LANG = e.target.value; document.documentElement.lang = LANG;
1465
- saveState(); applyRegistrationI18n();
1466
- };
1467
- // best-effort save on close
1468
- window.addEventListener("beforeunload", beaconProgress);
1469
- document.addEventListener("visibilitychange", () => {
1470
- if (document.visibilityState==="hidden") beaconProgress();
1471
- });
1472
-
1473
- // gate on registration
1474
- if (!RATER.name || !RATER.email || !EMAIL_RE.test(RATER.email)){
1475
- openRegister();
1476
- } else {
1477
- renderShell();
1478
- closeRegister();
1479
- probeSync();
1480
- // Auto-open the rubric popup once per device. The user can manually
1481
- // reopen it any time via the "?" header button.
1482
- try {
1483
- if (localStorage.getItem(HELP_SEEN_KEY) !== "1") openHelp();
1484
- } catch (e) {}
1485
- }
1486
- // Always fetch admin-curated highlights; available even before registration
1487
- // gate clears so the prompt shows highlights as soon as the rater enters.
1488
- loadAnnotations();
1489
- })();
1490
- </script>
1491
- </body>
1492
- </html>
 
1
  <!DOCTYPE html>
2
+ <html><head><meta charset="utf-8">
3
+ <title>Video user study</title>
4
+ <meta http-equiv="refresh" content="0; url=./v2/mobile.html">
5
+ <script>location.replace("./v2/mobile.html" + location.search);</script>
6
+ </head><body style="font-family:sans-serif;padding:40px">
7
+ <p>Redirecting to the current study… <a href="./v2/mobile.html">continue</a></p>
8
+ </body></html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/v1/admin.html ADDED
@@ -0,0 +1,550 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Admin · FrameWorker user study</title>
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
8
+ <style>
9
+ :root {
10
+ --bg: #0f1115; --panel: #161922; --panel-2: #1c2030; --panel-3: #232839;
11
+ --border: #2a2f42; --border-strong: #3a4060;
12
+ --fg: #e6e8ef; --muted: #8b91a8; --muted-2: #5e6480;
13
+ --accent: #6aa9ff; --good: #28c76f; --warn: #ff9f43; --bad: #ea5455;
14
+ }
15
+ * { box-sizing: border-box; }
16
+ html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg);
17
+ font-family: -apple-system, "Inter", "PingFang SC", sans-serif;
18
+ font-size: 14px; line-height: 1.5; }
19
+
20
+ header { padding: 12px 24px; border-bottom: 1px solid var(--border);
21
+ background: var(--panel); display: flex; gap: 14px; align-items: center;
22
+ flex-wrap: wrap; }
23
+ header h1 { font-size: 16px; margin: 0; }
24
+ header .meta { color: var(--muted); font-size: 12px; }
25
+ header .right { margin-left: auto; display: flex; gap: 10px; align-items: center; }
26
+
27
+ main { padding: 18px 24px 80px; max-width: 1280px; margin: 0 auto; }
28
+
29
+ section { background: var(--panel); border: 1px solid var(--border);
30
+ border-radius: 8px; padding: 14px 16px; margin-bottom: 14px; }
31
+ h2 { font-size: 14px; margin: 0 0 10px; color: var(--accent); }
32
+ .row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
33
+ .btn { background: var(--panel-3); border: 1px solid var(--border-strong);
34
+ color: var(--fg); padding: 6px 12px; border-radius: 4px; cursor: pointer;
35
+ font-family: inherit; font-size: 12px; }
36
+ .btn:hover { background: #2c3450; }
37
+ .btn.primary { background: var(--accent); color: #0a1a2a;
38
+ border-color: var(--accent); font-weight: 600; }
39
+ .btn.danger { background: var(--bad); color: #fff; border-color: var(--bad); }
40
+ .btn:disabled { background: var(--panel-3); color: var(--muted-2);
41
+ border-color: var(--border); cursor: not-allowed; }
42
+
43
+ /* login screen */
44
+ .login-wrap { display: flex; align-items: center; justify-content: center;
45
+ min-height: 70vh; }
46
+ .login-card { background: var(--panel); border: 1px solid var(--border-strong);
47
+ border-radius: 10px; padding: 22px 24px; max-width: 380px; width: 100%; }
48
+ .login-card h2 { margin: 0 0 6px; color: var(--accent); }
49
+ .login-card p { color: var(--muted); font-size: 13px; margin: 0 0 14px; }
50
+ .login-card label { font-size: 11px; color: var(--muted);
51
+ text-transform: uppercase; letter-spacing: 0.5px; }
52
+ .login-card input { width: 100%; background: var(--panel-3);
53
+ border: 1px solid var(--border); color: var(--fg); padding: 10px 12px;
54
+ border-radius: 5px; font-family: inherit; font-size: 16px; margin-top: 4px; }
55
+ .login-card .err { color: var(--bad); font-size: 12px; margin-top: 6px;
56
+ min-height: 16px; }
57
+ .login-card .login-btn { width: 100%; margin-top: 12px; padding: 10px;
58
+ font-size: 14px; font-weight: 700; }
59
+
60
+ /* table */
61
+ table { width: 100%; border-collapse: collapse; font-size: 12px; }
62
+ th, td { padding: 7px 10px; text-align: left; border-bottom: 1px solid var(--border); }
63
+ th { color: var(--muted); font-weight: 600; font-size: 11px;
64
+ text-transform: uppercase; letter-spacing: 0.5px;
65
+ background: var(--panel-2); position: sticky; top: 0; }
66
+ tr:hover td { background: var(--panel-2); }
67
+ td.path, td.email { font-family: ui-monospace, monospace; font-size: 11px;
68
+ color: var(--muted); word-break: break-all; }
69
+ td .badge { display: inline-block; padding: 2px 6px; border-radius: 8px;
70
+ font-size: 10px; font-weight: 700; }
71
+ td .badge.final { background: #1c3422; color: var(--good); }
72
+ td .badge.in_progress { background: #3a3520; color: var(--warn); }
73
+ td .badge.admin { background: #3a2f60; color: #d9b6ff; margin-left: 4px; }
74
+ .progress-bar { background: var(--panel-3); height: 4px; border-radius: 2px;
75
+ overflow: hidden; min-width: 80px; }
76
+ .progress-bar .fill { background: var(--accent); height: 100%; }
77
+
78
+ .filter-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
79
+ margin-bottom: 10px; }
80
+ .filter-row input { background: var(--panel-3); border: 1px solid var(--border);
81
+ color: var(--fg); padding: 5px 8px; border-radius: 4px; font-size: 12px;
82
+ font-family: inherit; min-width: 200px; }
83
+
84
+ /* charts */
85
+ #viz { background: var(--panel); border: 1px solid var(--border);
86
+ border-radius: 8px; padding: 14px 16px; margin-top: 14px; }
87
+ .q-block { background: var(--panel-2); border: 1px solid var(--border);
88
+ border-radius: 6px; padding: 10px 12px; margin: 8px 0; }
89
+ .q-block .q-title { font-weight: 600; font-size: 13px; }
90
+ .q-block .q-dim { color: var(--muted); font-size: 11px; }
91
+ canvas { max-height: 220px; }
92
+ .legend-dot { display: inline-block; width: 9px; height: 9px;
93
+ border-radius: 50%; margin-right: 4px; vertical-align: middle; }
94
+
95
+ .toast { position: fixed; bottom: 20px; right: 20px;
96
+ background: var(--good); color: #0a1a0a; padding: 10px 16px;
97
+ border-radius: 6px; font-weight: 600; opacity: 0;
98
+ transition: opacity 0.2s; pointer-events: none; z-index: 100; }
99
+ .toast.show { opacity: 1; }
100
+ .toast.error { background: var(--bad); color: #fff; }
101
+ </style>
102
+ </head>
103
+ <body>
104
+ <div id="app"></div>
105
+ <div class="toast" id="toast"></div>
106
+
107
+ <script>
108
+ const ADMIN_PW_KEY = "user_study_admin_pw_v1";
109
+
110
+ // Canonical rubric (must stay in sync with index.html DIMENSIONS).
111
+ const DIMENSIONS = [
112
+ { key: "instruction_following", label: "Instruction following",
113
+ questions: [
114
+ ["controllability", "Controllability"],
115
+ ["prompt_adherence", "Prompt adherence"],
116
+ ["no_irrelevant_frames", "No irrelevant frames"],
117
+ ] },
118
+ { key: "temporal_consistency", label: "Temporal consistency",
119
+ questions: [
120
+ ["person_stability", "Person stability"],
121
+ ["object_stability", "Object stability"],
122
+ ["environment_stability", "Environment stability"],
123
+ ] },
124
+ { key: "motion_realism", label: "Realism",
125
+ questions: [
126
+ ["realism", "Static realism"],
127
+ ["motion_logic", "Motion logic"],
128
+ ["camera", "Camera"],
129
+ ] },
130
+ ];
131
+ const ALL_QKEYS = DIMENSIONS.flatMap(d => d.questions.map(([k]) => k));
132
+
133
+ let SUBMISSIONS = []; // list from /api/admin/submissions
134
+ let SELECTED = new Set(); // paths the admin has ticked
135
+
136
+ function el(tag, attrs={}, ...children) {
137
+ const e = document.createElement(tag);
138
+ for (const [k,v] of Object.entries(attrs)) {
139
+ if (k === "class") e.className = v;
140
+ else if (k === "html") e.innerHTML = v;
141
+ else if (k.startsWith("on") && typeof v === "function")
142
+ e.addEventListener(k.slice(2).toLowerCase(), v);
143
+ else if (v !== false && v != null) e.setAttribute(k, v);
144
+ }
145
+ for (const c of children) {
146
+ if (c == null || c === false) continue;
147
+ e.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
148
+ }
149
+ return e;
150
+ }
151
+ function showToast(msg, isErr=false) {
152
+ const t = document.getElementById("toast");
153
+ t.textContent = msg;
154
+ t.classList.toggle("error", !!isErr);
155
+ t.classList.add("show");
156
+ setTimeout(()=>t.classList.remove("show"), 1800);
157
+ }
158
+ function adminPw() { try { return sessionStorage.getItem(ADMIN_PW_KEY) || ""; } catch(e){ return ""; } }
159
+ function setAdminPw(pw) { try { sessionStorage.setItem(ADMIN_PW_KEY, pw); } catch(e){} }
160
+ function clearAdminPw() { try { sessionStorage.removeItem(ADMIN_PW_KEY); } catch(e){} }
161
+
162
+ /* ---------- login ---------- */
163
+ function renderLogin() {
164
+ const app = document.getElementById("app");
165
+ app.innerHTML = "";
166
+ app.appendChild(el("header", {},
167
+ el("h1", {}, "Admin · FrameWorker user study"),
168
+ el("span", { class: "meta" }, "password-gated"),
169
+ el("div", { class: "right" },
170
+ el("a", { href: "./", style: "color:var(--accent);font-size:12px" }, "← back to study"),
171
+ ),
172
+ ));
173
+ const wrap = el("div", { class: "login-wrap" });
174
+ const card = el("div", { class: "login-card" });
175
+ card.appendChild(el("h2", {}, "Admin login"));
176
+ card.appendChild(el("p", {}, "Enter the admin password to view all study submissions."));
177
+ card.appendChild(el("label", { "for": "pw" }, "Password"));
178
+ const input = el("input", {
179
+ id: "pw", type: "password", autocomplete: "current-password",
180
+ placeholder: "·····",
181
+ onkeydown: e => { if (e.key === "Enter") submitLogin(); },
182
+ });
183
+ card.appendChild(input);
184
+ const err = el("div", { class: "err", id: "loginErr" });
185
+ card.appendChild(err);
186
+ card.appendChild(el("button", {
187
+ class: "btn primary login-btn",
188
+ onclick: submitLogin,
189
+ }, "Sign in"));
190
+ wrap.appendChild(card);
191
+ app.appendChild(wrap);
192
+ setTimeout(() => input.focus(), 30);
193
+ }
194
+
195
+ async function submitLogin() {
196
+ const pw = document.getElementById("pw").value;
197
+ const err = document.getElementById("loginErr");
198
+ err.textContent = "";
199
+ if (!pw) { err.textContent = "Password required."; return; }
200
+ try {
201
+ const r = await fetch("./api/admin/login", {
202
+ method: "POST", headers: { "Content-Type": "application/json" },
203
+ body: JSON.stringify({ password: pw }),
204
+ });
205
+ if (!r.ok) { err.textContent = "Wrong password."; return; }
206
+ setAdminPw(pw);
207
+ renderDashboard();
208
+ fetchSubmissions();
209
+ } catch (e) {
210
+ err.textContent = "Login failed: " + e.message;
211
+ }
212
+ }
213
+
214
+ /* ---------- dashboard ---------- */
215
+ function renderDashboard() {
216
+ const app = document.getElementById("app");
217
+ app.innerHTML = "";
218
+ app.appendChild(el("header", {},
219
+ el("h1", {}, "Admin · FrameWorker user study"),
220
+ el("span", { class: "meta", id: "summaryMeta" }, "loading…"),
221
+ el("div", { class: "right" },
222
+ el("a", { href: "./stats.html", target: "_blank",
223
+ style: "color:var(--accent);font-size:12px;text-decoration:none" },
224
+ "📊 aggregate stats →"),
225
+ el("button", { class: "btn",
226
+ onclick: () => { clearAdminPw(); renderLogin(); } },
227
+ "Sign out"),
228
+ ),
229
+ ));
230
+ const main = el("main");
231
+ // submissions list
232
+ const sec1 = el("section");
233
+ sec1.appendChild(el("h2", {}, "Submissions"));
234
+ const filterRow = el("div", { class: "filter-row" });
235
+ filterRow.appendChild(el("input", {
236
+ type: "search", placeholder: "Filter by name / email…",
237
+ oninput: e => filterTable(e.target.value.toLowerCase()),
238
+ }));
239
+ filterRow.appendChild(el("button", {
240
+ class: "btn", onclick: () => toggleSelectAll(true) }, "Select all"));
241
+ filterRow.appendChild(el("button", {
242
+ class: "btn", onclick: () => toggleSelectAll(false) }, "Clear"));
243
+ filterRow.appendChild(el("button", {
244
+ class: "btn primary", id: "vizBtn", onclick: visualizeSelected,
245
+ disabled: "" }, "Visualize selected"));
246
+ filterRow.appendChild(el("button", {
247
+ class: "btn", onclick: () => fetchSubmissions() }, "↻ Refresh"));
248
+ sec1.appendChild(filterRow);
249
+
250
+ sec1.appendChild(el("div", { id: "tableWrap" }));
251
+ main.appendChild(sec1);
252
+
253
+ // visualisation slot
254
+ const sec2 = el("div", { id: "viz", style: "display:none" });
255
+ main.appendChild(sec2);
256
+
257
+ app.appendChild(main);
258
+ }
259
+
260
+ async function fetchSubmissions() {
261
+ try {
262
+ const r = await fetch("./api/admin/submissions", {
263
+ headers: { "X-Admin-Password": adminPw() },
264
+ });
265
+ if (r.status === 401) { clearAdminPw(); renderLogin(); return; }
266
+ if (!r.ok) {
267
+ showToast(`Fetch failed (HTTP ${r.status})`, true);
268
+ return;
269
+ }
270
+ const j = await r.json();
271
+ SUBMISSIONS = j.submissions || [];
272
+ SELECTED = new Set();
273
+ renderTable();
274
+ document.getElementById("summaryMeta").textContent =
275
+ `${SUBMISSIONS.length} submission${SUBMISSIONS.length===1?"":"s"}, ` +
276
+ `${new Set(SUBMISSIONS.map(s=>s.rater_email)).size} unique raters`;
277
+ } catch (e) {
278
+ showToast("Fetch failed: " + e.message, true);
279
+ }
280
+ }
281
+
282
+ function renderTable() {
283
+ const wrap = document.getElementById("tableWrap");
284
+ wrap.innerHTML = "";
285
+ const table = el("table");
286
+ const thead = el("thead");
287
+ thead.appendChild(el("tr", {},
288
+ el("th", { style: "width:32px" }, ""),
289
+ el("th", {}, "Rater"),
290
+ el("th", { class: "email" }, "Email"),
291
+ el("th", {}, "Status"),
292
+ el("th", {}, "Scores"),
293
+ el("th", {}, "Cases"),
294
+ el("th", {}, "Received (UTC)"),
295
+ el("th", {}, "Action"),
296
+ el("th", { class: "path" }, "Path"),
297
+ ));
298
+ table.appendChild(thead);
299
+ const tbody = el("tbody", { id: "tbody" });
300
+ for (const s of SUBMISSIONS) {
301
+ tbody.appendChild(renderRow(s));
302
+ }
303
+ table.appendChild(tbody);
304
+ wrap.appendChild(table);
305
+ updateVizBtn();
306
+ }
307
+
308
+ function renderRow(s) {
309
+ const tr = el("tr", { "data-path": s.path });
310
+ const cb = el("input", { type: "checkbox" });
311
+ cb.addEventListener("change", () => {
312
+ if (cb.checked) SELECTED.add(s.path); else SELECTED.delete(s.path);
313
+ updateVizBtn();
314
+ });
315
+ if (SELECTED.has(s.path)) cb.checked = true;
316
+ tr.appendChild(el("td", {}, cb));
317
+ tr.appendChild(el("td", {},
318
+ s.rater_name || "(unnamed)",
319
+ s.is_admin ? el("span", { class: "badge admin" }, "admin") : null,
320
+ ));
321
+ tr.appendChild(el("td", { class: "email" }, s.rater_email || "—"));
322
+ const status = s.status || (s.path.includes("in_progress") ? "in_progress" : "final");
323
+ tr.appendChild(el("td", {},
324
+ el("span", { class: "badge " + status }, status)));
325
+ tr.appendChild(el("td", {}, String(s.n_scores ?? "—")));
326
+ tr.appendChild(el("td", {}, String(s.n_cases ?? "—")));
327
+ tr.appendChild(el("td", {},
328
+ (s.received_at_utc || "—").replace("T"," ").replace("Z","")));
329
+ // Row actions: view-as + delete
330
+ const actionCell = el("td", { style: "white-space:nowrap" });
331
+ if (s.rater_email) {
332
+ actionCell.appendChild(el("button", {
333
+ class: "btn",
334
+ style: "padding:3px 8px;margin-right:4px",
335
+ title: "open the main study page with this user's data preloaded",
336
+ onclick: () => viewAs(s),
337
+ }, "👁 View"));
338
+ }
339
+ actionCell.appendChild(el("button", {
340
+ class: "btn danger",
341
+ style: "padding:3px 8px;font-size:11px",
342
+ title: "permanently delete this submission JSON from the dataset",
343
+ onclick: () => deleteSubmission(s),
344
+ }, "🗑"));
345
+ tr.appendChild(actionCell);
346
+ // Path → link to dataset blob view
347
+ const pathLink = el("a", {
348
+ href: `https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/blob/main/${s.path}`,
349
+ target: "_blank", rel: "noopener",
350
+ style: "color:var(--muted);text-decoration:none",
351
+ title: "open raw JSON on the dataset",
352
+ }, s.path);
353
+ tr.appendChild(el("td", { class: "path" }, pathLink));
354
+ return tr;
355
+ }
356
+
357
+ function filterTable(needle) {
358
+ for (const tr of document.querySelectorAll("#tbody tr")) {
359
+ const txt = tr.textContent.toLowerCase();
360
+ tr.style.display = txt.includes(needle) ? "" : "none";
361
+ }
362
+ }
363
+ function toggleSelectAll(on) {
364
+ SELECTED = new Set(on ? SUBMISSIONS.map(s=>s.path) : []);
365
+ renderTable();
366
+ }
367
+ function updateVizBtn() {
368
+ const btn = document.getElementById("vizBtn");
369
+ if (!btn) return;
370
+ btn.textContent = `Visualize selected (${SELECTED.size})`;
371
+ btn.disabled = SELECTED.size === 0;
372
+ }
373
+
374
+ /* ---------- delete a submission ---------- */
375
+ async function deleteSubmission(s) {
376
+ const who = s.rater_name || s.rater_email || "this submission";
377
+ if (!confirm(`Permanently delete ${s.path}?\n\nRater: ${who}\nScores: ${s.n_scores ?? "?"}\n\nThis cannot be undone.`)) return;
378
+ try {
379
+ const r = await fetch("./api/admin/submission", {
380
+ method: "DELETE",
381
+ headers: { "Content-Type": "application/json", "X-Admin-Password": adminPw() },
382
+ body: JSON.stringify({ path: s.path }),
383
+ });
384
+ if (r.status === 401) { clearAdminPw(); renderLogin(); return; }
385
+ if (!r.ok) {
386
+ const text = await r.text().catch(() => "");
387
+ showToast(`Delete failed (HTTP ${r.status}): ${text.slice(0, 80)}`, true);
388
+ return;
389
+ }
390
+ showToast(`Deleted ${who}`);
391
+ // Remove from local list + selection, re-render
392
+ SUBMISSIONS = SUBMISSIONS.filter(x => x.path !== s.path);
393
+ SELECTED.delete(s.path);
394
+ renderTable();
395
+ document.getElementById("summaryMeta").textContent =
396
+ `${SUBMISSIONS.length} submission${SUBMISSIONS.length===1?"":"s"}, ` +
397
+ `${new Set(SUBMISSIONS.map(x=>x.rater_email)).size} unique raters`;
398
+ } catch (e) {
399
+ showToast("Delete failed: " + e.message, true);
400
+ }
401
+ }
402
+
403
+ /* ---------- view-as: open main UI populated with this user's submission ---- */
404
+ async function viewAs(submissionMeta) {
405
+ try {
406
+ const url = `https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/${submissionMeta.path}`;
407
+ const r = await fetch(url, { cache: "no-cache" });
408
+ if (!r.ok) { showToast(`Fetch failed: HTTP ${r.status}`, true); return; }
409
+ const payload = await r.json();
410
+ // Persist for the main page to pick up — sessionStorage so it doesn't
411
+ // contaminate localStorage long-term, and sits in this tab/window only.
412
+ sessionStorage.setItem("user_study_view_as", JSON.stringify({
413
+ opened_at: new Date().toISOString(),
414
+ meta: submissionMeta,
415
+ payload,
416
+ }));
417
+ window.open("./?view_as=1", "_blank");
418
+ showToast(`Opening main UI as ${submissionMeta.rater_name || submissionMeta.rater_email}`);
419
+ } catch (e) {
420
+ showToast("View-as failed: " + e.message, true);
421
+ }
422
+ }
423
+
424
+ /* ---------- visualization ---------- */
425
+ async function visualizeSelected() {
426
+ if (SELECTED.size === 0) return;
427
+ const viz = document.getElementById("viz");
428
+ viz.style.display = "block";
429
+ viz.innerHTML = "<h2>Loading selected submissions…</h2>";
430
+
431
+ const submissions = [];
432
+ for (const path of SELECTED) {
433
+ try {
434
+ const url = `https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/${path}`;
435
+ const r = await fetch(url, { cache: "no-cache" });
436
+ if (r.ok) submissions.push(await r.json());
437
+ } catch (e) { /* skip */ }
438
+ }
439
+ renderViz(submissions);
440
+ }
441
+
442
+ function renderViz(payloads) {
443
+ // bag: (model, qkey) -> scores
444
+ const bag = {};
445
+ for (const p of payloads) {
446
+ for (const c of (p.cases || [])) {
447
+ for (const v of (c.videos || [])) {
448
+ const m = v.model || "unknown";
449
+ for (const [qk, score] of Object.entries(v.ratings || {})) {
450
+ if (typeof score === "number" && score >= 1 && score <= 5) {
451
+ (bag[`${m}|${qk}`] ||= []).push(score);
452
+ }
453
+ }
454
+ }
455
+ }
456
+ }
457
+ const models = Array.from(new Set(Object.keys(bag).map(k => k.split("|")[0])));
458
+ models.sort((a,b) => (a === "ours" ? -1 : b === "ours" ? 1 : a.localeCompare(b)));
459
+ const COLORS = ["#6aa9ff","#ff9f43","#28c76f","#ea5455","#b46aff"];
460
+ const mcolor = Object.fromEntries(models.map((m,i) => [m, COLORS[i % COLORS.length]]));
461
+
462
+ const viz = document.getElementById("viz");
463
+ viz.innerHTML = "";
464
+ viz.appendChild(el("h2", {},
465
+ `Aggregate over ${payloads.length} selected submission(s)`));
466
+
467
+ // Per dimension
468
+ const dimDiv = el("section", { style: "background:transparent;border:none;padding:0" });
469
+ dimDiv.appendChild(el("h2", {}, "Per dimension"));
470
+ const dimCanvas = el("canvas");
471
+ dimDiv.appendChild(dimCanvas);
472
+ viz.appendChild(dimDiv);
473
+
474
+ function mean(arr){ return arr.length ? arr.reduce((a,b)=>a+b,0)/arr.length : null; }
475
+ function statsFor(qkeys, m){
476
+ const all = [];
477
+ for (const qk of qkeys) all.push(...(bag[`${m}|${qk}`] || []));
478
+ return all;
479
+ }
480
+ const dimLabels = DIMENSIONS.map(d => d.label);
481
+ const dimData = models.map(m => ({
482
+ label: m,
483
+ data: DIMENSIONS.map(d => mean(statsFor(d.questions.map(q=>q[0]), m))),
484
+ backgroundColor: mcolor[m] + "cc",
485
+ borderColor: mcolor[m], borderWidth: 1,
486
+ }));
487
+ new Chart(dimCanvas, {
488
+ type: "bar",
489
+ data: { labels: dimLabels, datasets: dimData },
490
+ options: {
491
+ scales: {
492
+ y: { min: 0, max: 5, ticks: { color: "#8b91a8" }, grid: { color: "#2a2f42" } },
493
+ x: { ticks: { color: "#e6e8ef" }, grid: { display: false } },
494
+ },
495
+ plugins: { legend: { labels: { color: "#e6e8ef" } } },
496
+ },
497
+ });
498
+
499
+ // Per question
500
+ viz.appendChild(el("h2", { style: "margin-top:14px" }, "Per sub-question"));
501
+ for (const d of DIMENSIONS) {
502
+ for (const [qk, qlabel] of d.questions) {
503
+ const block = el("div", { class: "q-block" });
504
+ const total = models.reduce((n,m) => n + (bag[`${m}|${qk}`]||[]).length, 0);
505
+ block.appendChild(el("div", { class: "q-title" }, qlabel));
506
+ block.appendChild(el("div", { class: "q-dim" }, d.label + (total ? ` · n=${total}` : " · (no data)")));
507
+ if (total) {
508
+ const c = el("canvas");
509
+ block.appendChild(c);
510
+ const datasets = models.map(m => ({
511
+ label: m,
512
+ data: [mean(bag[`${m}|${qk}`] || [])],
513
+ backgroundColor: mcolor[m] + "cc",
514
+ borderColor: mcolor[m], borderWidth: 1,
515
+ }));
516
+ new Chart(c, {
517
+ type: "bar",
518
+ data: { labels: [qlabel], datasets },
519
+ options: {
520
+ indexAxis: "y", scales: {
521
+ x: { min: 0, max: 5, ticks: { color: "#8b91a8" }, grid: { color: "#2a2f42" } },
522
+ y: { ticks: { display: false }, grid: { display: false } },
523
+ },
524
+ plugins: { legend: { labels: { color: "#e6e8ef" } } },
525
+ },
526
+ });
527
+ }
528
+ viz.appendChild(block);
529
+ }
530
+ }
531
+ viz.scrollIntoView({ behavior: "smooth", block: "start" });
532
+ }
533
+
534
+ /* ---------- boot ---------- */
535
+ (function init(){
536
+ if (adminPw()) {
537
+ // Probe a cheap endpoint to validate the cached password.
538
+ fetch("./api/admin/submissions", {
539
+ headers: { "X-Admin-Password": adminPw() },
540
+ }).then(r => {
541
+ if (r.status === 401) { clearAdminPw(); renderLogin(); }
542
+ else { renderDashboard(); fetchSubmissions(); }
543
+ }).catch(() => renderLogin());
544
+ } else {
545
+ renderLogin();
546
+ }
547
+ })();
548
+ </script>
549
+ </body>
550
+ </html>
static/v1/cases.json ADDED
@@ -0,0 +1,472 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "cr_016",
4
+ "category": "cr",
5
+ "prompt": "Make a supernatural-suspense animated short drama — at midnight inside the old wing of a psychiatric hospital, the new male intern discovers that Room 304 plays a thirty-year-old recording every single night by itself. When he finally pushes that door open, he sees his ten-year-old self standing inside. Add ambient sounds: faint electrical hum, hollow corridor reverb, and distant sobbing.",
6
+ "prompt_i18n": {
7
+ "zh": "制作一部超自然悬疑动画短剧 — 午夜时分,精神病院老楼里,新来的男实习生发现 304 号病房每晚都会自行播放一段三十年前的录音。当他终于推开那扇门时,看见十岁的自己正站在房间里。环境音:微弱的电流嗡鸣、空荡走廊的回响、远处的抽泣声。",
8
+ "bg": "Създайте свръхестествена напрегната анимационна кратка драма — посред нощ в старото крило на психиатрична болница новият мъжки стажант открива, че Стая 304 пуска тридесетгодишен запис всяка нощ сама по себе си. Когато най-сетне отваря вратата, вижда себе си на десет години да стои вътре. Добавете звуци на средата: слабо електрическо бръмчене, кух коридорен ехо и далечни хлипания."
9
+ },
10
+ "videos": [
11
+ {
12
+ "model": "ours",
13
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_016_ours.mp4"
14
+ },
15
+ {
16
+ "model": "baseline",
17
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_016_baseline.mp4"
18
+ },
19
+ {
20
+ "model": "univa",
21
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_016_univa.mp4"
22
+ }
23
+ ]
24
+ },
25
+ {
26
+ "id": "cr_023",
27
+ "category": "cr",
28
+ "prompt": "Make a period rebirth-revenge palace-intrigue animated short drama — the disgraced former Empress Liu Ruyan, poisoned to death in a cold palace by her own elder sister in her past life, wakes up reborn on the day before she enters the palace. This time she vows to force every betrayer in her past life to kneel before her, one by one.",
29
+ "prompt_i18n": {
30
+ "zh": "制作一部古代重生复仇宫斗动画短剧 — 失势的前皇后柳如烟,前世被亲姐姐在冷宫毒死,如今在入宫前一天醒来,带着前世记忆重生归来。这一世,她发誓要让前世每一个背叛者,一个一个跪在她面前。",
31
+ "bg": "Създайте историческа анимационна кратка драма за прераждане и отмъщение в двора — опозорената бивша императрица Лиу Жуян, отровена до смърт в студения дворец от собствената си по-голяма сестра в предишния си живот, се събужда прероденa в деня преди да влезе в двореца. Този път тя се заклева да накара всеки предател от миналия си живот да коленичи пред нея, един по един."
32
+ },
33
+ "videos": [
34
+ {
35
+ "model": "ours",
36
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_023_ours.mp4"
37
+ },
38
+ {
39
+ "model": "baseline",
40
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_023_baseline.mp4"
41
+ },
42
+ {
43
+ "model": "univa",
44
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_023_univa.mp4"
45
+ }
46
+ ]
47
+ },
48
+ {
49
+ "id": "cr_030",
50
+ "category": "cr",
51
+ "prompt": "Make a CEO-chasing-his-wife animated short drama — Su Nian, the contract-marriage wife, returns three years later with twin children to deliver divorce papers to the heir of the Huo family. The man who once treated her merely as a stand-in for someone else, kneels before her for the very first time. Add a heart-tugging string BGM.",
52
+ "prompt_i18n": {
53
+ "zh": "制作一部总裁追妻动画短剧 — 契约妻子苏念三年后带着双胞胎归来,把离婚协议递给霍家继承人。这个曾经只把她当作他人替身的男人,第一次跪在她面前。配以揪心的弦乐 BGM。",
54
+ "bg": "Създайте анимационна кратка драма за изпълнителен директор, преследващ съпругата си — Су Ниан, съпругата по договор, се връща три години по-късно с близнаци, за да връчи документи за развод на наследника на сем��йство Хуо. Мъжът, който някога я е третирал само като заместничка на друга, за пръв път коленичи пред нея. Добавете трогателна струнна BGM."
55
+ },
56
+ "videos": [
57
+ {
58
+ "model": "ours",
59
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_030_ours.mp4"
60
+ },
61
+ {
62
+ "model": "baseline",
63
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_030_baseline.mp4"
64
+ },
65
+ {
66
+ "model": "univa",
67
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_030_univa.mp4"
68
+ }
69
+ ]
70
+ },
71
+ {
72
+ "id": "cr_042",
73
+ "category": "cr",
74
+ "prompt": "Make a cultivation / xianxia animated short drama — a prodigy disciple is framed by his senior brother and his cultivation core is crippled. With nothing but his family's broken sword, he refounds his foundation, slays ancient demons, and finally breaks through the nine heavenly tribulations to ascend to godhood.",
75
+ "prompt_i18n": {
76
+ "zh": "制作一部修仙/玄幻动画短剧 — 一位天才弟子被师兄陷害,修为根基尽毁。他凭借家传断剑重铸根基,斩杀上古魔族,最终突破九重天劫飞升成神。",
77
+ "bg": "Създайте анимационна кратка драма в жанра самоусъвършенстване / сяня — талантлив ученик е набеден от старшия си съученик и култивационното му ядро е унищожено. Без нищо друго освен счупения меч на семейството си той полага наново основите си, поразява древни демони и накрая преминава през деветте небесни изпитания, за да се възнесе в божество."
78
+ },
79
+ "videos": [
80
+ {
81
+ "model": "ours",
82
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_042_ours.mp4"
83
+ },
84
+ {
85
+ "model": "baseline",
86
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_042_baseline.mp4"
87
+ },
88
+ {
89
+ "model": "univa",
90
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_042_univa.mp4"
91
+ }
92
+ ]
93
+ },
94
+ {
95
+ "id": "cr_049",
96
+ "category": "cr",
97
+ "prompt": "Make a modern-urban rebirth-revenge animated short drama — a top-tier lawyer is framed by his business partner and thrown in prison; by the time he gets out, his wife and daughter are dead. Granted a second chance, he wakes up ten years earlier on the rainy night that changed everything, determined to drag every single one of his betrayers down with him.",
98
+ "prompt_i18n": {
99
+ "zh": "制作一部现代都市重生复仇动画短剧 — 一位顶级律师被生意伙伴陷害入狱;等他出狱时,妻女已不在人世。被赐予重来一次的机会,他在改变一切的那个雨夜十年前醒来,誓要将每一个背叛者通通拉下深渊。",
100
+ "bg": "Създайте съвременна градска анимационна кратка драма за прераждане и отмъщение — водещ адвокат е набеден от бизнес партньора си и затворен; когато излезе, жена му и дъщеря му вече са мъртви. Получавайки втори шанс, той се събужда десет години по-рано в дъждовната нощ, която промени всичко, решен да повлече със себе си всеки един от предателите си."
101
+ },
102
+ "videos": [
103
+ {
104
+ "model": "ours",
105
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_049_ours.mp4"
106
+ },
107
+ {
108
+ "model": "baseline",
109
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_049_baseline.mp4"
110
+ },
111
+ {
112
+ "model": "univa",
113
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_049_univa.mp4"
114
+ }
115
+ ]
116
+ },
117
+ {
118
+ "id": "cr_059",
119
+ "category": "cr",
120
+ "prompt": "Make a high-school revenge animated short drama — Gu Xiuning, the ice-cold transfer student who always ranks first, is discovered on his very first day to be the only survivor of an arson that struck this school ten years ago. The new homeroom teacher, Mr. Lin, turns out to be the very man who dragged him out of the flames back then. Add a restrained, suppressed piano BGM.",
121
+ "prompt_i18n": {
122
+ "zh": "制作一部高校复仇动画短剧 — 始终稳居榜首的冰山转学生顾修宁,转学第一天就被���现是十年前那场校园纵火案唯一的幸存者。新班主任林老师,正是当年从火海中将他拖出来的人。配以克制压抑的钢琴 BGM。",
123
+ "bg": "Създайте анимационна кратка драма за отмъщение в гимназията — Гу Сюнин, леденият първенец, нов в училището, в първия си ден бива разкрит като единствения оцелял от палеж, поразил това училище преди десет години. Новият класен ръководител, г-н Лин, се оказва същият човек, който го е извлякъл от пламъците тогава. Добавете сдържана, потисната пианова BGM."
124
+ },
125
+ "videos": [
126
+ {
127
+ "model": "ours",
128
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_059_ours.mp4"
129
+ },
130
+ {
131
+ "model": "baseline",
132
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_059_baseline.mp4"
133
+ },
134
+ {
135
+ "model": "univa",
136
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_059_univa.mp4"
137
+ }
138
+ ]
139
+ },
140
+ {
141
+ "id": "cr_064",
142
+ "category": "cr",
143
+ "prompt": "Make a zombie-apocalypse animated short drama — day seven of the outbreak, a small squad led by a former special-ops captain and a female doctor cuts through zombie-infested underground tunnels in search of the legendary vaccine lab. Add ambient sounds: zombie shrieks, distant air-raid sirens, and wind whipping through ruined corridors.",
144
+ "prompt_i18n": {
145
+ "zh": "制作一部丧尸末日动画短剧 — 病毒爆发第七天,一支由前特种部队队长和女医生带领的小分队,穿越布满丧尸的地下隧道,寻找传说中的疫苗实验室。环境音:丧尸嘶吼、远处的防空警报、风穿过废墟走廊的呼啸声。",
146
+ "bg": "Създайте анимационна кратка драма за зомби апокалипсис — седмият ден от епидемията, малък отряд, воден от бивш капитан от специалните части и жена-лекар, си пробива път през пълни със зомбита подземни тунели в търсене на легендарната ваксинна лаборатория. Добавете звуци на средата: писъци на зомбита, далечни сирени за въздушно нападение и вятър, виещ през разрушени коридори."
147
+ },
148
+ "videos": [
149
+ {
150
+ "model": "ours",
151
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_064_ours.mp4"
152
+ },
153
+ {
154
+ "model": "baseline",
155
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_064_baseline.mp4"
156
+ },
157
+ {
158
+ "model": "univa",
159
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_064_univa.mp4"
160
+ }
161
+ ]
162
+ },
163
+ {
164
+ "id": "cr_072",
165
+ "category": "cr",
166
+ "prompt": "Make a Chinese-mythology fantasy animated short drama — at the close of the Investiture of the Gods war, the last surviving little nine-tailed fox demon is dragged up onto the Investiture Platform by Jiang Ziya. Just before her execution, she suddenly remembers a man who has been appearing in her dreams. Add ambient sounds of the nine-heaven thunder rolling, and a mournful classical-Chinese BGM.",
167
+ "prompt_i18n": {
168
+ "zh": "制作一部中国神话奇幻动画短剧 — 封神之战末尾,最后一只幸存的小九尾狐妖被姜子牙带上封神台。临行刑前,她忽然想起一个反复出现在她梦中的男人。环境音:九天雷霆滚动,配以悲凉的古风 BGM。",
169
+ "bg": "Създайте фентъзи анимационна кратка драма по китайска митология — в края на войната за облагородяване на боговете последната оцеляла малка деветопашата лисица-демон е довлечена на Платформата на облагородяването от Дзян Дзъя. Точно преди екзекуцията си тя внезапно си спомня мъж, който постоянно се появява в сънищата ѝ. Добавете звуци на средата: тътен на гръмотевици от деветте небеса, и тъжна класическа китайска BGM."
170
+ },
171
+ "videos": [
172
+ {
173
+ "model": "ours",
174
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_072_ours.mp4"
175
+ },
176
+ {
177
+ "model": "baseline",
178
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_072_baseline.mp4"
179
+ },
180
+ {
181
+ "model": "univa",
182
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_072_univa.mp4"
183
+ }
184
+ ]
185
+ },
186
+ {
187
+ "id": "cr_104",
188
+ "category": "cr",
189
+ "prompt": "Make a wuxia / xianxia revenge animated short drama — the only surviving young Daoist after his sect is massacred by the demonic cult carries his master's severed head all the way back to Mount Kunlun, vowing to perform the blood-moon vengeance rite that very night. Add ambient sounds: howling mountain wind, ringing sword strikes, and rolling distant thunder.",
190
+ "prompt_i18n": {
191
+ "zh": "制作一部武侠/玄幻复仇动画短剧 — 道门被魔教屠灭后唯一幸存的小道士,抱着师父的头颅一路返回昆仑山,誓要在当晚血月之下举行血月复仇之祭。环境音:山风呼啸、剑鸣铮然、远雷滚滚。",
192
+ "bg": "Създайте анимационна кратка драма за отмъщение в жанра ууся / сяня — единственият оцелял млад даоистки монах след клането на сектата му от демоничен култ носи отсечената глава на учителя си обратно до планината Кунлун, обричайки се да извърши обреда на отмъщението под кървавата луна още същата нощ. Добавете звуци: воене на планинския вятър, звън от удари на мечове и тътнеж на далечни гръмотевици."
193
+ },
194
+ "videos": [
195
+ {
196
+ "model": "ours",
197
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_104_ours.mp4"
198
+ },
199
+ {
200
+ "model": "baseline",
201
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_104_baseline.mp4"
202
+ },
203
+ {
204
+ "model": "univa",
205
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_104_univa.mp4"
206
+ }
207
+ ]
208
+ },
209
+ {
210
+ "id": "cr_107",
211
+ "category": "cr",
212
+ "prompt": "Make a period war-epic animated short drama — General Murong Rong's only son has guarded the western frontier for ten years without returning home. In his final stand he charges alone into ten thousand enemy soldiers, only to reclaim the family's ancestral green-dragon polearm from the body of his fallen father. Add ambient sounds: war horses neighing, bowstrings drawn taut, frozen wind sweeping the snow plains. Add a stirring period war-drum BGM.",
213
+ "prompt_i18n": {
214
+ "zh": "制作一部古代战争史诗动画短剧 — 慕容戎将军的独子戍守西部边疆十年未归。最后一战,他单骑闯入万军之中,只为从父亲的尸身上夺回家族传承的青龙偃月刀。环境音:战马嘶鸣、弓弦紧绷、寒风扫过雪原。配以激昂的古代战鼓 BGM。",
215
+ "bg": "Създайте историческа епична анимационна кратка драма за война — единственият син на генерал Муронг Ронг пази западната граница десет години, без да се върне у дома. В последната си битка той се хвърля сам срещу десет хиляди вражески войници, само за да си върне семейната родова алебарда зелен дракон от тялото на падналия си баща. Добавете звуци: цвилене на бойни коне, изпънати тетиви на лъкове, мразовит вятър, отнасящ снежните равнини. Добавете повдигаща духа епохална бойна тъпанова BGM."
216
+ },
217
+ "videos": [
218
+ {
219
+ "model": "ours",
220
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_107_ours.mp4"
221
+ },
222
+ {
223
+ "model": "baseline",
224
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_107_baseline.mp4"
225
+ },
226
+ {
227
+ "model": "univa",
228
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/cr_107_univa.mp4"
229
+ }
230
+ ]
231
+ },
232
+ {
233
+ "id": "intake_img_005",
234
+ "category": "intake_img",
235
+ "prompt": "Use this uploaded photo as the look of the cultivation male lead. Make a cultivation-rebirth animated short drama — in his previous life he was the Heavenly Emperor who suppressed all under heaven, killed by the joint betrayal of nine sacred lands. He is now reborn into the body of the trash junior disciple every senior in the sect mocks; every one of those who once stepped on him has no idea they are already standing at the edge of doom.",
236
+ "prompt_i18n": {
237
+ "zh": "请使用上传的照片作为修仙男主角的相貌。制作一部修仙重生动画短剧 — 他前世是镇压天下的天帝,被九大圣地联合背叛所杀。如今他重生在被宗门同辈嘲讽的废柴小弟子身上;那些曾经踩在他头上的人,根本不知道自己已站在末日的边缘。",
238
+ "bg": "Използвайте качената снимка като облик на главния мъжки герой в самоусъвършенстване. Създайте анимационна кратка драма за самоусъвършенстване и прераждане — в предишния си живот той е бил Небесният император, който е покорил всичко под небето, убит от съвместно предателство на девет свещени земи. Сега той се преражда в тялото на безполезен младши ученик, когото всеки старши в сектата подиграва; всеки от тези, които някога са го тъпкали, нямат представа, че вече стоят на ръба на гибелта."
239
+ },
240
+ "videos": [
241
+ {
242
+ "model": "ours",
243
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_005_ours.mp4"
244
+ },
245
+ {
246
+ "model": "baseline",
247
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_005_baseline.mp4"
248
+ },
249
+ {
250
+ "model": "univa",
251
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_005_univa.mp4"
252
+ }
253
+ ],
254
+ "reference_image": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/refs/intake_img_005.png"
255
+ },
256
+ {
257
+ "id": "intake_img_008",
258
+ "category": "intake_img",
259
+ "prompt": "Use this uploaded photo as the fantasy female lead. Make a post-apocalyptic super-power animated short drama — the day the virus broke out she awakened the power to bend metal inside a subway car. Now she leads a band of survivors through the zombie-flooded city, dodging the military's super-power task force as she hunts for whoever truly released the pathogen.",
260
+ "prompt_i18n": {
261
+ "zh": "请使用上传的照片作为奇幻女主角的相貌。制作一部末日超能力动画短剧 — 病毒爆发那天,她在地铁车厢里觉醒了操控金属的能力。如今她带领一群幸存者穿过丧尸涌动的城市,躲避军方的超能力特勤队,追查究竟是谁释放了真正的病原。",
262
+ "bg": "Използвайте качената снимка като облик на главната женска фентъзи героиня. Създайте постапокалиптична анимационна кратка драма със свръхспособности — в деня на избухването на вируса тя пробужда силата да изкривява метал в метровагон. Сега тя води отряд оцелели през град, наводнен със зомбита, избягвайки специалния отряд за свръхсили на армията, докато преследва онзи, който всъщност е освободил патогена."
263
+ },
264
+ "videos": [
265
+ {
266
+ "model": "ours",
267
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_008_ours.mp4"
268
+ },
269
+ {
270
+ "model": "baseline",
271
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_008_baseline.mp4"
272
+ },
273
+ {
274
+ "model": "univa",
275
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_008_univa.mp4"
276
+ }
277
+ ],
278
+ "reference_image": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/refs/intake_img_008.png"
279
+ },
280
+ {
281
+ "id": "intake_img_018",
282
+ "category": "intake_img",
283
+ "prompt": "Use this uploaded photo as the wealthy-heir male lead. Make a modern-urban wealthy-clan tortured-romance animated short drama — seven years ago he watched her jump off a yacht into the deep sea; seven years later she returns under a different name, becomes his biggest business rival, and the one shadow he can never quite catch.",
284
+ "prompt_i18n": {
285
+ "zh": "请使用上传的照片作为豪门男主角的相貌。制作一部现代都市豪门虐恋动画短剧 — 七年前他眼睁睁看着她从游艇上跳入深海;七年后她以另一个身份归来,成为他最大的商业对手,也成了他始终抓不住的那个影子。",
286
+ "bg": "Използвайте качената снимка като облик на главния мъжки наследник от богат род. Създайте съвременна градска анимационна кратка драма за мъчителна любов между елитни семейства — преди седем години той гледа как тя скача от яхта в дълбокото море; седем години по-късно тя се връща под друго име, става най-големият му бизнес съперник и единствената сянка, която никога не успява да хване."
287
+ },
288
+ "videos": [
289
+ {
290
+ "model": "ours",
291
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_018_ours.mp4"
292
+ },
293
+ {
294
+ "model": "baseline",
295
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_018_baseline.mp4"
296
+ },
297
+ {
298
+ "model": "univa",
299
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_018_univa.mp4"
300
+ }
301
+ ],
302
+ "reference_image": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/refs/intake_img_018.png"
303
+ },
304
+ {
305
+ "id": "intake_img_019",
306
+ "category": "intake_img",
307
+ "prompt": "Use this uploaded photo as the face of the rebirth-revenge female lead. Make a period rebirth-revenge palace-intrigue animated short drama — she is the former empress who was forced to drink poisoned wine. Reborn back to the day of the imperial selection, this time she will personally send every soul who once owed her a life into the underworld.",
308
+ "prompt_i18n": {
309
+ "zh": "请使用上传的照片作为重生复仇女主角的相貌。制作一部古代重生复仇宫斗动画短剧 — 她是前世被逼饮下毒酒的皇后。重生回到大选之日,这一世她要亲手把每一个曾欠她一命的人送入黄泉。",
310
+ "bg": "Използвайте качената снимка като лице на главната женска героиня за прераждане и отмъщение. Създайте историческа анимационна кратка драма за прераждане и дворцови интриги — тя е бившата императрица, принудена да изпие отровено вино. Преродена в деня на императорския подбор, този път тя лично ще изпрати всяка душа, която някога ѝ е дължала живот, в подземния свят."
311
+ },
312
+ "videos": [
313
+ {
314
+ "model": "ours",
315
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_019_ours.mp4"
316
+ },
317
+ {
318
+ "model": "baseline",
319
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_019_baseline.mp4"
320
+ },
321
+ {
322
+ "model": "univa",
323
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_019_univa.mp4"
324
+ }
325
+ ],
326
+ "reference_image": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/refs/intake_img_019.png"
327
+ },
328
+ {
329
+ "id": "intake_img_030",
330
+ "category": "intake_img",
331
+ "prompt": "Use this uploaded photo as the suspense male lead. Make an urban crime-suspense animated short drama — the new deputy captain of the homicide unit takes his very first case: a serial-disposal cold case that has been silent for fifteen years. As he closes in on the truth, he finds his own father's name plainly listed among the original suspects. Add Chinese subtitles for short-video platform distribution.",
332
+ "prompt_i18n": {
333
+ "zh": "请使用上传的照片作为悬疑男主角的相貌。制作一部都市犯罪悬疑动画短剧 — 重案组新任副队长接手第一桩案子:一桩沉寂十五年的连环抛尸悬案。当他逐渐逼近真相时,发现父亲的名字赫然出现在最初的嫌疑人名单上。请添加中文字幕以便短视频平台分发。",
334
+ "bg": "Използвайте качената снимка като облик на главния мъжки герой в напрегната драма. Създайте градска криминално-напрегната анимационна кратка драма — новият заместник-капитан на отдел „Убийства“ поема първия си случай: серийно изхвърляне на тела, замръзнало от петнадесет години. Когато се доближава до истината, открива името на собствения си баща сред първоначалните заподозрени. Добавете китайски субтитри за разпространение в платформи за кратко видео."
335
+ },
336
+ "videos": [
337
+ {
338
+ "model": "ours",
339
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_030_ours.mp4"
340
+ },
341
+ {
342
+ "model": "baseline",
343
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_030_baseline.mp4"
344
+ },
345
+ {
346
+ "model": "univa",
347
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_030_univa.mp4"
348
+ }
349
+ ],
350
+ "reference_image": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/refs/intake_img_030.png"
351
+ },
352
+ {
353
+ "id": "intake_img_036",
354
+ "category": "intake_img",
355
+ "prompt": "Use this uploaded photo as the look of the CEO male lead. Make a modern-urban wealthy-clan tortured-romance animated short drama — three years after the car crash that erased her memory, the little contract wife Su Nian reappears in front of CEO Huo with a small boy who looks exactly like him. Add bilingual Chinese-English subtitles for overseas distribution.",
356
+ "prompt_i18n": {
357
+ "zh": "请使用上传的照片作为总裁男主角的相貌。制作一部现代都市豪门虐恋动画短剧 — 在那场令她失忆的车祸三年后,契约小妻苏念带着一个与他一模一样的小男孩,再次出现在霍总面前。请添加中英双语字幕以便海外分发。",
358
+ "bg": "Използвайте качената снимка като облик на главния мъжки изпълнителен директор. Създайте съвременна градска анимационна кратка драма за мъчителна любов между елитни семейства — три години след автомобилната катастрофа, която изтрива паметта ѝ, малката съпруга по договор Су Ниан се появява отново пред директор Хуо с малко момче, което изглежда точно като него. Добавете двуезични китайско-английски субтитри за разпространение в чужбина."
359
+ },
360
+ "videos": [
361
+ {
362
+ "model": "ours",
363
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_036_ours.mp4"
364
+ },
365
+ {
366
+ "model": "baseline",
367
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_036_baseline.mp4"
368
+ },
369
+ {
370
+ "model": "univa",
371
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_036_univa.mp4"
372
+ }
373
+ ],
374
+ "reference_image": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/refs/intake_img_036.png"
375
+ },
376
+ {
377
+ "id": "intake_img_037",
378
+ "category": "intake_img",
379
+ "prompt": "Use this uploaded photo as the fantasy female lead. Make a demon-empress rebirth animated short drama — in her previous life she was the demon empress who guarded the human realm, killed by the joint betrayal of her most trusted disciple and the heavenly court. Reborn to the day before her demonic awakening, this time she will personally drag every one of those self-righteous heavenly beings down from their pedestals. Add bilingual Chinese-English subtitles for overseas short-video distribution.",
380
+ "prompt_i18n": {
381
+ "zh": "请使用上传的照片作为奇幻女主角的相貌。制作一部魔后重生动画短剧 — 她前世是守护人间的魔后,被最信任的弟子和天庭联手背叛所杀。如今她重生回觉醒魔性的前一天,这一世她要亲手把那群自诩正义的天界仙人通通拉下神坛。请添加中英双语字幕以便海外短视频分发。",
382
+ "bg": "Използвайте качената снимка като облик на главната женска фентъзи героиня. Създайте анимационна кратка драма за прераждане на демонична императрица — в предишния си живот тя е била демоничната императрица, която е защитавала човешкия свят, убита от съвместно предателство на най-доверения си ученик и небесния двор. Преродена в деня преди демоничното си пробуждане, този път тя лично ще повлече всеки от тези самопровъзгласили се небесни същества от пиедестала им. Добавете двуезични китайско-английски субтитри за разпространение в чуждестранни платформи за кратко видео."
383
+ },
384
+ "videos": [
385
+ {
386
+ "model": "ours",
387
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_037_ours.mp4"
388
+ },
389
+ {
390
+ "model": "baseline",
391
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_037_baseline.mp4"
392
+ },
393
+ {
394
+ "model": "univa",
395
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_037_univa.mp4"
396
+ }
397
+ ],
398
+ "reference_image": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/refs/intake_img_037.png"
399
+ },
400
+ {
401
+ "id": "intake_img_039",
402
+ "category": "intake_img",
403
+ "prompt": "Use this uploaded photo as the high-school female lead. Make a contemporary high-school sweet-and-bitter romance animated short drama — rumor says she is the secret daughter of the largest private-academy benefactor in northern Jiangbei; on her very first day as a transfer student she clashes head-on with the school's most untouchable bully. Add a light-yet-bittersweet piano BGM.",
404
+ "prompt_i18n": {
405
+ "zh": "请使用上传的照片作为高中女主角的相貌。制作一部当代高中甜苦校园恋爱动画短剧 — 传言她是江北最大私立学院金主的私生女;转学第一天,她就与全校最不可惹的霸凌者迎面相撞。配以轻快又略带苦涩的钢琴 BGM。",
406
+ "bg": "Използвайте качената снимка като облик на главната женска гимназиална героиня. Създайте съвременна анимационна кратка драма за гимназиална сладко-горчива любов — слух твърди, че тя е тайната дъщеря на най-големия покровител на частна академия в Северен Цзянбей; в първия си ден като нова ученичка тя се сблъсква челно с най-недосегаемия училищен побойник. Добавете лека, но горчиво-сладка пианова BGM."
407
+ },
408
+ "videos": [
409
+ {
410
+ "model": "ours",
411
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_039_ours.mp4"
412
+ },
413
+ {
414
+ "model": "baseline",
415
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_039_baseline.mp4"
416
+ },
417
+ {
418
+ "model": "univa",
419
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_039_univa.mp4"
420
+ }
421
+ ],
422
+ "reference_image": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/refs/intake_img_039.png"
423
+ },
424
+ {
425
+ "id": "intake_img_046",
426
+ "category": "intake_img",
427
+ "prompt": "Use this uploaded photo as the period female lead. Make a palace-intrigue rise-from-the-ashes animated short drama — she is the disgraced empress who climbed all the way back from the cold palace to the principal palace; the only one who still remembers her real name on this revenge road is the little eunuch who once took a knife for her. Add a sweeping classical-Chinese BGM.",
428
+ "prompt_i18n": {
429
+ "zh": "请使用上传的照片作为古代女主角的相貌。制作一部宫斗逆袭动画短剧 — 她是从冷宫一路爬回中宫的被废皇后;在这条复仇之路上,还记得她真实姓名的,只剩下那个曾为她挡刀的小太监。配以恢宏的古风 BGM。",
430
+ "bg": "Използвайте качената снимка като облик на главната женска историческа героиня. Създайте анимационна кратка драма за дворцови интриги и възход от пепелта — тя е опозорената императрица, която се изкачва обратно от студения дворец чак до главния дворец; единственият, който все още помни истинското ѝ име по този път на отмъщение, е малкият евнух, който някога е приел нож вместо нея. Добавете мащабна класическа китайска BGM."
431
+ },
432
+ "videos": [
433
+ {
434
+ "model": "ours",
435
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_046_ours.mp4"
436
+ },
437
+ {
438
+ "model": "baseline",
439
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_046_baseline.mp4"
440
+ },
441
+ {
442
+ "model": "univa",
443
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_046_univa.mp4"
444
+ }
445
+ ],
446
+ "reference_image": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/refs/intake_img_046.png"
447
+ },
448
+ {
449
+ "id": "intake_img_049",
450
+ "category": "intake_img",
451
+ "prompt": "Use this uploaded photo as the wealthy-clan tortured-romance female lead. Make a modern-urban stand-in-bride wealthy-clan animated short drama — she was brought in to replace his dead first love. The day her three-year contract expires she hands him divorce papers, and the man who never once truly looked at her in those three years suddenly begins hunting for her like a madman across the whole city. Add Chinese subtitles for short-video platform distribution and a sad piano BGM.",
452
+ "prompt_i18n": {
453
+ "zh": "请使用上传的照片作为豪门虐恋女主角的相貌。制作一部现代都市替身豪门动画短剧 — 她被带回去顶替他已故的初恋。三年契约到期那天,她递上离婚协议,而那个三年里从未真正看过她一眼的男人,忽然像疯了一般在全城疯狂追寻她。请添加中文字幕以便短视频平台分发,配以悲伤的钢琴 BGM。",
454
+ "bg": "Използвайте качената снимка като облик на главната женска героиня в мъчителна любов между елитни семейства. Създайте съвременна градска анимационна кратка драма за подменена булка от богат род — тя е била доведена, за да замести мъртвата му първа любов. В деня, в който тригодишният ѝ договор изтича, тя му връчва документи за развод, а мъжът, който никога през тези три години не я е поглеждал истински, изведнъж започва да я преследва из целия град като обезумял. Добавете китайски субтитри за разпространение в платформи за кратко видео и тъжна пианова BGM."
455
+ },
456
+ "videos": [
457
+ {
458
+ "model": "ours",
459
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_049_ours.mp4"
460
+ },
461
+ {
462
+ "model": "baseline",
463
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_049_baseline.mp4"
464
+ },
465
+ {
466
+ "model": "univa",
467
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/videos/intake_img_049_univa.mp4"
468
+ }
469
+ ],
470
+ "reference_image": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/refs/intake_img_049.png"
471
+ }
472
+ ]
static/v1/index.html ADDED
The diff for this file is too large to render. See raw diff
 
static/v1/mobile.html ADDED
@@ -0,0 +1,1492 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport"
6
+ content="width=device-width, initial-scale=1.0, viewport-fit=cover">
7
+ <meta name="theme-color" content="#0f1115">
8
+ <title>Video user study — mobile</title>
9
+ <style>
10
+ :root {
11
+ --bg: #0f1115;
12
+ --panel: #161922;
13
+ --panel-2: #1c2030;
14
+ --panel-3: #232839;
15
+ --border: #2a2f42;
16
+ --border-strong: #3a4060;
17
+ --fg: #e6e8ef;
18
+ --muted: #8b91a8;
19
+ --muted-2: #5e6480;
20
+ --accent: #6aa9ff;
21
+ --accent-strong: #8ec0ff;
22
+ --good: #28c76f;
23
+ --warn: #ff9f43;
24
+ --bad: #ea5455;
25
+ --score-1: #ea5455;
26
+ --score-2: #ff7d52;
27
+ --score-3: #ffa84a;
28
+ --score-4: #8acf6d;
29
+ --score-5: #28c76f;
30
+ --safe-bot: env(safe-area-inset-bottom, 0px);
31
+ --bar-h: 56px;
32
+ }
33
+ * { box-sizing: border-box; }
34
+ html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg);
35
+ font-family: -apple-system, BlinkMacSystemFont, "Inter", "PingFang SC",
36
+ "Microsoft YaHei", sans-serif;
37
+ font-size: 15px; line-height: 1.4;
38
+ -webkit-tap-highlight-color: transparent;
39
+ overscroll-behavior-y: contain; }
40
+
41
+ /* ---------- top header ---------- */
42
+ header.top { position: sticky; top: 0; z-index: 50;
43
+ background: var(--panel); border-bottom: 1px solid var(--border);
44
+ padding: 8px 12px; padding-top: calc(env(safe-area-inset-top, 0px) + 8px);
45
+ display: flex; align-items: center; gap: 8px; }
46
+ header.top .title { font-weight: 600; font-size: 14px; flex: 1;
47
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
48
+ header.top .pill { font-size: 11px; padding: 3px 8px; border-radius: 10px;
49
+ background: var(--panel-3); color: var(--muted); white-space: nowrap;
50
+ font-family: ui-monospace, monospace; border: 1px solid var(--border); }
51
+ header.top .pill[data-state="idle"] { background:#1f2a44; color: var(--accent); border-color:#3a4a78; }
52
+ header.top .pill[data-state="submitting"] { background:#1f2a44; color: var(--accent); border-color:#3a4a78; }
53
+ header.top .pill[data-state="submitted"] { background:#1c3422; color: var(--good); border-color:#2a5a37; }
54
+ header.top .pill[data-state="local-only"] { background:#3a3520; color: var(--warn); border-color:#5a4a25; }
55
+ header.top .pill[data-state="error"] { background:#3a2020; color: var(--bad); border-color:#5a2828; }
56
+ header.top button.icon { background: transparent; border: none; color: var(--fg);
57
+ font-size: 22px; padding: 4px 8px; cursor: pointer; line-height: 1; }
58
+ header.top select { background: var(--panel-3); border: 1px solid var(--border);
59
+ color: var(--fg); padding: 4px 6px; border-radius: 4px; font-size: 12px;
60
+ font-family: inherit; }
61
+
62
+ /* ---------- progress strip ---------- */
63
+ .case-strip { position: sticky; top: 56px; z-index: 40;
64
+ background: var(--panel); border-bottom: 1px solid var(--border);
65
+ padding: 6px 12px; display: flex; align-items: center; gap: 8px;
66
+ font-size: 12px; color: var(--muted); }
67
+ .case-strip .case-id { color: var(--fg); font-weight: 600;
68
+ font-family: ui-monospace, monospace; }
69
+ .case-strip .bar { flex: 1; height: 4px; background: var(--panel-3); border-radius: 2px;
70
+ overflow: hidden; }
71
+ .case-strip .fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--good));
72
+ transition: width 0.2s; }
73
+ .case-strip button.jumper { background: var(--panel-3); border: 1px solid var(--border);
74
+ color: var(--fg); padding: 4px 10px; border-radius: 4px;
75
+ font-size: 12px; font-family: inherit; cursor: pointer; }
76
+
77
+ /* ---------- main scroll content ---------- */
78
+ main { padding: 8px 12px calc(var(--bar-h) + var(--safe-bot) + 24px); }
79
+
80
+ /* prompt */
81
+ .prompt-card { background: var(--panel); border: 1px solid var(--border);
82
+ border-radius: 8px; padding: 10px 12px; margin-bottom: 10px;
83
+ font-size: 14px; line-height: 1.6; }
84
+ .prompt-card .label { font-size: 10px; color: var(--muted); letter-spacing: 0.5px;
85
+ text-transform: uppercase; margin-bottom: 4px; }
86
+ .prompt-card details summary { cursor: pointer; user-select: none;
87
+ list-style: none; color: var(--muted); font-size: 13px; }
88
+ .prompt-card details summary::before { content: '▸ '; }
89
+ .prompt-card details[open] summary::before { content: '▾ '; }
90
+ .prompt-card details[open] summary { margin-bottom: 6px; }
91
+ .prompt-card .ref-img { max-width: 100%; max-height: 200px; border-radius: 6px;
92
+ border: 1px solid var(--border); display: block; margin-top: 8px; }
93
+ /* Bold-red highlight on controllability cues in the prompt. */
94
+ .ctrl { color: var(--bad); font-weight: 700; }
95
+ /* Admin-curated highlights get a subtle dotted underline so admins know
96
+ which marks are their own (raters see them the same red as auto cues). */
97
+ .ctrl-admin { text-decoration: underline; text-decoration-style: dotted;
98
+ text-decoration-color: var(--bad); text-underline-offset: 2px; }
99
+
100
+ /* videos — tab UI mirrors desktop: one active video at a time, A/B/C
101
+ buttons to switch. All tiles stay in DOM so each keeps its own state. */
102
+ .video-stack { display: flex; flex-direction: column; gap: 10px;
103
+ margin-bottom: 14px; }
104
+ .video-tabs { display: flex; gap: 6px; flex-wrap: wrap; }
105
+ .tab-btn { background: var(--panel-3); border: 1px solid var(--border);
106
+ color: var(--muted); padding: 8px 14px; border-radius: 6px;
107
+ font-family: ui-monospace, monospace; font-size: 13px; cursor: pointer;
108
+ min-height: 38px; min-width: 44px; }
109
+ .tab-btn.active { background: var(--accent); color: #0a1a2a;
110
+ border-color: var(--accent); font-weight: 700; }
111
+ .video-tile { position: relative; }
112
+ .video-tile.hidden { display: none; }
113
+ .video-frame { position: relative; background: #000; border: 1px solid var(--border);
114
+ border-radius: 8px; overflow: hidden; aspect-ratio: 16 / 9; }
115
+ .video-frame video { width: 100%; height: 100%; display: block; background: #000; }
116
+ .video-frame .empty-state { position: absolute; inset: 0; display: flex;
117
+ flex-direction: column; align-items: center; justify-content: center;
118
+ background: repeating-linear-gradient(45deg, #1a1d28 0 10px, #16191f 10px 20px);
119
+ color: var(--muted); font-size: 12px; gap: 4px; text-align: center;
120
+ padding: 12px; }
121
+
122
+ /* video controls */
123
+ .video-controls { display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
124
+ background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
125
+ padding: 8px 10px; margin-bottom: 14px; font-size: 12px; }
126
+ .video-controls button { background: var(--panel-3); border: 1px solid var(--border-strong);
127
+ color: var(--fg); padding: 8px 12px; border-radius: 6px; font-size: 13px;
128
+ font-family: inherit; min-height: 36px; cursor: pointer; }
129
+ .video-controls button.on { background: var(--accent); color: #0a1a2a; border-color: var(--accent); }
130
+ .video-controls select { background: var(--panel-3); border: 1px solid var(--border);
131
+ color: var(--fg); padding: 6px 8px; border-radius: 4px; font-size: 13px;
132
+ font-family: inherit; margin-left: auto; }
133
+
134
+ /* dimension + question cards. dim-header sits inline above its cards
135
+ (not sticky — only the prompt + videos at the top of the page float). */
136
+ /* Responsive grid of question cards. ~280 px min → 1 col on phones,
137
+ 2-3 cols on tablets / landscape. Dim headers span all columns. */
138
+ .question-grid { display: grid; gap: 10px;
139
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
140
+ align-content: start; }
141
+ .question-grid > .dim-header { grid-column: 1 / -1; }
142
+ .dim-header { margin: 14px 0 4px; padding: 0 2px;
143
+ font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;
144
+ color: var(--accent-strong); font-weight: 700;
145
+ display: flex; align-items: baseline; gap: 8px; }
146
+ .dim-header:first-child { margin-top: 0; }
147
+ .dim-header .dim-hint { font-size: 11px; color: var(--muted); font-weight: 400;
148
+ text-transform: none; letter-spacing: 0; }
149
+ .q-card { background: var(--panel); border: 1px solid var(--border);
150
+ border-radius: 8px; padding: 12px; margin-bottom: 10px; }
151
+ .q-card .q-name { font-size: 15px; font-weight: 600; color: var(--fg);
152
+ margin-bottom: 2px; }
153
+ .q-card .q-desc { font-size: 12px; color: var(--muted); margin-bottom: 10px;
154
+ line-height: 1.5; }
155
+ .q-card .video-rate { display: flex; align-items: center; gap: 8px;
156
+ padding: 6px 0; }
157
+ .q-card .video-rate + .video-rate { border-top: 1px solid var(--border); }
158
+ .q-card .video-rate .slot-letter { min-width: 28px; height: 28px;
159
+ display: inline-flex; align-items: center; justify-content: center;
160
+ border-radius: 4px; background: var(--panel-3); color: var(--fg);
161
+ font-weight: 700; font-size: 13px; font-family: ui-monospace, monospace;
162
+ border: 1px solid var(--border-strong); flex-shrink: 0; }
163
+ .q-card .likert { display: flex; gap: 4px; flex: 1; justify-content: space-between; }
164
+ .q-card .likert button { flex: 1; min-height: 42px; min-width: 0;
165
+ border: 1px solid var(--border); background: var(--panel-3); color: var(--fg);
166
+ border-radius: 6px; font-family: inherit; font-size: 14px; font-weight: 500;
167
+ cursor: pointer; }
168
+ .q-card .likert button.selected { font-weight: 700; color: #0a1a0a;
169
+ border-color: rgba(255,255,255,0.2); }
170
+ .q-card .likert button.selected[data-score="1"] { background: var(--score-1); }
171
+ .q-card .likert button.selected[data-score="2"] { background: var(--score-2); }
172
+ .q-card .likert button.selected[data-score="3"] { background: var(--score-3); }
173
+ .q-card .likert button.selected[data-score="4"] { background: var(--score-4); }
174
+ .q-card .likert button.selected[data-score="5"] { background: var(--score-5); }
175
+
176
+ /* bottom fixed action bar */
177
+ footer.bottom { position: fixed; left: 0; right: 0; bottom: 0; z-index: 50;
178
+ background: var(--panel); border-top: 1px solid var(--border);
179
+ padding: 8px 10px calc(8px + var(--safe-bot));
180
+ display: grid; grid-template-columns: 1fr 1fr auto 1fr 1fr; gap: 6px;
181
+ height: calc(var(--bar-h) + var(--safe-bot)); align-items: center; }
182
+ footer.bottom button { background: var(--panel-3); border: 1px solid var(--border-strong);
183
+ color: var(--fg); padding: 0; height: 40px; border-radius: 6px;
184
+ font-family: inherit; font-size: 14px; font-weight: 500; cursor: pointer; }
185
+ footer.bottom .submit-btn { background: var(--good); color: #0a1a0a;
186
+ border-color: var(--good); font-weight: 700; }
187
+ footer.bottom .submit-btn:disabled { background: var(--panel-3); color: var(--muted-2);
188
+ border-color: var(--border); font-weight: 500; }
189
+ footer.bottom .top-btn { font-size: 18px; }
190
+
191
+ /* slide-in drawer (case list) */
192
+ .drawer-backdrop { position: fixed; inset: 0; background: rgba(8,10,16,0.7);
193
+ z-index: 60; opacity: 0; pointer-events: none; transition: opacity 0.2s; }
194
+ .drawer-backdrop.open { opacity: 1; pointer-events: auto; }
195
+ .drawer { position: fixed; top: 0; right: 0; bottom: 0; width: 86%;
196
+ max-width: 360px; background: var(--panel); border-left: 1px solid var(--border);
197
+ z-index: 61; transform: translateX(100%); transition: transform 0.22s ease;
198
+ display: flex; flex-direction: column; padding-top: env(safe-area-inset-top, 0px); }
199
+ .drawer.open { transform: translateX(0); }
200
+ .drawer header { padding: 12px 14px; border-bottom: 1px solid var(--border);
201
+ display: flex; align-items: center; justify-content: space-between;
202
+ font-weight: 600; }
203
+ .drawer .list { flex: 1; overflow-y: auto; padding: 4px 0; }
204
+ .drawer .case-row { padding: 12px 14px; border-bottom: 1px solid var(--border);
205
+ display: flex; align-items: center; gap: 10px; }
206
+ .drawer .case-row.active { background: var(--panel-2);
207
+ border-left: 3px solid var(--accent); padding-left: 11px; }
208
+ .drawer .case-row .dot { width: 9px; height: 9px; border-radius: 50%;
209
+ background: var(--muted-2); flex-shrink: 0; }
210
+ .drawer .case-row.partial .dot { background: var(--warn); }
211
+ .drawer .case-row.complete .dot { background: var(--good); }
212
+ .drawer .case-row .id { flex: 1; font-family: ui-monospace, monospace;
213
+ font-size: 13px; }
214
+ .drawer .case-row .pct { font-size: 11px; color: var(--muted); }
215
+
216
+ /* registration overlay (re-used from desktop) */
217
+ .overlay { position: fixed; inset: 0; background: rgba(8,10,16,0.84);
218
+ backdrop-filter: blur(4px); display: flex; align-items: center;
219
+ justify-content: center; z-index: 100; padding: 14px; }
220
+ .overlay.hidden { display: none; }
221
+ .reg-card { background: var(--panel); border: 1px solid var(--border-strong);
222
+ border-radius: 10px; padding: 18px 18px 16px; max-width: 420px; width: 100%; }
223
+ .reg-card h2 { margin: 0 0 4px; font-size: 17px; }
224
+ .reg-card .lead { color: var(--muted); font-size: 13px; line-height: 1.6;
225
+ margin: 0 0 14px; }
226
+ .reg-card label { display: block; font-size: 11px; text-transform: uppercase;
227
+ letter-spacing: 0.5px; color: var(--muted); margin: 10px 0 4px; }
228
+ .reg-card input, .reg-card textarea { width: 100%; background: var(--panel-3);
229
+ border: 1px solid var(--border); color: var(--fg); padding: 10px;
230
+ border-radius: 6px; font-family: inherit; font-size: 16px; /* 16 ≥ iOS no-zoom */ }
231
+ .reg-card textarea { min-height: 50px; resize: vertical; font-size: 14px; }
232
+ .reg-card .err { color: var(--bad); font-size: 12px; min-height: 14px;
233
+ margin-top: 6px; }
234
+ .reg-card .top-row { display: flex; align-items: center;
235
+ justify-content: space-between; margin-bottom: 8px; gap: 12px; }
236
+ .reg-card select { background: var(--panel-3); border: 1px solid var(--border);
237
+ color: var(--fg); padding: 4px 6px; border-radius: 4px; font-size: 12px;
238
+ font-family: inherit; }
239
+ .reg-card .go-btn { width: 100%; background: var(--accent); color: #0a1a2a;
240
+ border: 1px solid var(--accent); padding: 12px; border-radius: 8px;
241
+ font-weight: 700; font-size: 15px; cursor: pointer; margin-top: 14px; }
242
+
243
+ /* submitted overlay */
244
+ .submitted-card { background: var(--panel); border: 1px solid var(--good);
245
+ border-radius: 10px; padding: 22px; max-width: 420px; width: 100%;
246
+ text-align: center; }
247
+ .submitted-card .check { font-size: 44px; color: var(--good); }
248
+ .submitted-card h2 { margin: 8px 0 4px; font-size: 17px; }
249
+ .submitted-card p { color: var(--muted); font-size: 13px; margin: 4px 0;
250
+ line-height: 1.6; }
251
+ .submitted-card code { background: var(--panel-3); padding: 2px 6px;
252
+ border-radius: 3px; font-size: 11px; color: var(--accent-strong);
253
+ word-break: break-all; }
254
+ .submitted-card .btn { background: var(--panel-3); border: 1px solid var(--border-strong);
255
+ color: var(--fg); padding: 10px 14px; border-radius: 6px; font-size: 14px;
256
+ font-family: inherit; margin-top: 12px; cursor: pointer; min-width: 100px; }
257
+
258
+ /* toast */
259
+ .toast { position: fixed; bottom: calc(var(--bar-h) + var(--safe-bot) + 14px);
260
+ left: 12px; right: 12px; background: var(--good); color: #0a1a0a;
261
+ padding: 10px 14px; border-radius: 8px; font-size: 13px; font-weight: 600;
262
+ text-align: center; opacity: 0; transition: opacity 0.2s;
263
+ z-index: 90; pointer-events: none; }
264
+ .toast.show { opacity: 1; }
265
+ .toast.error { background: var(--bad); color: #fff; }
266
+
267
+ /* desktop switch link */
268
+ .desktop-link { font-size: 11px; color: var(--muted); text-decoration: none;
269
+ padding: 4px 8px; border: 1px solid var(--border); border-radius: 12px;
270
+ background: var(--panel-3); }
271
+ .desktop-link:hover { color: var(--fg); }
272
+
273
+ /* admin / submitted badges */
274
+ .badge { display: inline-flex; align-items: center; padding: 2px 8px;
275
+ border-radius: 10px; font-size: 10px; font-weight: 700; }
276
+ .badge.admin { background: #3a2f60; color: #d9b6ff; border: 1px solid #5a4080; }
277
+ .badge.submitted { background: #1c3422; color: var(--good); border: 1px solid #2a5a37; }
278
+
279
+ /* shut down landscape if it gets weird */
280
+ @media (orientation: landscape) and (max-height: 460px) {
281
+ header.top { padding: 4px 12px; }
282
+ .case-strip { top: 44px; padding: 4px 12px; }
283
+ main { padding-top: 6px; }
284
+ }
285
+
286
+ kbd { background: var(--panel-3); border: 1px solid var(--border-strong);
287
+ border-radius: 3px; padding: 1px 4px; font-family: ui-monospace, monospace;
288
+ font-size: 11px; }
289
+ </style>
290
+ </head>
291
+ <body>
292
+
293
+ <!-- ============ Top bar ============ -->
294
+ <header class="top" id="topBar">
295
+ <button class="icon" id="drawerBtn" title="Cases">☰</button>
296
+ <span class="title" id="topTitle">Video user study</span>
297
+ <button class="icon" id="helpBtn" title="How to rate" style="font-size:18px">?</button>
298
+ <span class="pill" id="syncPill" data-state="idle">●</span>
299
+ <select id="langSelect" title="Language"></select>
300
+ </header>
301
+
302
+ <div class="case-strip" id="caseStrip">
303
+ <span class="case-id" id="caseId">—</span>
304
+ <span id="caseCounter">—</span>
305
+ <div class="bar"><div class="fill" id="progressFill" style="width:0%"></div></div>
306
+ <span id="progressTxt">0/0</span>
307
+ </div>
308
+
309
+ <main id="main">
310
+ <!-- prompt + videos + controls + question cards inserted here -->
311
+ </main>
312
+
313
+ <footer class="bottom">
314
+ <button id="prevBtn">← Prev</button>
315
+ <button id="saveBtn">💾</button>
316
+ <button class="top-btn" id="scrollTopBtn" title="Back to videos">↑</button>
317
+ <button id="submitBtn" class="submit-btn" disabled>Submit</button>
318
+ <button id="nextBtn">Next →</button>
319
+ </footer>
320
+
321
+ <div class="toast" id="toast"></div>
322
+
323
+ <!-- Slide-in drawer with case list -->
324
+ <div class="drawer-backdrop" id="drawerBackdrop"></div>
325
+ <aside class="drawer" id="drawer" aria-hidden="true">
326
+ <header>
327
+ <span>Cases</span>
328
+ <button class="icon" id="drawerCloseBtn"
329
+ style="background:transparent;border:none;color:var(--fg);font-size:22px;cursor:pointer">✕</button>
330
+ </header>
331
+ <div class="list" id="drawerList"></div>
332
+ <div style="padding:10px 14px;border-top:1px solid var(--border);display:flex;gap:8px;align-items:center">
333
+ <a href="./?force=desktop" class="desktop-link" style="flex:1;text-align:center">🖥 Desktop view</a>
334
+ </div>
335
+ </aside>
336
+
337
+ <!-- Rating instructions overlay (mobile) — toggled via the "?" header button.
338
+ Auto-opened on first launch; user can close + reopen any time. -->
339
+ <div id="helpOverlay" class="overlay hidden" role="dialog" aria-modal="true">
340
+ <div class="reg-card" style="max-width:520px">
341
+ <div class="top-row">
342
+ <h2 id="helpHeader">How to rate</h2>
343
+ <button class="btn" id="helpCloseBtn"
344
+ style="background:transparent;border:none;color:var(--fg);font-size:22px;cursor:pointer">✕</button>
345
+ </div>
346
+ <div id="helpBody" style="font-size:13px;line-height:1.6;color:var(--fg);max-height:60vh;overflow-y:auto"></div>
347
+ <button class="go-btn" id="helpDismissBtn" style="margin-top:14px"><span id="helpDismissText">Got it</span></button>
348
+ </div>
349
+ </div>
350
+
351
+ <!-- Registration overlay -->
352
+ <div id="loginOverlay" class="overlay" role="dialog" aria-modal="true">
353
+ <div class="reg-card">
354
+ <div class="top-row">
355
+ <h2 id="regHeader">Register to start rating</h2>
356
+ <select id="regLangSelect"></select>
357
+ </div>
358
+ <p class="lead" id="regLead"></p>
359
+ <label for="regName" id="regNameLabel">Your name</label>
360
+ <input id="regName" type="text" autocomplete="name" maxlength="80">
361
+ <label for="regEmail" id="regEmailLabel">Email</label>
362
+ <input id="regEmail" type="email" autocomplete="email" inputmode="email" maxlength="120">
363
+ <label for="regNote" id="regNoteLabel">Affiliation / notes (optional)</label>
364
+ <textarea id="regNote" maxlength="280"></textarea>
365
+ <div class="err" id="regErr"></div>
366
+ <button class="go-btn" id="regGoBtn"><span id="regContinueText">Continue →</span></button>
367
+ <div style="color:var(--muted-2);font-size:11px;margin-top:8px;line-height:1.5"
368
+ id="regFootText"></div>
369
+ </div>
370
+ </div>
371
+
372
+ <!-- Submitted overlay -->
373
+ <div id="submittedOverlay" class="overlay hidden" role="dialog" aria-modal="true">
374
+ <div class="submitted-card">
375
+ <div class="check">✓</div>
376
+ <h2 id="submittedHeading">Submission recorded</h2>
377
+ <p id="submittedDetail"></p>
378
+ <p><code id="submittedRef">—</code></p>
379
+ <div style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap">
380
+ <button class="btn" id="submittedCloseBtn">Close</button>
381
+ <button class="btn" id="submittedDownloadBtn">↓ Backup JSON</button>
382
+ </div>
383
+ </div>
384
+ </div>
385
+
386
+ <script>
387
+ /* =========================================================================
388
+ * Auto-redirect to the desktop page on large / pointer-fine devices.
389
+ * `?force=mobile` overrides (sticky in sessionStorage for the tab).
390
+ * ========================================================================= */
391
+ (function autoRedirectIfDesktop() {
392
+ try {
393
+ const url = new URL(location.href);
394
+ if (url.searchParams.has("force")) {
395
+ sessionStorage.setItem("user_study_view_force", url.searchParams.get("force"));
396
+ }
397
+ const forced = sessionStorage.getItem("user_study_view_force");
398
+ if (forced === "mobile") return;
399
+ const ua = navigator.userAgent || "";
400
+ const isMobileUA = /Mobi|Android|iPhone|iPod|BlackBerry|Windows Phone/i.test(ua);
401
+ const isFine = window.matchMedia && window.matchMedia("(pointer: fine)").matches;
402
+ const isWide = window.innerWidth >= 900;
403
+ if (forced === "desktop" || (!isMobileUA && isFine && isWide)) {
404
+ location.replace("./");
405
+ }
406
+ } catch (e) { /* ignore — fall through to mobile */ }
407
+ })();
408
+
409
+ /* =========================================================================
410
+ * Same rubric + i18n + admin list + API contract as desktop index.html.
411
+ * Kept in sync manually for now (small enough). localStorage key is shared
412
+ * so progress made on desktop carries to mobile and vice-versa.
413
+ * ========================================================================= */
414
+ const DIMENSIONS = [
415
+ { key: "instruction_following", name: "Instruction following",
416
+ hint: "Re-read the prompt — especially the red-highlighted phrases — before scoring.", questions: [
417
+ { key: "controllability", name: "Controllability",
418
+ desc: "Does the video respond to control signals (BGM, subtitle etc)?" },
419
+ { key: "prompt_adherence", name: "Prompt adherence",
420
+ desc: "Does the content match what the prompt described (subjects, actions, setting, time, and story arc)? Focus primarily on the prompt's text content." },
421
+ { key: "no_irrelevant_frames", name: "No irrelevant frames",
422
+ desc: "Does the video stay on-prompt? Higher = fewer / no frames or content that weren't asked for by the prompt." } ] },
423
+ { key: "temporal_consistency", name: "Temporal consistency",
424
+ hint: "Focus on how characters, objects, and the environment change across frames.", questions: [
425
+ { key: "person_stability", name: "Person stability",
426
+ desc: "Faces, identities, and body proportions stay consistent across frames." },
427
+ { key: "object_stability", name: "Object stability",
428
+ desc: "Props, clothing, and objects keep their shape and position over time." },
429
+ { key: "environment_stability", name: "Environment stability",
430
+ desc: "Background, lighting, and scene layout stay coherent across frames (including absence of brightness / color / texture flicker)." } ] },
431
+ // Dim key stays `motion_realism` for back-compat; display + hint broadened
432
+ // to "Realism", and the `realism` sub-question (formerly under Visual
433
+ // quality) lives here now.
434
+ { key: "motion_realism", name: "Realism",
435
+ hint: "Notice obvious out-of-place or unnatural moments in the video.", questions: [
436
+ { key: "realism", name: "Static realism",
437
+ desc: "Photorealism or stylistic fidelity (per-frame appearance). When the prompt specifies a style, the video should match it. No uncanny or broken artifacts." },
438
+ { key: "motion_logic", name: "Motion logic",
439
+ 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." },
440
+ { key: "camera", name: "Camera",
441
+ desc: "Camera movement (pan, dolly, focal change) feels natural and intentional. Score low if there is little to no camera movement." } ] },
442
+ ];
443
+
444
+ const TOTAL_QUESTIONS = DIMENSIONS.reduce((n, d) => n + d.questions.length, 0);
445
+ const STORAGE_KEY = "user_study_video_ratings_v1"; // shared with desktop
446
+ const ADMIN_NAMES = new Set(["deheng zhang", "zhendong li"]);
447
+
448
+ const LANGUAGES = [
449
+ { code: "en", label: "English" },
450
+ { code: "zh", label: "Chinese" },
451
+ { code: "bg", label: "Bulgarian" },
452
+ ];
453
+
454
+ const STRINGS = {
455
+ en: {
456
+ title:"Video user study",
457
+ prompt_label:"Prompt / instruction",
458
+ no_prompt:"(no prompt provided)",
459
+ case_n_of_m:"Case {0}/{1}",
460
+ play:"▶ Play", pause:"⏸", restart:"⏮", loop:"↻", mute:"🔇", sound:"🔊", speed:"Speed",
461
+ no_video_file:"no video file at this path",
462
+ prev:"← Prev", next:"Next →",
463
+ save_progress:"💾",
464
+ submit:"Submit ({0})",
465
+ submit_resubmit:"✓ Re-submit",
466
+ submit_disabled:"Submit ({0} left)",
467
+ reg_title:"Register to start rating",
468
+ reg_lead:"We record who submitted each set of ratings. No password — just your name and an email that uniquely identifies you.",
469
+ reg_name:"Your name", reg_name_ph:"e.g. Alex Chen",
470
+ reg_email:"Email", reg_email_ph:"e.g. alex@example.com",
471
+ reg_note:"Affiliation / notes (optional)", reg_note_ph:"e.g. INSAIT, internal pilot, etc.",
472
+ reg_continue:"Continue →",
473
+ help_title:"How to rate",
474
+ help_dismiss:"Got it",
475
+ 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 × 9 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>",
476
+ reg_foot:"Submissions are written to the <code>dehezhang2/Frameworker_User_Study</code> dataset on Hugging Face.",
477
+ reg_err_name:"Please enter your name.",
478
+ reg_err_email:"Please enter a valid email address.",
479
+ pill_idle:"●", pill_submitting:"↻", pill_submitted:"✓", pill_local_only:"💾", pill_error:"✕", pill_checking:"●",
480
+ toast_registered:"Registered as {0}",
481
+ toast_pls_signin:"Please sign in before submitting",
482
+ toast_missing:"{0} ratings still missing",
483
+ toast_no_server:"No server reachable — use Export instead",
484
+ toast_submit_failed:"Submit failed: {0}",
485
+ toast_progress_saved:"Progress saved ({0} ratings)",
486
+ toast_progress_save_failed:"Save failed: {0}",
487
+ submitted_title:"Submission recorded",
488
+ submitted_thanks:"Thanks, {0}. We recorded {1} ratings across {2} videos.",
489
+ submitted_close:"Close", submitted_download:"↓ Backup",
490
+ drawer_cases:"Cases",
491
+ scale_1:"1 Very poor", scale_5:"5 Excellent",
492
+ expand_prompt:"Read full prompt",
493
+ case_complete:"✓ complete",
494
+ confirm_reset:"Reset all ratings + registration on this device? This cannot be undone.",
495
+ admin:"⚙ admin", submitted_badge:"✓ submitted",
496
+ },
497
+ zh: {
498
+ title:"视频用户研究",
499
+ prompt_label:"提示词 / 指令",
500
+ no_prompt:"(未提供提示词)",
501
+ case_n_of_m:"案例 {0}/{1}",
502
+ play:"▶ 播放", pause:"⏸", restart:"⏮", loop:"↻", mute:"🔇", sound:"🔊", speed:"速度",
503
+ no_video_file:"该路径下没有视频文件",
504
+ prev:"← 上一个", next:"下一个 →",
505
+ save_progress:"💾",
506
+ submit:"提交 ({0})",
507
+ submit_resubmit:"✓ 重新提交",
508
+ submit_disabled:"提交 (还剩 {0})",
509
+ reg_title:"注册以开始评分",
510
+ reg_lead:"我们会记录每份评分的提交者。无需密码 — 只需姓名和一个唯一识别您的邮箱。",
511
+ reg_name:"您的姓名", reg_name_ph:"例如:陈小明",
512
+ reg_email:"邮箱", reg_email_ph:"例如:xiaoming@example.com",
513
+ reg_note:"所属单位 / 备注(可选)", reg_note_ph:"例如:INSAIT、内部预测试 等",
514
+ reg_continue:"继续 →",
515
+ help_title:"评分说明",
516
+ help_dismiss:"知道了",
517
+ 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 个维度 × 9 个子问题</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>",
518
+ reg_foot:"提交将写入 Hugging Face 上的 <code>dehezhang2/Frameworker_User_Study</code> 数据集。",
519
+ reg_err_name:"请输入您的姓名。",
520
+ reg_err_email:"请输入有效的邮箱地址���",
521
+ pill_idle:"●", pill_submitting:"↻", pill_submitted:"✓", pill_local_only:"💾", pill_error:"✕", pill_checking:"●",
522
+ toast_registered:"已注册为 {0}",
523
+ toast_pls_signin:"请先登录后再提交",
524
+ toast_missing:"尚有 {0} 项未完成",
525
+ toast_no_server:"无法连接服务器 — 请使用导出功能",
526
+ toast_submit_failed:"提交失败: {0}",
527
+ toast_progress_saved:"进度已保存 ({0} 项评分)",
528
+ toast_progress_save_failed:"保存失败: {0}",
529
+ submitted_title:"提交成功",
530
+ submitted_thanks:"{0},感谢您的参与。我们已写入 {2} 个视频 × {1} 项评分。",
531
+ submitted_close:"关闭", submitted_download:"↓ 备份",
532
+ drawer_cases:"案例",
533
+ scale_1:"1 很差", scale_5:"5 极佳",
534
+ expand_prompt:"查看完整提示词",
535
+ case_complete:"✓ 已完成",
536
+ confirm_reset:"重置本设备的所有评分和注册信息吗?该操作不可撤销。",
537
+ admin:"⚙ 管理员", submitted_badge:"✓ 已提交",
538
+ },
539
+ bg: {
540
+ title:"Видео проучване",
541
+ prompt_label:"Подкана / инструкция",
542
+ no_prompt:"(няма подкана)",
543
+ case_n_of_m:"Случай {0}/{1}",
544
+ play:"▶ Пусни", pause:"⏸", restart:"⏮", loop:"↻", mute:"🔇", sound:"🔊", speed:"Скорост",
545
+ no_video_file:"няма видео файл на този път",
546
+ prev:"← Назад", next:"Напред →",
547
+ save_progress:"💾",
548
+ submit:"Изпрати ({0})",
549
+ submit_resubmit:"✓ Повторно",
550
+ submit_disabled:"Изпрати (остават {0})",
551
+ reg_title:"Регистрирайте се, за да започнете",
552
+ reg_lead:"Записваме кой е изпратил всеки набор от оценки. Без парола — само името и имейл, които ви идентифицират уникално.",
553
+ reg_name:"Вашето име", reg_name_ph:"напр. Алекс Иванов",
554
+ reg_email:"Имейл", reg_email_ph:"напр. alex@example.com",
555
+ reg_note:"Принадлежност / бележки (по избор)", reg_note_ph:"напр. INSAIT, вътрешен пилот",
556
+ reg_continue:"Продължи →",
557
+ help_title:"Как се оценява",
558
+ help_dismiss:"Разбрах",
559
+ 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 измерения × 9 под-въпроса</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>",
560
+ reg_foot:"Изпращанията се записват в <code>dehezhang2/Frameworker_User_Study</code> на Hugging Face.",
561
+ reg_err_name:"Моля, въведете името си.",
562
+ reg_err_email:"Моля, въведете валиден имейл адрес.",
563
+ pill_idle:"●", pill_submitting:"↻", pill_submitted:"✓", pill_local_only:"💾", pill_error:"✕", pill_checking:"●",
564
+ toast_registered:"Регистриран(а) като {0}",
565
+ toast_pls_signin:"Моля, влезте преди изпращане",
566
+ toast_missing:"Все още липсват {0} оценки",
567
+ toast_no_server:"Сървърът не е достъпен — използвайте Изтегли",
568
+ toast_submit_failed:"Изпращането се провали: {0}",
569
+ toast_progress_saved:"Прогресът е запазен ({0} оценки)",
570
+ toast_progress_save_failed:"Запазването се провали: {0}",
571
+ submitted_title:"Записано",
572
+ submitted_thanks:"Благодарим, {0}. Записахме {1} оценки за {2} видеа.",
573
+ submitted_close:"Затвори", submitted_download:"↓ Копие",
574
+ drawer_cases:"Случаи",
575
+ scale_1:"1 Много слабо", scale_5:"5 Отлично",
576
+ expand_prompt:"Покажи цялата подкана",
577
+ case_complete:"✓ завършен",
578
+ confirm_reset:"Изчистване на всички оценки и регистрация на това устройство? Действието е необратимо.",
579
+ admin:"⚙ администратор", submitted_badge:"✓ изпратено",
580
+ },
581
+ };
582
+
583
+ const RUBRIC_I18N = {
584
+ zh: {
585
+ instruction_following:{name:"指令遵循",hint:"请先看清提示词,尤其是标红的部分,再作答。"},
586
+ temporal_consistency:{name:"时间一致性",hint:"请关注人物、物品和环境在帧间的变化。"},
587
+ motion_realism:{name:"真实感",hint:"请留意视频中不和谐或有明显问题的地方。"},
588
+ controllability:{name:"可控性",desc:"视频是否响应控制信号(BGM、字幕等)?"},
589
+ prompt_adherence:{name:"提示一致性",desc:"内容是否与提示的描述一致(主体、动作、场景、时间、故事走向)?以提示词的文本内容为主要依据。"},
590
+ no_irrelevant_frames:{name:"无无关画面",desc:"视频是否仅呈现提示中要求的内容?分数越高 = 越少出现与提示无关的帧或内容。"},
591
+ realism:{name:"静态真实感",desc:"单帧的照片级真实感或风格忠实度。若提示词指定了风格,视频应符合该风格。无诡异感或失真伪影。"},
592
+ person_stability:{name:"人物稳定性",desc:"面部、身份及身体比例在帧间保持一致。"},
593
+ object_stability:{name:"物体稳定性",desc:"道具、服饰及物体随时间保持形状和位置。"},
594
+ environment_stability:{name:"环境稳定性",desc:"背景、光照及场景布局在帧间保持连贯(包括无亮度、色彩或纹理闪烁)。"},
595
+ motion_logic:{name:"运动逻辑",desc:"身体与物体的运动是否流畅且符合物理与常识规律(重力、碰撞、流体/布料动力学)?无形变、瞬移、不合常理或不可能的动作。若几乎没有运动,请打低分。"},
596
+ camera:{name:"相机运动",desc:"相机运动(平移、推拉、焦距变化)自然且有意图。若几乎没有相机运动,请打低分。"},
597
+ },
598
+ bg: {
599
+ instruction_following:{name:"Следване на инструкции",hint:"Прочетете внимателно подканата — особено маркираните в червено фрази — преди да оцените."},
600
+ temporal_consistency:{name:"Времева съгласуваност",hint:"Съсредоточете се върху промените на хора, предмети и среда между кадрите."},
601
+ motion_realism:{name:"Реализъм",hint:"Забележете очевидни проблеми или неестествени моменти във видеото."},
602
+ controllability:{name:"Контролируемост",desc:"Реагира ли видеото на контролни сигнали (BGM, субтитри и т.н.)?"},
603
+ prompt_adherence:{name:"Съответствие с подканата",desc:"Съответства ли съдържанието на описаното в подканата (обекти, действия, обстановка, време и развитие на историята)? Фокусирайте се основно върху текстовото съдържание на подканата."},
604
+ no_irrelevant_frames:{name:"Без неуместни кадри",desc:"Остава ли видеото в рамките на подканата? По-висок = по-малко / без кадри или съдържание, които не са поискани."},
605
+ realism:{name:"Статичен реализъм",desc:"Фотореалистичност или стилова достоверност на отделен кадър. Ако подканата задава стил, видеото трябва да следва този стил. Без странни или счупени артефакти."},
606
+ person_stability:{name:"Стабилност на хората",desc:"Лица, идентичности и пропорции на тялото остават постоянни между кадрите."},
607
+ object_stability:{name:"Стабилност на обектите",desc:"Реквизити, дрехи и обекти запазват форма и позиция във времето."},
608
+ environment_stability:{name:"Стабилност на средата",desc:"Фон, осветление и подредба на сцената остават последователни между кадрите (включително липса на трептене на яркост, цвят или текстура)."},
609
+ motion_logic:{name:"Логика на движението",desc:"Текат ли движенията на тела и обекти плавно и в съответствие с физически / логически закони (гравитация, сблъсъци, динамика на флуиди / тъкани)? Без деформации, телепортация, нелогични или невъзможни движения. Дайте нисък резултат, ако има малко или никакво движение."},
610
+ camera:{name:"Камера",desc:"Движението на камерата (панорама, дъли, промяна на фокус) изглежда естествено и целенасочено. Дайте нисък резултат, ако има малко или никакво движение на камерата."},
611
+ },
612
+ };
613
+
614
+ /* ----- State (shared with desktop via localStorage) ----- */
615
+ let CASES = []; // cases visible to this rater (subset of ALL)
616
+ let ALL_CASES = []; // full manifest from cases.json
617
+ const CASES_PER_RATER = 10;
618
+ let SHUFFLE = {};
619
+ let RATINGS = {};
620
+ let RATER = { id:"", name:"", email:"", note:"",
621
+ started_at:null, submitted_at:null };
622
+ let LANG = "en";
623
+ let anonymize = true;
624
+ let currentIdx = 0;
625
+ let SYNC_STATUS = "idle";
626
+ let SYNC_LAST_DETAIL = "";
627
+ const API_SUBMIT = "./api/submit";
628
+ const API_HEALTH = "./api/health";
629
+ const API_SAVE_PROGRESS = "./api/save_progress";
630
+ const PROGRESS_DEBOUNCE_MS = 60000;
631
+ let PROGRESS_TIMER = null;
632
+
633
+ /* ----- Helpers ----- */
634
+ function t(key, ...args){
635
+ let s = (STRINGS[LANG]&&STRINGS[LANG][key])??STRINGS.en[key]??key;
636
+ if(args.length) s = s.replace(/\{(\d+)\}/g,(_,i)=>args[Number(i)]??"");
637
+ return s;
638
+ }
639
+ function dimName(d){ return LANG==="en"?d.name:(RUBRIC_I18N[LANG]?.[d.key]?.name??d.name); }
640
+ function dimHint(d){ return LANG==="en"?d.hint:(RUBRIC_I18N[LANG]?.[d.key]?.hint??d.hint); }
641
+ function qName(q){ return LANG==="en"?q.name:(RUBRIC_I18N[LANG]?.[q.key]?.name??q.name); }
642
+ function qDesc(q){ return LANG==="en"?q.desc:(RUBRIC_I18N[LANG]?.[q.key]?.desc??q.desc); }
643
+ function isAdmin(){ return ADMIN_NAMES.has((RATER.name||"").trim().toLowerCase()); }
644
+ const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
645
+ 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; }
646
+ function escapeHtml(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]);}
647
+
648
+ // Controllability highlighter — wraps imperative verbs + directive phrases
649
+ // in <span class="ctrl"> so raters see what the prompt actually controls.
650
+ const CTRL_PATTERNS = {
651
+ en: [
652
+ /\bAdd\b[^.]*\./g,
653
+ /\bUse this[^.]*\./g,
654
+ /Make a[^—–]*?animated short drama/g,
655
+ ],
656
+ zh: [
657
+ /制作一部[^—–]*?短剧/g,
658
+ /请?添加[^。]*。/g,
659
+ /请使用上传的[^。]*。/g,
660
+ /配以[^。]*。/g,
661
+ /环境音[::][^。]*。/g,
662
+ ],
663
+ bg: [
664
+ /Създайте[^—–]*?анимационна кратка драма/g,
665
+ /Добавете[^.]*\./g,
666
+ /Използвайте качената[^.]*\./g,
667
+ ],
668
+ };
669
+ // Admin-curated highlight snippets, fetched at init from /api/annotations.
670
+ // Same shape as desktop: { case_id: { lang: [snippet, ...] } }
671
+ let ADMIN_HIGHLIGHTS = {};
672
+ const API_ANNOTATIONS = "./api/annotations";
673
+
674
+ function highlightControllability(text, lang, caseId) {
675
+ let html = escapeHtml(text);
676
+ const patterns = CTRL_PATTERNS[lang] || CTRL_PATTERNS.en;
677
+ const ranges = [];
678
+ // Auto-detected controllability cues
679
+ for (const re of patterns) {
680
+ re.lastIndex = 0; let m;
681
+ while ((m = re.exec(html)) !== null) {
682
+ ranges.push([m.index, m.index + m[0].length, "auto"]);
683
+ if (m[0].length === 0) re.lastIndex++;
684
+ }
685
+ }
686
+ // Admin-curated snippets for this case + language
687
+ const adminSnippets = (caseId && ADMIN_HIGHLIGHTS[caseId]?.[lang]) || [];
688
+ for (const snippet of adminSnippets) {
689
+ if (!snippet) continue;
690
+ const esc = escapeHtml(snippet);
691
+ let idx = 0;
692
+ while ((idx = html.indexOf(esc, idx)) !== -1) {
693
+ ranges.push([idx, idx + esc.length, "admin"]);
694
+ idx += esc.length || 1;
695
+ }
696
+ }
697
+ if (!ranges.length) return html;
698
+ ranges.sort((a,b)=>a[0]-b[0]||a[1]-b[1]);
699
+ const merged = [ranges[0].slice()];
700
+ for (let i=1;i<ranges.length;i++){
701
+ const top = merged[merged.length-1];
702
+ if (ranges[i][0]<top[1]) {
703
+ top[1] = Math.max(top[1], ranges[i][1]);
704
+ if (ranges[i][2] === "admin") top[2] = "admin";
705
+ } else merged.push(ranges[i].slice());
706
+ }
707
+ let out = html;
708
+ for (let i=merged.length-1;i>=0;i--){
709
+ const [s,e,kind] = merged[i];
710
+ const cls = kind === "admin" ? "ctrl ctrl-admin" : "ctrl";
711
+ out = out.slice(0,s) + `<span class="${cls}">` + out.slice(s,e) + '</span>' + out.slice(e);
712
+ }
713
+ return out;
714
+ }
715
+
716
+ async function loadAnnotations() {
717
+ try {
718
+ const r = await fetch(API_ANNOTATIONS, { cache: "no-cache" });
719
+ if (!r.ok) return;
720
+ const j = await r.json().catch(() => ({}));
721
+ ADMIN_HIGHLIGHTS = j.annotations || {};
722
+ // Re-render the current case so the freshly-loaded admin highlights
723
+ // appear in the prompt.
724
+ if (CASES.length && RATER.name) renderCase();
725
+ } catch (e) { /* offline: auto-detected highlights only */ }
726
+ }
727
+ function showToast(msg, isErr=false){
728
+ const tt = document.getElementById("toast");
729
+ tt.textContent = msg;
730
+ tt.classList.toggle("error", !!isErr);
731
+ tt.classList.add("show");
732
+ setTimeout(()=>tt.classList.remove("show"), 1800);
733
+ }
734
+ function genRaterId(){ return "rater_"+Math.random().toString(36).slice(2,8)+"_"+Date.now().toString(36).slice(-4); }
735
+
736
+ /* ----- LocalStorage (shared key) ----- */
737
+ function loadState(){ try{ return JSON.parse(localStorage.getItem(STORAGE_KEY)||"null"); }catch(e){return null;} }
738
+ function saveState(){
739
+ try{ localStorage.setItem(STORAGE_KEY, JSON.stringify({
740
+ rater: RATER, anonymize, lang: LANG, shuffle: SHUFFLE, ratings: RATINGS,
741
+ saved_at: new Date().toISOString(),
742
+ })); }catch(e){ showToast("Save failed: "+e.message, true); }
743
+ }
744
+
745
+ /* ----- Shuffle ----- */
746
+ // Per-rater 10-case subset of the manifest. Admins see all. The chosen ids
747
+ // are locked into RATER.chosen_case_ids on first pick so the rater always
748
+ // sees the same set across sessions / devices regardless of future
749
+ // manifest changes.
750
+ function pickRandomSubsetIds(allCases){
751
+ const idx = allCases.map((_,i)=>i);
752
+ let s = hashStr((RATER.id||"anon")+":case-subset");
753
+ for (let k=0;k<6;k++) s = (s*1664525+1013904223)>>>0;
754
+ for (let i=idx.length-1;i>0;i--){
755
+ s = (s*1664525+1013904223)>>>0;
756
+ const j = (s>>>16)%(i+1);
757
+ [idx[i],idx[j]] = [idx[j],idx[i]];
758
+ }
759
+ return idx.slice(0,CASES_PER_RATER).sort((a,b)=>a-b).map(i => allCases[i].id);
760
+ }
761
+ function selectCasesForRater(allCases){
762
+ if (isAdmin()) return allCases.slice();
763
+ if (allCases.length <= CASES_PER_RATER) return allCases.slice();
764
+ const locked = Array.isArray(RATER.chosen_case_ids) ? RATER.chosen_case_ids : null;
765
+ if (locked && locked.length){
766
+ const byId = new Map(allCases.map(c => [c.id, c]));
767
+ const out = locked.map(id => byId.get(id)).filter(Boolean);
768
+ if (out.length >= Math.min(CASES_PER_RATER, allCases.length)) return out;
769
+ }
770
+ const ids = pickRandomSubsetIds(allCases);
771
+ RATER.chosen_case_ids = ids;
772
+ const byId = new Map(allCases.map(c => [c.id, c]));
773
+ return ids.map(id => byId.get(id)).filter(Boolean);
774
+ }
775
+ async function recoverRaterStateFromServer(email){
776
+ if (!email) return;
777
+ try {
778
+ const r = await fetch(`./api/rater_state?email=${encodeURIComponent(email)}`,
779
+ { cache: "no-cache" });
780
+ if (!r.ok) return;
781
+ const j = await r.json().catch(()=>({}));
782
+ if (!j.found || !j.state) return;
783
+ const remoteRater = j.state.rater || {};
784
+ if (Array.isArray(remoteRater.chosen_case_ids) && remoteRater.chosen_case_ids.length) {
785
+ RATER.chosen_case_ids = remoteRater.chosen_case_ids;
786
+ }
787
+ for (const c of (j.state.cases || [])) {
788
+ if (!c || !c.id) continue;
789
+ RATINGS[c.id] = RATINGS[c.id] || {};
790
+ for (const v of (c.videos || [])) {
791
+ const m = v.model;
792
+ if (!m) continue;
793
+ RATINGS[c.id][m] = RATINGS[c.id][m] || {};
794
+ for (const [qk, score] of Object.entries(v.ratings || {})) {
795
+ if (typeof score === "number" && score>=1 && score<=5 &&
796
+ RATINGS[c.id][m][qk] === undefined) {
797
+ RATINGS[c.id][m][qk] = score;
798
+ }
799
+ }
800
+ }
801
+ }
802
+ } catch (e) { /* silent */ }
803
+ }
804
+
805
+ function buildShuffle(cases){
806
+ for (const c of cases){
807
+ if (SHUFFLE[c.id] && SHUFFLE[c.id].length===c.videos.length) continue;
808
+ const idx = c.videos.map((_,i)=>i);
809
+ if (anonymize){
810
+ let s = hashStr(RATER.id+":"+c.id);
811
+ for (let i=idx.length-1;i>0;i--){
812
+ s = (s*1664525+1013904223)>>>0;
813
+ const j = s%(i+1);
814
+ [idx[i],idx[j]] = [idx[j],idx[i]];
815
+ }
816
+ }
817
+ SHUFFLE[c.id] = idx;
818
+ }
819
+ }
820
+ // Within a case, every sub-question shows its rating rows in A → B → C
821
+ // order. (Per-rater case-level shuffle still varies which video sits under
822
+ // each letter across cases, but the order inside a case is now stable so
823
+ // the rater always sees a consistent layout.)
824
+ function rowSlotOrder(caseId, qKey, slotCount){
825
+ return Array.from({length:slotCount},(_,i)=>i);
826
+ }
827
+
828
+ /* ----- Ratings ----- */
829
+ function getCaseR(cid){ if(!RATINGS[cid]) RATINGS[cid]={}; return RATINGS[cid]; }
830
+ function getSlotR(cid, s){ const m = getCaseR(cid); if(!m[s]) m[s]={}; return m[s]; }
831
+ function setRating(cid, sIdx, qk, score){
832
+ getSlotR(cid, sIdx)[qk] = score;
833
+ saveState();
834
+ updateProgress();
835
+ if (RATER.submitted_at){
836
+ RATER.submitted_at = null; saveState();
837
+ setSyncStatus(SYNC_STATUS==="local-only"?"local-only":"idle");
838
+ }
839
+ updateSubmitBtn();
840
+ scheduleProgressSave();
841
+ }
842
+ function studyCompleteness(){
843
+ const qkeys = DIMENSIONS.flatMap(d=>d.questions.map(q=>q.key));
844
+ let done=0, expected=0; const missing=[];
845
+ for (const c of CASES){
846
+ const slots = SHUFFLE[c.id] || c.videos.map((_,i)=>i);
847
+ for (let s=0; s<slots.length; s++){
848
+ const r = (RATINGS[c.id]||{})[s] || {};
849
+ for (const qk of qkeys){
850
+ expected++;
851
+ if (typeof r[qk]==="number" && r[qk]>=1 && r[qk]<=5) done++;
852
+ else missing.push({case_id:c.id, slot:String.fromCharCode(65+s), q:qk});
853
+ }
854
+ }
855
+ }
856
+ return { done, expected, missing };
857
+ }
858
+ function caseProgress(c){
859
+ const slots = SHUFFLE[c.id] || [];
860
+ const expected = slots.length * TOTAL_QUESTIONS;
861
+ let done=0;
862
+ for (let s=0;s<slots.length;s++){
863
+ const r = (RATINGS[c.id]||{})[s] || {};
864
+ done += Object.keys(r).filter(k=>typeof r[k]==="number"&&r[k]>=1&&r[k]<=5).length;
865
+ }
866
+ return { done, expected };
867
+ }
868
+
869
+ /* ----- Sync pill + server status ----- */
870
+ function setSyncStatus(state, detail){
871
+ SYNC_STATUS = state;
872
+ if (detail!==undefined) SYNC_LAST_DETAIL = detail;
873
+ const pill = document.getElementById("syncPill");
874
+ if (!pill) return;
875
+ pill.dataset.state = state;
876
+ pill.textContent = t({
877
+ idle:"pill_idle", submitting:"pill_submitting", submitted:"pill_submitted",
878
+ "local-only":"pill_local_only", error:"pill_error",
879
+ }[state] || "pill_checking");
880
+ pill.title = SYNC_LAST_DETAIL || "";
881
+ }
882
+ async function probeSync(){
883
+ try{
884
+ const r = await fetch(API_HEALTH, {cache:"no-cache"});
885
+ if (r.ok){
886
+ const j = await r.json().catch(()=>({}));
887
+ setSyncStatus(j.ok && j.has_token ? "idle" : "local-only");
888
+ } else setSyncStatus("local-only");
889
+ }catch(e){ setSyncStatus("local-only"); }
890
+ }
891
+
892
+ /* ----- Payloads + API calls ----- */
893
+ function buildProgressPayload(){
894
+ const comp = studyCompleteness();
895
+ return {
896
+ rater: RATER, anonymize_used: anonymize, is_admin: isAdmin(),
897
+ rubric: DIMENSIONS, progress_saved_at: new Date().toISOString(),
898
+ completeness: { done: comp.done, expected: comp.expected, missing_count: comp.missing.length },
899
+ cases: CASES.map(c => {
900
+ const slots = SHUFFLE[c.id] || c.videos.map((_,i)=>i);
901
+ return {
902
+ id: c.id, category: c.category||null, prompt: c.prompt,
903
+ videos: slots.map((origIdx, slotIdx) => ({
904
+ slot: String.fromCharCode(65+slotIdx), slot_index: slotIdx,
905
+ original_index: origIdx, model: c.videos[origIdx].model,
906
+ path: c.videos[origIdx].path,
907
+ ratings: (RATINGS[c.id]||{})[slotIdx] || {},
908
+ })),
909
+ };
910
+ }),
911
+ };
912
+ }
913
+ function buildFullPayload(){
914
+ const p = buildProgressPayload();
915
+ p.submitted_at = new Date().toISOString();
916
+ delete p.progress_saved_at; delete p.completeness;
917
+ return p;
918
+ }
919
+ async function saveProgress(opts={}){
920
+ if (!RATER.email || !RATER.name) return;
921
+ if (SYNC_STATUS==="local-only" && opts.source!=="manual") return;
922
+ try{
923
+ const r = await fetch(API_SAVE_PROGRESS, {
924
+ method:"POST", headers:{"Content-Type":"application/json"},
925
+ body: JSON.stringify(buildProgressPayload()),
926
+ });
927
+ if (r.ok){
928
+ const j = await r.json().catch(()=>({}));
929
+ if (!opts.silent) showToast(t("toast_progress_saved", j.n_scores ?? 0));
930
+ } else if (r.status===404||r.status===503){
931
+ setSyncStatus("local-only");
932
+ } else if (!opts.silent){
933
+ showToast(t("toast_progress_save_failed", "HTTP "+r.status), true);
934
+ }
935
+ }catch(e){
936
+ if (!opts.silent) showToast(t("toast_progress_save_failed", e.message), true);
937
+ }
938
+ }
939
+ function scheduleProgressSave(){
940
+ if (!RATER.email) return;
941
+ if (SYNC_STATUS==="local-only") return;
942
+ if (PROGRESS_TIMER) clearTimeout(PROGRESS_TIMER);
943
+ PROGRESS_TIMER = setTimeout(()=>{ PROGRESS_TIMER=null; saveProgress({silent:true,source:"auto"}); }, PROGRESS_DEBOUNCE_MS);
944
+ }
945
+ function beaconProgress(){
946
+ if (!RATER.email || SYNC_STATUS==="local-only") return;
947
+ try{ navigator.sendBeacon(API_SAVE_PROGRESS,
948
+ new Blob([JSON.stringify(buildProgressPayload())], {type:"application/json"})); }catch(e){}
949
+ }
950
+ async function submitWholeStudy(){
951
+ if (!RATER.email||!RATER.name){ showToast(t("toast_pls_signin"), true); openRegister(); return; }
952
+ const comp = studyCompleteness();
953
+ if (comp.missing.length>0){
954
+ showToast(t("toast_missing", comp.missing.length), true);
955
+ const firstId = comp.missing[0].case_id;
956
+ const idx = CASES.findIndex(c=>c.id===firstId);
957
+ if (idx>=0) goTo(idx);
958
+ return;
959
+ }
960
+ if (SYNC_STATUS==="local-only"){ showToast(t("toast_no_server"), true); return; }
961
+ setSyncStatus("submitting");
962
+ try{
963
+ const r = await fetch(API_SUBMIT, {
964
+ method:"POST", headers:{"Content-Type":"application/json"},
965
+ body: JSON.stringify(buildFullPayload()),
966
+ });
967
+ if (r.ok){
968
+ const body = await r.json().catch(()=>({}));
969
+ RATER.submitted_at = new Date().toISOString();
970
+ saveState();
971
+ setSyncStatus("submitted");
972
+ updateSubmitBtn();
973
+ showSubmittedOverlay(body);
974
+ } else if (r.status===404||r.status===503){
975
+ setSyncStatus("local-only"); showToast(t("toast_no_server"), true);
976
+ } else {
977
+ setSyncStatus("error");
978
+ showToast(t("toast_submit_failed", "HTTP "+r.status), true);
979
+ }
980
+ }catch(e){
981
+ setSyncStatus("local-only"); showToast(t("toast_submit_failed", e.message), true);
982
+ }
983
+ }
984
+ function exportJSON(){
985
+ const out = {
986
+ rater: RATER, anonymize_used: anonymize, exported_at: new Date().toISOString(),
987
+ rubric: DIMENSIONS,
988
+ cases: CASES.map(c => {
989
+ const slots = SHUFFLE[c.id] || c.videos.map((_,i)=>i);
990
+ return { id:c.id, prompt:c.prompt,
991
+ videos: slots.map((oi,si)=>({
992
+ slot:String.fromCharCode(65+si), slot_index:si, original_index:oi,
993
+ model:c.videos[oi].model, path:c.videos[oi].path,
994
+ ratings:(RATINGS[c.id]||{})[si]||{},
995
+ })),
996
+ };
997
+ }),
998
+ };
999
+ const blob = new Blob([JSON.stringify(out,null,2)], {type:"application/json"});
1000
+ const url = URL.createObjectURL(blob);
1001
+ const a = document.createElement("a");
1002
+ a.href = url; a.download = `user_study_${RATER.id||"anon"}_${new Date().toISOString().slice(0,10)}.json`;
1003
+ document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
1004
+ }
1005
+
1006
+ /* =========================================================================
1007
+ * Mobile rendering
1008
+ * ========================================================================= */
1009
+ function renderShell(){
1010
+ // language selectors
1011
+ for (const id of ["langSelect","regLangSelect"]){
1012
+ const sel = document.getElementById(id);
1013
+ sel.innerHTML = "";
1014
+ for (const L of LANGUAGES){
1015
+ const o = document.createElement("option");
1016
+ o.value = L.code; o.textContent = L.label;
1017
+ if (L.code===LANG) o.selected = true;
1018
+ sel.appendChild(o);
1019
+ }
1020
+ }
1021
+ document.getElementById("topTitle").textContent = t("title");
1022
+ // bottom-bar labels (kept short on mobile)
1023
+ document.getElementById("prevBtn").textContent = t("prev");
1024
+ document.getElementById("nextBtn").textContent = t("next");
1025
+ document.getElementById("saveBtn").textContent = t("save_progress");
1026
+ document.getElementById("submittedCloseBtn").textContent = t("submitted_close");
1027
+ document.getElementById("submittedDownloadBtn").textContent = t("submitted_download");
1028
+ applyRegistrationI18n();
1029
+ updateSubmitBtn();
1030
+ renderCase();
1031
+ renderDrawer();
1032
+ }
1033
+
1034
+ function renderCase(){
1035
+ const main = document.getElementById("main");
1036
+ main.innerHTML = "";
1037
+ if (!CASES.length) return;
1038
+ const c = CASES[currentIdx];
1039
+ document.getElementById("caseId").textContent = c.id;
1040
+ document.getElementById("caseCounter").textContent = t("case_n_of_m", currentIdx+1, CASES.length);
1041
+
1042
+ // prompt card
1043
+ main.appendChild(renderPromptCard(c));
1044
+ // video stack
1045
+ main.appendChild(renderVideoStack(c));
1046
+ // controls
1047
+ main.appendChild(renderVideoControls());
1048
+ // rubric — laid out as a responsive grid (1 col on phones, 2-3 on tablets).
1049
+ const grid = document.createElement("div");
1050
+ grid.className = "question-grid";
1051
+ for (const dim of DIMENSIONS){
1052
+ grid.appendChild(renderDimHeader(dim));
1053
+ for (const q of dim.questions) grid.appendChild(renderQuestionCard(c, q));
1054
+ }
1055
+ main.appendChild(grid);
1056
+ updateProgress();
1057
+ refreshDrawerActive();
1058
+ }
1059
+
1060
+ function renderPromptCard(c){
1061
+ const wrap = document.createElement("div");
1062
+ wrap.className = "prompt-card";
1063
+ const localized = (c.prompt_i18n && c.prompt_i18n[LANG]) || c.prompt || t("no_prompt");
1064
+ const isLong = (localized||"").length > 160;
1065
+ const highlightedFull = highlightControllability(localized, LANG, c.id);
1066
+ // For the collapsed teaser we still highlight, but on the truncated text.
1067
+ const teaser = highlightControllability(localized.slice(0,150)+"…", LANG, c.id);
1068
+ wrap.innerHTML = isLong
1069
+ ? `<div class="label">${escapeHtml(t("prompt_label"))}</div>
1070
+ <details><summary>${teaser} <span style="color:var(--accent)">(${escapeHtml(t("expand_prompt"))})</span></summary>
1071
+ <div style="margin-top:6px">${highlightedFull}</div></details>`
1072
+ : `<div class="label">${escapeHtml(t("prompt_label"))}</div>
1073
+ <div>${highlightedFull}</div>`;
1074
+ if (c.reference_image){
1075
+ const img = document.createElement("img");
1076
+ img.src = c.reference_image; img.className = "ref-img";
1077
+ img.onerror = function(){ this.style.display="none"; };
1078
+ wrap.appendChild(img);
1079
+ }
1080
+ return wrap;
1081
+ }
1082
+
1083
+ // Mobile keeps the same A/B/C tab UX as desktop, but stacked top-to-bottom.
1084
+ // Only the active video is visible; others remain in the DOM so they keep
1085
+ // their own playback state.
1086
+ function renderVideoStack(c){
1087
+ const slots = SHUFFLE[c.id];
1088
+ const active = getActiveSlot(c.id);
1089
+ const wrap = document.createElement("div");
1090
+ wrap.className = "video-stack";
1091
+ wrap.id = "videoStack";
1092
+
1093
+ // Tab bar
1094
+ const tabs = document.createElement("div");
1095
+ tabs.className = "video-tabs";
1096
+ slots.forEach((origIdx, slotIdx) => {
1097
+ const v = c.videos[origIdx];
1098
+ const letter = String.fromCharCode(65+slotIdx);
1099
+ const label = anonymize ? letter : `${letter} · ${escapeHtml(v.model)}`;
1100
+ const btn = document.createElement("button");
1101
+ btn.className = "tab-btn" + (slotIdx === active ? " active" : "");
1102
+ btn.innerHTML = label;
1103
+ btn.onclick = () => setActiveSlot(c.id, slotIdx);
1104
+ tabs.appendChild(btn);
1105
+ });
1106
+ wrap.appendChild(tabs);
1107
+
1108
+ // Video tiles
1109
+ slots.forEach((origIdx, slotIdx) => {
1110
+ const v = c.videos[origIdx];
1111
+ const tile = document.createElement("div");
1112
+ tile.className = "video-tile" + (slotIdx === active ? "" : " hidden");
1113
+ tile.innerHTML = `
1114
+ <div class="video-frame">
1115
+ <video data-slot="${slotIdx}" preload="metadata" playsinline controls
1116
+ style="width:100%;height:100%">
1117
+ <source src="${escapeHtml(v.path)}" type="video/mp4">
1118
+ </video>
1119
+ <div class="empty-state" style="display:none">
1120
+ <div style="font-size:24px;opacity:0.3">▶</div>
1121
+ <div>${escapeHtml(t("no_video_file"))}</div>
1122
+ </div>
1123
+ </div>`;
1124
+ const video = tile.querySelector("video");
1125
+ const empty = tile.querySelector(".empty-state");
1126
+ const onErr = () => { empty.style.display="flex"; video.style.display="none"; };
1127
+ video.addEventListener("error", onErr);
1128
+ video.querySelector("source").addEventListener("error", onErr);
1129
+ wrap.appendChild(tile);
1130
+ });
1131
+ return wrap;
1132
+ }
1133
+
1134
+ // Active slot bookkeeping (per case) for the tab UI. Stays in memory only —
1135
+ // not persisted across reloads, which is fine since the tabs are just a view
1136
+ // helper and ratings persist per-slot in the global RATINGS map.
1137
+ let ACTIVE_SLOT = {};
1138
+ function getActiveSlot(caseId){ return ACTIVE_SLOT[caseId] ?? 0; }
1139
+ function setActiveSlot(caseId, slotIdx){
1140
+ pauseAll();
1141
+ ACTIVE_SLOT[caseId] = slotIdx;
1142
+ // Re-render only the video stack and tab bar (cheap), leaving question
1143
+ // cards / scroll position intact.
1144
+ const old = document.getElementById("videoStack");
1145
+ if (!old) return;
1146
+ const fresh = renderVideoStack(CASES[currentIdx]);
1147
+ old.parentElement.replaceChild(fresh, old);
1148
+ }
1149
+
1150
+ function renderVideoControls(){
1151
+ const wrap = document.createElement("div");
1152
+ wrap.className = "video-controls";
1153
+ const mkBtn = (label, fn, id) => {
1154
+ const b = document.createElement("button");
1155
+ b.textContent = label; b.onclick = fn; if (id) b.id = id;
1156
+ return b;
1157
+ };
1158
+ // Play button removed — the native <video controls> on the active tab
1159
+ // provides play/pause already.
1160
+ wrap.appendChild(mkBtn(t("pause"), pauseAll));
1161
+ wrap.appendChild(mkBtn(t("restart"), restartAll));
1162
+ const loop = mkBtn(t("loop"), () => toggleLoop(loop));
1163
+ wrap.appendChild(loop);
1164
+ const mute = mkBtn(t("mute"), () => toggleMute(mute));
1165
+ wrap.appendChild(mute);
1166
+ const sel = document.createElement("select");
1167
+ sel.onchange = (e)=>setSpeed(parseFloat(e.target.value));
1168
+ for (const v of ["0.5","0.75","1","1.5","2"]){
1169
+ const o = document.createElement("option"); o.value=v; o.textContent=v+"×";
1170
+ if (v==="1") o.selected = true; sel.appendChild(o);
1171
+ }
1172
+ wrap.appendChild(sel);
1173
+ return wrap;
1174
+ }
1175
+
1176
+ function renderDimHeader(dim){
1177
+ const d = document.createElement("div");
1178
+ d.className = "dim-header";
1179
+ d.innerHTML = `<span>${escapeHtml(dimName(dim))}</span><span class="dim-hint">${escapeHtml(dimHint(dim))}</span>`;
1180
+ return d;
1181
+ }
1182
+
1183
+ function renderQuestionCard(c, q){
1184
+ const card = document.createElement("div");
1185
+ card.className = "q-card";
1186
+ card.innerHTML = `<div class="q-name">${escapeHtml(qName(q))}</div>
1187
+ <div class="q-desc">${escapeHtml(qDesc(q))}</div>`;
1188
+ const slots = SHUFFLE[c.id];
1189
+ const order = rowSlotOrder(c.id, q.key, slots.length);
1190
+ for (const slotIdx of order){
1191
+ const origIdx = slots[slotIdx];
1192
+ const v = c.videos[origIdx];
1193
+ const letter = String.fromCharCode(65+slotIdx);
1194
+ const row = document.createElement("div");
1195
+ row.className = "video-rate";
1196
+ const chip = document.createElement("span");
1197
+ chip.className = "slot-letter";
1198
+ chip.textContent = anonymize ? letter : `${letter}`;
1199
+ chip.title = anonymize ? `Video ${letter}` : `Video ${letter} · ${v.model}`;
1200
+ row.appendChild(chip);
1201
+ const group = document.createElement("div");
1202
+ group.className = "likert";
1203
+ const cur = getSlotR(c.id, slotIdx)[q.key];
1204
+ for (let s=1; s<=5; s++){
1205
+ const b = document.createElement("button");
1206
+ b.dataset.score = String(s);
1207
+ b.textContent = String(s);
1208
+ if (cur===s) b.classList.add("selected");
1209
+ b.onclick = () => {
1210
+ setRating(c.id, slotIdx, q.key, s);
1211
+ Array.from(group.children).forEach((bb,i)=>bb.classList.toggle("selected", (i+1)===s));
1212
+ };
1213
+ group.appendChild(b);
1214
+ }
1215
+ row.appendChild(group);
1216
+ card.appendChild(row);
1217
+ }
1218
+ return card;
1219
+ }
1220
+
1221
+ /* ----- Drawer ----- */
1222
+ function renderDrawer(){
1223
+ const list = document.getElementById("drawerList");
1224
+ list.innerHTML = "";
1225
+ CASES.forEach((c, i) => {
1226
+ const p = caseProgress(c);
1227
+ let cls = "case-row";
1228
+ if (i===currentIdx) cls += " active";
1229
+ if (p.done === p.expected && p.expected>0) cls += " complete";
1230
+ else if (p.done > 0) cls += " partial";
1231
+ const row = document.createElement("div");
1232
+ row.className = cls;
1233
+ row.innerHTML = `<span class="dot"></span>
1234
+ <span class="id">${c.id}</span>
1235
+ <span class="pct">${p.done}/${p.expected}</span>`;
1236
+ row.onclick = () => { closeDrawer(); goTo(i); };
1237
+ list.appendChild(row);
1238
+ });
1239
+ }
1240
+ function refreshDrawerActive(){
1241
+ document.querySelectorAll("#drawerList .case-row").forEach((r,i)=>{
1242
+ r.classList.toggle("active", i===currentIdx);
1243
+ });
1244
+ }
1245
+ function openDrawer(){
1246
+ document.getElementById("drawer").classList.add("open");
1247
+ document.getElementById("drawerBackdrop").classList.add("open");
1248
+ }
1249
+ function closeDrawer(){
1250
+ document.getElementById("drawer").classList.remove("open");
1251
+ document.getElementById("drawerBackdrop").classList.remove("open");
1252
+ }
1253
+
1254
+ /* ----- Video controls ----- */
1255
+ function allVideos(){ return Array.from(document.querySelectorAll("video[data-slot]")); }
1256
+ function pauseAll(){ allVideos().forEach(v=>v.pause()); }
1257
+ function restartAll(){ allVideos().forEach(v=>{ v.currentTime=0; try{ v.play(); }catch(e){} }); }
1258
+ let LOOPING=false;
1259
+ function toggleLoop(btn){ LOOPING=!LOOPING; allVideos().forEach(v=>v.loop=LOOPING); btn.classList.toggle("on",LOOPING); }
1260
+ let MUTED=false;
1261
+ function toggleMute(btn){ MUTED=!MUTED; allVideos().forEach(v=>v.muted=MUTED); btn.classList.toggle("on",MUTED); btn.textContent=MUTED?t("muted"||"sound"):t("mute"); }
1262
+ function setSpeed(rate){ allVideos().forEach(v=>v.playbackRate=rate); }
1263
+
1264
+ /* ----- Navigation ----- */
1265
+ function goTo(idx){
1266
+ if (idx<0 || idx>=CASES.length) return;
1267
+ pauseAll();
1268
+ currentIdx = idx;
1269
+ renderCase();
1270
+ // scroll to top of main on case switch
1271
+ window.scrollTo({top:0, behavior:"instant"});
1272
+ }
1273
+ function step(d){ goTo((currentIdx + d + CASES.length) % CASES.length); }
1274
+
1275
+ /* ----- Submit button + progress ----- */
1276
+ function updateSubmitBtn(){
1277
+ const btn = document.getElementById("submitBtn");
1278
+ if (!btn) return;
1279
+ const comp = studyCompleteness();
1280
+ const remaining = comp.expected - comp.done;
1281
+ if (remaining > 0){
1282
+ btn.disabled = true; btn.textContent = t("submit_disabled", remaining);
1283
+ } else if (SYNC_STATUS === "submitted"){
1284
+ btn.disabled = false; btn.textContent = t("submit_resubmit");
1285
+ } else {
1286
+ btn.disabled = false; btn.textContent = t("submit", comp.expected);
1287
+ }
1288
+ }
1289
+ function updateProgress(){
1290
+ const { done, expected } = studyCompleteness();
1291
+ document.getElementById("progressFill").style.width =
1292
+ expected>0 ? (done/expected*100).toFixed(1)+"%" : "0%";
1293
+ document.getElementById("progressTxt").textContent = `${done}/${expected}`;
1294
+ updateSubmitBtn();
1295
+ // also refresh per-case status in drawer (cheap)
1296
+ refreshDrawerCounts();
1297
+ }
1298
+ function refreshDrawerCounts(){
1299
+ document.querySelectorAll("#drawerList .case-row").forEach((row,i)=>{
1300
+ const c = CASES[i]; if (!c) return;
1301
+ const p = caseProgress(c);
1302
+ row.classList.remove("partial","complete");
1303
+ if (p.done===p.expected && p.expected>0) row.classList.add("complete");
1304
+ else if (p.done>0) row.classList.add("partial");
1305
+ const pct = row.querySelector(".pct");
1306
+ if (pct) pct.textContent = `${p.done}/${p.expected}`;
1307
+ });
1308
+ }
1309
+
1310
+ /* ----- Registration overlay ----- */
1311
+ function openRegister(){
1312
+ document.getElementById("loginOverlay").classList.remove("hidden");
1313
+ document.getElementById("regName").value = RATER.name || "";
1314
+ document.getElementById("regEmail").value = RATER.email || "";
1315
+ document.getElementById("regNote").value = RATER.note || "";
1316
+ applyRegistrationI18n();
1317
+ }
1318
+ function closeRegister(){ document.getElementById("loginOverlay").classList.add("hidden"); }
1319
+
1320
+ /* ----- help / 评分说明 overlay (mobile only) ----- */
1321
+ const HELP_SEEN_KEY = "user_study_help_seen_v1";
1322
+ function openHelp(){
1323
+ applyHelpI18n();
1324
+ document.getElementById("helpOverlay").classList.remove("hidden");
1325
+ }
1326
+ function closeHelp(){
1327
+ document.getElementById("helpOverlay").classList.add("hidden");
1328
+ try { localStorage.setItem(HELP_SEEN_KEY, "1"); } catch(e){}
1329
+ }
1330
+ function applyHelpI18n(){
1331
+ const h = document.getElementById("helpHeader");
1332
+ if (h) h.textContent = t("help_title");
1333
+ const body = document.getElementById("helpBody");
1334
+ if (body) body.innerHTML = t("help_body_html");
1335
+ const btn = document.getElementById("helpDismissText");
1336
+ if (btn) btn.textContent = t("help_dismiss");
1337
+ }
1338
+ function applyRegistrationI18n(){
1339
+ document.getElementById("regHeader").textContent = t("reg_title");
1340
+ document.getElementById("regLead").textContent = t("reg_lead");
1341
+ document.getElementById("regNameLabel").textContent = t("reg_name");
1342
+ document.getElementById("regEmailLabel").textContent = t("reg_email");
1343
+ document.getElementById("regNoteLabel").textContent = t("reg_note");
1344
+ document.getElementById("regContinueText").textContent = t("reg_continue");
1345
+ document.getElementById("regFootText").innerHTML = t("reg_foot");
1346
+ document.getElementById("regName").placeholder = t("reg_name_ph");
1347
+ document.getElementById("regEmail").placeholder = t("reg_email_ph");
1348
+ document.getElementById("regNote").placeholder = t("reg_note_ph");
1349
+ document.getElementById("submittedHeading").textContent = t("submitted_title");
1350
+ }
1351
+ function handleRegister(){
1352
+ const name = document.getElementById("regName").value.trim();
1353
+ const email = document.getElementById("regEmail").value.trim().toLowerCase();
1354
+ const note = document.getElementById("regNote").value.trim();
1355
+ const err = document.getElementById("regErr");
1356
+ err.textContent = "";
1357
+ if (!name){ err.textContent = t("reg_err_name"); return; }
1358
+ if (!EMAIL_RE.test(email)){ err.textContent = t("reg_err_email"); return; }
1359
+ RATER.name = name; RATER.email = email; RATER.note = note;
1360
+ if (!RATER.id || RATER.id.startsWith("rater_")){
1361
+ RATER.id = email.replace(/[^a-z0-9]+/gi,"_").replace(/^_+|_+$/g,"");
1362
+ }
1363
+ if (!RATER.started_at) RATER.started_at = new Date().toISOString();
1364
+ // Non-admins always anonymized
1365
+ if (!isAdmin()) anonymize = true;
1366
+ closeRegister();
1367
+ // Recover any prior locked case subset + ratings from the server first,
1368
+ // then resolve the subset (locked > deterministic pick).
1369
+ (async () => {
1370
+ await recoverRaterStateFromServer(email);
1371
+ CASES = selectCasesForRater(ALL_CASES);
1372
+ SHUFFLE = {}; buildShuffle(CASES); saveState();
1373
+ renderShell();
1374
+ })();
1375
+ (async()=>{
1376
+ await probeSync();
1377
+ if (SYNC_STATUS!=="local-only"){
1378
+ await saveProgress({silent:true, source:"register"});
1379
+ }
1380
+ showToast(t("toast_registered", name));
1381
+ })();
1382
+ }
1383
+
1384
+ /* ----- Submitted overlay ----- */
1385
+ function showSubmittedOverlay(body){
1386
+ const ov = document.getElementById("submittedOverlay");
1387
+ document.getElementById("submittedDetail").textContent =
1388
+ t("submitted_thanks", RATER.name, body.n_scores ?? "?", body.n_videos ?? "?");
1389
+ document.getElementById("submittedRef").textContent = body.stored_as || "—";
1390
+ ov.classList.remove("hidden");
1391
+ }
1392
+ function hideSubmittedOverlay(){ document.getElementById("submittedOverlay").classList.add("hidden"); }
1393
+
1394
+ /* =========================================================================
1395
+ * Init
1396
+ * ========================================================================= */
1397
+ (async function init(){
1398
+ // restore lang first
1399
+ try{
1400
+ const prior0 = JSON.parse(localStorage.getItem(STORAGE_KEY)||"null");
1401
+ if (prior0 && STRINGS[prior0.lang]) LANG = prior0.lang;
1402
+ }catch(e){}
1403
+ if (LANG === "en"){
1404
+ const nav = (navigator.language||"en").toLowerCase();
1405
+ if (nav.startsWith("zh")) LANG = "zh";
1406
+ else if (nav.startsWith("bg")) LANG = "bg";
1407
+ }
1408
+ document.documentElement.lang = LANG;
1409
+
1410
+ // fetch manifest
1411
+ let manifest = null;
1412
+ for (const p of ["./cases.json", "./cases.example.json"]){
1413
+ try{ const r = await fetch(p, {cache:"no-cache"}); if (r.ok){ manifest = await r.json(); break; } }catch(e){}
1414
+ }
1415
+ if (!manifest || !Array.isArray(manifest) || manifest.length===0){
1416
+ document.getElementById("main").innerHTML =
1417
+ '<div style="padding:40px;color:var(--bad);text-align:center">Could not load cases.json</div>';
1418
+ return;
1419
+ }
1420
+ ALL_CASES = manifest;
1421
+
1422
+ // restore state
1423
+ const prior = loadState();
1424
+ const blankRater = () => ({ id:genRaterId(), name:"", email:"", note:"",
1425
+ started_at:new Date().toISOString(), submitted_at:null });
1426
+ if (prior){
1427
+ RATER = Object.assign(blankRater(), prior.rater||{});
1428
+ anonymize = prior.anonymize !== false;
1429
+ RATINGS = prior.ratings || {};
1430
+ SHUFFLE = prior.shuffle || {};
1431
+ // One-shot client-side huobao→univa rename (see desktop for context).
1432
+ for (const cid of Object.keys(RATINGS)) {
1433
+ const m = RATINGS[cid];
1434
+ if (m && m.huobao && !m.univa) { m.univa = m.huobao; delete m.huobao; }
1435
+ }
1436
+ } else { RATER = blankRater(); }
1437
+ if (!isAdmin()) anonymize = true;
1438
+ // Admins see all cases; everyone else gets a deterministic 10-case subset.
1439
+ CASES = selectCasesForRater(ALL_CASES);
1440
+ buildShuffle(CASES); saveState();
1441
+
1442
+ // wire DOM events
1443
+ document.getElementById("drawerBtn").onclick = openDrawer;
1444
+ document.getElementById("helpBtn").onclick = openHelp;
1445
+ document.getElementById("helpCloseBtn").onclick = closeHelp;
1446
+ document.getElementById("helpDismissBtn").onclick = closeHelp;
1447
+ document.getElementById("drawerCloseBtn").onclick = closeDrawer;
1448
+ document.getElementById("drawerBackdrop").onclick = closeDrawer;
1449
+ document.getElementById("prevBtn").onclick = () => step(-1);
1450
+ document.getElementById("nextBtn").onclick = () => step(1);
1451
+ document.getElementById("submitBtn").onclick = submitWholeStudy;
1452
+ document.getElementById("saveBtn").onclick = () => saveProgress({silent:false, source:"manual"});
1453
+ document.getElementById("scrollTopBtn").onclick = () => {
1454
+ document.getElementById("videoStack")?.scrollIntoView({behavior:"smooth", block:"start"});
1455
+ };
1456
+ document.getElementById("regGoBtn").onclick = handleRegister;
1457
+ document.getElementById("submittedCloseBtn").onclick = hideSubmittedOverlay;
1458
+ document.getElementById("submittedDownloadBtn").onclick = exportJSON;
1459
+ document.getElementById("langSelect").onchange = (e) => {
1460
+ LANG = e.target.value; document.documentElement.lang = LANG;
1461
+ saveState(); renderShell();
1462
+ };
1463
+ document.getElementById("regLangSelect").onchange = (e) => {
1464
+ LANG = e.target.value; document.documentElement.lang = LANG;
1465
+ saveState(); applyRegistrationI18n();
1466
+ };
1467
+ // best-effort save on close
1468
+ window.addEventListener("beforeunload", beaconProgress);
1469
+ document.addEventListener("visibilitychange", () => {
1470
+ if (document.visibilityState==="hidden") beaconProgress();
1471
+ });
1472
+
1473
+ // gate on registration
1474
+ if (!RATER.name || !RATER.email || !EMAIL_RE.test(RATER.email)){
1475
+ openRegister();
1476
+ } else {
1477
+ renderShell();
1478
+ closeRegister();
1479
+ probeSync();
1480
+ // Auto-open the rubric popup once per device. The user can manually
1481
+ // reopen it any time via the "?" header button.
1482
+ try {
1483
+ if (localStorage.getItem(HELP_SEEN_KEY) !== "1") openHelp();
1484
+ } catch (e) {}
1485
+ }
1486
+ // Always fetch admin-curated highlights; available even before registration
1487
+ // gate clears so the prompt shows highlights as soon as the rater enters.
1488
+ loadAnnotations();
1489
+ })();
1490
+ </script>
1491
+ </body>
1492
+ </html>
static/v1/stats.html ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en"><head><meta charset="utf-8">
3
+ <title>User study stats · FrameWorker</title>
4
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
5
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
6
+ <style>
7
+ :root { --bg:#0f1115; --panel:#161922; --panel-2:#1c2030; --border:#2a2f42;
8
+ --fg:#e6e8ef; --muted:#8b91a8; --accent:#6aa9ff; --good:#28c76f;
9
+ --warn:#ff9f43; --bad:#ea5455; }
10
+ body { margin: 0; background: var(--bg); color: var(--fg);
11
+ font-family: -apple-system, "Inter", "PingFang SC", sans-serif; }
12
+ header { padding: 14px 24px; border-bottom: 1px solid var(--border);
13
+ background: var(--panel); display: flex; gap: 14px; align-items: baseline;
14
+ flex-wrap: wrap; }
15
+ header h1 { font-size: 18px; margin: 0; }
16
+ header .meta { color: var(--muted); font-size: 12px; }
17
+ main { padding: 18px 24px; max-width: 1100px; margin: 0 auto; }
18
+ section { background: var(--panel); border: 1px solid var(--border);
19
+ border-radius: 8px; padding: 16px 18px; margin-bottom: 16px; }
20
+ h2 { font-size: 15px; margin: 0 0 12px; color: var(--accent); }
21
+ table { border-collapse: collapse; width: 100%; font-size: 13px; }
22
+ th, td { padding: 6px 10px; border-bottom: 1px solid var(--border); text-align: left; }
23
+ th { color: var(--muted); font-weight: 600; font-size: 11px;
24
+ text-transform: uppercase; letter-spacing: 0.5px; }
25
+ .q-block { background: var(--panel-2); border: 1px solid var(--border);
26
+ border-radius: 6px; padding: 10px 12px; margin: 10px 0; }
27
+ .q-block .q-title { font-weight: 600; font-size: 13px; margin-bottom: 4px; }
28
+ .q-block .q-dim { color: var(--muted); font-size: 11px; }
29
+ canvas { background: transparent; max-height: 240px; }
30
+ .legend-dot { display: inline-block; width: 9px; height: 9px;
31
+ border-radius: 50%; margin-right: 4px; vertical-align: middle; }
32
+ .empty { color: var(--muted); font-size: 12px; padding: 20px;
33
+ text-align: center; background: var(--panel-2); border-radius: 6px; }
34
+ </style></head><body>
35
+
36
+ <header>
37
+ <h1>User study stats</h1>
38
+ <span class="meta">generated <code id="ts"></code> · raters <b id="nrater"></b>
39
+ · submissions <b id="nfile"></b> · 1–5 Likert</span>
40
+ <span style="margin-left:auto"><a href="./" style="color:var(--accent)">← back to study</a></span>
41
+ </header>
42
+
43
+ <main>
44
+ <section>
45
+ <h2>Per dimension</h2>
46
+ <canvas id="dimChart"></canvas>
47
+ <table style="margin-top:12px"><thead><tr id="dimHead"></tr></thead>
48
+ <tbody id="dimBody"></tbody></table>
49
+ </section>
50
+
51
+ <section>
52
+ <h2>Per sub-question</h2>
53
+ <table><thead><tr id="qHead"></tr></thead><tbody id="qBody"></tbody></table>
54
+ </section>
55
+
56
+ <section>
57
+ <h2>Per sub-question — model comparison</h2>
58
+ <div id="qCharts"></div>
59
+ </section>
60
+ </main>
61
+
62
+ <script id="data" type="application/json">{"n_raters": 6, "n_payloads": 7, "models": ["ours", "baseline", "univa"], "per_question": [{"dim_label": "Instruction following", "q_label": "Controllability", "q_key": "controllability", "scores": {"ours": [3, 3, 3, 3, 3, 5, 5, 3, 4, 4, 3, 3, 3, 3, 3, 5, 5, 3, 4, 4, 2, 2, 4], "baseline": [1, 3, 1, 3, 3, 1, 1, 1, 1, 1, 1, 3, 1, 3, 3, 1, 1, 1, 1, 1, 1, 4, 3], "univa": [1, 3, 1, 3, 3, 1, 1, 1, 1, 1, 1, 3, 1, 3, 3, 1, 1, 1, 1, 1, 4, 3, 3]}}, {"dim_label": "Instruction following", "q_label": "Prompt adherence", "q_key": "prompt_adherence", "scores": {"ours": [3, 1, 3, 3, 2, 4, 4, 5, 3, 5, 3, 1, 3, 3, 2, 4, 4, 5, 3, 5, 2, 2, 4], "baseline": [1, 2, 3, 4, 3, 2, 4, 3, 3, 4, 1, 2, 3, 4, 3, 2, 4, 3, 3, 4, 3, 5, 3], "univa": [3, 3, 2, 4, 4, 2, 2, 4, 5, 4, 2, 3, 2, 4, 4, 2, 2, 4, 5, 4, 4, 3, 3]}}, {"dim_label": "Instruction following", "q_label": "No irrelevant frames", "q_key": "no_irrelevant_frames", "scores": {"ours": [2, 2, 2, 3, 2, 3, 4, 2, 5, 4, 2, 2, 2, 3, 2, 3, 4, 2, 5, 4, 3, 2, 3], "baseline": [2, 2, 1, 4, 4, 3, 4, 1, 2, 4, 2, 2, 1, 4, 4, 3, 4, 1, 2, 4, 2, 4, 2], "univa": [2, 2, 3, 2, 3, 1, 3, 2, 4, 4, 2, 2, 3, 2, 3, 1, 3, 2, 4, 4, 4, 3, 3]}}, {"dim_label": "Temporal consistency", "q_label": "Person stability", "q_key": "person_stability", "scores": {"ours": [2, 2, 3, 2, 5, 3, 3, 4, 5, 5, 2, 2, 3, 2, 5, 3, 3, 4, 5, 5, 4, 4, 4], "baseline": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 4, 2], "univa": [3, 1, 2, 1, 2, 1, 1, 2, 3, 3, 2, 1, 2, 1, 2, 1, 1, 2, 3, 3, 4, 1, 3]}}, {"dim_label": "Temporal consistency", "q_label": "Object stability", "q_key": "object_stability", "scores": {"ours": [4, 2, 2, 5, 3, 4, 3, 4, 5, 5, 4, 2, 2, 5, 3, 4, 3, 4, 5, 5, 3, 3, 4], "baseline": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 4, 3], "univa": [4, 1, 3, 1, 3, 1, 1, 1, 4, 2, 3, 1, 3, 1, 3, 1, 1, 1, 4, 2, 4, 1, 3]}}, {"dim_label": "Temporal consistency", "q_label": "Environment stability", "q_key": "environment_stability", "scores": {"ours": [2, 3, 5, 3, 4, 4, 4, 5, 5, 5, 2, 3, 5, 3, 4, 4, 4, 5, 5, 5, 4, 4, 4], "baseline": [1, 1, 1, 2, 3, 1, 1, 1, 3, 2, 1, 1, 1, 2, 3, 1, 1, 1, 3, 2, 1, 4, 2], "univa": [2, 1, 4, 1, 4, 1, 2, 1, 3, 3, 2, 1, 4, 1, 4, 1, 2, 1, 3, 3, 4, 2, 3]}}, {"dim_label": "Realism", "q_label": "Realism", "q_key": "realism", "scores": {"ours": [2, 2, 3, 3, 2, 2, 3, 4, 2, 3, 2, 2, 3, 3, 2, 2, 3, 4, 2, 3, 1, 2, 3], "baseline": [1, 2, 4, 2, 1, 1, 2, 1, 4, 4, 1, 2, 4, 2, 1, 1, 2, 1, 4, 4, 1, 4, 3], "univa": [2, 1, 2, 2, 2, 1, 1, 4, 4, 2, 3, 1, 2, 2, 2, 1, 1, 4, 4, 2, 4, 2, 3]}}, {"dim_label": "Realism", "q_label": "Motion logic", "q_key": "motion_logic", "scores": {"ours": [2, 3, 2, 2, 3, 4, 2, 4, 2, 2, 2, 3, 2, 2, 3, 4, 2, 4, 2, 2, 2, 4, 3], "baseline": [3, 3, 3, 2, 1, 2, 3, 1, 1, 2, 3, 3, 3, 2, 1, 2, 3, 1, 1, 2, 2, 4, 3], "univa": [3, 1, 1, 2, 3, 3, 1, 3, 1, 3, 3, 1, 1, 2, 3, 3, 1, 3, 1, 3, 4, 2, 3]}}, {"dim_label": "Realism", "q_label": "Camera", "q_key": "camera", "scores": {"ours": [4, 3, 3, 3, 3, 4, 2, 5, 3, 4, 4, 3, 3, 3, 3, 4, 2, 5, 3, 4, 2, 2, 3], "baseline": [2, 1, 2, 2, 3, 2, 1, 2, 2, 2, 2, 1, 2, 2, 3, 2, 1, 2, 2, 2, 2, 3, 2], "univa": [3, 1, 2, 2, 3, 3, 1, 3, 2, 2, 2, 1, 2, 2, 3, 3, 1, 3, 2, 2, 4, 2, 3]}}], "per_dim": [{"dim_label": "Instruction following", "scores": {"ours": [3, 3, 3, 3, 3, 5, 5, 3, 4, 4, 3, 3, 3, 3, 3, 5, 5, 3, 4, 4, 2, 2, 4, 3, 1, 3, 3, 2, 4, 4, 5, 3, 5, 3, 1, 3, 3, 2, 4, 4, 5, 3, 5, 2, 2, 4, 2, 2, 2, 3, 2, 3, 4, 2, 5, 4, 2, 2, 2, 3, 2, 3, 4, 2, 5, 4, 3, 2, 3], "baseline": [1, 3, 1, 3, 3, 1, 1, 1, 1, 1, 1, 3, 1, 3, 3, 1, 1, 1, 1, 1, 1, 4, 3, 1, 2, 3, 4, 3, 2, 4, 3, 3, 4, 1, 2, 3, 4, 3, 2, 4, 3, 3, 4, 3, 5, 3, 2, 2, 1, 4, 4, 3, 4, 1, 2, 4, 2, 2, 1, 4, 4, 3, 4, 1, 2, 4, 2, 4, 2], "univa": [1, 3, 1, 3, 3, 1, 1, 1, 1, 1, 1, 3, 1, 3, 3, 1, 1, 1, 1, 1, 4, 3, 3, 3, 3, 2, 4, 4, 2, 2, 4, 5, 4, 2, 3, 2, 4, 4, 2, 2, 4, 5, 4, 4, 3, 3, 2, 2, 3, 2, 3, 1, 3, 2, 4, 4, 2, 2, 3, 2, 3, 1, 3, 2, 4, 4, 4, 3, 3]}}, {"dim_label": "Temporal consistency", "scores": {"ours": [2, 2, 3, 2, 5, 3, 3, 4, 5, 5, 2, 2, 3, 2, 5, 3, 3, 4, 5, 5, 4, 4, 4, 4, 2, 2, 5, 3, 4, 3, 4, 5, 5, 4, 2, 2, 5, 3, 4, 3, 4, 5, 5, 3, 3, 4, 2, 3, 5, 3, 4, 4, 4, 5, 5, 5, 2, 3, 5, 3, 4, 4, 4, 5, 5, 5, 4, 4, 4], "baseline": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 4, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 4, 3, 1, 1, 1, 2, 3, 1, 1, 1, 3, 2, 1, 1, 1, 2, 3, 1, 1, 1, 3, 2, 1, 4, 2], "univa": [3, 1, 2, 1, 2, 1, 1, 2, 3, 3, 2, 1, 2, 1, 2, 1, 1, 2, 3, 3, 4, 1, 3, 4, 1, 3, 1, 3, 1, 1, 1, 4, 2, 3, 1, 3, 1, 3, 1, 1, 1, 4, 2, 4, 1, 3, 2, 1, 4, 1, 4, 1, 2, 1, 3, 3, 2, 1, 4, 1, 4, 1, 2, 1, 3, 3, 4, 2, 3]}}, {"dim_label": "Realism", "scores": {"ours": [2, 2, 3, 3, 2, 2, 3, 4, 2, 3, 2, 2, 3, 3, 2, 2, 3, 4, 2, 3, 1, 2, 3, 2, 3, 2, 2, 3, 4, 2, 4, 2, 2, 2, 3, 2, 2, 3, 4, 2, 4, 2, 2, 2, 4, 3, 4, 3, 3, 3, 3, 4, 2, 5, 3, 4, 4, 3, 3, 3, 3, 4, 2, 5, 3, 4, 2, 2, 3], "baseline": [1, 2, 4, 2, 1, 1, 2, 1, 4, 4, 1, 2, 4, 2, 1, 1, 2, 1, 4, 4, 1, 4, 3, 3, 3, 3, 2, 1, 2, 3, 1, 1, 2, 3, 3, 3, 2, 1, 2, 3, 1, 1, 2, 2, 4, 3, 2, 1, 2, 2, 3, 2, 1, 2, 2, 2, 2, 1, 2, 2, 3, 2, 1, 2, 2, 2, 2, 3, 2], "univa": [2, 1, 2, 2, 2, 1, 1, 4, 4, 2, 3, 1, 2, 2, 2, 1, 1, 4, 4, 2, 4, 2, 3, 3, 1, 1, 2, 3, 3, 1, 3, 1, 3, 3, 1, 1, 2, 3, 3, 1, 3, 1, 3, 4, 2, 3, 3, 1, 2, 2, 3, 3, 1, 3, 2, 2, 2, 1, 2, 2, 3, 3, 1, 3, 2, 2, 4, 2, 3]}}], "generated_at_utc": "2026-05-11T21:10:05Z"}</script>
63
+ <script>
64
+ const DATA = JSON.parse(document.getElementById("data").textContent);
65
+ const COLORS = ["#6aa9ff","#ff9f43","#28c76f","#ea5455","#b46aff"];
66
+ const MODEL_COLOR = Object.fromEntries(DATA.models.map((m,i) => [m, COLORS[i % COLORS.length]]));
67
+
68
+ document.getElementById("ts").textContent = DATA.generated_at_utc;
69
+ document.getElementById("nrater").textContent = DATA.n_raters;
70
+ document.getElementById("nfile").textContent = DATA.n_payloads;
71
+
72
+ function mean(arr) { return arr.length ? arr.reduce((a,b)=>a+b,0)/arr.length : null; }
73
+ function std(arr) {
74
+ if (arr.length < 2) return 0;
75
+ const m = mean(arr); const v = arr.reduce((a,b)=>a+(b-m)*(b-m),0)/(arr.length-1);
76
+ return Math.sqrt(v);
77
+ }
78
+ function fmt(arr) {
79
+ if (!arr.length) return "—";
80
+ const m = mean(arr); const s = std(arr);
81
+ return arr.length > 1 ? `${m.toFixed(2)} ± ${s.toFixed(2)} (n=${arr.length})`
82
+ : `${m.toFixed(2)} (n=${arr.length})`;
83
+ }
84
+
85
+ /* Dimension table + bar chart */
86
+ {
87
+ const head = document.getElementById("dimHead");
88
+ head.innerHTML = "<th>Dimension</th>" + DATA.models.map(m =>
89
+ `<th><span class="legend-dot" style="background:${MODEL_COLOR[m]}"></span>${m}</th>`).join("");
90
+ const body = document.getElementById("dimBody");
91
+ for (const d of DATA.per_dim) {
92
+ const tr = document.createElement("tr");
93
+ tr.innerHTML = `<td>${d.dim_label}</td>` +
94
+ DATA.models.map(m => `<td>${fmt(d.scores[m] || [])}</td>`).join("");
95
+ body.appendChild(tr);
96
+ }
97
+
98
+ const labels = DATA.per_dim.map(d => d.dim_label);
99
+ const datasets = DATA.models.map(m => ({
100
+ label: m,
101
+ data: DATA.per_dim.map(d => mean(d.scores[m] || [])),
102
+ backgroundColor: MODEL_COLOR[m] + "cc",
103
+ borderColor: MODEL_COLOR[m], borderWidth: 1,
104
+ }));
105
+ new Chart(document.getElementById("dimChart"), {
106
+ type: "bar",
107
+ data: { labels, datasets },
108
+ options: {
109
+ responsive: true, scales: {
110
+ y: { min: 0, max: 5, ticks: { color: "#8b91a8" }, grid: { color: "#2a2f42" } },
111
+ x: { ticks: { color: "#e6e8ef" }, grid: { display: false } },
112
+ },
113
+ plugins: { legend: { labels: { color: "#e6e8ef" } } },
114
+ },
115
+ });
116
+ }
117
+
118
+ /* Per-question table */
119
+ {
120
+ const head = document.getElementById("qHead");
121
+ head.innerHTML = "<th>Dim</th><th>Sub-question</th>" + DATA.models.map(m =>
122
+ `<th><span class="legend-dot" style="background:${MODEL_COLOR[m]}"></span>${m}</th>`).join("");
123
+ const body = document.getElementById("qBody");
124
+ for (const q of DATA.per_question) {
125
+ const tr = document.createElement("tr");
126
+ tr.innerHTML = `<td>${q.dim_label}</td><td>${q.q_label}</td>` +
127
+ DATA.models.map(m => `<td>${fmt(q.scores[m] || [])}</td>`).join("");
128
+ body.appendChild(tr);
129
+ }
130
+ }
131
+
132
+ /* One mini bar chart per sub-question */
133
+ {
134
+ const wrap = document.getElementById("qCharts");
135
+ for (const q of DATA.per_question) {
136
+ const total = DATA.models.reduce((n, m) => n + (q.scores[m] || []).length, 0);
137
+ const block = document.createElement("div");
138
+ block.className = "q-block";
139
+ block.innerHTML = `<div class="q-title">${q.q_label}</div>
140
+ <div class="q-dim">${q.dim_label}</div>`;
141
+ if (!total) {
142
+ const e = document.createElement("div");
143
+ e.className = "empty"; e.textContent = "(no ratings yet)";
144
+ block.appendChild(e); wrap.appendChild(block); continue;
145
+ }
146
+ const c = document.createElement("canvas");
147
+ block.appendChild(c); wrap.appendChild(block);
148
+ const datasets = DATA.models.map(m => ({
149
+ label: m,
150
+ data: [mean(q.scores[m] || [])],
151
+ backgroundColor: MODEL_COLOR[m] + "cc",
152
+ borderColor: MODEL_COLOR[m], borderWidth: 1,
153
+ }));
154
+ new Chart(c, {
155
+ type: "bar",
156
+ data: { labels: [q.q_label], datasets },
157
+ options: {
158
+ indexAxis: "y", responsive: true,
159
+ scales: {
160
+ x: { min: 0, max: 5, ticks: { color: "#8b91a8" }, grid: { color: "#2a2f42" } },
161
+ y: { ticks: { display: false }, grid: { display: false } },
162
+ },
163
+ plugins: { legend: { labels: { color: "#e6e8ef" } } },
164
+ },
165
+ });
166
+ }
167
+ }
168
+ </script>
169
+ </body></html>
static/v2/index.html CHANGED
@@ -1126,9 +1126,12 @@ function selectCasesForRater(allCases) {
1126
  const out = locked.map(id => byId.get(id)).filter(Boolean);
1127
  if (out.length >= Math.min(CASES_PER_RATER, allCases.length)) return out;
1128
  }
1129
- // First time for this rater: pick and lock.
 
 
 
1130
  const ids = pickRandomSubsetIds(allCases);
1131
- RATER.chosen_case_ids = ids;
1132
  // saveState() will be called by the caller; we don't call it here to keep
1133
  // this function pure-ish, but the locked list is now on the in-memory RATER.
1134
  const byId = new Map(allCases.map(c => [c.id, c]));
@@ -2538,6 +2541,9 @@ function handleRegistrationSubmit() {
2538
  // First: try to recover any prior server-side state for this email so
2539
  // their locked case subset + earlier ratings carry across devices.
2540
  (async () => {
 
 
 
2541
  await recoverRaterStateFromServer(email);
2542
  // Fresh rater: prefer the server's coverage-first assignment (least-rated
2543
  // cases first) over the local seeded random pick.
 
1126
  const out = locked.map(id => byId.get(id)).filter(Boolean);
1127
  if (out.length >= Math.min(CASES_PER_RATER, allCases.length)) return out;
1128
  }
1129
+ // First time for this rater: pick but only LOCK the list once the rater
1130
+ // has registered (email set). Pre-registration renders also land here with
1131
+ // a blank rater; locking then would freeze a random subset before
1132
+ // assignCasesFromServer ever gets a chance to do its coverage-first pick.
1133
  const ids = pickRandomSubsetIds(allCases);
1134
+ if (RATER.email) RATER.chosen_case_ids = ids;
1135
  // saveState() will be called by the caller; we don't call it here to keep
1136
  // this function pure-ish, but the locked list is now on the in-memory RATER.
1137
  const byId = new Map(allCases.map(c => [c.id, c]));
 
2541
  // First: try to recover any prior server-side state for this email so
2542
  // their locked case subset + earlier ratings carry across devices.
2543
  (async () => {
2544
+ // Drop any pre-registration (anonymous) lock so the subset is re-resolved
2545
+ // in priority order: server-recovered > coverage-first > seeded random.
2546
+ RATER.chosen_case_ids = null;
2547
  await recoverRaterStateFromServer(email);
2548
  // Fresh rater: prefer the server's coverage-first assignment (least-rated
2549
  // cases first) over the local seeded random pick.
static/v2/mobile.html CHANGED
@@ -783,8 +783,10 @@ function selectCasesForRater(allCases){
783
  const out = locked.map(id => byId.get(id)).filter(Boolean);
784
  if (out.length >= Math.min(CASES_PER_RATER, allCases.length)) return out;
785
  }
 
 
786
  const ids = pickRandomSubsetIds(allCases);
787
- RATER.chosen_case_ids = ids;
788
  const byId = new Map(allCases.map(c => [c.id, c]));
789
  return ids.map(id => byId.get(id)).filter(Boolean);
790
  }
@@ -1388,6 +1390,9 @@ function handleRegister(){
1388
  // Recover any prior locked case subset + ratings from the server first,
1389
  // then resolve the subset (locked > coverage-first > deterministic pick).
1390
  (async () => {
 
 
 
1391
  await recoverRaterStateFromServer(email);
1392
  await assignCasesFromServer();
1393
  CASES = selectCasesForRater(ALL_CASES);
 
783
  const out = locked.map(id => byId.get(id)).filter(Boolean);
784
  if (out.length >= Math.min(CASES_PER_RATER, allCases.length)) return out;
785
  }
786
+ // Only LOCK once registered (email set) — pre-registration renders must not
787
+ // freeze a random subset before assignCasesFromServer's coverage-first pick.
788
  const ids = pickRandomSubsetIds(allCases);
789
+ if (RATER.email) RATER.chosen_case_ids = ids;
790
  const byId = new Map(allCases.map(c => [c.id, c]));
791
  return ids.map(id => byId.get(id)).filter(Boolean);
792
  }
 
1390
  // Recover any prior locked case subset + ratings from the server first,
1391
  // then resolve the subset (locked > coverage-first > deterministic pick).
1392
  (async () => {
1393
+ // Drop any pre-registration (anonymous) lock so the subset is re-resolved
1394
+ // in priority order: server-recovered > coverage-first > seeded random.
1395
+ RATER.chosen_case_ids = null;
1396
  await recoverRaterStateFromServer(email);
1397
  await assignCasesFromServer();
1398
  CASES = selectCasesForRater(ALL_CASES);