// 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; }