atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
4b690ff501b89a8185e0f0c9ceaa61a89ce1a056
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git

Blame: src/ui.html

99524cf9f17catagen2026-10-10 10:53 UTC1
<!DOCTYPE html>
99524cf9f17catagen2026-10-10 10:53 UTC2
<html>
99524cf9f17catagen2026-10-10 10:53 UTC3
<head>
99524cf9f17catagen2026-10-10 10:53 UTC4
<meta charset="utf-8">
99524cf9f17catagen2026-10-10 10:53 UTC5
<title>jelqd</title>
185851447cebatagen2026-10-10 12:58 UTC6
<style>
185851447cebatagen2026-10-10 12:58 UTC7
.frame { border: 2px inset; background: #fff; height: 85vh; overflow: scroll; padding: 4px; }
185851447cebatagen2026-10-10 12:58 UTC8
#log { white-space: pre-wrap; margin: 0; }
185851447cebatagen2026-10-10 12:58 UTC9
pre, li, p, td, label { overflow-wrap: anywhere; }
185851447cebatagen2026-10-10 12:58 UTC10
</style>
99524cf9f17catagen2026-10-10 10:53 UTC11
</head>
99524cf9f17catagen2026-10-10 10:53 UTC12
<body>
99524cf9f17catagen2026-10-10 10:53 UTC13
185851447cebatagen2026-10-10 12:58 UTC14
<h1>jelqd</h1>
ef7d6dee5976atagen2026-10-10 13:23 UTC15
<p>A roguelike played over HTTP. Pick a resource, pick a method, send it. The log on the right is everything the server has said to you.
ef7d6dee5976atagen2026-10-10 13:23 UTC16
Language <select id="locale"><option>en-GB</option><option>en-US</option><option>en-AU</option></select></p>
185851447cebatagen2026-10-10 12:58 UTC17
185851447cebatagen2026-10-10 12:58 UTC18
<table width="100%" cellspacing="6">
185851447cebatagen2026-10-10 12:58 UTC19
<tr>
185851447cebatagen2026-10-10 12:58 UTC20
<td width="45%" valign="top">
185851447cebatagen2026-10-10 12:58 UTC21
<div class="frame">
185851447cebatagen2026-10-10 12:58 UTC22
431c1bc8ded1atagen2026-10-10 13:20 UTC23
<div id="alerts"></div>
431c1bc8ded1atagen2026-10-10 13:20 UTC24
185851447cebatagen2026-10-10 12:58 UTC25
<form id="form">
185851447cebatagen2026-10-10 12:58 UTC26
<h3>Method</h3>
185851447cebatagen2026-10-10 12:58 UTC27
<div id="methods"></div>
185851447cebatagen2026-10-10 12:58 UTC28
185851447cebatagen2026-10-10 12:58 UTC29
<h3>Request</h3>
185851447cebatagen2026-10-10 12:58 UTC30
<table id="fields" cellpadding="2"></table>
185851447cebatagen2026-10-10 12:58 UTC31
185851447cebatagen2026-10-10 12:58 UTC32
<p><input type="submit" id="send" value="Send" style="font-size: 1.3em"></p>
185851447cebatagen2026-10-10 12:58 UTC33
185851447cebatagen2026-10-10 12:58 UTC34
<h3>Stats</h3>
185851447cebatagen2026-10-10 12:58 UTC35
<table id="stats" border="1" cellspacing="0" cellpadding="3"></table>
185851447cebatagen2026-10-10 12:58 UTC36
185851447cebatagen2026-10-10 12:58 UTC37
<h3>Resource</h3>
185851447cebatagen2026-10-10 12:58 UTC38
<div id="targets"></div>
185851447cebatagen2026-10-10 12:58 UTC39
</form>
185851447cebatagen2026-10-10 12:58 UTC40
185851447cebatagen2026-10-10 12:58 UTC41
<p><small>Every request goes through <code>/ui/send</code>, which replays it inside the server, so the page can show redirects and send methods and headers that browsers refuse.</small></p>
185851447cebatagen2026-10-10 12:58 UTC42
</div>
185851447cebatagen2026-10-10 12:58 UTC43
</td>
185851447cebatagen2026-10-10 12:58 UTC44
<td valign="top">
185851447cebatagen2026-10-10 12:58 UTC45
<div class="frame" id="frame"><pre id="log"></pre></div>
185851447cebatagen2026-10-10 12:58 UTC46
</td>
185851447cebatagen2026-10-10 12:58 UTC47
</tr>
185851447cebatagen2026-10-10 12:58 UTC48
</table>
99524cf9f17catagen2026-10-10 10:53 UTC49
99524cf9f17catagen2026-10-10 10:53 UTC50
<script>
185851447cebatagen2026-10-10 12:58 UTC51
const REL = "https://jelq.dev/rels/";
185851447cebatagen2026-10-10 12:58 UTC52
const METHODS = ["JELQ", "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE"];
185851447cebatagen2026-10-10 12:58 UTC53
const TECHNIQUES = ["warmup", "wet", "dry", "stretch", "uli", "edge", "pump", "ice", "supplement"];
9683736a2c5eatagen2026-10-10 13:15 UTC54
const DEATHS = ["vascular-event", "caught", "banned", "degloved", "zip", "run-ended"];
185851447cebatagen2026-10-10 12:58 UTC55
const STATS = [
185851447cebatagen2026-10-10 12:58 UTC56
  ["x-floor", "Floor"],
185851447cebatagen2026-10-10 12:58 UTC57
  ["x-self-reported-length", "Self-reported length"],
185851447cebatagen2026-10-10 12:58 UTC58
  ["x-girth", "Girth"],
185851447cebatagen2026-10-10 12:58 UTC59
  ["x-foreskin", "Foreskin"],
185851447cebatagen2026-10-10 12:58 UTC60
  ["x-eq", "EQ (aim for 50 to 70)"],
185851447cebatagen2026-10-10 12:58 UTC61
  ["x-stamina", "Stamina"],
185851447cebatagen2026-10-10 12:58 UTC62
  ["x-suspicion", "Suspicion"],
185851447cebatagen2026-10-10 12:58 UTC63
  ["x-shame", "Shame"],
185851447cebatagen2026-10-10 12:58 UTC64
  ["x-cash", "Cash"],
185851447cebatagen2026-10-10 12:58 UTC65
];
185851447cebatagen2026-10-10 12:58 UTC66
const QUIET = new Set(["link", "set-cookie", "server", "x-powered-by", "cache-control", "content-length", "content-type", "date"].concat(STATS.map(s => s[0])));
99524cf9f17catagen2026-10-10 10:53 UTC67
185851447cebatagen2026-10-10 12:58 UTC68
const FIELDS = {
185851447cebatagen2026-10-10 12:58 UTC69
  seed: { label: "seed (unranked)", kind: "text" },
185851447cebatagen2026-10-10 12:58 UTC70
  technique: { label: "technique", kind: "select", options: TECHNIQUES },
185851447cebatagen2026-10-10 12:58 UTC71
  also: { label: "combo with", kind: "select", options: ["", "wet", "dry", "stretch", "uli"] },
185851447cebatagen2026-10-10 12:58 UTC72
  intensity: { label: "intensity, 1 to 10", kind: "number", value: "5" },
185851447cebatagen2026-10-10 12:58 UTC73
  lube: { label: "Content-Type", header: "Content-Type", kind: "select", options: ["", "application/baby-oil", "application/coconut-oil", "application/lotion", "application/vaseline", "text/plain; charset=spit", "image/jpeg"] },
185851447cebatagen2026-10-10 12:58 UTC74
  patch: { label: "body", kind: "textarea", value: '[{"op": "test", "path": "/length", "value": 6}]' },
185851447cebatagen2026-10-10 12:58 UTC75
  accept: { label: "Accept", header: "Accept", kind: "text" },
185851447cebatagen2026-10-10 12:58 UTC76
  range: { label: "Range", header: "Range", kind: "text", placeholder: "inches=0-2" },
185851447cebatagen2026-10-10 12:58 UTC77
  ifMatch: { label: "If-Match", header: "If-Match", kind: "text" },
185851447cebatagen2026-10-10 12:58 UTC78
  ifNoneMatch: { label: "If-None-Match", header: "If-None-Match", kind: "text" },
185851447cebatagen2026-10-10 12:58 UTC79
  ifModifiedSince: { label: "If-Modified-Since", header: "If-Modified-Since", kind: "text", placeholder: "an HTTP-date" },
185851447cebatagen2026-10-10 12:58 UTC80
  proxy: { label: "Proxy-Authorization", header: "Proxy-Authorization", kind: "text", placeholder: "Ruler 5.4" },
185851447cebatagen2026-10-10 12:58 UTC81
  upgrade: { label: "Upgrade", header: "Upgrade", kind: "select", options: ["", "underhand", "overhand"] },
185851447cebatagen2026-10-10 12:58 UTC82
  authorization: { label: "Authorization", header: "Authorization", kind: "text", placeholder: 'Excuse "homework"' },
185851447cebatagen2026-10-10 12:58 UTC83
  extra: { label: "other headers, one per line", kind: "textarea", placeholder: "Expect: gains" },
185851447cebatagen2026-10-10 12:58 UTC84
};
185851447cebatagen2026-10-10 12:58 UTC85
const BY_TARGET = {
185851447cebatagen2026-10-10 12:58 UTC86
  "runs POST": ["seed"],
185851447cebatagen2026-10-10 12:58 UTC87
  "run JELQ": ["technique", "also", "intensity", "lube"],
185851447cebatagen2026-10-10 12:58 UTC88
  "run PATCH": ["patch"],
185851447cebatagen2026-10-10 12:58 UTC89
  "length GET": ["range"],
185851447cebatagen2026-10-10 12:58 UTC90
  "length HEAD": ["range"],
185851447cebatagen2026-10-10 12:58 UTC91
  "foreskin DELETE": ["ifMatch"],
185851447cebatagen2026-10-10 12:58 UTC92
  "elder GET": ["proxy", "ifModifiedSince"],
185851447cebatagen2026-10-10 12:58 UTC93
  "grip GET": ["upgrade"],
4b690ff501b8atagen2026-10-10 13:27 UTC94
  "diagnosis GET": ["authorization", "ifNoneMatch"],
185851447cebatagen2026-10-10 12:58 UTC95
};
185851447cebatagen2026-10-10 12:58 UTC96
const GLOBALS = [
185851447cebatagen2026-10-10 12:58 UTC97
  { key: "runs", name: "start a new run", path: "/runs", methods: ["POST"] },
185851447cebatagen2026-10-10 12:58 UTC98
  { key: "global", name: "leaderboard", path: "/leaderboard", methods: ["GET", "HEAD", "OPTIONS"] },
185851447cebatagen2026-10-10 12:58 UTC99
  { key: "global", name: "graveyard", path: "/graveyard", methods: ["GET", "HEAD", "OPTIONS"] },
185851447cebatagen2026-10-10 12:58 UTC100
  { key: "global", name: "the 2009 forum thread", path: "/forum/threads/2009", methods: ["GET", "HEAD", "OPTIONS"] },
185851447cebatagen2026-10-10 12:58 UTC101
];
185851447cebatagen2026-10-10 12:58 UTC102
185851447cebatagen2026-10-10 12:58 UTC103
let run = null;
185851447cebatagen2026-10-10 12:58 UTC104
let linked = [];
185851447cebatagen2026-10-10 12:58 UTC105
let offered = [];
4b690ff501b8atagen2026-10-10 13:27 UTC106
let challenged = false;
185851447cebatagen2026-10-10 12:58 UTC107
let target = "/runs";
185851447cebatagen2026-10-10 12:58 UTC108
let method = "POST";
185851447cebatagen2026-10-10 12:58 UTC109
const values = {};
185851447cebatagen2026-10-10 12:58 UTC110
185851447cebatagen2026-10-10 12:58 UTC111
const $ = id => document.getElementById(id);
185851447cebatagen2026-10-10 12:58 UTC112
const fieldValue = name => values[name] ?? FIELDS[name].value ?? (FIELDS[name].options ? FIELDS[name].options[0] : "");
185851447cebatagen2026-10-10 12:58 UTC113
185851447cebatagen2026-10-10 12:58 UTC114
function short(path) {
185851447cebatagen2026-10-10 12:58 UTC115
  return path.replace(/[0-9a-f]{32}/, id => id.slice(0, 6) + "…");
99524cf9f17catagen2026-10-10 10:53 UTC116
}
99524cf9f17catagen2026-10-10 10:53 UTC117
185851447cebatagen2026-10-10 12:58 UTC118
function el(tag, props, ...children) {
185851447cebatagen2026-10-10 12:58 UTC119
  const node = Object.assign(document.createElement(tag), props || {});
185851447cebatagen2026-10-10 12:58 UTC120
  for (const child of children) node.append(child);
185851447cebatagen2026-10-10 12:58 UTC121
  return node;
99524cf9f17catagen2026-10-10 10:53 UTC122
}
99524cf9f17catagen2026-10-10 10:53 UTC123
185851447cebatagen2026-10-10 12:58 UTC124
function targets() {
185851447cebatagen2026-10-10 12:58 UTC125
  return (run ? linked : []).concat(offered, GLOBALS);
185851447cebatagen2026-10-10 12:58 UTC126
}
185851447cebatagen2026-10-10 12:58 UTC127
185851447cebatagen2026-10-10 12:58 UTC128
function current() {
185851447cebatagen2026-10-10 12:58 UTC129
  return targets().find(t => t.path === target) || GLOBALS[0];
185851447cebatagen2026-10-10 12:58 UTC130
}
185851447cebatagen2026-10-10 12:58 UTC131
185851447cebatagen2026-10-10 12:58 UTC132
function chooseTarget(t) {
185851447cebatagen2026-10-10 12:58 UTC133
  target = t.path;
185851447cebatagen2026-10-10 12:58 UTC134
  method = t.methods.includes("JELQ") ? "JELQ" : t.methods.find(m => m !== "HEAD" && m !== "OPTIONS") || t.methods[0];
185851447cebatagen2026-10-10 12:58 UTC135
  render();
185851447cebatagen2026-10-10 12:58 UTC136
}
185851447cebatagen2026-10-10 12:58 UTC137
185851447cebatagen2026-10-10 12:58 UTC138
function renderStats(headers) {
185851447cebatagen2026-10-10 12:58 UTC139
  const table = $("stats");
185851447cebatagen2026-10-10 12:58 UTC140
  table.replaceChildren();
185851447cebatagen2026-10-10 12:58 UTC141
  for (let i = 0; i < STATS.length; i += 2) {
185851447cebatagen2026-10-10 12:58 UTC142
    const row = el("tr");
185851447cebatagen2026-10-10 12:58 UTC143
    for (const [name, label] of STATS.slice(i, i + 2)) {
185851447cebatagen2026-10-10 12:58 UTC144
      const value = headers ? headers[name] : undefined;
185851447cebatagen2026-10-10 12:58 UTC145
      row.append(el("th", { align: "left", textContent: label }), el("td", { textContent: value === undefined ? "—" : value }));
185851447cebatagen2026-10-10 12:58 UTC146
    }
185851447cebatagen2026-10-10 12:58 UTC147
    table.append(row);
99524cf9f17catagen2026-10-10 10:53 UTC148
  }
185851447cebatagen2026-10-10 12:58 UTC149
}
185851447cebatagen2026-10-10 12:58 UTC150
ef7d6dee5976atagen2026-10-10 13:23 UTC151
function titled(text) {
ef7d6dee5976atagen2026-10-10 13:23 UTC152
  return text.charAt(0).toUpperCase() + text.slice(1);
ef7d6dee5976atagen2026-10-10 13:23 UTC153
}
ef7d6dee5976atagen2026-10-10 13:23 UTC154
431c1bc8ded1atagen2026-10-10 13:20 UTC155
function renderAlerts(list) {
431c1bc8ded1atagen2026-10-10 13:20 UTC156
  const box = $("alerts");
431c1bc8ded1atagen2026-10-10 13:20 UTC157
  box.replaceChildren();
431c1bc8ded1atagen2026-10-10 13:20 UTC158
  const alerts = [];
431c1bc8ded1atagen2026-10-10 13:20 UTC159
  for (const [name, value] of list || []) {
ef7d6dee5976atagen2026-10-10 13:23 UTC160
    if (name === "via") alerts.push(titled(value.split(" ").pop()) + " is at the door.");
ef7d6dee5976atagen2026-10-10 13:23 UTC161
    if (name === "x-observed-by") alerts.push((value === "cat" ? "The cat" : titled(value)) + " is watching.");
431c1bc8ded1atagen2026-10-10 13:20 UTC162
    if (name === "warning") {
431c1bc8ded1atagen2026-10-10 13:20 UTC163
      const text = (value.match(/"([^"]*)"/) || [, value])[1];
ef7d6dee5976atagen2026-10-10 13:23 UTC164
      alerts.push(titled(text) + ".");
431c1bc8ded1atagen2026-10-10 13:20 UTC165
    }
431c1bc8ded1atagen2026-10-10 13:20 UTC166
  }
431c1bc8ded1atagen2026-10-10 13:20 UTC167
  for (const alert of alerts) box.append(el("h2", { textContent: alert }));
431c1bc8ded1atagen2026-10-10 13:20 UTC168
}
431c1bc8ded1atagen2026-10-10 13:20 UTC169
185851447cebatagen2026-10-10 12:58 UTC170
function renderTargets() {
185851447cebatagen2026-10-10 12:58 UTC171
  const box = $("targets");
185851447cebatagen2026-10-10 12:58 UTC172
  box.replaceChildren();
185851447cebatagen2026-10-10 12:58 UTC173
  for (const t of targets()) {
185851447cebatagen2026-10-10 12:58 UTC174
    const radio = el("input", { type: "radio", name: "target", checked: t.path === target, onchange: () => chooseTarget(t) });
185851447cebatagen2026-10-10 12:58 UTC175
    box.append(el("label", {}, radio, " " + t.name + " ", el("small", { textContent: short(t.path) })), el("br"));
185851447cebatagen2026-10-10 12:58 UTC176
  }
185851447cebatagen2026-10-10 12:58 UTC177
}
185851447cebatagen2026-10-10 12:58 UTC178
185851447cebatagen2026-10-10 12:58 UTC179
function renderMethods() {
185851447cebatagen2026-10-10 12:58 UTC180
  const box = $("methods");
185851447cebatagen2026-10-10 12:58 UTC181
  box.replaceChildren();
185851447cebatagen2026-10-10 12:58 UTC182
  const allowed = current().methods;
185851447cebatagen2026-10-10 12:58 UTC183
  for (const m of METHODS) {
185851447cebatagen2026-10-10 12:58 UTC184
    const radio = el("input", { type: "radio", name: "method", disabled: !allowed.includes(m), checked: m === method, onchange: () => { method = m; render(); } });
185851447cebatagen2026-10-10 12:58 UTC185
    const label = el("label", {}, radio, " ");
185851447cebatagen2026-10-10 12:58 UTC186
    label.append(m === "JELQ" ? el("big", {}, el("b", { textContent: "JELQ" })) : m);
185851447cebatagen2026-10-10 12:58 UTC187
    box.append(label, " ");
185851447cebatagen2026-10-10 12:58 UTC188
  }
185851447cebatagen2026-10-10 12:58 UTC189
}
185851447cebatagen2026-10-10 12:58 UTC190
185851447cebatagen2026-10-10 12:58 UTC191
function fieldNames() {
4b690ff501b8atagen2026-10-10 13:27 UTC192
  const t = current();
4b690ff501b8atagen2026-10-10 13:27 UTC193
  const names = BY_TARGET[t.key + " " + method] || [];
4b690ff501b8atagen2026-10-10 13:27 UTC194
  const scoped = run && !["runs", "global"].includes(t.key);
4b690ff501b8atagen2026-10-10 13:27 UTC195
  const asked = challenged && scoped && !names.includes("authorization");
4b690ff501b8atagen2026-10-10 13:27 UTC196
  return (asked ? ["authorization"] : []).concat(names);
185851447cebatagen2026-10-10 12:58 UTC197
}
185851447cebatagen2026-10-10 12:58 UTC198
185851447cebatagen2026-10-10 12:58 UTC199
function renderFields() {
185851447cebatagen2026-10-10 12:58 UTC200
  const table = $("fields");
185851447cebatagen2026-10-10 12:58 UTC201
  table.replaceChildren();
185851447cebatagen2026-10-10 12:58 UTC202
  for (const name of fieldNames()) {
185851447cebatagen2026-10-10 12:58 UTC203
    const f = FIELDS[name];
185851447cebatagen2026-10-10 12:58 UTC204
    const value = fieldValue(name);
185851447cebatagen2026-10-10 12:58 UTC205
    let input;
185851447cebatagen2026-10-10 12:58 UTC206
    if (f.kind === "select") {
185851447cebatagen2026-10-10 12:58 UTC207
      input = el("select", {}, ...f.options.map(o => el("option", { value: o, textContent: o || "(none)", selected: o === value })));
185851447cebatagen2026-10-10 12:58 UTC208
    } else if (f.kind === "textarea") {
185851447cebatagen2026-10-10 12:58 UTC209
      input = el("textarea", { rows: 3, cols: 40, value, placeholder: f.placeholder || "" });
185851447cebatagen2026-10-10 12:58 UTC210
    } else {
185851447cebatagen2026-10-10 12:58 UTC211
      input = el("input", { type: f.kind, value, placeholder: f.placeholder || "", size: 30 });
185851447cebatagen2026-10-10 12:58 UTC212
    }
185851447cebatagen2026-10-10 12:58 UTC213
    input.oninput = input.onchange = () => { values[name] = input.value; };
185851447cebatagen2026-10-10 12:58 UTC214
    table.append(el("tr", {}, el("td", { align: "right", valign: "top", textContent: f.label }), el("td", {}, input)));
185851447cebatagen2026-10-10 12:58 UTC215
  }
4b690ff501b8atagen2026-10-10 13:27 UTC216
  const extra = el("textarea", { rows: 3, cols: 40, value: fieldValue("extra"), placeholder: FIELDS.extra.placeholder });
4b690ff501b8atagen2026-10-10 13:27 UTC217
  extra.oninput = () => { values.extra = extra.value; };
4b690ff501b8atagen2026-10-10 13:27 UTC218
  const more = el("details", { open: Boolean(values.extra) }, el("summary", { textContent: "more headers" }), extra);
4b690ff501b8atagen2026-10-10 13:27 UTC219
  table.append(el("tr", {}, el("td"), el("td", {}, more)));
185851447cebatagen2026-10-10 12:58 UTC220
  $("send").value = method + " " + short(current().path);
185851447cebatagen2026-10-10 12:58 UTC221
}
185851447cebatagen2026-10-10 12:58 UTC222
185851447cebatagen2026-10-10 12:58 UTC223
function render() {
185851447cebatagen2026-10-10 12:58 UTC224
  if (!current().methods.includes(method)) return chooseTarget(current());
185851447cebatagen2026-10-10 12:58 UTC225
  renderTargets();
185851447cebatagen2026-10-10 12:58 UTC226
  renderMethods();
185851447cebatagen2026-10-10 12:58 UTC227
  renderFields();
185851447cebatagen2026-10-10 12:58 UTC228
}
185851447cebatagen2026-10-10 12:58 UTC229
185851447cebatagen2026-10-10 12:58 UTC230
function parseLinks(header) {
185851447cebatagen2026-10-10 12:58 UTC231
  const found = [];
185851447cebatagen2026-10-10 12:58 UTC232
  for (const match of header.matchAll(/<([^>]*)>((?:\s*;\s*[a-z-]+="[^"]*")*)/g)) {
185851447cebatagen2026-10-10 12:58 UTC233
    const params = Object.fromEntries([...match[2].matchAll(/([a-z-]+)="([^"]*)"/g)].map(p => [p[1], p[2]]));
185851447cebatagen2026-10-10 12:58 UTC234
    const name = (params.rel || "").replace(REL, "");
185851447cebatagen2026-10-10 12:58 UTC235
    const methods = (params.allow || "GET").split(",").map(s => s.trim());
185851447cebatagen2026-10-10 12:58 UTC236
    found.push({ key: name, name: name === "run" ? "the run" : name, path: match[1], methods });
185851447cebatagen2026-10-10 12:58 UTC237
  }
185851447cebatagen2026-10-10 12:58 UTC238
  return found;
99524cf9f17catagen2026-10-10 10:53 UTC239
}
99524cf9f17catagen2026-10-10 10:53 UTC240
99524cf9f17catagen2026-10-10 10:53 UTC241
function hrefs(value, found) {
99524cf9f17catagen2026-10-10 10:53 UTC242
  if (value && typeof value === "object") {
185851447cebatagen2026-10-10 12:58 UTC243
    if (typeof value.href === "string") {
185851447cebatagen2026-10-10 12:58 UTC244
      const name = value.title || value.item || value.name || value.href.split("/").pop();
185851447cebatagen2026-10-10 12:58 UTC245
      found.push({ key: "offered", name, path: value.href, methods: [value.method || "GET"] });
185851447cebatagen2026-10-10 12:58 UTC246
    }
99524cf9f17catagen2026-10-10 10:53 UTC247
    for (const key in value) hrefs(value[key], found);
99524cf9f17catagen2026-10-10 10:53 UTC248
  }
99524cf9f17catagen2026-10-10 10:53 UTC249
  return found;
99524cf9f17catagen2026-10-10 10:53 UTC250
}
99524cf9f17catagen2026-10-10 10:53 UTC251
185851447cebatagen2026-10-10 12:58 UTC252
function log(text) {
185851447cebatagen2026-10-10 12:58 UTC253
  $("log").append(text + "\n");
185851447cebatagen2026-10-10 12:58 UTC254
  $("frame").scrollTop = $("frame").scrollHeight;
99524cf9f17catagen2026-10-10 10:53 UTC255
}
99524cf9f17catagen2026-10-10 10:53 UTC256
185851447cebatagen2026-10-10 12:58 UTC257
function describe(r) {
185851447cebatagen2026-10-10 12:58 UTC258
  const lines = ["< " + r.status + " " + r.reason];
185851447cebatagen2026-10-10 12:58 UTC259
  for (const [name, value] of r.headers) {
185851447cebatagen2026-10-10 12:58 UTC260
    if (!QUIET.has(name)) lines.push("< " + name + ": " + short(value));
185851447cebatagen2026-10-10 12:58 UTC261
  }
185851447cebatagen2026-10-10 12:58 UTC262
  let body = null;
185851447cebatagen2026-10-10 12:58 UTC263
  try { body = JSON.parse(r.body); } catch (e) {}
185851447cebatagen2026-10-10 12:58 UTC264
  if (body && typeof body === "object") {
185851447cebatagen2026-10-10 12:58 UTC265
    const rest = Object.assign({}, body);
185851447cebatagen2026-10-10 12:58 UTC266
    if (rest.detail) lines.push("  " + rest.detail);
185851447cebatagen2026-10-10 12:58 UTC267
    for (const key of ["type", "title", "status", "detail"]) delete rest[key];
185851447cebatagen2026-10-10 12:58 UTC268
    if (Object.keys(rest).length) lines.push(short(JSON.stringify(rest, null, 2)).replace(/^/gm, "  "));
185851447cebatagen2026-10-10 12:58 UTC269
  } else if (r.body) {
185851447cebatagen2026-10-10 12:58 UTC270
    lines.push("  " + r.body);
185851447cebatagen2026-10-10 12:58 UTC271
  }
185851447cebatagen2026-10-10 12:58 UTC272
  return lines.join("\n");
99524cf9f17catagen2026-10-10 10:53 UTC273
}
99524cf9f17catagen2026-10-10 10:53 UTC274
185851447cebatagen2026-10-10 12:58 UTC275
function absorb(r, sentMethod, sentPath) {
185851447cebatagen2026-10-10 12:58 UTC276
  const headers = Object.fromEntries(r.headers.map(([n, v]) => [n.toLowerCase(), v]));
185851447cebatagen2026-10-10 12:58 UTC277
  let body = null;
185851447cebatagen2026-10-10 12:58 UTC278
  try { body = JSON.parse(r.body); } catch (e) {}
185851447cebatagen2026-10-10 12:58 UTC279
  const problem = body && typeof body.type === "string" ? body.type.split("/").pop() : null;
185851447cebatagen2026-10-10 12:58 UTC280
185851447cebatagen2026-10-10 12:58 UTC281
  if (sentPath.startsWith("/runs?") || sentPath === "/runs") {
185851447cebatagen2026-10-10 12:58 UTC282
    if (r.status === 201) {
185851447cebatagen2026-10-10 12:58 UTC283
      run = headers.location;
185851447cebatagen2026-10-10 12:58 UTC284
      log("--- a new run has started at " + short(run) + " ---");
185851447cebatagen2026-10-10 12:58 UTC285
    }
185851447cebatagen2026-10-10 12:58 UTC286
  }
431c1bc8ded1atagen2026-10-10 13:20 UTC287
  if ("x-eq" in headers) {
431c1bc8ded1atagen2026-10-10 13:20 UTC288
    renderStats(headers);
431c1bc8ded1atagen2026-10-10 13:20 UTC289
    renderAlerts(r.headers);
4b690ff501b8atagen2026-10-10 13:27 UTC290
    challenged = r.status === 401 || "via" in headers;
431c1bc8ded1atagen2026-10-10 13:20 UTC291
  }
185851447cebatagen2026-10-10 12:58 UTC292
  if (headers.link) linked = parseLinks(headers.link);
185851447cebatagen2026-10-10 12:58 UTC293
  offered = body ? hrefs(body, []) : [];
185851447cebatagen2026-10-10 12:58 UTC294
  if (headers.location && !(sentPath === "/runs" || sentPath.startsWith("/runs?"))) {
185851447cebatagen2026-10-10 12:58 UTC295
    offered.unshift({ key: "offered", name: "Location", path: headers.location, methods: ["GET"] });
185851447cebatagen2026-10-10 12:58 UTC296
  }
185851447cebatagen2026-10-10 12:58 UTC297
  const abandoned = sentMethod === "DELETE" && sentPath === run && r.status === 204;
185851447cebatagen2026-10-10 12:58 UTC298
  if (run && (abandoned || DEATHS.includes(problem))) {
185851447cebatagen2026-10-10 12:58 UTC299
    log("--- the run has ended ---");
431c1bc8ded1atagen2026-10-10 13:20 UTC300
    renderAlerts(null);
4b690ff501b8atagen2026-10-10 13:27 UTC301
    challenged = false;
185851447cebatagen2026-10-10 12:58 UTC302
    run = null;
185851447cebatagen2026-10-10 12:58 UTC303
    linked = [];
185851447cebatagen2026-10-10 12:58 UTC304
  }
185851447cebatagen2026-10-10 12:58 UTC305
  const started = r.status === 201 && (sentPath === "/runs" || sentPath.startsWith("/runs?"));
185851447cebatagen2026-10-10 12:58 UTC306
  if (started || !targets().some(t => t.path === target)) {
185851447cebatagen2026-10-10 12:58 UTC307
    const home = linked.find(t => t.key === "run");
185851447cebatagen2026-10-10 12:58 UTC308
    if (home) chooseTarget(home); else chooseTarget(GLOBALS[0]);
185851447cebatagen2026-10-10 12:58 UTC309
  } else {
185851447cebatagen2026-10-10 12:58 UTC310
    render();
99524cf9f17catagen2026-10-10 10:53 UTC311
  }
99524cf9f17catagen2026-10-10 10:53 UTC312
}
99524cf9f17catagen2026-10-10 10:53 UTC313
185851447cebatagen2026-10-10 12:58 UTC314
async function go(event) {
185851447cebatagen2026-10-10 12:58 UTC315
  event.preventDefault();
185851447cebatagen2026-10-10 12:58 UTC316
  const t = current();
185851447cebatagen2026-10-10 12:58 UTC317
  const key = t.key + " " + method;
185851447cebatagen2026-10-10 12:58 UTC318
  const value = name => fieldValue(name).trim();
185851447cebatagen2026-10-10 12:58 UTC319
  const headers = [];
185851447cebatagen2026-10-10 12:58 UTC320
  let path = t.path;
185851447cebatagen2026-10-10 12:58 UTC321
  let body = "";
185851447cebatagen2026-10-10 12:58 UTC322
  for (const name of fieldNames()) {
185851447cebatagen2026-10-10 12:58 UTC323
    const f = FIELDS[name];
185851447cebatagen2026-10-10 12:58 UTC324
    if (f.header && value(name)) headers.push([f.header, value(name)]);
185851447cebatagen2026-10-10 12:58 UTC325
  }
185851447cebatagen2026-10-10 12:58 UTC326
  if (fieldNames().includes("upgrade") && value("upgrade")) headers.push(["Connection", "upgrade"]);
185851447cebatagen2026-10-10 12:58 UTC327
  for (const line of value("extra").split("\n")) {
185851447cebatagen2026-10-10 12:58 UTC328
    const at = line.indexOf(":");
185851447cebatagen2026-10-10 12:58 UTC329
    if (at > 0) headers.push([line.slice(0, at).trim(), line.slice(at + 1).trim()]);
185851447cebatagen2026-10-10 12:58 UTC330
  }
185851447cebatagen2026-10-10 12:58 UTC331
  if (key === "run JELQ") {
185851447cebatagen2026-10-10 12:58 UTC332
    const techniques = value("also") ? value("technique") + "," + value("also") : value("technique");
185851447cebatagen2026-10-10 12:58 UTC333
    path += "?technique=" + encodeURIComponent(techniques) + "&intensity=" + encodeURIComponent(value("intensity"));
185851447cebatagen2026-10-10 12:58 UTC334
  }
185851447cebatagen2026-10-10 12:58 UTC335
  if (key === "runs POST" && value("seed")) path += "?seed=" + encodeURIComponent(value("seed"));
185851447cebatagen2026-10-10 12:58 UTC336
  if (method === "PATCH") {
185851447cebatagen2026-10-10 12:58 UTC337
    headers.push(["Content-Type", "application/json-patch+json"]);
185851447cebatagen2026-10-10 12:58 UTC338
    body = value("patch");
185851447cebatagen2026-10-10 12:58 UTC339
  }
99524cf9f17catagen2026-10-10 10:53 UTC340
185851447cebatagen2026-10-10 12:58 UTC341
  log("> " + method + " " + short(path));
185851447cebatagen2026-10-10 12:58 UTC342
  for (const [name, v] of headers) log("> " + name + ": " + v);
ef7d6dee5976atagen2026-10-10 13:23 UTC343
  headers.push(["Accept-Language", $("locale").value]);
185851447cebatagen2026-10-10 12:58 UTC344
  const response = await fetch("/ui/send", {
185851447cebatagen2026-10-10 12:58 UTC345
    method: "POST",
185851447cebatagen2026-10-10 12:58 UTC346
    headers: { "Content-Type": "application/json" },
185851447cebatagen2026-10-10 12:58 UTC347
    body: JSON.stringify({ method, path, headers, body }),
185851447cebatagen2026-10-10 12:58 UTC348
  });
185851447cebatagen2026-10-10 12:58 UTC349
  const r = await response.json();
185851447cebatagen2026-10-10 12:58 UTC350
  log(describe(r) + "\n");
185851447cebatagen2026-10-10 12:58 UTC351
  absorb(r, method, path);
99524cf9f17catagen2026-10-10 10:53 UTC352
}
185851447cebatagen2026-10-10 12:58 UTC353
185851447cebatagen2026-10-10 12:58 UTC354
$("form").onsubmit = go;
ef7d6dee5976atagen2026-10-10 13:23 UTC355
$("locale").value = localStorage.getItem("locale") || "en-GB";
ef7d6dee5976atagen2026-10-10 13:23 UTC356
$("locale").onchange = () => localStorage.setItem("locale", $("locale").value);
185851447cebatagen2026-10-10 12:58 UTC357
renderStats(null);
185851447cebatagen2026-10-10 12:58 UTC358
render();
185851447cebatagen2026-10-10 12:58 UTC359
log("Welcome. Choose \"start a new run\" and send POST to begin.\n");
99524cf9f17catagen2026-10-10 10:53 UTC360
</script>
99524cf9f17catagen2026-10-10 10:53 UTC361
</body>
99524cf9f17catagen2026-10-10 10:53 UTC362
</html>