CoolFace
Apppublic

pacuaviva/image-composition-human-eval

sourceHugging Faceupdated 1mo agoView on Hugging Face
0likes
app.js973 linesDownload Raw Back to root
1"use strict";2 3const state = {4  manifest: null,5  mode: "main",6  raterId: null,7  index: 0,8  responses: new Map(),9  itemStartedAt: null,10  startedAt: null,11  tutorialVersion: 0,12  autosaveTimer: null,13  saveQueue: Promise.resolve(),14  saveRequestId: 0,15  editRevision: 0,16};17 18const participantStorageKey = "natural_composition_human_v2.participant_code";19const tutorialVersion = 1;20const autosaveDelayMs = 500;21const svgNamespace = "http://www.w3.org/2000/svg";22const hostedApiUrl = String(window.HUMAN_EVAL_API_URL || "").replace(/\/+$/, "");23 24const dimensions = [25  "edit_a_applied",26  "edit_b_applied",27  "composition_success",28  "source_preservation",29  "absence_unintended_changes",30  "visual_realism",31  "overall",32];33 34const tourState = {35  active: false,36  required: false,37  index: 0,38  previousFocus: null,39  layoutFrame: null,40  busy: false,41};42 43const tourSteps = [44  {45    title: "Edits",46    description: "The two Source-Target pairs demonstrate the transformations the model should compose.",47    targets: [48      {49        label: "A",50        selectors: ["[data-tour-target='edit-a-source']", "[data-tour-target='edit-a-target']"],51      },52      {53        label: "B",54        selectors: ["[data-tour-target='edit-b-source']", "[data-tour-target='edit-b-target']"],55      },56    ],57    callouts: [58      {label: "Edit A", text: "Compare A Source with A Target to infer the first transformation."},59      {label: "Edit B", text: "Compare B Source with B Target to infer the second transformation."},60    ],61    placement: ["right", "bottom", "left", "top"],62  },63  {64    title: "Composition",65    description: "Query is the new image to edit. Output is the model's attempt to compose both demonstrated transformations into a single result.",66    targets: [67      {label: "Query", selectors: ["[data-tour-target='query']"]},68      {label: "Output", selectors: ["[data-tour-target='output']"]},69    ],70    placement: ["right", "top", "left", "bottom"],71  },72  {73    title: "Ratings",74    description: "Rate each criterion independently from 1 to 5. The labels below every scale explain what 1, 3, and 5 mean; use 2 or 4 for an intermediate judgment.",75    targets: [76      {label: "Ratings", selectors: [".questions"]},77    ],78    placement: ["left", "bottom", "top", "right"],79  },80  {81    title: "Unclear demonstrations",82    description: "Mark A or B as unclear only when you cannot understand the intended edit from that Source-Target pair.",83    targets: [84      {label: "Unclear", selectors: [".unclear-row"]},85    ],86    placement: ["left", "bottom", "top", "right"],87  },88  {89    title: "Navigation",90    description: "Save and continue when an item is complete. You may also skip it or save and exit, then resume later with the same participant code.",91    targets: [92      {label: "Continue", selectors: [".form-footer"]},93    ],94    placement: ["left", "top", "bottom", "right"],95  },96  {97    title: "Guide",98    description: "Use Guide whenever you want to see this overview again. Replaying it will not change your ratings or move you to another item.",99    targets: [100      {label: "Guide", selectors: ["#guide-button"]},101    ],102    placement: ["left", "bottom", "top", "right"],103  },104];105 106function element(id) { return document.getElementById(id); }107 108function setGuideVisible(visible) {109  element("guide-button").hidden = !visible;110}111 112function nextAnimationFrame() {113  return new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));114}115 116async function waitForStudyImages() {117  const images = ["a-source", "a-target", "b-source", "b-target", "query", "output"]118    .map(element);119  const loaded = Promise.all(images.map(image => {120    if (image.complete) return Promise.resolve();121    return new Promise(resolve => {122      image.addEventListener("load", resolve, {once: true});123      image.addEventListener("error", resolve, {once: true});124    });125  }));126  await Promise.race([loaded, new Promise(resolve => setTimeout(resolve, 2500))]);127  await nextAnimationFrame();128}129 130function tourElementsForTarget(target) {131  const found = [];132  for (const selector of target.selectors) {133    found.push(...document.querySelectorAll(selector));134  }135  return found.filter(node => !node.hidden);136}137 138function tourTargetRect(target) {139  const rects = tourElementsForTarget(target).map(node => node.getBoundingClientRect());140  if (!rects.length) throw new Error(`Guide target not found: ${target.label || target.selectors[0]}`);141  const padding = 6;142  const left = Math.max(4, Math.min(...rects.map(rect => rect.left)) - padding);143  const top = Math.max(4, Math.min(...rects.map(rect => rect.top)) - padding);144  const right = Math.min(window.innerWidth - 4, Math.max(...rects.map(rect => rect.right)) + padding);145  const bottom = Math.min(window.innerHeight - 4, Math.max(...rects.map(rect => rect.bottom)) + padding);146  return {left, top, right, bottom, width: right - left, height: bottom - top};147}148 149function unionTourRects(rects) {150  const left = Math.min(...rects.map(rect => rect.left));151  const top = Math.min(...rects.map(rect => rect.top));152  const right = Math.max(...rects.map(rect => rect.right));153  const bottom = Math.max(...rects.map(rect => rect.bottom));154  return {left, top, right, bottom, width: right - left, height: bottom - top};155}156 157function makeSvgRect(attributes) {158  const rect = document.createElementNS(svgNamespace, "rect");159  for (const [name, value] of Object.entries(attributes)) rect.setAttribute(name, value);160  return rect;161}162 163function renderTourSpotlights(targets) {164  const width = window.innerWidth;165  const height = window.innerHeight;166  const spotlight = element("tour-spotlight");167  spotlight.setAttribute("viewBox", `0 0 ${width} ${height}`);168  for (const id of ["tour-mask-base", "tour-shade"]) {169    element(id).setAttribute("width", width);170    element(id).setAttribute("height", height);171  }172 173  const holes = element("tour-mask-holes");174  const outlines = element("tour-outlines");175  holes.replaceChildren();176  outlines.replaceChildren();177 178  const rects = targets.map(tourTargetRect);179  rects.forEach((rect, index) => {180    holes.append(makeSvgRect({181      x: rect.left,182      y: rect.top,183      width: rect.width,184      height: rect.height,185      rx: 6,186      fill: "black",187    }));188    outlines.append(makeSvgRect({189      x: rect.left,190      y: rect.top,191      width: rect.width,192      height: rect.height,193      rx: 6,194      fill: "none",195      stroke: "#58b99a",196      "stroke-width": 3,197    }));198  });199  return rects;200}201 202function popoverCandidate(position, target, width, height) {203  const gap = 16;204  if (position === "right") {205    return {left: target.right + gap, top: target.top + (target.height - height) / 2};206  }207  if (position === "left") {208    return {left: target.left - width - gap, top: target.top + (target.height - height) / 2};209  }210  if (position === "top") {211    return {left: target.left + (target.width - width) / 2, top: target.top - height - gap};212  }213  return {left: target.left + (target.width - width) / 2, top: target.bottom + gap};214}215 216function positionTourPopover(rects, placement) {217  const popover = element("tour-popover");218  const margin = 14;219  const width = popover.offsetWidth;220  const height = popover.offsetHeight;221  const target = unionTourRects(rects);222  let candidate = null;223  for (const position of placement) {224    const proposed = popoverCandidate(position, target, width, height);225    const fits = (226      proposed.left >= margin227      && proposed.top >= margin228      && proposed.left + width <= window.innerWidth - margin229      && proposed.top + height <= window.innerHeight - margin230    );231    if (fits) {232      candidate = proposed;233      break;234    }235  }236  if (!candidate) candidate = popoverCandidate(placement[0], target, width, height);237  const left = Math.min(Math.max(candidate.left, margin), window.innerWidth - width - margin);238  const top = Math.min(Math.max(candidate.top, margin), window.innerHeight - height - margin);239  popover.style.left = `${Math.round(left)}px`;240  popover.style.top = `${Math.round(top)}px`;241}242 243function updateTourLayout() {244  if (!tourState.active) return;245  const step = tourSteps[tourState.index];246  try {247    const rects = renderTourSpotlights(step.targets);248    positionTourPopover(rects, step.placement);249    element("tour-error").textContent = "";250  } catch (error) {251    element("tour-error").textContent = error.message;252  }253}254 255function scheduleTourLayout() {256  if (!tourState.active || tourState.layoutFrame !== null) return;257  tourState.layoutFrame = requestAnimationFrame(() => {258    tourState.layoutFrame = null;259    updateTourLayout();260  });261}262 263function centerTourTargetsOnSmallScreen(step) {264  if (window.innerWidth > 900) return;265  const elements = step.targets.flatMap(tourElementsForTarget);266  if (!elements.length) return;267  const rects = elements.map(node => node.getBoundingClientRect());268  const documentTop = window.scrollY + Math.min(...rects.map(rect => rect.top));269  const documentBottom = window.scrollY + Math.max(...rects.map(rect => rect.bottom));270  const targetScroll = (documentTop + documentBottom) / 2 - window.innerHeight / 2;271  window.scrollTo({top: Math.max(0, targetScroll), behavior: "auto"});272}273 274function renderTourDescription(step) {275  const root = element("tour-description");276  root.replaceChildren();277  const description = document.createElement("p");278  description.textContent = step.description;279  root.append(description);280  if (step.callouts?.length) {281    const callouts = document.createElement("div");282    callouts.className = "tour-callouts";283    for (const item of step.callouts) {284      const callout = document.createElement("div");285      callout.className = "tour-callout";286      const label = document.createElement("strong");287      label.textContent = item.label;288      const text = document.createElement("span");289      text.textContent = item.text;290      callout.append(label, text);291      callouts.append(callout);292    }293    root.append(callouts);294  }295}296 297function renderTourStep() {298  const step = tourSteps[tourState.index];299  element("tour-progress").textContent = `Guide ${tourState.index + 1} of ${tourSteps.length}`;300  element("tour-title").textContent = step.title;301  renderTourDescription(step);302  element("tour-back-button").disabled = tourState.index === 0;303  const isLast = tourState.index === tourSteps.length - 1;304  element("tour-next-button").textContent = isLast305    ? (tourState.required ? "Finish tutorial" : "Done")306    : "Next";307  element("tour-close-button").hidden = tourState.required;308  element("tour-error").textContent = "";309  centerTourTargetsOnSmallScreen(step);310  updateTourLayout();311  element("tour-popover").focus({preventScroll: true});312}313 314function startTour({required = false} = {}) {315  if (tourState.active) return;316  tourState.active = true;317  tourState.required = required;318  tourState.index = 0;319  tourState.busy = false;320  tourState.previousFocus = document.activeElement;321  element("study-view").inert = true;322  element("tour-overlay").hidden = false;323  element("tour-overlay").setAttribute("aria-hidden", "false");324  document.body.classList.add("tour-open");325  renderTourStep();326}327 328function endTour() {329  if (!tourState.active) return;330  tourState.active = false;331  if (tourState.layoutFrame !== null) cancelAnimationFrame(tourState.layoutFrame);332  tourState.layoutFrame = null;333  element("study-view").inert = false;334  element("tour-overlay").hidden = true;335  element("tour-overlay").setAttribute("aria-hidden", "true");336  document.body.classList.remove("tour-open");337  if (tourState.previousFocus?.isConnected && tourState.previousFocus.getClientRects().length) {338    tourState.previousFocus.focus({preventScroll: true});339  } else {340    element("guide-button").focus({preventScroll: true});341  }342}343 344async function advanceTour() {345  if (tourState.busy) return;346  if (tourState.index < tourSteps.length - 1) {347    tourState.index += 1;348    renderTourStep();349    return;350  }351  if (!tourState.required) {352    endTour();353    return;354  }355  const nextButton = element("tour-next-button");356  tourState.busy = true;357  nextButton.disabled = true;358  element("tour-error").textContent = "";359  state.tutorialVersion = tutorialVersion;360  try {361    await save(false);362    endTour();363    state.itemStartedAt = performance.now();364  } catch (error) {365    state.tutorialVersion = 0;366    element("tour-error").textContent = `Could not save tutorial progress: ${error.message}`;367  } finally {368    tourState.busy = false;369    nextButton.disabled = false;370  }371}372 373function retreatTour() {374  if (tourState.busy || tourState.index === 0) return;375  tourState.index -= 1;376  renderTourStep();377}378 379async function replayTour() {380  await waitForStudyImages();381  startTour({required: false});382}383 384function handleTourKeydown(event) {385  if (!tourState.active) return;386  if (event.key === "Escape" && !tourState.required) {387    event.preventDefault();388    endTour();389    return;390  }391  if (event.key === "ArrowRight") {392    event.preventDefault();393    advanceTour();394    return;395  }396  if (event.key === "ArrowLeft") {397    event.preventDefault();398    retreatTour();399    return;400  }401  if (event.key !== "Tab") return;402  const focusable = [...element("tour-popover").querySelectorAll("button:not([hidden]):not(:disabled)")];403  if (!focusable.length) return;404  const first = focusable[0];405  const last = focusable[focusable.length - 1];406  if (event.shiftKey && document.activeElement === first) {407    event.preventDefault();408    last.focus();409  } else if (!event.shiftKey && document.activeElement === last) {410    event.preventDefault();411    first.focus();412  }413}414 415function generateParticipantCode() {416  const bytes = new Uint8Array(16);417  crypto.getRandomValues(bytes);418  const suffix = Array.from(bytes, value => value.toString(16).padStart(2, "0")).join("");419  return `participant_${suffix}`;420}421 422function storedParticipantCode() {423  try {424    return localStorage.getItem(participantStorageKey);425  } catch (_) {426    return null;427  }428}429 430function rememberParticipantCode(code) {431  try {432    localStorage.setItem(participantStorageKey, code);433  } catch (_) {434    // The code remains usable for this session when browser storage is unavailable.435  }436}437 438function participantProgressKey(code) {439  return `${participantStorageKey}.progress.${code}`;440}441 442function participantDraftsKey(code) {443  return `${participantStorageKey}.drafts.${code}`;444}445 446function storedParticipantProgress(code) {447  try {448    const serialized = localStorage.getItem(participantProgressKey(code));449    return serialized ? JSON.parse(serialized) : null;450  } catch (_) {451    return null;452  }453}454 455function rememberParticipantProgress(responsePayload) {456  try {457    localStorage.setItem(458      participantProgressKey(responsePayload.rater_id),459      JSON.stringify(responsePayload),460    );461  } catch (_) {462    // Remote persistence still succeeds if browser storage is unavailable.463  }464}465 466function storedParticipantDrafts(code) {467  try {468    const serialized = localStorage.getItem(participantDraftsKey(code));469    return serialized ? JSON.parse(serialized) : {};470  } catch (_) {471    return {};472  }473}474 475function writeParticipantDrafts(code, drafts) {476  try {477    if (Object.keys(drafts).length) {478      localStorage.setItem(participantDraftsKey(code), JSON.stringify(drafts));479    } else {480      localStorage.removeItem(participantDraftsKey(code));481    }482  } catch (_) {483    // Remote autosave remains available when browser storage is unavailable.484  }485}486 487function currentFormDraft() {488  const ratings = {};489  for (const dimension of dimensions) {490    const selected = document.querySelector(`input[name="${dimension}"]:checked`);491    ratings[dimension] = selected ? Number(selected.value) : null;492  }493  return {494    blind_id: currentItem().blind_id,495    ratings,496    unclear: {497      edit_a: element("unclear-a").checked,498      edit_b: element("unclear-b").checked,499    },500    comment: element("item-comment").value.trim(),501  };502}503 504function rememberCurrentDraft() {505  if (!state.manifest || !state.raterId) return;506  const draft = currentFormDraft();507  const drafts = storedParticipantDrafts(state.raterId);508  drafts[draft.blind_id] = draft;509  writeParticipantDrafts(state.raterId, drafts);510  state.editRevision += 1;511  element("save-status").textContent = "Draft saved";512}513 514function draftMatchesResponse(draft, response) {515  return JSON.stringify({516    ratings: draft.ratings,517    unclear: draft.unclear,518    comment: draft.comment,519  }) === JSON.stringify({520    ratings: response.ratings,521    unclear: response.unclear,522    comment: response.comment,523  });524}525 526function forgetPersistedDrafts(responsePayload) {527  const drafts = storedParticipantDrafts(responsePayload.rater_id);528  let changed = false;529  for (const response of responsePayload.responses) {530    const draft = drafts[response.blind_id];531    if (draft && draftMatchesResponse(draft, response)) {532      delete drafts[response.blind_id];533      changed = true;534    }535  }536  if (changed) writeParticipantDrafts(responsePayload.rater_id, drafts);537}538 539function initializeParticipantCode() {540  const code = storedParticipantCode() || generateParticipantCode();541  element("rater-code").value = code;542  rememberParticipantCode(code);543}544 545function replaceParticipantCode() {546  const code = generateParticipantCode();547  element("rater-code").value = code;548  rememberParticipantCode(code);549  element("participant-status").textContent = "A new participant code was created.";550  element("start-error").textContent = "";551}552 553function createQuestions() {554  const root = element("questions");555  root.replaceChildren();556  for (const question of state.manifest.questions) {557    const row = document.createElement("section");558    row.className = "question";559    row.dataset.dimension = question.key;560    const description = document.createElement("div");561    description.innerHTML = `<div class="question-title"></div><div class="question-text"></div>`;562    description.querySelector(".question-title").textContent = question.title;563    description.querySelector(".question-text").textContent = question.question;564    const control = document.createElement("div");565    const scale = document.createElement("div");566    scale.className = "scale";567    for (let value = 1; value <= 5; value += 1) {568      const label = document.createElement("label");569      label.innerHTML = `<input type="radio" name="${question.key}" value="${value}"><span>${value}</span>`;570      scale.append(label);571    }572    const anchors = document.createElement("div");573    anchors.className = "anchors";574    for (const value of ["1", "3", "5"]) {575      const anchor = document.createElement("span");576      anchor.textContent = question.anchors[value];577      anchors.append(anchor);578    }579    control.append(scale, anchors);580    row.append(description, control);581    root.append(row);582  }583}584 585function currentItem() { return state.manifest.items[state.index]; }586 587function nextUnansweredIndex(afterIndex) {588  const count = state.manifest.items.length;589  for (let offset = 1; offset <= count; offset += 1) {590    const index = (afterIndex + offset) % count;591    if (!state.responses.has(state.manifest.items[index].blind_id)) return index;592  }593  return null;594}595 596function updateProgressControls() {597  const item = currentItem();598  const count = state.manifest.items.length;599  element("progress").textContent = `Item ${state.index + 1} of ${count} · ${state.responses.size} completed`;600  element("previous-button").disabled = state.index === 0;601  const completesStudy = (602    state.responses.size === count603    || (!state.responses.has(item.blind_id) && state.responses.size === count - 1)604  );605  element("next-button").textContent = completesStudy ? "Finish" : "Save and next";606}607 608function setImage(id, relativePath) {609  element(id).src = `/${relativePath}`;610}611 612function renderItem() {613  cancelPendingAutosave();614  const item = currentItem();615  const assets = item.assets;616  setImage("a-source", assets.a_source);617  setImage("a-target", assets.a_target);618  setImage("b-source", assets.b_source);619  setImage("b-target", assets.b_target);620  setImage("query", assets.query);621  setImage("output", assets.output);622  updateProgressControls();623  element("form-error").textContent = "";624  element("rating-form").reset();625  element("unclear-a").checked = false;626  element("unclear-b").checked = false;627  const saved = state.responses.get(item.blind_id);628  const draft = storedParticipantDrafts(state.raterId)[item.blind_id];629  const displayed = draft || saved;630  if (displayed) {631    element("unclear-a").checked = displayed.unclear.edit_a;632    element("unclear-b").checked = displayed.unclear.edit_b;633    for (const [dimension, value] of Object.entries(displayed.ratings)) {634      if (value !== null) {635        const input = document.querySelector(`input[name="${dimension}"][value="${value}"]`);636        if (input) input.checked = true;637      }638    }639  }640  element("item-comment").value = displayed?.comment || "";641  updateUnclearState();642  state.itemStartedAt = performance.now();643  if (draft) {644    element("save-status").textContent = "Draft restored";645    scheduleAutosave();646  }647  window.scrollTo({top: 0, behavior: "instant"});648}649 650function updateUnclearState() {651  for (const [dimension, checkboxId] of [["edit_a_applied", "unclear-a"], ["edit_b_applied", "unclear-b"]]) {652    const disabled = element(checkboxId).checked;653    const row = document.querySelector(`[data-dimension="${dimension}"]`);654    row.classList.toggle("disabled", disabled);655    for (const input of row.querySelectorAll("input")) input.disabled = disabled;656    if (disabled) {657      for (const input of row.querySelectorAll("input")) input.checked = false;658    }659  }660}661 662function collectCurrentResponse() {663  const unclear = {edit_a: element("unclear-a").checked, edit_b: element("unclear-b").checked};664  const ratings = {};665  for (const dimension of dimensions) {666    const selected = document.querySelector(`input[name="${dimension}"]:checked`);667    const allowedNull = (dimension === "edit_a_applied" && unclear.edit_a) || (dimension === "edit_b_applied" && unclear.edit_b);668    if (!selected && !allowedNull) throw new Error("Please answer every question or mark the corresponding edit demonstration as unclear.");669    ratings[dimension] = selected ? Number(selected.value) : null;670  }671  const previous = state.responses.get(currentItem().blind_id);672  const elapsed = (performance.now() - state.itemStartedAt) / 1000 + (previous?.elapsed_seconds || 0);673  return {674    blind_id: currentItem().blind_id,675    position: state.index,676    ratings,677    unclear,678    comment: element("item-comment").value.trim(),679    elapsed_seconds: Math.round(elapsed * 10) / 10,680  };681}682 683function payload(complete = false) {684  return {685    schema_version: state.manifest.schema_version,686    study_id: state.manifest.study_id,687    set: state.mode,688    rater_id: state.raterId,689    started_at: state.startedAt,690    updated_at: new Date().toISOString(),691    tutorial_version: state.tutorialVersion,692    complete,693    responses: Array.from(state.responses.values()),694  };695}696 697async function callHostedApi(apiName, data) {698  const started = await fetch(`${hostedApiUrl}/gradio_api/call/${apiName}`, {699    method: "POST",700    headers: {"Content-Type": "application/json"},701    body: JSON.stringify({data}),702  });703  if (!started.ok) throw new Error(await started.text() || "Could not contact the evaluation server");704  const {event_id: eventId} = await started.json();705  if (!eventId) throw new Error("The evaluation server returned an invalid response");706 707  const completed = await fetch(`${hostedApiUrl}/gradio_api/call/${apiName}/${eventId}`);708  if (!completed.ok) throw new Error(await completed.text() || "The evaluation server request failed");709  const eventStream = await completed.text();710  for (const block of eventStream.split(/\r?\n\r?\n/)) {711    const lines = block.split(/\r?\n/);712    const event = lines.find(line => line.startsWith("event:"))?.slice(6).trim();713    const dataLine = lines.find(line => line.startsWith("data:"));714    if (!dataLine) continue;715    const eventData = dataLine.slice(5).trim();716    if (event === "complete") {717      const values = JSON.parse(eventData);718      return values[0];719    }720    if (event === "error") {721      let message = eventData;722      try { message = JSON.parse(eventData); } catch (_) {}723      throw new Error(typeof message === "string" ? message : "The evaluation server rejected the request");724    }725  }726  throw new Error("The evaluation server did not complete the request");727}728 729async function persistResponse(responsePayload) {730  let result;731  if (hostedApiUrl) {732    result = await callHostedApi("save_response", [responsePayload]);733  } else {734    const response = await fetch("/api/save", {735      method: "POST",736      headers: {"Content-Type": "application/json"},737      body: JSON.stringify(responsePayload),738    });739    result = await response.json();740    if (!response.ok) throw new Error(result.error || "Could not save responses");741  }742  rememberParticipantProgress(responsePayload);743  forgetPersistedDrafts(responsePayload);744  return result;745}746 747async function loadStoredResponse(mode, raterId) {748  if (hostedApiUrl) {749    const manifest = await callHostedApi("load_study", [mode, raterId]);750    const response = storedParticipantProgress(raterId);751    const responseMatches = response752      && response.schema_version === manifest.schema_version753      && response.study_id === manifest.study_id754      && response.set === mode755      && response.rater_id === raterId;756    return {manifest, response: responseMatches ? response : null};757  }758  const response = await fetch(`/api/response?mode=${encodeURIComponent(mode)}&rater=${encodeURIComponent(raterId)}`);759  const result = await response.json();760  if (!response.ok) throw new Error(result.error || "Invalid participant code");761  return result;762}763 764async function save(complete = false) {765  const responsePayload = payload(complete);766  const requestId = ++state.saveRequestId;767  const editRevision = state.editRevision;768  element("save-status").textContent = "Saving";769  const operation = state.saveQueue770    .catch(() => {})771    .then(() => persistResponse(responsePayload));772  state.saveQueue = operation;773  try {774    await operation;775    if (requestId === state.saveRequestId) {776      element("save-status").textContent = editRevision === state.editRevision777        ? (complete ? "Complete" : "Saved")778        : "Draft saved";779    }780  } catch (error) {781    if (requestId === state.saveRequestId) {782      element("save-status").textContent = "Saved on this device";783    }784    throw error;785  }786}787 788function cancelPendingAutosave() {789  if (state.autosaveTimer !== null) {790    clearTimeout(state.autosaveTimer);791    state.autosaveTimer = null;792  }793}794 795function scheduleAutosave() {796  cancelPendingAutosave();797  if (!state.manifest || !state.raterId) return;798  const blindId = currentItem().blind_id;799  state.autosaveTimer = setTimeout(async () => {800    state.autosaveTimer = null;801    if (currentItem().blind_id !== blindId) return;802    let response;803    try {804      response = collectCurrentResponse();805    } catch (_) {806      return;807    }808    state.responses.set(response.blind_id, response);809    updateProgressControls();810    try {811      await save(state.responses.size === state.manifest.items.length);812    } catch (_) {813      // The local draft remains available and a later save retries the response.814    }815  }, autosaveDelayMs);816}817 818function handleResponseInput() {819  rememberCurrentDraft();820  scheduleAutosave();821}822 823function handleUnclearChange() {824  updateUnclearState();825  handleResponseInput();826}827 828async function startStudy(event) {829  event?.preventDefault();830  const mode = "main";831  let raterId = element("rater-code").value.trim();832  if (!raterId) {833    raterId = generateParticipantCode();834    element("rater-code").value = raterId;835  }836  element("start-error").textContent = "";837  try {838    const result = await loadStoredResponse(mode, raterId);839    state.manifest = result.manifest;840    state.mode = mode;841    state.raterId = raterId;842    rememberParticipantCode(raterId);843    state.startedAt = result.response?.started_at || new Date().toISOString();844    state.tutorialVersion = Number(result.response?.tutorial_version || 0);845    state.responses = new Map((result.response?.responses || []).map(item => [item.blind_id, item]));846    if (result.response?.complete) {847      element("start-view").hidden = true;848      element("complete-view").hidden = false;849      element("progress").textContent = "Complete";850      element("save-status").textContent = "Complete";851      setGuideVisible(false);852      return;853    }854    const firstUnanswered = state.manifest.items.findIndex(item => !state.responses.has(item.blind_id));855    state.index = firstUnanswered < 0 ? state.manifest.items.length - 1 : firstUnanswered;856    createQuestions();857    element("start-view").hidden = true;858    element("study-view").hidden = false;859    setGuideVisible(true);860    renderItem();861    if (result.response === null) {862      await waitForStudyImages();863      startTour({required: true});864    }865  } catch (error) {866    element("start-error").textContent = error.message;867  }868}869 870async function submitCurrent(event) {871  event.preventDefault();872  cancelPendingAutosave();873  try {874    const response = collectCurrentResponse();875    state.responses.set(response.blind_id, response);876    const isComplete = state.responses.size === state.manifest.items.length;877    await save(isComplete);878    if (isComplete) {879      element("study-view").hidden = true;880      element("complete-view").hidden = false;881      element("progress").textContent = "Complete";882      setGuideVisible(false);883      return;884    }885    state.index = nextUnansweredIndex(state.index);886    renderItem();887  } catch (error) {888    element("form-error").textContent = error.message;889  }890}891 892async function skipItem() {893  cancelPendingAutosave();894  try {895    await save(false);896    const nextIndex = nextUnansweredIndex(state.index);897    if (nextIndex !== null && nextIndex !== state.index) {898      state.index = nextIndex;899      renderItem();900    } else {901      element("form-error").textContent = "This is the only remaining unanswered item. You may rate it or save and exit.";902    }903  } catch (error) {904    element("form-error").textContent = error.message;905  }906}907 908async function saveAndExit() {909  cancelPendingAutosave();910  try {911    try {912      const response = collectCurrentResponse();913      state.responses.set(response.blind_id, response);914    } catch (_) {915      // An incomplete current item remains unanswered and will be shown on resume.916    }917    const isComplete = state.responses.size === state.manifest.items.length;918    await save(isComplete);919    element("study-view").hidden = true;920    setGuideVisible(false);921    if (isComplete) {922      element("complete-view").hidden = false;923      element("progress").textContent = "Complete";924      return;925    }926    element("paused-view").hidden = false;927    element("progress").textContent = `${state.responses.size} of ${state.manifest.items.length} completed`;928  } catch (error) {929    element("form-error").textContent = error.message;930  }931}932 933function resumeStudy() {934  element("paused-view").hidden = true;935  element("study-view").hidden = false;936  setGuideVisible(true);937  renderItem();938}939 940function previousItem() {941  if (state.index === 0) return;942  cancelPendingAutosave();943  try {944    const response = collectCurrentResponse();945    state.responses.set(response.blind_id, response);946    save(false).catch(error => { element("save-status").textContent = error.message; });947  } catch (_) {948    // Moving back is allowed even if a newly visited item is incomplete.949  }950  state.index -= 1;951  renderItem();952}953 954element("start-form").addEventListener("submit", startStudy);955element("new-code-button").addEventListener("click", replaceParticipantCode);956element("guide-button").addEventListener("click", replayTour);957element("rating-form").addEventListener("submit", submitCurrent);958element("rating-form").addEventListener("change", handleResponseInput);959element("item-comment").addEventListener("input", handleResponseInput);960element("previous-button").addEventListener("click", previousItem);961element("skip-button").addEventListener("click", skipItem);962element("save-exit-button").addEventListener("click", saveAndExit);963element("resume-button").addEventListener("click", resumeStudy);964element("unclear-a").addEventListener("change", handleUnclearChange);965element("unclear-b").addEventListener("change", handleUnclearChange);966element("tour-back-button").addEventListener("click", retreatTour);967element("tour-next-button").addEventListener("click", advanceTour);968element("tour-close-button").addEventListener("click", endTour);969document.addEventListener("keydown", handleTourKeydown);970window.addEventListener("resize", scheduleTourLayout);971window.addEventListener("scroll", scheduleTourLayout, true);972initializeParticipantCode();973