html-api-retry-lab

v1 → v2 · +198 −42 · View latest

<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Retry policy lab</title><style>:root{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#292723;background:#faf8f5;line-height:1.55;--accent:#985238;--line:#dedbd4;--muted:#69645c;--green:#356346;color-scheme:light}*{box-sizing:border-box}body{margin:0}button,input,textarea,select{font:inherit}button,a,input,textarea,select{touch-action:manipulation}a{color:var(--accent);text-underline-offset:4px}button{cursor:pointer}button:disabled{cursor:wait;opacity:.65}button:focus-visible,a:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible{outline:3px solid #b86c4b;outline-offset:4px}::selection{background:#edd3c3}main{max-width:840px;margin:0 auto;padding:54px 28px 70px}h1,h2,h3,p{margin:0}h1{font:500 clamp(28px,4vw,40px)/1.18 Georgia,serif;letter-spacing:-.025em;text-wrap:balance}h2{font-size:19px;line-height:1.3;margin-bottom:14px}h3{font-size:16px}p{max-width:68ch}.intro{color:var(--muted);margin:14px 0 30px}.meta{font-size:13px;color:var(--muted);padding-bottom:22px;border-bottom:1px solid var(--line);margin-bottom:30px}.section{margin-top:32px}.options{display:grid;grid-template-columns:1fr 1fr;gap:18px}.option{position:relative;display:block;border:1px solid var(--line);border-radius:12px;background:#fff;padding:20px;cursor:pointer}.option:has(input:checked){border-color:var(--accent);background:#fcf2ea}.option-heading{display:flex;align-items:center;gap:9px;font-weight:600;margin-bottom:18px}input[type=radio],input[type=checkbox]{width:18px;height:18px;margin:0;accent-color:var(--accent)}.mock{background:#f1f0ec;border-radius:6px;padding:20px;min-height:170px}.mock .mock-title{font-size:16px;margin-bottom:8px}.mock p{font-size:13px;color:var(--muted);margin-bottom:20px}.mock-action{display:inline-block;font-size:12px;font-weight:600;background:#292723;color:#fff;padding:8px 12px;border-radius:5px}.mock-link{display:block;font-size:12px;margin-top:12px;color:var(--muted)}fieldset{border:0;padding:0;margin:0;min-width:0}legend{font-size:18px;font-weight:600;margin-bottom:16px}.field{display:block;margin:24px 0 8px;font-size:14px;font-weight:600}.optional{font-weight:400;color:var(--muted)}textarea{display:block;width:100%;min-height:88px;resize:vertical;border:1px solid #b9b5ad;border-radius:6px;background:#fff;padding:12px;color:inherit;caret-color:var(--accent)}.primary{background:var(--accent);color:#fff;border:0;border-radius:6px;min-height:44px;padding:10px 18px;font-size:14px;font-weight:600}.actions{display:flex;gap:14px;align-items:center;margin-top:20px;flex-wrap:wrap}.status{font-size:13px;color:var(--green)}.status.error{color:#a02e23}.saved{margin-top:28px;border-top:1px solid var(--line);padding-top:20px}.saved h2{font-size:15px;margin-bottom:4px}.saved time{font-size:12px;color:var(--muted)}dl{margin:14px 0 0;font-size:14px;display:grid;grid-template-columns:150px 1fr;gap:8px 20px}dt{color:var(--muted)}dd{margin:0;overflow-wrap:anywhere;white-space:pre-wrap}.check-row{display:flex;align-items:start;gap:12px;padding:18px 0;border-bottom:1px solid var(--line);cursor:pointer}.check-row input{margin-top:4px;flex-shrink:0}.check-row small{display:block;color:var(--muted);font-size:13px;margin-top:3px}.inline-choices{display:flex;gap:20px;flex-wrap:wrap}.inline-choices label{display:flex;gap:8px;align-items:center}.policy{display:grid;grid-template-columns:1fr 1fr;gap:28px;padding:24px;background:#eeede7;border-radius:10px;margin:26px 0}.policy label{display:block;font-size:14px;font-weight:600}.policy output{float:right;font-variant-numeric:tabular-nums}.policy input{width:100%;accent-color:var(--accent);margin-top:20px}.timeline{display:flex;gap:0;margin:28px 0;padding:0;list-style:none}.timeline li{flex:1;border-top:2px solid #8b9988;padding:14px 8px 0 0;font-size:12px;color:var(--muted)}.timeline strong{display:block;font-size:15px;color:#292723;font-variant-numeric:tabular-nums}.result{font-size:14px;color:var(--muted);margin-bottom:28px}.demo-links{margin:32px 0;border-top:1px solid var(--line)}.demo-links a{display:flex;gap:20px;justify-content:space-between;text-decoration:none;padding:23px 0;border-bottom:1px solid var(--line);color:inherit}.demo-links a:hover strong{color:var(--accent)}.demo-links strong{font-size:18px;display:block}.demo-links p{font-size:14px;color:var(--muted);margin-top:4px}.demo-links span{align-self:center;color:var(--accent)}.notice{font-size:13px;color:var(--muted)}.secondary{border:1px solid #b9b5ad;border-radius:6px;background:transparent;padding:8px 12px;color:inherit;margin-top:20px;min-height:40px;font-size:13px}[hidden]{display:none!important}@media(max-width:560px){main{padding:32px 20px 50px}.options,.policy{grid-template-columns:1fr}.mock{min-height:130px}.option{padding:16px}.intro{margin-bottom:22px}dl{grid-template-columns:1fr;gap:3px}dd{margin-bottom:10px}.timeline li{font-size:11px}.timeline strong{font-size:13px}}
<!doctype html><html lang="en"><head><meta name="thing:inputs" content="feedback"><meta name="thing:report" content="inline"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Retry policy lab</title><style>:root{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#292723;background:#faf8f5;line-height:1.55;--accent:#985238;--line:#dedbd4;--muted:#69645c;--green:#356346;color-scheme:light}*{box-sizing:border-box}body{margin:0}button,input,textarea,select{font:inherit}button,a,input,textarea,select{touch-action:manipulation}a{color:var(--accent);text-underline-offset:4px}button{cursor:pointer}button:disabled{cursor:wait;opacity:.65}button:focus-visible,a:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible{outline:3px solid #b86c4b;outline-offset:4px}::selection{background:#edd3c3}main{max-width:840px;margin:0 auto;padding:54px 28px 70px}h1,h2,h3,p{margin:0}h1{font:500 clamp(28px,4vw,40px)/1.18 Georgia,serif;letter-spacing:-.025em;text-wrap:balance}h2{font-size:19px;line-height:1.3;margin-bottom:14px}h3{font-size:16px}p{max-width:68ch}.intro{color:var(--muted);margin:14px 0 30px}.meta{font-size:13px;color:var(--muted);padding-bottom:22px;border-bottom:1px solid var(--line);margin-bottom:30px}.section{margin-top:32px}.options{display:grid;grid-template-columns:1fr 1fr;gap:18px}.option{position:relative;display:block;border:1px solid var(--line);border-radius:12px;background:#fff;padding:20px;cursor:pointer}.option:has(input:checked){border-color:var(--accent);background:#fcf2ea}.option-heading{display:flex;align-items:center;gap:9px;font-weight:600;margin-bottom:18px}input[type=radio],input[type=checkbox]{width:18px;height:18px;margin:0;accent-color:var(--accent)}.mock{background:#f1f0ec;border-radius:6px;padding:20px;min-height:170px}.mock .mock-title{font-size:16px;margin-bottom:8px}.mock p{font-size:13px;color:var(--muted);margin-bottom:20px}.mock-action{display:inline-block;font-size:12px;font-weight:600;background:#292723;color:#fff;padding:8px 12px;border-radius:5px}.mock-link{display:block;font-size:12px;margin-top:12px;color:var(--muted)}fieldset{border:0;padding:0;margin:0;min-width:0}legend{font-size:18px;font-weight:600;margin-bottom:16px}.field{display:block;margin:24px 0 8px;font-size:14px;font-weight:600}.optional{font-weight:400;color:var(--muted)}textarea{display:block;width:100%;min-height:88px;resize:vertical;border:1px solid #b9b5ad;border-radius:6px;background:#fff;padding:12px;color:inherit;caret-color:var(--accent)}.primary{background:var(--accent);color:#fff;border:0;border-radius:6px;min-height:44px;padding:10px 18px;font-size:14px;font-weight:600}.actions{display:flex;gap:14px;align-items:center;margin-top:20px;flex-wrap:wrap}.status{font-size:13px;color:var(--green)}.status.error{color:#a02e23}.saved{margin-top:28px;border-top:1px solid var(--line);padding-top:20px}.saved h2{font-size:15px;margin-bottom:4px}.saved time{font-size:12px;color:var(--muted)}dl{margin:14px 0 0;font-size:14px;display:grid;grid-template-columns:150px 1fr;gap:8px 20px}dt{color:var(--muted)}dd{margin:0;overflow-wrap:anywhere;white-space:pre-wrap}.check-row{display:flex;align-items:start;gap:12px;padding:18px 0;border-bottom:1px solid var(--line);cursor:pointer}.check-row input{margin-top:4px;flex-shrink:0}.check-row small{display:block;color:var(--muted);font-size:13px;margin-top:3px}.inline-choices{display:flex;gap:20px;flex-wrap:wrap}.inline-choices label{display:flex;gap:8px;align-items:center}.policy{display:grid;grid-template-columns:1fr 1fr;gap:28px;padding:24px;background:#eeede7;border-radius:10px;margin:26px 0}.policy label{display:block;font-size:14px;font-weight:600}.policy output{float:right;font-variant-numeric:tabular-nums}.policy input{width:100%;accent-color:var(--accent);margin-top:20px}.timeline{display:flex;gap:0;margin:28px 0;padding:0;list-style:none}.timeline li{flex:1;border-top:2px solid #8b9988;padding:14px 8px 0 0;font-size:12px;color:var(--muted)}.timeline strong{display:block;font-size:15px;color:#292723;font-variant-numeric:tabular-nums}.result{font-size:14px;color:var(--muted);margin-bottom:28px}.demo-links{margin:32px 0;border-top:1px solid var(--line)}.demo-links a{display:flex;gap:20px;justify-content:space-between;text-decoration:none;padding:23px 0;border-bottom:1px solid var(--line);color:inherit}.demo-links a:hover strong{color:var(--accent)}.demo-links strong{font-size:18px;display:block}.demo-links p{font-size:14px;color:var(--muted);margin-top:4px}.demo-links span{align-self:center;color:var(--accent)}.notice{font-size:13px;color:var(--muted)}.secondary{border:1px solid #b9b5ad;border-radius:6px;background:transparent;padding:8px 12px;color:inherit;margin-top:20px;min-height:40px;font-size:13px}[hidden]{display:none!important}@media(max-width:560px){main{padding:32px 20px 50px}.options,.policy{grid-template-columns:1fr}.mock{min-height:130px}.option{padding:16px}.intro{margin-bottom:22px}dl{grid-template-columns:1fr;gap:3px}dd{margin-bottom:10px}.timeline li{font-size:11px}.timeline strong{font-size:13px}}
.auth-prompt{margin:0 0 22px;color:var(--muted);font-size:14px}.auth-prompt button{border:0;padding:0;background:none;color:var(--accent);text-decoration:underline;text-underline-offset:4px;font-weight:600}.auth-prompt [role=status]{display:block;margin-top:4px}
.auth-note{margin:0 0 24px;color:var(--muted);font-size:14px}.auth-note button{background:none;border:0;padding:0;color:var(--accent);text-decoration:underline;text-underline-offset:4px;font-weight:600}fieldset:disabled{opacity:.65}#review-note{min-height:150px}footer{margin-top:48px;padding-top:20px;border-top:1px solid var(--line);font-size:12px;color:var(--muted)}footer a{margin-right:16px}</style></head><body><main><h1>How long should we keep trying?</h1><p class="intro">Explore a proposed webhook retry policy. Adjust the number of retries and the starting delay, then share the configuration you prefer.</p><p class="meta">Engineering proposal · Illustrative exponential backoff, without jitter</p><p class="auth-note"><button id="login" type="button" hidden>Sign in</button><span id="auth-message" role="status">Checking Thing sign-in…</span></p><form><fieldset id="review-fields" disabled><div class="policy"><div><label for="retries">Retries <output id="retry-count" for="retries">3</output></label><input id="retries" name="retries" data-label="Retries" type="range" min="1" max="4" value="3"></div><div><label for="delay">Starting delay <output id="delay-count" for="delay">2 seconds</output></label><input id="delay" name="delay" data-label="Initial delay (seconds)" type="range" min="1" max="10" value="2"></div></div><h2>If every attempt fails</h2><ol class="timeline" id="timeline" aria-label="Attempt timeline"></ol><p class="result" id="result" aria-live="polite"></p><label class="field" for="note">Why this configuration? <span class="optional">Optional</span></label><textarea id="note" name="note" data-label="Your note" placeholder="Consider delivery urgency, downstream load, or recovery time…"></textarea><div class="actions"><button class="primary" type="submit">Prepare policy note</button><span id="status" class="status" role="status"></span></div></fieldset></form><section class="saved" id="prepared" hidden><h2>Your technical exploration note</h2><label class="field" for="review-note">Ready to share</label><textarea id="review-note" readonly></textarea><button class="secondary" id="copy" type="button">Copy note</button><p class="notice">To share with the team, open <strong>Comments</strong> in Thing’s top bar and paste your note. Preparing a note does not post it.</p></section><footer><p>An interactive Thing example · Sign in before preparing a note. Your choices stay in this page until you reload; only comments you post are saved.</p><p><a target="_blank" rel="noopener" href="./html-api-design-review">Design review</a><a target="_blank" rel="noopener" href="./html-api-launch-rehearsal">Launch rehearsal</a><a target="_blank" rel="noopener" href="./html-api-retry-lab">Retry lab</a></p></footer></main><script>
.auth-note{margin:0 0 24px;color:var(--muted);font-size:14px}.auth-note button{background:none;border:0;padding:0;color:var(--accent);text-decoration:underline;text-underline-offset:4px;font-weight:600}fieldset:disabled{opacity:.65}#review-note{min-height:150px}footer{margin-top:48px;padding-top:20px;border-top:1px solid var(--line);font-size:12px;color:var(--muted)}footer a{margin-right:16px}</style></head><body><main><h1>How long should we keep trying?</h1><p class="intro">Explore a proposed webhook retry policy. Adjust the number of retries and the starting delay, then share the configuration you prefer.</p><p class="meta">Engineering proposal · Illustrative exponential backoff, without jitter</p><p class="auth-note"><button id="login" type="button" hidden>Sign in</button><span id="auth-message" role="status">Checking Thing sign-in…</span></p><form><fieldset id="review-fields" disabled><div class="policy"><div><label for="retries">Retries <output id="retry-count" for="retries">3</output></label><input id="retries" name="retries" data-label="Retries" type="range" min="1" max="4" value="3"></div><div><label for="delay">Starting delay <output id="delay-count" for="delay">2 seconds</output></label><input id="delay" name="delay" data-label="Initial delay (seconds)" type="range" min="1" max="10" value="2"></div></div><h2>If every attempt fails</h2><ol class="timeline" id="timeline" aria-label="Attempt timeline"></ol><p class="result" id="result" aria-live="polite"></p><label class="field" for="note">Why this configuration? <span class="optional">Optional</span></label><textarea id="note" name="note" data-label="Your note" placeholder="Consider delivery urgency, downstream load, or recovery time…"></textarea><div class="actions"><button class="primary" type="submit">Submit preference</button><span id="status" class="status" role="status"></span></div></fieldset></form><section class="saved" id="prepared" hidden><h2>Your saved response</h2><label class="field" for="review-note">Saved with your account</label><textarea id="review-note" readonly></textarea><p class="notice">Your latest response appears here. Updates are saved as new submissions so the publisher can follow how answers change.</p></section><footer><p>Thing Inputs · Responses are saved with your account and artifact version. The publisher can open Responses in the toolbar to explore the results and a custom report.</p><p><a target="_blank" rel="noopener" href="./html-api-design-review">Design review</a><a target="_blank" rel="noopener" href="./html-api-launch-rehearsal">Launch rehearsal</a><a target="_blank" rel="noopener" href="./html-api-retry-lab">Retry lab</a></p></footer></main><script>
function render(){const n=Number(document.getElementById('retries').value),d=Number(document.getElementById('delay').value);document.getElementById('retry-count').textContent=n;document.getElementById('delay-count').textContent=d+(d===1?' second':' seconds');const line=document.getElementById('timeline');line.replaceChildren();let t=0;for(let i=0;i<=n;i++){if(i)t+=d*2**(i-1);const li=document.createElement('li'),strong=document.createElement('strong');strong.textContent=t+'s';li.append(strong,document.createTextNode(i?'Retry '+i:'First attempt'));line.append(li);}document.getElementById('result').textContent=(n+1)+' attempts over '+t+' seconds, excluding request duration. After that, mark the delivery as failed.';}
document.querySelector('form').addEventListener('input',render);render();
</script><script>const form=document.querySelector('form');
const fields=document.getElementById('review-fields');
const login=document.getElementById('login');
const authMessage=document.getElementById('auth-message');
const status=document.getElementById('status');
const prepared=document.getElementById('prepared');
const note=document.getElementById('review-note');
async function refreshAuth(){
 if(!window.thing?.auth){authMessage.textContent='Open this example in the Thing viewer to use sign-in. This feature may not be enabled for this page.';return false;}
 try{
  const {signedIn}=await thing.auth.getState();
  login.hidden=signedIn;fields.disabled=!signedIn;
  authMessage.textContent=signedIn?'Signed in · Explore the page and prepare a note for the discussion.':' to prepare your review. You can read the proposal first.';
  if(!signedIn){prepared.hidden=true;note.value='';}
  return signedIn;
 }catch(error){fields.disabled=true;authMessage.textContent=error.message;return false;}
</script><script>const form = document.querySelector("form");
const fields = document.getElementById("review-fields");
const login = document.getElementById("login");
const authMessage = document.getElementById("auth-message");
const status = document.getElementById("status");
const prepared = document.getElementById("prepared");
const note = document.getElementById("review-note");
function restore(data) {
  for (const el of form.elements) {
    if (!el.name || !(el.name in data)) continue;
    const value = data[el.name];
    if (el.type === "radio") el.checked = el.value === value;
    else if (el.type === "checkbox") el.checked = value === "Checked";
    else el.value = value;
  }
  form.dispatchEvent(new Event("input", { bubbles: true }));
}
login.addEventListener('click',async()=>{
 login.disabled=true;
 try{await thing.auth.signIn();await refreshAuth();}
 catch(error){authMessage.textContent=error.message;}
 finally{login.disabled=false;}
});
form.addEventListener('submit',async event=>{
 event.preventDefault();
 if(!await refreshAuth())return;
 const data=Object.fromEntries(new FormData(form));
 for(const input of form.querySelectorAll('input[type=checkbox]'))data[input.name]=input.checked?'Checked':'Not checked';
 const lines=[document.title,''];
 for(const [name,value] of Object.entries(data)){
  const field=[...form.elements].find(el=>el.name===name);
  lines.push((field?.dataset.label||name)+': '+(value||'No note added'));
 }
 const result=document.getElementById('result');if(result)lines.push('',result.textContent);
 note.value=lines.join('\n');prepared.hidden=false;status.textContent='Note prepared. Share it through Comments when you’re ready.';
 note.focus();
function showSaved(row) {
  const lines = [
    "Saved response · v" + row.version,
    new Date(row.submittedAt).toLocaleString(),
    "",
  ];
  for (const [key, value] of Object.entries(row.data)) {
    const field = [...form.elements].find((el) => el.name === key);
    lines.push((field?.dataset.label || key) + ": " + (value ?? ""));
  }
  note.value = lines.join("\n");
  prepared.hidden = false;
  form.querySelector("button[type=submit]").textContent = "Update response";
}
async function refreshAuth() {
  if (!window.thing?.auth || !window.thing?.responses) {
    authMessage.textContent =
      "Inputs are unavailable on this page. Open it in the Thing viewer.";
    return false;
  }
  try {
    const { signedIn } = await thing.auth.getState();
    login.hidden = signedIn;
    fields.disabled = !signedIn;
    authMessage.textContent = signedIn
      ? "Signed in · Your response will be visible to the publisher and editors."
      : " to submit your response. You can read the proposal first.";
    if (!signedIn) {
      prepared.hidden = true;
      note.value = "";
    }
    return signedIn;
  } catch (error) {
    fields.disabled = true;
    authMessage.textContent = error.message;
    return false;
  }
}
login.addEventListener("click", async () => {
  login.disabled = true;
  try {
    await thing.auth.signIn();
    await refreshAuth();
  } catch (error) {
    authMessage.textContent = error.message;
  } finally {
    login.disabled = false;
  }
});
document.getElementById('copy').addEventListener('click',()=>{
 note.select();
 let copied=false;try{copied=document.execCommand('copy');}catch{}
 status.textContent=copied?'Copied. Open Comments in the top bar to share it.':'Note selected. Copy it, then open Comments in the top bar.';
let pending = null;
form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const button = form.querySelector("button[type=submit]");
  button.disabled = true;
  try {
    if (!(await refreshAuth())) return;
    const data = Object.fromEntries(new FormData(form));
    for (const input of form.querySelectorAll("input[type=checkbox]"))
      data[input.name] = input.checked ? "Checked" : "Not checked";
    const serialized = JSON.stringify(data);
    if (!pending || pending.serialized !== serialized)
      pending = { serialized, requestId: crypto.randomUUID() };
    status.textContent = "Saving…";
    const row = await thing.responses.submit("feedback", data, {
      requestId: pending.requestId,
    });
    pending = null;
    showSaved(row);
    status.textContent = "Response saved.";
  } catch (error) {
    status.textContent = error.message;
  } finally {
    button.disabled = false;
  }
});
refreshAuth();
async function report() {
  const main = document.querySelector("main");
  main.replaceChildren();
  const heading = document.createElement("h1");
  heading.textContent = document.title + " · Responses";
  const intro = document.createElement("p");
  intro.className = "intro";
  intro.textContent =
    "Latest answers by person and artifact version. Only the publisher and editors can open this report.";
  const summary = document.createElement("p"),
    chart = document.createElement("div"),
    table = document.createElement("div"),
    nav = document.createElement("div");
  nav.className = "actions";
  main.append(heading, intro, summary, chart, table, nav);
  let offset = 0;
  async function load() {
    summary.textContent = "Loading responses…";
    chart.replaceChildren();
    table.replaceChildren();
    nav.replaceChildren();
    try {
      const result = await thing.responses.list("feedback", {
        limit: 50,
        offset,
      });
      summary.textContent = result.total
        ? `${result.total} response${result.total === 1 ? "" : "s"} · Showing ${offset + 1}–${offset + result.responses.length}`
        : "No responses yet. Share the artifact to invite input.";
      const counts = new Map();
      for (const row of result.responses) {
        const key =
          row.data.direction || row.data.assessment || row.data.retries;
        if (key !== undefined)
          counts.set(String(key), (counts.get(String(key)) || 0) + 1);
      }
      if (counts.size) {
        const title = document.createElement("h2");
        title.textContent = "Choices on this page";
        title.style.marginTop = "28px";
        chart.append(title);
        for (const [label, count] of counts) {
          const p = document.createElement("p");
          p.textContent = `${label} · ${count}`;
          const bar = document.createElement("div");
          bar.style.cssText =
            "height:8px;background:#985238;border-radius:4px;margin:8px 0 16px";
          bar.style.width = (count / result.responses.length) * 100 + "%";
          chart.append(p, bar);
        }
      }
      for (const row of result.responses) {
        const section = document.createElement("section");
        section.className = "saved";
        const title = document.createElement("h2");
        title.textContent =
          (row.respondent.name || row.respondent.email) + " · v" + row.version;
        const time = document.createElement("p");
        time.className = "notice";
        time.textContent = row.respondent.email + " · " + new Date(row.submittedAt).toLocaleString();
        const dl = document.createElement("dl");
        for (const [key, value] of Object.entries(row.data)) {
          const dt = document.createElement("dt"),
            dd = document.createElement("dd");
          dt.textContent = key;
          dd.textContent = String(value ?? "");
          dl.append(dt, dd);
        }
        section.append(title, time, dl);
        table.append(section);
      }
      for (const [label, next] of [
        ["Previous", offset - 50],
        ["Next", offset + 50],
      ]) {
        if (next < 0 || next >= result.total) continue;
        const button = document.createElement("button");
        button.className = "secondary";
        button.textContent = label;
        button.onclick = () => {
          offset = next;
          load();
        };
        nav.append(button);
      }
    } catch (error) {
      summary.textContent = error.message;
    }
  }
  await load();
}
if (window.thing?.context?.view === "report") report();
else
  (async () => {
    if (await refreshAuth()) {
      try {
        const result = await thing.responses.listMine("feedback");
        const row = result.responses[0];
        if (row) {
          restore(row.data);
          showSaved(row);
        }
      } catch (error) {
        status.textContent = error.message;
      }
    }
  })();
</script></body></html>