// Relmio setup guide. Every wizard page loads this module and names itself in
//
. It stays small: the dock (/guide-dock.js and
// /guide-dock.css) and the page's content (/guide/content-.js) load
// asynchronously. The help chip stays available even with the walkthrough off.
// Pure helpers are exported for the dock and for tests; the bootstrap runs
// only inside a page.
export const GUIDE_PAGES = Object.freeze(["vps", "local", "assistant", "supergrok-vps", "claude-vps", "local-model-vps", "hosting"]);
export const ACTION_LABELS = Object.freeze({
type: "Type here", press: "Press this", choose: "Choose one", check: "Tick this", read: "Read this", wait: "Wait here",
});
export const ERROR_FLAGS = Object.freeze([
"retryBlocked", "oauthRetryBlocked", "remoteOutcomeUnknown", "managedPartialStack", "retryableNgrokSetup", "retryablePlan",
]);
// The first chapter whose panel is on screen, or -1.
export function selectChapter(chapters, shown) {
return chapters.findIndex((chapter) => shown(chapter.panel));
}
// probe: { shown(selector), filled(selector), checked(selector), marked(tip, kind) }
export function tipDone(tip, probe) {
const done = String(tip.done ?? "manual");
const at = done.indexOf(":");
const kind = at < 0 ? done : done.slice(0, at);
if (kind === "filled") return probe.filled(tip.target);
if (kind === "checked") return probe.checked(tip.target);
if (kind === "visible") return probe.shown(done.slice(at + 1));
if (kind === "hidden") return !probe.shown(done.slice(at + 1));
return probe.marked(tip, kind);
}
// The first tip whose target is on screen and not done yet, or -1.
export function currentTip(tips, probe) {
return tips.findIndex((tip) => probe.shown(tip.target) && !tipDone(tip, probe));
}
// Badges and the finish view, decided from one visit to a chapter: the tips
// seen on screen and which are done. A badge needs every action tip seen done;
// reading tips (done: manual) are optional. The guide finishes on the last
// chapter once nothing is left there, so current is -1.
export function visitOutcome(seen, isDone, { last = false, current = -1 } = {}) {
const badge = seen.length > 0 && seen.every((tip) => (tip.done ?? "manual") === "manual" || isDone(tip));
return { badge, finished: last && current < 0 && seen.length > 0 };
}
const own = (table, key) => (typeof key === "string" && table && Object.hasOwn(table, key) ? table[key] : null);
// Error flags, then codes, recovery (unless none), HTTP status, then the fallback.
// Request advice ("check the marked box") needs a field: the server reports
// every unclassified failure as a 400 request error, so without a named or
// marked field that advice would blame a box that is fine.
export function lookupError(errors, error) {
if (!errors || !error) return null;
for (const flag of ERROR_FLAGS) if (error[flag] === true && own(errors.codes, flag)) return errors.codes[flag];
for (const key of [error.code, error.errorCode, error.stoppedReason]) {
if (own(errors.codes, key)) return errors.codes[key];
}
const field = error.marked === true || (typeof error.param === "string" && error.param.trim() !== "");
if (error.recovery === "fix-request" && !field) return errors.fallback ?? null;
if (error.recovery !== "none" && own(errors.recovery, error.recovery)) return errors.recovery[error.recovery];
if (error.status === 400 && !field) return errors.fallback ?? null;
return own(errors.status, error.status == null ? null : String(error.status)) ?? errors.fallback ?? null;
}
// The control that fixes an error: the entry's target on this page, else the named field.
export function errorTarget(entry, error, page) {
const target = own(entry?.targets, page);
if (target) return target;
const param = error?.param;
return /^[A-Za-z][\w-]{0,63}$/u.test(param ?? "") ? `#${param}, [name="${param}"]` : null;
}
// Only the fields the lookup reads, so page errors never carry more into the guide.
export function errorInfo(error) {
const info = {};
for (const key of ["code", "errorCode", "stoppedReason", "recovery", "param"]) {
if (typeof error?.[key] === "string" && error[key].length <= 128) info[key] = error[key];
}
if (Number.isInteger(error?.status)) info.status = error.status;
for (const flag of ERROR_FLAGS) if (error?.[flag] === true) info[flag] = true;
return info;
}
export const box = (left, top, width, height) => ({ left, top, width, height, right: left + width, bottom: top + height });
export const overlaps = (a, b) => a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
const covered = (a, b) => Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left)) *
Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top));
// The candidate that overlaps no protected rect and covers the least content.
// A soft rect may carry a weight, so covering controls costs more than text.
export function choosePlacement(candidates, protectedRects, softRects = []) {
let best = null;
let least = Infinity;
for (const candidate of candidates) {
if (protectedRects.some((rect) => overlaps(candidate, rect))) continue;
const area = softRects.reduce((sum, rect) => sum + covered(candidate, rect) * (rect.weight ?? 1), 0);
if (area < least) [best, least] = [candidate, area];
}
return best;
}
const page = globalThis.document?.body?.dataset.guidePage;
if (GUIDE_PAGES.includes(page)) boot(page);
function boot(page) {
const KEY = "relmio-guide";
const toggles = [...document.querySelectorAll("#guide-toggle, [data-guide-toggle]")];
let stored = null;
try { stored = sessionStorage.getItem(KEY); } catch {}
let dock; let dockLoad; let contentLoad; let errorsLoad; let applied = false;
const host = {
page, toggles, content: undefined, failure: null, failureCount: 0,
pref: stored === "on" || stored === "off" ? stored : null,
choose(value) {
void api({ guide: value });
apply(value);
},
};
async function api(body) {
try {
await globalThis.__relmioWizardSessionReady;
const token = history.state?.relmioWizardSession;
if (!token) return null;
const response = await fetch("/api/ui/preferences", {
method: body ? "POST" : "GET", body: body ? JSON.stringify(body) : undefined, cache: "no-store",
headers: { "Content-Type": "application/json", "X-Setup-Token": token },
});
return response.ok ? await response.json() : null;
} catch { return null; }
}
function apply(value) {
applied = true;
host.pref = value;
try { if (value) sessionStorage.setItem(KEY, value); else sessionStorage.removeItem(KEY); } catch {}
for (const toggle of toggles) {
if (toggle.getAttribute("aria-pressed") !== String(value === "on")) toggle.setAttribute("aria-pressed", String(value === "on"));
}
contentLoad ??= import(`/guide/content-${page}.js`).then(
(module) => { host.content = Array.isArray(module.GUIDE?.chapters) ? module.GUIDE : null; },
() => { host.content = null; },
);
void Promise.all([contentLoad, mount()]).then(() => dock?.refresh());
dock?.refresh();
}
// The stylesheet loads asynchronously with the dock, including its help chip.
function mount() {
dockLoad ??= Promise.all([import("/guide-dock.js"), new Promise((resolve, reject) => {
const link = Object.assign(document.createElement("link"), { rel: "stylesheet", href: "/guide-dock.css" });
link.addEventListener("load", resolve);
link.addEventListener("error", reject);
document.head.append(link);
})]).then(([module]) => { dock = module.mountGuide(host); }, () => {});
return dockLoad;
}
globalThis.relmioGuide = Object.freeze({
help() { void mount().then(() => dock?.openHelp()); },
error(error) {
host.failureCount++;
const info = errorInfo(error);
// Pages mark a rejected field before they report the failure.
if ([...document.querySelectorAll("[aria-invalid='true']")].some(isShown)) info.marked = true;
const record = host.failure = { info, surfaces: [...document.querySelectorAll("[role='alert']")].filter(isShown), entry: null };
errorsLoad ??= import("/guide/errors.js").then((module) => module.GUIDE_ERRORS ?? null, () => null);
void Promise.all([errorsLoad, mount()]).then(([errors]) => {
if (host.failure !== record) return;
record.entry = lookupError(errors, info);
dock?.refresh();
});
},
clearError() {
host.failure = null;
dock?.refresh();
},
});
for (const toggle of toggles) {
toggle.hidden = false;
toggle.addEventListener("click", () => host.choose(host.pref === "on" ? "off" : "on"));
}
for (const trigger of document.querySelectorAll("[data-guide-help]")) {
trigger.addEventListener("click", () => globalThis.relmioGuide.help());
}
if (host.pref) apply(host.pref);
void api().then((saved) => {
const value = saved?.guide === "on" || saved?.guide === "off" ? saved.guide : saved ? null : host.pref;
if (!applied || value !== host.pref) apply(value);
});
}
function isShown(node) {
return node.checkVisibility ? node.checkVisibility() : node.getClientRects().length > 0;
}