edit-preference-study / index.html
Oshikaka's picture
study pbe1-382599f9df
760dc75 verified
Raw History Blame Contribute Delete
46.5 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<style>html, body { margin: 0; } img { max-width: 100%; } [hidden] { display: none !important; }</style>
<title>Pick the Better Edit</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible+Mono:wght@400;600&family=Atkinson+Hyperlegible+Next:wght@400;600;800&display=swap">
<style>
/* Light palette. The media stage is the same neutral gray in both themes on purpose: colour judgements
(coffee -> matcha, gray suit -> tan) need a neutral surround, as in subjective image-quality labs. */
:root {
--bg: #f1f1f0;
--surface: #ffffff;
--ink: #17171a;
--muted: #5d5d66;
--line: #d6d6da;
--soft: #e7e7ea;
--src: #1d5bbf; /* source words, as in the paper's figures */
--tgt: #b3261e; /* target words */
--focus: #1d5bbf;
--stage: #3b3b3b;
--stage-ink: #ececec;
--stage-muted: #b4b4b4;
--sel-bg: #17171a;
--sel-ink: #ffffff;
--ok: #1e7a3c;
--warn: #9a5b00;
--sans: "Atkinson Hyperlegible Next", "Atkinson Hyperlegible", system-ui, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
--mono: "Atkinson Hyperlegible Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--bg: #141415; --surface: #1d1d1f; --ink: #ececef; --muted: #a2a2ab; --line: #34343a; --soft: #26262a;
--src: #86aef7; --tgt: #ff8f84; --focus: #86aef7; --sel-bg: #ececef; --sel-ink: #141415; --ok: #6fcf8e; --warn: #f0b35a;
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--bg: #141415; --surface: #1d1d1f; --ink: #ececef; --muted: #a2a2ab; --line: #34343a; --soft: #26262a;
--src: #86aef7; --tgt: #ff8f84; --focus: #86aef7; --sel-bg: #ececef; --sel-ink: #141415; --ok: #6fcf8e; --warn: #f0b35a;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { background: var(--bg); color: var(--ink); }
body { font-family: var(--sans); font-size: 16px; line-height: 1.5; padding-inline: 16px; padding-block: 0 48px; }
h1, h2, h3 { text-wrap: balance; margin: 0; }
button, input, select, textarea { font: inherit; color: inherit; }
:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.wrap { max-width: 1240px; margin-inline: auto; }
.narrow { max-width: 680px; margin-inline: auto; }
.eyebrow { font-size: 12px; letter-spacing: .09em; text-transform: uppercase; color: var(--muted); font-weight: 600; }
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
.btn { appearance: none; border: 1.5px solid var(--ink); background: var(--surface); border-radius: 8px; padding: 10px 18px; cursor: pointer; font-weight: 600; }
.btn:hover:not(:disabled) { background: var(--soft); }
.btn.primary { background: var(--sel-bg); color: var(--sel-ink); border-color: var(--sel-bg); }
.btn.primary:hover:not(:disabled) { opacity: .88; background: var(--sel-bg); }
.btn.quiet { border-color: var(--line); font-weight: 400; padding: 7px 12px; }
.btn:disabled { opacity: .4; cursor: default; }
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
.seg button { border: 0; background: transparent; padding: 6px 12px; cursor: pointer; }
.seg button[aria-pressed="true"] { background: var(--sel-bg); color: var(--sel-ink); }
.w-src { color: var(--src); font-weight: 600; }
.w-tgt { color: var(--tgt); font-weight: 600; }
.arrow { color: var(--muted); padding-inline: .35em; }
/* ---------- welcome ---------- */
#welcome { padding-block: 40px 0; display: grid; gap: 28px; }
.topline { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
.title { font-size: clamp(34px, 6vw, 56px); font-weight: 800; letter-spacing: -.02em; line-height: 1.04; }
.lede { font-size: 19px; max-width: 62ch; }
.facts { display: flex; flex-wrap: wrap; gap: 8px 22px; color: var(--muted); }
.facts b { color: var(--ink); font-family: var(--mono); font-weight: 600; }
.qs { display: grid; gap: 14px; border-top: 1px solid var(--line); padding-top: 22px; }
.qrow { display: grid; grid-template-columns: 34px 1fr; gap: 12px; align-items: start; }
.qbadge { width: 30px; height: 30px; border-radius: 50%; border: 1.5px solid var(--ink); display: grid; place-items: center; font-weight: 800; font-size: 14px; }
.qrow h3 { font-size: 18px; }
.qrow p { margin: 2px 0 0; color: var(--muted); max-width: 62ch; }
.rules { margin: 0; padding-left: 20px; display: grid; gap: 6px; max-width: 64ch; }
.form { display: grid; gap: 16px; border-top: 1px solid var(--line); padding-top: 22px; }
.form label { display: grid; gap: 6px; font-weight: 600; }
.form input[type=text] { border: 1px solid var(--line); background: var(--surface); border-radius: 8px; padding: 10px 12px; max-width: 360px; font-weight: 400; }
.radios { display: flex; flex-wrap: wrap; gap: 8px; font-weight: 400; }
.radios label { display: inline-flex; gap: 6px; align-items: center; border: 1px solid var(--line); border-radius: 999px; padding: 6px 14px; cursor: pointer; font-weight: 400; background: var(--surface); }
.radios input { accent-color: var(--ink); }
.startbar { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.keys { color: var(--muted); font-size: 14px; }
kbd { font-family: var(--mono); font-size: 12px; border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 4px; padding: 0 5px; background: var(--surface); color: var(--ink); }
#organizer { border: 1px dashed var(--line); border-radius: 10px; padding: 16px; display: grid; gap: 10px; background: var(--surface); }
#organizer textarea { width: 100%; min-height: 80px; border: 1px solid var(--line); border-radius: 8px; padding: 8px; background: var(--bg); font-family: var(--mono); font-size: 13px; }
#orgStats { font-family: var(--mono); font-size: 14px; }
/* ---------- trial ---------- */
#trial { display: grid; gap: 14px; }
.bar { position: sticky; top: env(safe-area-inset-top, 0px); z-index: 5; background: var(--bg); padding-block: 12px 8px; display: grid; gap: 8px; }
.barrow { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: space-between; }
.where { display: flex; gap: 12px; align-items: baseline; }
.where .part { font-weight: 800; }
.track { height: 4px; background: var(--soft); border-radius: 2px; overflow: hidden; }
.track i { display: block; height: 100%; background: var(--ink); width: 0; transition: width .25s; }
.barbtns { display: flex; gap: 8px; flex-wrap: wrap; }
.editcard { display: grid; gap: 6px; }
.instr { font-size: clamp(20px, 2.6vw, 28px); font-weight: 600; line-height: 1.25; }
.desc { color: var(--muted); font-size: 14px; max-width: 110ch; }
.desc .w-tgt { font-weight: 600; }
.grid3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.stage { background: var(--stage); border-radius: 10px; padding: 12px; }
.cell { display: grid; gap: 6px; }
.cell .lab { color: var(--stage-ink); font-weight: 800; font-size: 15px; display: flex; justify-content: space-between; }
.cell .lab span { color: var(--stage-muted); font-weight: 400; font-size: 13px; }
.media { width: 100%; max-width: 100%; background-color: #2c2c2c; background-repeat: no-repeat; border-radius: 4px; cursor: zoom-in; display: block; }
canvas.media { height: auto; }
.cell.orig .lab { color: var(--stage-muted); }
.vctl { display: flex; gap: 10px; align-items: center; color: var(--stage-ink); padding-top: 10px; flex-wrap: wrap; }
.vctl button { background: transparent; color: var(--stage-ink); border: 1px solid #6a6a6a; border-radius: 6px; padding: 4px 10px; cursor: pointer; }
.vctl button[aria-pressed="true"] { background: var(--stage-ink); color: var(--stage); }
.vctl input[type=range] { flex: 1; min-width: 120px; accent-color: var(--stage-ink); }
.vctl .fr { font-family: var(--mono); font-size: 13px; color: var(--stage-muted); min-width: 64px; }
.loading { color: var(--stage-muted); font-size: 14px; padding: 6px 0 0; min-height: 1.5em; }
.stage.zoomed { position: fixed; inset: 0; z-index: 50; border-radius: 0; padding: max(12px, env(safe-area-inset-top, 0px)) 16px 16px; overflow: auto; display: grid; align-content: center; }
.stage.zoomed .media { cursor: zoom-out; }
.zoomnote { color: var(--stage-muted); font-size: 13px; text-align: center; padding-top: 8px; }
.answers { display: grid; gap: 10px; }
.arow { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; align-items: center; padding: 10px 12px; border-radius: 10px; border: 1.5px solid transparent; }
.arow.active { border-color: var(--line); background: var(--surface); }
.arow .qt { font-weight: 600; }
.arow .qt small { display: block; font-weight: 400; color: var(--muted); font-size: 13px; line-height: 1.35; margin-top: 2px; }
.pick { appearance: none; border: 1.5px solid var(--line); background: var(--surface); border-radius: 8px; padding: 12px; cursor: pointer; font-weight: 800; font-size: 17px; width: 100%; }
.pick:hover:not(:disabled) { border-color: var(--ink); }
.pick[aria-pressed="true"] { background: var(--sel-bg); color: var(--sel-ink); border-color: var(--sel-bg); }
.pick:disabled { opacity: .35; cursor: default; }
.navrow { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
.gate { color: var(--warn); font-size: 14px; }
.saved { font-size: 13px; color: var(--muted); }
.saved.ok { color: var(--ok); }
/* ---------- break / done ---------- */
.panel { padding-block: 56px 0; display: grid; gap: 18px; }
.codebox { font-family: var(--mono); font-size: 14px; word-break: break-all; background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 14px; user-select: all; }
.status { font-weight: 600; }
.status.ok { color: var(--ok); }
@media (max-width: 760px) {
.grid3 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid3 .cell.orig { grid-column: 1 / -1; }
.grid3 .cell.orig .media { max-width: 70%; justify-self: center; }
.arow { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.arow .qt { grid-column: 1 / -1; }
.instr { font-size: 20px; }
}
@media (prefers-reduced-motion: reduce) { .track i { transition: none; } }
</style>
</head>
<body>
<main class="wrap">
<section id="welcome" class="narrow">
<div class="topline">
<span class="eyebrow" data-t="eyebrow"></span>
<div class="seg" role="group" aria-label="Language">
<button type="button" id="lang-en" data-lang="en">English</button>
<button type="button" id="lang-zh" data-lang="zh">中文</button>
</div>
</div>
<h1 class="title" data-t="title"></h1>
<p class="lede" data-t="intro"></p>
<div class="facts" id="facts"></div>
<div class="qs">
<div class="qrow"><div class="qbadge">1</div><div><h3 data-t="q1"></h3><p data-t="q1hint"></p></div></div>
<div class="qrow"><div class="qbadge">2</div><div><h3 data-t="q2"></h3><p data-t="q2hint"></p></div></div>
<ul class="rules">
<li data-t="rule1"></li>
<li data-t="rule2"></li>
<li data-t="rule3"></li>
</ul>
</div>
<div class="form">
<label for="pname"><span data-t="name"></span>
<input type="text" id="pname" autocomplete="off" maxlength="60">
</label>
<div style="display:grid;gap:8px">
<span style="font-weight:600" data-t="exp"></span>
<div class="radios" id="exp">
<label><input type="radio" name="exp" id="exp0" value="0"><span data-t="exp0"></span></label>
<label><input type="radio" name="exp" id="exp1" value="1"><span data-t="exp1"></span></label>
<label><input type="radio" name="exp" id="exp2" value="2"><span data-t="exp2"></span></label>
</div>
</div>
<div class="startbar">
<button type="button" class="btn primary" id="start" disabled></button>
<button type="button" class="btn quiet" id="restart" hidden data-t="restart"></button>
</div>
<div class="keys" id="keys"></div>
<div class="keys" id="loadmsg"></div>
</div>
<section id="organizer" hidden>
<div class="eyebrow">Organizer · only you see this</div>
<div id="orgStats">…</div>
<label for="codesIn" style="font-weight:600">Paste participant codes (one per line) to keep them with the study</label>
<textarea id="codesIn" spellcheck="false" placeholder="PBE1.xxxxxx.1.ABC…"></textarea>
<div class="startbar"><button type="button" class="btn quiet" id="addCodes">Add codes</button><span id="codeMsg" class="saved"></span></div>
<p class="keys" style="margin:0">Answers save here automatically for people who open the page signed in with Contributor access. Everyone else gets a code at the end to send you.</p>
</section>
</section>
<section id="trial" hidden>
<div class="bar">
<div class="barrow">
<div class="where"><span class="part" id="partName"></span><span class="mono" id="count"></span><span class="saved" id="savedMsg"></span></div>
<div class="barbtns">
<button type="button" class="btn quiet" id="zoomBtn" data-t="enlarge"></button>
<button type="button" class="btn quiet" id="stopBtn" data-t="stop"></button>
</div>
</div>
<div class="track"><i id="trackFill"></i></div>
</div>
<div class="editcard">
<span class="eyebrow" data-t="editLabel"></span>
<div class="instr" id="instr"></div>
<div class="desc" id="desc"></div>
</div>
<div class="stage" id="stage">
<div class="grid3">
<div class="cell orig"><div class="lab" id="origLab"></div><div id="m0"></div></div>
<div class="cell"><div class="lab">A</div><div id="m1"></div></div>
<div class="cell"><div class="lab">B</div><div id="m2"></div></div>
</div>
<div class="vctl" id="vctl" hidden>
<button type="button" id="playBtn"></button>
<button type="button" id="slowBtn" aria-pressed="false">0.5×</button>
<input type="range" id="scrub" min="0" max="40" value="0" aria-label="Frame">
<span class="fr" id="frameNo"></span>
</div>
<div class="loading" id="stageMsg"></div>
<div class="zoomnote" id="zoomNote" hidden data-t="zoomNote"></div>
</div>
<div class="answers">
<div class="arow" id="row1">
<div class="qt"><span data-t="q1"></span><small data-t="q1short"></small></div>
<button type="button" class="pick" id="q1L">A</button>
<button type="button" class="pick" id="q1R">B</button>
</div>
<div class="arow" id="row2">
<div class="qt"><span data-t="q2"></span><small data-t="q2short"></small></div>
<button type="button" class="pick" id="q2L">A</button>
<button type="button" class="pick" id="q2R">B</button>
</div>
</div>
<div class="navrow">
<button type="button" class="btn quiet" id="backBtn" data-t="back"></button>
<span class="gate" id="gateMsg"></span>
<button type="button" class="btn primary" id="nextBtn" data-t="next"></button>
</div>
</section>
<section id="break" class="panel narrow" hidden>
<span class="eyebrow" data-t="halfway"></span>
<h2 class="title" style="font-size:40px" data-t="breakTitle"></h2>
<p class="lede" id="breakBody"></p>
<div class="startbar"><button type="button" class="btn primary" id="toVideos" data-t="toVideos"></button></div>
</section>
<section id="done" class="panel narrow" hidden>
<span class="eyebrow" data-t="eyebrow"></span>
<h2 class="title" style="font-size:44px" data-t="doneTitle"></h2>
<p class="status" id="doneStatus"></p>
<p id="doneBody" data-t="sendCode"></p>
<div class="codebox" id="codeBox"></div>
<div class="startbar">
<button type="button" class="btn primary" id="copyBtn" data-t="copy"></button>
<span class="saved" id="copyMsg"></span>
<button type="button" class="btn quiet" id="reviewBtn" data-t="review"></button>
</div>
</section>
</main>
<script>
"use strict";
const STR = {
en: {
eyebrow: "Image & video editing study",
title: "Pick the better edit",
intro: "Each screen shows an original image or video, an edit request, and two results, A and B, made by two different editing methods. Compare A with B and answer two questions.",
facts: "<b>{ni}</b> image pairs · then <b>{nv}</b> video pairs · about <b>{min}</b> minutes · your progress stays in this browser",
factsImg: "<b>{ni}</b> image pairs · about <b>{min}</b> minutes · your progress stays in this browser",
q1: "Which result makes the requested edit better?",
q1hint: "Look only at the edit: did the change asked for actually happen, and how completely?",
q1short: "Did the requested change happen?",
q2: "Overall, which result do you prefer?",
q2hint: "Weigh everything: the edit is done, the result looks natural, and what the request does not mention stays as in the original.",
q2short: "Edit done, looks natural, the rest kept.",
rule1: "A result that looks the same as the original has not made the edit.",
rule2: "Method names are hidden and A and B are shuffled on every screen. There are no right answers.",
rule3: "Click any picture to enlarge it. Videos loop; you can slow them down or drag through the frames.",
name: "Name or initials (optional)",
exp: "How familiar are you with AI image or video editing?",
exp0: "Not at all", exp1: "A little", exp2: "Very",
start: "Start", resume: "Continue ({n} of {N} done)", restart: "Start over",
keys: "Keys: <kbd>A</kbd>/<kbd>B</kbd> or <kbd>←</kbd>/<kbd>→</kbd> answer the highlighted question · <kbd>Enter</kbd> next · <kbd>Backspace</kbd> back · <kbd>Space</kbd> play or pause · <kbd>Z</kbd> enlarge",
partImg: "Images", partVid: "Videos",
editLabel: "Edit request",
orig: "Original", origV: "Original",
enlarge: "Enlarge", shrink: "Close", stop: "Stop here and get my code",
zoomNote: "Press Z or Esc, or click a picture, to close.",
next: "Next", back: "Back", finish: "Finish",
watch: "Watch the clip once before answering.",
loading: "Loading…", loadFail: "This file did not load. Check your connection and press Next to skip it.",
play: "Pause", paused: "Play",
target: "Target description:",
halfway: "Part 1 of 2 done",
breakTitle: "Images done. Now videos.",
breakBody: "Next come {nv} video pairs. Each clip is about 2.5 seconds and loops. A, B and the original play in sync.",
toVideos: "Start the videos",
doneTitle: "Thank you",
savedOk: "Your answers are saved.",
savedPartial: "{n} of {N} answered.",
sendCode: "Please send this code to the person who shared the study with you. It contains your answers and nothing else.",
keepCode: "Nothing else to do. The code below is a backup of your answers; send it only if the organizer asks.",
copy: "Copy code", copied: "Copied", copyFail: "Select the code above and copy it.",
review: "Back to the last pair", continue: "Continue where I stopped", skip: "Skip this one",
savedSync: "saved", savedLocal: "kept in this browser",
loadingData: "Loading the study…", dataFail: "The study data did not load. Reload the page to try again.",
},
zh: {
eyebrow: "图像与视频编辑用户研究",
title: "选出更好的编辑",
intro: "每一页给出一张原图或一段原视频、一个编辑要求,以及两种不同编辑方法给出的结果 A 和 B。请比较 A 和 B,回答两个问题。",
facts: "<b>{ni}</b> 组图片 · 然后 <b>{nv}</b> 组视频 · 约 <b>{min}</b> 分钟 · 进度保存在本浏览器中",
factsImg: "<b>{ni}</b> 组图片 · 约 <b>{min}</b> 分钟 · 进度保存在本浏览器中",
q1: "哪个结果更好地完成了要求的编辑?",
q1hint: "只看编辑本身:要求的变化有没有发生,完成得是否彻底。",
q1short: "要求的变化有没有发生?",
q2: "综合来看,你更喜欢哪个结果?",
q2hint: "综合考虑:编辑是否完成、结果是否自然、要求中没有提到的部分是否和原图一致。",
q2short: "编辑完成、自然、其余部分保持不变。",
rule1: "和原图几乎一样的结果,说明没有完成编辑。",
rule2: "方法名称已隐藏,每一页的 A、B 顺序都是随机的。没有标准答案。",
rule3: "点击任意图片可以放大。视频循环播放,可以放慢或拖动逐帧查看。",
name: "姓名或缩写(选填)",
exp: "你对 AI 图像或视频编辑熟悉吗?",
exp0: "不了解", exp1: "了解一些", exp2: "很熟悉",
start: "开始", resume: "继续(已完成 {n} / {N})", restart: "重新开始",
keys: "快捷键:<kbd>A</kbd>/<kbd>B</kbd> 或 <kbd>←</kbd>/<kbd>→</kbd> 回答高亮的问题 · <kbd>Enter</kbd> 下一题 · <kbd>Backspace</kbd> 上一题 · <kbd>空格</kbd> 播放或暂停 · <kbd>Z</kbd> 放大",
partImg: "图片", partVid: "视频",
editLabel: "编辑要求",
orig: "原图", origV: "原视频",
enlarge: "放大", shrink: "关闭", stop: "到此结束并获取代码",
zoomNote: "按 Z 或 Esc,或点击图片关闭。",
next: "下一题", back: "上一题", finish: "完成",
watch: "请先完整看一遍视频再作答。",
loading: "加载中…", loadFail: "文件没有加载成功。请检查网络,或点“下一题”跳过。",
play: "暂停", paused: "播放",
target: "目标描述:",
halfway: "第 1 部分(共 2 部分)完成",
breakTitle: "图片部分完成,接下来是视频。",
breakBody: "接下来是 {nv} 组视频。每段约 2.5 秒,循环播放;原视频、A、B 同步播放。",
toVideos: "开始视频部分",
doneTitle: "谢谢参与",
savedOk: "你的答案已保存。",
savedPartial: "已回答 {n} / {N}。",
sendCode: "请把下面的代码发给邀请你参加的人。代码里只有你的答案。",
keepCode: "不需要再做什么了。下面的代码是你答案的备份,组织者需要时再发给他们。",
copy: "复制代码", copied: "已复制", copyFail: "请选中上面的代码手动复制。",
review: "回到最后一题", continue: "从停下的地方继续", skip: "跳过这一题",
savedSync: "已保存", savedLocal: "已保存在本浏览器",
loadingData: "正在加载…", dataFail: "数据没有加载成功,请刷新页面重试。",
},
};
const ALPH = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn"; // 40 symbols: v*8 + s*4 + q1*2 + q2
// "#extra" = only the trials marked x (the random SPRITE set added 11-07 + one catch), for people who already did the rest
const MODE = location.hash === "#extra" ? "extra" : "full";
const LS_KEY = "pbe-study-state" + (MODE === "extra" ? "-extra" : "");
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
let D = null, T = {}, IDX = {};
let S = null; // participant state
let lang = "en";
let cur = null; // current trial view: {tid, t, L, R, shownAt, watched, video, stop}
let activeQ = 1, advTimer = null;
const blobs = new Map(); // prefetched video blobs
let dbDoc = null, docExists = false, writing = false, dirty = false, syncTimer = null, dbState = "none", begun = false;
const runKey = () => `${D.version}_${MODE}`;
const modeTrials = () => D.trials.filter((t) => MODE === "full" || t.x);
function tr(k, vars) {
let s = (STR[lang] && STR[lang][k]) || STR.en[k] || k;
if (vars) for (const [a, b] of Object.entries(vars)) s = s.split("{" + a + "}").join(b);
return s;
}
function lsGet() { try { return JSON.parse(localStorage.getItem(LS_KEY) || "null"); } catch (e) { return null; } }
function lsSet() { try { localStorage.setItem(LS_KEY, JSON.stringify(S)); } catch (e) { /* storage blocked: keep going */ } }
function lsDel() { try { localStorage.removeItem(LS_KEY); } catch (e) {} }
function mulberry32(a) { return function () { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
function shuffle(arr, rnd) { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; }
function segs(list) { return (list || []).map(([txt, cls]) => cls ? `<span class="${cls === "arrow" ? "arrow" : "w-" + cls}">${esc(txt)}</span>` : esc(txt)).join(""); }
/* ---------- text ---------- */
function applyLang() {
document.documentElement.lang = lang === "zh" ? "zh-CN" : "en";
document.querySelectorAll("[data-t]").forEach((el) => { el.innerHTML = tr(el.dataset.t); });
$("lang-en").setAttribute("aria-pressed", String(lang === "en"));
$("lang-zh").setAttribute("aria-pressed", String(lang === "zh"));
$("keys").innerHTML = tr("keys");
if (D) {
const T0 = modeTrials(), ni = T0.filter((t) => t.kind === "image").length, nv = T0.length - ni;
const min = Math.max(5, Math.round((ni * 9 + nv * 14) / 60 / 5) * 5);
$("facts").innerHTML = nv ? tr("facts", { ni, nv, min }) : tr("factsImg", { ni, min });
$("breakBody").textContent = tr("breakBody", { nv });
}
setStartLabel();
if (cur) renderTrialText();
}
function setStartLabel() {
const b = $("start");
if (!D) { b.textContent = tr("start"); return; }
const n = nDone();
b.textContent = n > 0 ? tr("resume", { n, N: modeTrials().length }) : tr("start");
$("restart").hidden = n === 0;
}
/* ---------- state ---------- */
function newState() {
const buf = new Uint32Array(1);
try { crypto.getRandomValues(buf); } catch (e) { buf[0] = Math.floor(Math.random() * 4294967296); }
const seed = buf[0] >>> 0, rnd = mulberry32(seed);
const assign = {};
D.trials.forEach((t) => { assign[t.id] = { v: (seed + (t.rot || 0)) % t.pairs.length, s: rnd() < 0.5 ? 0 : 1 }; });
const img = shuffle(modeTrials().filter((t) => t.kind === "image").map((t) => t.id), rnd);
const vid = shuffle(modeTrials().filter((t) => t.kind === "video").map((t) => t.id), rnd);
return { study: D.version, mode: MODE, seed, pid: seed.toString(36), lang, name: "", exp: "", order: img.concat(vid), nImg: img.length,
assign, ans: {}, pos: 0, started: new Date().toISOString(), finished: false };
}
const answered = (r) => !!r && !r.skip && r.q1 !== undefined && r.q2 !== undefined;
const complete = (r) => !!r && (!!r.skip || answered(r));
function nDone() { return S ? S.order.filter((id) => complete(S.ans[id])).length : 0; }
function firstIncomplete() { const i = S.order.findIndex((id) => !complete(S.ans[id])); return i < 0 ? null : i; }
function code() {
const chars = D.trials.map((t) => {
const a = S.assign[t.id], r = S.ans[t.id];
return answered(r) ? ALPH[a.v * 8 + a.s * 4 + r.q1 * 2 + r.q2] : "~";
}).join("");
return `PBE1.${S.pid}.${S.exp === "" ? "x" : S.exp}.${chars}`;
}
function payload() {
return { study: S.study, mode: MODE, pid: S.pid, seed: S.seed, lang: S.lang, name: S.name, exp: S.exp, started: S.started,
updated: new Date().toISOString(), finished: S.finished, n: nDone(), order: S.order,
assign: S.assign, answers: S.ans, code: code() };
}
// Outside claude.ai (the public Hugging Face Space) the host page sets window.PBE_REMOTE to its save endpoint.
const REMOTE = typeof window.PBE_REMOTE === "string" ? window.PBE_REMOTE : null;
let rTimer = null, rBusy = false, rDirty = false;
async function remoteSync() {
if (!REMOTE || !S) return;
if (rBusy) { rDirty = true; return; }
rBusy = true;
try {
const r = await fetch(REMOTE, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload()) });
dbState = r.ok ? "ok" : "err";
} catch (e) { dbState = "err"; }
rBusy = false; showSaved();
if (rDirty) { rDirty = false; remoteSync(); }
}
function save() {
lsSet();
if (REMOTE) { clearTimeout(rTimer); rTimer = setTimeout(remoteSync, 800); }
if (!dbDoc) { showSaved(); return; }
clearTimeout(syncTimer);
syncTimer = setTimeout(sync, 500);
}
async function sync() {
if (!dbDoc) return;
if (writing) { dirty = true; return; }
writing = true;
try {
const body = { runs: { [runKey()]: payload() } }; // one doc per person, one entry per study version and mode
if (docExists) {
try { await dbDoc.update(body); }
catch (e) { if (e && e.code === "invalid_argument") { docExists = false; await dbDoc.set(body); } else throw e; }
} else await dbDoc.set(body);
docExists = true; dbState = "ok";
}
catch (e) {
const c = e && e.code;
if (c === "invalid_argument" || c === "not_granted" || c === "revoked" || c === "capability_disabled" || c === "capability_removed") { dbDoc = null; dbState = "off"; }
else dbState = "err";
}
writing = false; showSaved();
if (dirty && dbDoc) { dirty = false; sync(); }
}
function showSaved() {
const el = $("savedMsg");
if (!S || !Object.keys(S.ans).length) { el.textContent = ""; return; }
el.textContent = "· " + (dbState === "ok" ? tr("savedSync") : tr("savedLocal"));
el.className = "saved" + (dbState === "ok" ? " ok" : "");
}
/* ---------- screens ---------- */
function show(id) { ["welcome", "trial", "break", "done"].forEach((s) => { $(s).hidden = s !== id; }); window.scrollTo(0, 0); }
function begin() {
if (!S) S = newState();
S.lang = lang; S.name = $("pname").value.trim();
const e = document.querySelector('input[name="exp"]:checked'); S.exp = e ? e.value : S.exp;
begun = true;
save();
const k = firstIncomplete();
if (k === null) return finish();
if (k === S.nImg && k > 0) { S.pos = k; lsSet(); show("break"); return; }
goto(k);
}
function goto(pos) {
teardown();
S.pos = Math.max(0, Math.min(pos, S.order.length));
lsSet();
if (S.pos >= S.order.length) return finish();
show("trial");
mountTrial(S.order[S.pos]);
}
function teardown() {
clearTimeout(advTimer);
if (cur && cur.stop) cur.stop();
setZoom(false);
cur = null;
}
async function finish() {
teardown();
const n = nDone(), N = S.order.length;
S.finished = n === N;
lsSet();
show("done");
$("codeBox").textContent = code();
$("copyMsg").textContent = "";
$("reviewBtn").textContent = n === N ? tr("review") : tr("continue");
const st = $("doneStatus");
const paint = () => {
st.textContent = (n === N ? "" : tr("savedPartial", { n, N }) + " ") + (dbState === "ok" ? tr("savedOk") : "");
st.className = "status" + (dbState === "ok" ? " ok" : "");
};
paint();
if (dbDoc) { clearTimeout(syncTimer); await sync(); paint(); }
if (REMOTE) { clearTimeout(rTimer); await remoteSync(); paint(); }
$("doneBody").textContent = dbState === "ok" ? tr("keepCode") : tr("sendCode");
}
/* ---------- trial ---------- */
function mediaURL(file) { return blobs.get(file) || file; }
function mountTrial(tid) {
const t = T[tid], a = S.assign[tid], pair = t.pairs[a.v];
const L = a.s ? pair[1] : pair[0], R = a.s ? pair[0] : pair[1];
cur = { tid, t, L, R, shownAt: performance.now(), watched: t.kind === "image" || answered(S.ans[tid]), failed: false, stop: null };
const isVid = t.kind === "video";
const k = S.pos, nI = S.nImg;
$("partName").textContent = isVid ? tr("partVid") : tr("partImg");
$("count").textContent = isVid ? `${k - nI + 1} / ${S.order.length - nI}` : `${k + 1} / ${nI}`;
$("trackFill").style.width = (100 * k / S.order.length).toFixed(1) + "%";
renderTrialText();
$("stageMsg").textContent = "";
$("vctl").hidden = !isVid;
const panels = [0, L, R];
if (isVid) mountVideo(t, panels); else mountImages(t, panels);
const r = S.ans[tid];
activeQ = r && r.q1 !== undefined ? 2 : 1;
paintAnswers();
prefetch(S.pos + 1); prefetch(S.pos + 2);
}
function renderTrialText() {
if (!cur) return;
const t = cur.t;
$("instr").innerHTML = segs(t.instr);
$("desc").innerHTML = t.desc && t.desc.length ? `${esc(tr("target"))} ${segs(t.desc)}` : "";
$("origLab").textContent = t.kind === "video" ? tr("origV") : tr("orig");
$("partName").textContent = t.kind === "video" ? tr("partVid") : tr("partImg");
$("zoomBtn").textContent = $("stage").classList.contains("zoomed") ? tr("shrink") : tr("enlarge");
paintAnswers();
}
function mountImages(t, panels) {
panels.forEach((p, i) => {
const host = $("m" + i);
host.innerHTML = "";
const d = document.createElement("div");
d.className = "media";
d.style.aspectRatio = `${t.w} / ${t.h}`;
d.style.backgroundImage = `url("${t.file}")`;
d.style.backgroundSize = `${t.n * 100}% 100%`;
d.style.backgroundPosition = `${t.n > 1 ? (100 * p / (t.n - 1)).toFixed(4) : 0}% 0%`;
d.setAttribute("role", "img");
d.setAttribute("aria-label", i === 0 ? tr("orig") : (i === 1 ? "A" : "B"));
d.addEventListener("click", () => setZoom());
host.appendChild(d);
});
const probe = new Image();
probe.onerror = () => { if (cur && cur.t === t) { $("stageMsg").textContent = tr("loadFail"); cur.failed = true; paintAnswers(); } };
probe.src = t.file;
}
function mountVideo(t, panels) {
const v = document.createElement("video");
v.muted = true; v.loop = true; v.playsInline = true; v.preload = "auto";
v.setAttribute("playsinline", ""); v.setAttribute("muted", "");
v.style.cssText = "position:absolute;width:2px;height:2px;opacity:0;pointer-events:none;left:0;top:0";
document.body.appendChild(v);
const ctxs = panels.map((p, i) => {
const host = $("m" + i); host.innerHTML = "";
const c = document.createElement("canvas");
c.className = "media"; c.width = t.w; c.height = t.h;
c.style.aspectRatio = `${t.w} / ${t.h}`;
c.addEventListener("click", () => setZoom());
host.appendChild(c);
const g = c.getContext("2d"); g.fillStyle = "#2c2c2c"; g.fillRect(0, 0, t.w, t.h);
return g;
});
const fps = t.fps, nf = t.frames, dur = nf / fps;
const scrub = $("scrub"); scrub.max = String(nf - 1); scrub.value = "0";
let alive = true, raf = 0, lastNow = 0, playedMs = 0;
const draw = () => {
if (v.readyState >= 2) panels.forEach((p, i) => ctxs[i].drawImage(v, 0, p * (t.stride || t.h), t.w, t.h, 0, 0, t.w, t.h));
const f = Math.min(nf - 1, Math.floor(v.currentTime * fps + 1e-3));
if (document.activeElement !== scrub) scrub.value = String(f);
$("frameNo").textContent = `${f + 1} / ${nf}`;
// gate: one clip length of real playback (scrubbing does not count)
const now = performance.now();
if (lastNow && !v.paused && v.readyState >= 3) playedMs += (now - lastNow) * v.playbackRate;
lastNow = now;
if (!cur.watched && playedMs >= dur * 1000 * 0.97) { cur.watched = true; paintAnswers(); }
};
const tick = () => { if (!alive) return; draw(); raf = requestAnimationFrame(tick); };
const setPlayLabel = () => { $("playBtn").textContent = v.paused ? tr("paused") : tr("play"); };
v.addEventListener("loadeddata", () => { if (!alive) return; $("stageMsg").textContent = ""; draw(); });
v.addEventListener("playing", setPlayLabel);
v.addEventListener("pause", setPlayLabel);
v.addEventListener("error", () => { if (alive) { $("stageMsg").textContent = tr("loadFail"); cur.failed = true; paintAnswers(); } });
$("stageMsg").textContent = tr("loading");
v.src = mediaURL(t.file);
const slow = $("slowBtn");
v.playbackRate = slow.getAttribute("aria-pressed") === "true" ? 0.5 : 1;
v.play().catch(() => setPlayLabel());
raf = requestAnimationFrame(tick);
cur.video = v;
cur.toggle = () => { if (v.paused) v.play().catch(() => {}); else v.pause(); };
cur.stop = () => { alive = false; cancelAnimationFrame(raf); try { v.pause(); v.removeAttribute("src"); v.load(); } catch (e) {} v.remove(); };
setPlayLabel();
}
function paintAnswers() {
if (!cur) return;
const r = S.ans[cur.tid] || {}, gated = !cur.watched;
[["q1L", "q1", 0], ["q1R", "q1", 1], ["q2L", "q2", 0], ["q2R", "q2", 1]].forEach(([id, q, val]) => {
const b = $(id);
b.setAttribute("aria-pressed", String(r[q] === val));
b.disabled = gated;
});
$("row1").classList.toggle("active", activeQ === 1);
$("row2").classList.toggle("active", activeQ === 2);
$("gateMsg").textContent = gated && !cur.failed ? tr("watch") : "";
const last = S.pos === S.order.length - 1;
$("nextBtn").textContent = cur.failed && !answered(r) ? tr("skip") : (last ? tr("finish") : tr("next"));
$("nextBtn").disabled = !(answered(r) || cur.failed);
$("backBtn").disabled = S.pos === 0;
}
function answer(q, val) {
if (!cur || !cur.watched) return;
const prev = S.ans[cur.tid];
const r = Object.assign({}, prev || {});
const wasComplete = answered(prev);
delete r.skip;
r[q] = val;
r.p = [cur.L, cur.R];
r.v = S.assign[cur.tid].v; r.s = S.assign[cur.tid].s;
if (r.q1 !== undefined && r.q2 !== undefined && !wasComplete) { r.ms = Math.round(performance.now() - cur.shownAt); r.at = new Date().toISOString(); }
S.ans[cur.tid] = r;
activeQ = 2;
paintAnswers();
save();
clearTimeout(advTimer);
if (!wasComplete && r.q1 !== undefined && r.q2 !== undefined) advTimer = setTimeout(next, 420);
}
function next() {
if (!cur) return;
const r = S.ans[cur.tid];
if (!answered(r)) {
if (!cur.failed) return;
S.ans[cur.tid] = { skip: 1, at: new Date().toISOString() }; // media did not load: recorded as skipped
save();
}
const nxt = S.pos + 1;
if (nxt === S.nImg && nxt < S.order.length) { teardown(); S.pos = nxt; lsSet(); show("break"); return; }
goto(nxt);
}
function back() { if (S.pos > 0) goto(S.pos - 1); }
function setZoom(on) {
const st = $("stage");
const z = on === undefined ? !st.classList.contains("zoomed") : on;
st.classList.toggle("zoomed", z);
$("zoomNote").hidden = !z;
$("zoomBtn").textContent = z ? tr("shrink") : tr("enlarge");
}
function prefetch(pos) {
if (!S || pos >= S.order.length) return;
const t = T[S.order[pos]];
if (t.kind === "image") { const im = new Image(); im.src = t.file; return; }
if (blobs.has(t.file)) return;
blobs.set(t.file, t.file);
fetch(t.file).then((r) => r.ok ? r.blob() : null).then((b) => {
if (!b) { blobs.delete(t.file); return; }
blobs.set(t.file, URL.createObjectURL(b));
if (blobs.size > 8) {
const keep = new Set(S.order.slice(Math.max(0, S.pos - 1), S.pos + 4).map((id) => T[id].file));
for (const [f, u] of blobs) if (!keep.has(f) && blobs.size > 8) { if (u.startsWith("blob:")) URL.revokeObjectURL(u); blobs.delete(f); }
}
}).catch(() => blobs.delete(t.file));
}
/* ---------- wiring ---------- */
document.querySelectorAll("[data-lang]").forEach((b) => b.addEventListener("click", () => {
lang = b.dataset.lang; if (S) { S.lang = lang; lsSet(); }
try { localStorage.setItem("pbe-lang", lang); } catch (e) {}
applyLang();
}));
$("start").addEventListener("click", begin);
$("restart").addEventListener("click", () => {
lsDel(); S = null; setStartLabel();
if (dbDoc && docExists && D) dbDoc.update({ runs: { [runKey()]: null } }).catch(() => {});
});
$("q1L").addEventListener("click", () => answer("q1", 0));
$("q1R").addEventListener("click", () => answer("q1", 1));
$("q2L").addEventListener("click", () => answer("q2", 0));
$("q2R").addEventListener("click", () => answer("q2", 1));
$("row1").addEventListener("click", (e) => { if (e.target.closest("button")) return; activeQ = 1; paintAnswers(); });
$("row2").addEventListener("click", (e) => { if (e.target.closest("button")) return; activeQ = 2; paintAnswers(); });
$("nextBtn").addEventListener("click", next);
$("backBtn").addEventListener("click", back);
$("stopBtn").addEventListener("click", finish);
$("zoomBtn").addEventListener("click", () => setZoom());
$("toVideos").addEventListener("click", () => goto(S.pos));
$("reviewBtn").addEventListener("click", () => {
const k = firstIncomplete();
if (k !== null && k === S.nImg && k > 0) { S.pos = k; lsSet(); show("break"); return; }
goto(k === null ? S.order.length - 1 : k);
});
$("playBtn").addEventListener("click", () => cur && cur.toggle && cur.toggle());
$("slowBtn").addEventListener("click", () => {
const b = $("slowBtn"), on = b.getAttribute("aria-pressed") !== "true";
b.setAttribute("aria-pressed", String(on));
if (cur && cur.video) cur.video.playbackRate = on ? 0.5 : 1;
});
$("scrub").addEventListener("change", () => $("scrub").blur());
$("scrub").addEventListener("input", () => {
if (!cur || !cur.video) return;
const v = cur.video; v.pause();
v.currentTime = (Number($("scrub").value) + 0.5) / cur.t.fps;
});
$("copyBtn").addEventListener("click", () => {
const txt = $("codeBox").textContent;
const fail = () => {
const rg = document.createRange(); rg.selectNodeContents($("codeBox"));
const sel = getSelection(); sel.removeAllRanges(); sel.addRange(rg);
$("copyMsg").textContent = tr("copyFail");
};
try { navigator.clipboard.writeText(txt).then(() => { $("copyMsg").textContent = tr("copied"); }, fail); } catch (e) { fail(); }
});
document.addEventListener("keydown", (e) => {
if (e.target.matches && e.target.matches('input[type="text"], textarea, select')) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
if ($("trial").hidden) { if (e.key === "Enter" && !$("break").hidden) { e.preventDefault(); goto(S.pos); } return; }
const k = e.key;
if (k === "a" || k === "A" || k === "ArrowLeft") { e.preventDefault(); answer(activeQ === 1 ? "q1" : "q2", 0); }
else if (k === "b" || k === "B" || k === "ArrowRight") { e.preventDefault(); answer(activeQ === 1 ? "q1" : "q2", 1); }
else if (k === "ArrowUp") { activeQ = 1; paintAnswers(); }
else if (k === "ArrowDown") { activeQ = 2; paintAnswers(); }
else if (k === "Enter") { e.preventDefault(); next(); }
else if (k === "Backspace") { e.preventDefault(); back(); }
else if (k === " ") { if (cur && cur.toggle) { e.preventDefault(); cur.toggle(); } }
else if (k === "z" || k === "Z") setZoom();
else if (k === "Escape") setZoom(false);
});
/* ---------- organizer (owner only) ---------- */
async function organizer(db) {
$("organizer").hidden = false;
const refresh = async () => {
try {
const [rs, cs] = await Promise.all([db.collection("responses").get(), db.collection("codes").get()]);
let ans = 0, fin = 0;
let runs = 0;
rs.docs.forEach((d) => {
const x = d.data() || {};
const list = x.runs ? Object.values(x.runs).filter(Boolean) : [x];
list.forEach((r) => { runs++; ans += r.n || 0; if (r.finished) fin++; });
});
$("orgStats").textContent = `Saved here: ${rs.size} people, ${runs} runs (${fin} finished), ${ans} answered pairs · codes added: ${cs.size}`;
} catch (e) { $("orgStats").textContent = "Could not read the saved answers right now."; }
};
$("addCodes").addEventListener("click", async () => {
const N = D ? D.trials.length : 0;
const lines = $("codesIn").value.split(/\s+/).map((s) => s.trim())
.filter((s) => /^PBE1\.[0-9a-z]+\.[0-2x]\.[A-Za-n~]+$/.test(s) && s.split(".")[3].length === N);
if (!lines.length) { $("codeMsg").textContent = `No valid code found (codes start with PBE1. and have ${N} answer characters).`; return; }
let ok = 0;
for (const c of lines) {
const pid = c.split(".")[1];
try { await db.collection("codes").doc(pid).set({ code: c, added: new Date().toISOString() }); ok++; } catch (e) {}
}
$("codeMsg").textContent = `Added ${ok} of ${lines.length}.`;
$("codesIn").value = "";
refresh();
});
refresh();
}
async function connect() {
if (!window.claude || !window.claude.use) return;
let user = null, db = null;
try { [user, db] = await Promise.all([window.claude.use("user"), window.claude.use("db")]); } catch (e) { return; }
if (!user || !db) return;
let uid = null;
try { uid = await user.id(); } catch (e) {}
try { if (await user.isOwner()) organizer(db); } catch (e) {}
if (!uid) return;
const ref = db.doc("responses/" + uid);
try {
const snap = await ref.get();
const doc = snap.exists ? snap.data() : null;
let x = doc && doc.runs ? doc.runs[runKey()] : null;
if (!x && doc && MODE === "full" && doc.study === (D && D.version)) x = doc; // first-build docs kept one flat run
const nx = x ? Object.values(x.answers || {}).filter(complete).length : 0;
if (x && D && !begun && x.study === D.version && x.order && x.order.length === modeTrials().length && (!S || nx > nDone())) {
S = { study: x.study, mode: MODE, seed: x.seed, pid: x.pid, lang: x.lang || lang, name: x.name || "", exp: x.exp || "", order: x.order,
nImg: x.order.filter((id) => T[id] && T[id].kind === "image").length, assign: x.assign, ans: JSON.parse(JSON.stringify(x.answers || {})),
pos: 0, started: x.started, finished: !!x.finished };
S.pos = firstIncomplete() ?? S.order.length;
$("pname").value = S.name;
if (S.exp !== "") { const el = $("exp" + S.exp); if (el) el.checked = true; }
lsSet(); setStartLabel();
}
dbDoc = ref; docExists = snap.exists; dbState = snap.exists ? "ok" : "none";
if (S && Object.keys(S.ans).length && begun) sync();
} catch (e) { dbDoc = null; }
}
/* ---------- boot ---------- */
(async function boot() {
try { const l = localStorage.getItem("pbe-lang"); if (l === "zh" || l === "en") lang = l; } catch (e) {}
applyLang();
$("loadmsg").textContent = tr("loadingData");
try {
const r = await fetch("data.json");
D = await r.json();
} catch (e) { $("loadmsg").textContent = tr("dataFail"); return; }
D.trials.forEach((t, k) => { T[t.id] = t; IDX[t.id] = k; });
const saved = lsGet();
if (saved && saved.study === D.version && saved.order && saved.order.length === modeTrials().length) {
S = saved; if (S.lang) lang = S.lang;
$("pname").value = S.name || "";
if (S.exp !== "") { const r = $("exp" + S.exp); if (r) r.checked = true; }
}
applyLang();
const ready = () => { $("loadmsg").textContent = ""; $("start").disabled = false; };
const timer = setTimeout(ready, 8000);
connect().finally(() => { clearTimeout(timer); ready(); });
})();
</script>
</body>
</html>