${nav("character", cls.icon, "Character", `${skillOf("combat").icon} ${me.levels.combat}`, "", `Combat level ${me.levels.combat}`)}
${nav("bank", "🏦", "Bank", `${me.slots_used}/${me.slots}`, "", `${me.slots_used} of ${me.slots} bank slots used`)}`;
let group = null;
for (const skill of C.skills) {
if (skill.class && skill.class !== me.cls) continue;
if (skill.group !== group) {
group = skill.group;
html += `
`;
if (id === "combat") html += combatPanel();
if (id === "prayer") html += prayerPanel();
if (id === "construction") html += S.city.construction ? siteBanner()
: `
Nothing is being built right now. Fund a building on the Town square and its site opens here.
`;
if (id === "farming" && me.cls === "homesteader") html += plotsPanel();
html += potionPanel(id);
if (`tool_${id}` in me.equip) html += toolPanel(id);
if (id === "reconnaissance") html += `
Reconnaissance on a coming threat raises its intel. Each level takes more than the last, a bigger band takes longer to read, and the sharpest news (the exact day and their camp) only comes in the last hours before they strike. The spymaster's guild adds to every point and gathers some on its own. The council & threats page shows the progress.
`;
html += skillHappenings(id);
const live = new Set(S.happenings.problems.map((p) => p.kind));
const listed = C.actions.filter((a) => a.skill === id && (a.special !== "problem" || live.has(a.problem)));
// Tabs and sections keep their designed order; what's inside each is sorted by the level it needs.
const groups = [...new Set(listed.map((a) => a.group).filter(Boolean))];
const actions = [...listed].sort((a, b) => a.level - b.level);
if (groups.length > 2) {
// Many groups: one tab each.
const open = (g) => actions.some((a) => a.group === g && !isLocked(a));
let current = tabs[id] || remember(`tab.${id}`);
// Tier ladders open on the highest tier you can work; categories open on the first.
const ladder = ["smithing", "leatherworking", "herblore"].includes(id);
if (!groups.includes(current)) current = (ladder && [...groups].reverse().find(open)) || groups[0];
const bar = groups.map((g) => ``).join("");
// Ungrouped actions (the building site) stay above the tabs.
const loose = actions.filter((a) => !a.group);
if (loose.length) html += `
`;
}
// A few groups: each under its own heading, in the order they first appear.
let cards = actions.filter((a) => !a.group).map(actionCard).join("");
for (const g of groups) {
cards += `
`;
}
// Locked: missing a level, class or building, as opposed to just out of materials.
function isLocked(a) {
const why = S.me.blocked[a.id];
return Boolean(why && (why.startsWith("Needs") || why.startsWith("Only")));
}
// A homesteader's two plots: one crop 30% faster, or two crops sharing a 20% faster pace.
function plotsPanel() {
const a = S.me.action, farming = a && C.actions.find((x) => x.id === a.id)?.skill === "farming";
const name = (id) => esc(C.actions.find((x) => x.id === id).name);
const status = !farming ? `Start a crop to plant your first plot.`
: a.plot2 ? `Plot 1: ${name(a.id)} · Plot 2: ${name(a.plot2)}· harvests alternate, together 20% faster than one crop`
: `Plot 1: ${name(a.id)} · Plot 2: empty, so you're focused: 30% faster. Use "Plot 2" on another crop to grow both.`;
return `
🌱 ${status}
`;
}
// The skill's active potion, or the ones in the bank that could be drunk for it.
function potionPanel(skill) {
const me = S.me;
const kinds = Object.entries(C.items).filter(([, it]) => it.potion?.skill === skill);
if (!kinds.length) return "";
const active = me.potions[skill];
const owned = kinds.filter(([id]) => me.bank[id]);
const drink = owned.map(([id, it]) => ``).join("");
return `
`;
}
function prayerPanel() {
const me = S.me, city = S.city, level = me.levels.prayer;
const cost = C.prayers.filter((p) => me.prayers.includes(p.id)).reduce((sum, p) => sum + p.cost, 0);
const cards = C.prayers.map((p) => {
const on = me.prayers.includes(p.id), locked = level < p.level;
const full = !on && me.prayers.length >= C.max_prayers;
return ``;
}).join("");
const temple = city.buildings.temple.level;
return `
Prayers · ${me.prayers.length} / ${C.max_prayers} active
${skillOf("prayer").icon} ${fmt(me.pp)} prayer points${cost ? ` · ${cost} per fight, enough for about ${fmt(me.pp / cost)} fights` : ""}
${cards}
Active prayers work in every fight while you have the points to pay for them, and each point spent trains Prayer. Bury bones for more points.
Temple
${temple ? `
Devotion ${fmt(city.devotion)} · divine ward +${city.ward} defence for the next raid (up to +${15 * temple} at temple level ${temple}).
Offering bones builds devotion; it wards the city through the next raid, then starts again. The temple also adds ${temple} prayer point${temple > 1 ? "s" : ""} to every bone you bury.
`
: `
No temple yet. Build one on the Town square to offer bones for a divine ward in raids, and to get more prayer points from bones.
${tool
? `${item(tool).icon} ${esc(item(tool).name)}${Math.round(item(tool).speed * 100)}% faster
`
: `No tool. A smith can make you one that speeds this up.`}
`;
}
function combatPanel() {
const me = S.me;
const stat = (id) => [item(id).power && `+${item(id).power} power`, item(id).armour && `+${item(id).armour} armour`].filter(Boolean).join(", ");
const slot = (name, label) => {
const id = me.equip[name];
return `
Below half health you eat from your bank automatically.
${me.edible.length ? "" : `You have no food in your bank.`}
Out of food and badly hurt, you retreat: the fight is lost, but nothing else is.
Power ${me.power} (level + weapon + shield + half your armour) · armour ${me.armour} takes ${Math.round(me.reduction * 100)}% off the damage you take. Equip gear from the bank.
`;
}
// What an item does once made: healing, defence, tool speed, and who can use it.
function itemStats(id) {
const it = item(id);
const parts = [];
if (it.heal) parts.push(`❤️ Heals ${it.heal}`);
if (it.food) parts.push(`feeds ${it.food} ${it.food === 1 ? "person" : "people"} for two days`);
if (it.power) parts.push(`+${it.power} power`);
if (it.armour) parts.push(`+${it.armour} armour`);
if (it.tool) parts.push(`${Math.round(it.speed * 100)}% faster ${skillOf(it.tool).name.toLowerCase()}`);
if (it.req && it.req[1] > 1) parts.push(`needs ${skillOf(it.req[0]).name.toLowerCase()} ${it.req[1]}`);
if (it.potion) parts.push(`${it.potion.text}, ${C.potion_charges} uses`);
if (it.fitting) {
const f = C.fittings.find((x) => x.id === id), done = S.city.buildings[f.building].fittings.includes(id);
parts.push(`${C.buildings[f.building].icon} For the ${C.buildings[f.building].name.toLowerCase()} (Lv ${f.level}): ${f.effect}${done ? " · already fitted" : ""}`);
}
return parts.join(", ");
}
function actionCard(a) {
const me = S.me;
const active = me.action?.id === a.id;
const second = me.action?.plot2 === a.id; // a homesteader's second plot
const growingNow = me.action?.growing === a.id;
const canPlot = me.cls === "homesteader" && a.skill === "farming" && !a.special && !active && !second
&& me.action && C.actions.find((x) => x.id === me.action.id)?.skill === "farming" && !me.action.left;
const why = me.blocked[a.id];
const locked = isLocked(a);
const out = Object.keys(a.outputs)[0];
const icon = a.icon;
const time = me.times[a.id];
let io = "";
if (Object.keys(a.inputs).length) {
io += Object.entries(a.inputs).map(([id, q]) => itemChip(id, q, me.bank[id] || 0)).join("")
+ (Object.keys(a.outputs).length ? `→` : "");
}
io += Object.entries(a.outputs).map(([id, q]) => itemChip(id, q)).join("");
let extra = "";
if ("difficulty" in a) {
const chance = Math.min(0.97, Math.max(0.15, 0.5 + (me.power - a.difficulty) * 0.04));
extra = `
Win chance ${Math.round(chance * 100)}% · about ${me.fight_damage[a.id]} damage a fight${a.gold ? ` · ${a.gold} gold` : ""}
`;
if (a.drops) io += a.drops.map(([id, q, odds]) => `${item(id).icon} ${q} ${Math.round(odds * 100)}%`).join("");
if (a.special === "sabotage") extra += `
Each win weakens the coming raid by 3%, up to 30%.
`;
}
if (a.special === "drill") {
const s = S.favours.sergeant, boost = s.boost;
extra = !s.needed ? `
Fighters drill the townsfolk while a raid is coming. A fully drilled militia adds one defence per townsperson.
`
: `
${skillOf("combat").icon} +${+me.drill_per_action.toFixed(2)} drill · ${fmt(s.points)} / ${fmt(s.needed)} points, worth ${fmt(s.defence)} of ${fmt(s.cap)} defence${boost ? ` ×2 drill and XP for the next ${fmt(boost)}` : ""}
A bigger band needs more drilling. A fully drilled militia adds one defence per townsperson.
`;
}
if (a.special === "recon") {
const boost = S.favours.informant.boost;
const ev = S.upcoming, full = ev && ev.recon >= ev.recon_full;
extra = full ? `
The coming threat is fully scouted: this only trains the skill for now.
`
: `
${skillOf("reconnaissance").icon} +${+me.recon_gain[a.id].toFixed(2)} recon on the coming threat${boost ? ` ×2 recon and XP for the next ${fmt(boost)}` : ""}
`;
}
const temple = S.city.buildings.temple?.level || 0;
if (a.special === "labour") {
const boost = S.favours.builders.boost;
extra = `
${skillOf("construction").icon} +${me.labour_per_action} labour per action${boost ? ` ×2 labour and XP for the next ${fmt(boost)}` : ""}
`;
}
// --- bank -----------------------------------------------------------------
function bank() {
const me = S.me;
const entries = Object.entries(me.bank).sort((x, y) => Object.keys(C.items).indexOf(x[0]) - Object.keys(C.items).indexOf(y[0]));
if (selected && !me.bank[selected]) selected = null;
// An item past one full stack spills into more slots: 2,500 coal shows as 1,000 + 1,000 + 500.
const stacks = entries.flatMap(([id, q]) => {
const parts = [];
for (let left = q; left > 0; left -= me.stack) parts.push([id, Math.min(left, me.stack)]);
return parts;
});
let slots = stacks.map(([id, q]) => `
`).join("");
for (let i = stacks.length; i < me.slots; i++) slots += ``;
return `
Bank · ${me.slots_used} / ${me.slots} slots · ${fmt(me.stack)} per stack
${slots}
When an item doesn't fit:
Raids never touch your bank. Overflow sent to the city earns half the favour of giving it yourself;
anything the city has no use for is sold instead.
The treasury pays the gold part of new buildings. 1 favour per gold.
`;
}
function favourPanel() {
const me = S.me;
return `
Favour with the council
${FAVOUR} ${fmt(me.favour)} favour · ${fmt(me.donated)} given in total
Favour can also hire master builders, informants and drill sergeants: see .
`;
}
// --- town -----------------------------------------------------------------
function town() {
const city = S.city;
const growth = city.starving ? `Hungry: ${city.growth} a day`
: city.growth > 0 ? `+${city.growth} a day`
: city.growth < 0 ? `Overcrowded: ${city.growth} a day`
: `Full. Build more houses to grow`;
const next = city.next_tier ? `${city.next_tier.name} at ${city.next_tier.pop} people` : "The largest a settlement can be";
let html = `
Food, weapons and anything not yet in a fund. The granary and armoury draw from here, and so do raiders.
`;
return html;
}
// The building site: labour so far, who is helping, and ways to speed it up.
function siteBanner() {
const city = S.city, work = city.construction, me = S.me;
const spec = C.buildings[work.building];
const queued = city.queue.map((key) => esc(C.buildings[key].name)).join(", ");
const hire = S.favours.builders;
const working = me.action?.id === "labour";
const builders = S.players.filter((p) => p.skill === "construction" && p.doing === "Work on the building site").map((p) => esc(p.name));
return `
${builders.length ? `On site: ${builders.join(", ")}. ` : ""}${hire.boost ? `Master builder on site: the next ${fmt(hire.boost)} labour counts double, with double XP. ` : ""}The townsfolk add about ${fmt(city.townsfolk_labour)} labour an hour.${queued ? ` Next: ${queued}.` : ""}
`;
}
// "Rain: farming 10% faster. Clear skies in 3h 20m."
function weatherTip(w) {
return `${w.name}: ${w.text.charAt(0).toLowerCase()}${w.text.slice(1)}. ${w.next_name} in ${dur(w.until - serverNow())}.`;
}
function weatherCard() {
const h = S.happenings, w = h.weather;
return `
${h.season.icon} ${h.season.name}, day ${h.season.day}
${w.icon} ${esc(w.name)}
${w.next_icon} ${esc(w.next_name)} in ${countdown(w.until)}
`;
}
// Everything going on in the city right now, and what anyone can do about it.
function happeningsPanel() {
const h = S.happenings, me = S.me, city = S.city;
const rows = [];
if (h.weather.kind !== "clear") rows.push(`
`;
}
// Fittings Construction makes for this building: fitted, ready to fit, or waiting on a level.
function fittingRows(key, b) {
const rows = C.fittings.filter((f) => f.building === key).map((f) => {
const it = item(f.id), recipe = C.actions.find((a) => a.id === `make_${f.id}`);
let state;
if (b.fittings.includes(f.id)) {
state = b.damaged ? `Paused` : `Fitted`;
} else if (b.level < f.level) {
state = `Needs Lv ${f.level}`;
} else {
const mine = S.me.bank[f.id] || 0, stores = S.city.stockpile[f.id] || 0;
state = mine || stores
? `${mine ? `` : ""}
${stores ? `` : ""}`
: `Construction ${recipe.level}`;
}
return `
${it.icon}${esc(f.name)}${esc(f.effect)}${state}
`;
});
return rows.length ? `
${rows.join("")}
` : "";
}
// --- council --------------------------------------------------------------
function pips(level) {
return `${[1, 2, 3].map((i) => ``).join("")}`;
}
function reconText(ev) {
const points = `${fmt(ev.recon)} / ${fmt(ev.recon_full)} points`;
if (ev.intel >= 3) return `${points} · nothing more to learn`;
const boost = S.favours.informant.boost ? ` · informant's leads: ×2 for the next ${fmt(S.favours.informant.boost)}` : "";
if (ev.recon_next <= 0) return `${points} · enough for the next level, but they're too far off to read yet${boost}`;
return `${points} · ${fmt(ev.recon_next)} more for the next intel level${boost}`;
}
function council() {
const ev = S.upcoming, city = S.city, me = S.me;
let html = "";
if (!ev) {
html += `
All quiet
No news on the roads. The spymasters will report as soon as something stirs.
`;
}
return html;
}
function favourSpends() {
const f = S.favours, me = S.me;
const option = (kind, icon, title, text) => {
const o = f[kind];
const short = !o.why && me.favour < o.cost;
return `
${icon}${title}${text}
`;
};
return `
Spend favour on the city · you have ${FAVOUR} ${fmt(me.favour)}
${option("builders", "🏗️", "Hire a master builder", f.builders.why ? esc(f.builders.why) + "." : `The next ${f.builders.labour} labour anyone puts into the site counts double, with double Construction XP${f.builders.boost ? ` (${fmt(f.builders.boost)} still to use)` : ""}. Costs more each time on the same site.`)}
${option("informant", "🕵️", "Pay an informant", f.informant.why ? esc(f.informant.why) + "." : `Leads to follow up: the next ${f.informant.recon} reconnaissance on the coming threat counts double, with double XP${f.informant.boost ? ` (${fmt(f.informant.boost)} still to use)` : ""}. Costs more each time for the same threat.`)}
${option("sergeant", "📯", "Hire a drill sergeant", f.sergeant.why ? esc(f.sergeant.why) + "." : `The next ${f.sergeant.drill} drill points fighters put into the militia count double, with double combat XP${f.sergeant.boost ? ` (${fmt(f.sergeant.boost)} still to use)` : ""}. Costs more each time for the same threat.`)}
`;
}
function raidPrep(ev) {
const city = S.city, me = S.me, d = city.defence;
const names = { mercenaries: "Hired sellswords", ward: "Divine ward (temple)", rallied: "Rallied militia (favour)", drilled: "Drilled militia", townsfolk: "Townsfolk with pitchforks", walls: "Walls", militia: "Militia (barracks)", weapons: "Swords in the armoury", shields: "Shields in the armoury", arrows: "Arrows", garrison: "Garrison" };
const reqs = (ev.requests || []).map((r) => {
const mine = me.bank[r.item] || 0;
return `
Arrows, swords and shields in the stockpile arm the defenders: one sword and shield each, best first. Every fight uses up arrows and breaks some of the gear, more when it's close. Food keeps them fighting.
${C.skills.filter((s) => `tool_${s.id}` in p.equip).map((s) => gearSlot(`tool_${s.id}`, s.name, "No tool")).join("")}
${mine ? `
Equip gear and tools from the .
` : ""}
Skills
${skillRows}
`;
}
// --- registration ---------------------------------------------------------
function classCards() {
return Object.entries(C.classes).map(([id, c]) => `
`).join("");
}
// After the classes became trades, everyone picks once more. Nothing is lost.
function repickPage() {
return `
Pick your trade
Classes are now whole trades, from gathering through to the finished goods, so you can play
the quiet gathering side and the busier crafting side yourself. You keep every level and item. This choice
is free, once.
${classCards()}
Every class can still train every skill, scout and drill. A trade makes its skills faster and better.
You're one of a handful of adventurers settling a young city at the edge of the kingdom.
Train your skills, supply the council, and keep the place standing when the bandits come.
Your adventurer keeps working while you're away, for up to a day.
${cards}
Every class can train every skill. Your class is a trade: its skills are faster, its crafts sometimes make double, and it has one bonus of its own.
`;
}
// --- animation ------------------------------------------------------------
function animate() {
const now = serverNow();
const a = S?.me?.action;
if (a) {
const pct = Math.min(1, (now - a.start) / a.duration);
for (const el of document.querySelectorAll("[data-progress]")) el.style.width = (pct * 100) + "%";
if (pct >= 1 && refreshedFor !== a.start) {
refreshedFor = a.start;
setTimeout(refresh, 150);
}
}
for (const el of document.querySelectorAll("[data-span]")) {
const [start, end] = el.dataset.span.split(",").map(Number);
el.style.width = Math.min(100, (now - start) / (end - start) * 100) + "%";
}
for (const el of document.querySelectorAll("[data-countdown]")) el.textContent = dur(Number(el.dataset.countdown) - now);
}
function loop() {
if (S) animate();
requestAnimationFrame(loop);
}
// --- events ---------------------------------------------------------------
document.addEventListener("click", (e) => {
const t = e.target.closest("button, [data-menu], [data-close]");
if (!t) return;
const d = t.dataset;
if (t.matches("[data-close]") || e.target === $("#modal")) { $("#modal").hidden = true; return; }
if ("menu" in d) { setMenu(!$("#app").classList.contains("open")); return; }
if (d.nav) {
page = d.nav; remember("page", page); selected = null;
history.replaceState(null, "", "#" + page);
setMenu(false);
render(); window.scrollTo(0, 0); return;
}
if (d.start) return act("/api/action", { id: d.start });
if (d.once) return act("/api/action", { id: d.once, once: true });
if ("stop" in d) return act("/api/stop", {});
if ("select" in d) {
selected = d.select || null;
render();
// On a phone the item panel sits under the bank grid: bring it into view.
if (selected && matchMedia("(max-width: 900px)").matches) $(".item-panel")?.scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
const qty = d.qty === "all" ? "all" : Number(d.qty);
if (d.donate) return act("/api/donate", { item: d.donate, qty });
if (d.sell) return act("/api/sell", { item: d.sell, qty });
if (d.give) return act("/api/give", { item: d.give, qty, to: $("[data-keep=give-to]").value });
if (d.equip) return act("/api/equip", { item: d.equip });
if (d.unequip) return act("/api/equip", { slot: d.unequip });
if (d.fund) return act("/api/fund", { building: d.fund, item: d.item, source: d.source, qty: "all" });
if (d.install) return act("/api/install", { fitting: d.install, source: d.source });
if ("plot" in d) return act("/api/plot", { id: d.plot || null });
if (d.garrison) return act("/api/garrison", { join: d.garrison === "1" });
if (d.perk) return act("/api/perk", { perk: d.perk });
if (d.favour) return act("/api/favour", { kind: d.favour });
if (d.food) return act("/api/food", { item: d.food });
if (d.potion) return act("/api/potion", { item: d.potion });
if (d.unpost) {
if (!confirm("Delete this message?")) return;
return act("/api/board/delete", { id: Number(d.unpost) });
}
if (d.aid) return act("/api/aid", { kind: d.aid, item: d.item, source: d.source });
if ("hire" in d) return act("/api/hire", {});
if (d.devHappen) return act("/api/dev/happen", { track: d.devHappen });
if (d.pray) {
const on = S.me.prayers.includes(d.pray);
if (!on && S.me.prayers.length >= C.max_prayers) return toast(`Only ${C.max_prayers} prayers at once: turn one off first`, "bad");
return act("/api/prayers", { ids: on ? S.me.prayers.filter((x) => x !== d.pray) : [...S.me.prayers, d.pray] });
}
if (d.overflow) return act("/api/settings", { overflow: d.overflow });
if (d.class) { pickedClass = d.class; render(); return; }
if ("repick" in d) return act("/api/class", { cls: pickedClass });
if (d.tab) { tabs[d.tab] = d.group; remember(`tab.${d.tab}`, d.group); render(); return; }
if (d.devAdvance) return act("/api/dev/advance", { hours: Number(d.devAdvance) });
if ("devEvent" in d) return act("/api/dev/event", {});
});
document.addEventListener("change", (e) => {
if (e.target.matches("[data-food-pick]")) act("/api/food", { item: e.target.value || null });
});
$("#modal").addEventListener("click", (e) => { if (e.target.id === "modal") e.target.hidden = true; });
document.addEventListener("submit", async (e) => {
const form = e.target.closest("[data-form]");
if (!form) return;
e.preventDefault();
const field = form.querySelector("[data-keep]");
const value = field.value.trim();
if (form.dataset.form === "register") {
if (!pickedClass) return toast("Pick a class first", "bad");
return act("/api/register", { name: value, cls: pickedClass });
}
if (form.dataset.form === "board") {
if (!value) return;
field.value = "";
return act("/api/board", { text: value });
}
if (form.dataset.form === "gold") {
field.value = "";
return act("/api/donate", { item: "gold", qty: Number(value) });
}
if (form.dataset.form === "dev-user" && value) {
document.cookie = `gh_user=${encodeURIComponent(value)}; path=/; SameSite=Lax`;
field.value = "";
return refresh();
}
});
document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); });
// --- swipe the menu open and shut (phones) --------------------------------
// A mostly-sideways swipe drags the drawer with the finger; letting go snaps
// it open or shut by distance or speed. Vertical scrolling is left alone.
// Move and end listeners go on the touched element itself: touch events keep
// going to it even if a re-render detaches it mid-swipe, and then they no
// longer bubble up to the document.
(function swipeMenu() {
const phone = matchMedia("(max-width: 900px)");
let drag = null;
const reset = () => {
$("#side").style.transform = $("#side").style.transition = "";
$("#scrim").style.opacity = "";
$("#app").classList.remove("dragging");
};
document.addEventListener("touchstart", (e) => {
if (drag) release();
if (!phone.matches || e.touches.length !== 1 || !$("#modal").hidden || e.target.closest("input, textarea, select")) return;
const t = e.touches[0];
drag = { x: t.clientX, y: t.clientY, at: e.timeStamp, open: $("#app").classList.contains("open"), axis: null, dx: 0, target: e.target };
drag.target.addEventListener("touchmove", move, { passive: false });
drag.target.addEventListener("touchend", finish);
drag.target.addEventListener("touchcancel", finish);
}, { passive: true });
function release() {
drag.target.removeEventListener("touchmove", move);
drag.target.removeEventListener("touchend", finish);
drag.target.removeEventListener("touchcancel", finish);
if (drag.axis === "x") reset();
drag = null;
}
function move(e) {
if (!drag) return;
const t = e.touches[0];
const dx = t.clientX - drag.x, dy = t.clientY - drag.y;
if (!drag.axis) {
if (Math.hypot(dx, dy) < 10) return;
const sideways = Math.abs(dx) > Math.abs(dy) * 1.3 && (drag.open ? dx < 0 : dx > 0);
drag.axis = sideways ? "x" : "y";
if (!sideways) return;
drag.width = $("#side").offsetWidth;
$("#side").style.transition = "none";
$("#app").classList.add("dragging");
}
if (drag.axis !== "x") return;
e.preventDefault();
drag.dx = dx;
// 0 is fully open, -width fully shut.
const offset = Math.max(-drag.width, Math.min(0, drag.open ? dx : dx - drag.width));
$("#side").style.transform = `translateX(${offset}px)`;
$("#scrim").style.opacity = String(1 + offset / drag.width);
}
function finish(e) {
if (!drag) return;
const swiped = drag.axis === "x";
const speed = drag.dx / Math.max(1, e.timeStamp - drag.at); // px per ms
const open = drag.open
? !(drag.dx < -drag.width * 0.35 || speed < -0.4)
: drag.dx > drag.width * 0.35 || speed > 0.4;
// Hand back to the stylesheet; its transition carries on from here.
release();
if (swiped) setMenu(open);
}
})();
(async function boot() {
try {
C = iconise(await api("/api/content"));
await refresh();
} catch (err) {
$("#main").innerHTML = `