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