src/ui/guide-dock.js
Rama main · réplica de lectura del repositorio original.
// Setup guide dock: screen tracking, the dock and its mascot chip, the pointer,
// Show me, the mascot in the quest track and the badges on Ready screens.
// guide.js imports this module only while the guide or error help is in use,
// so pages with the guide off never load it. The placement helpers are pure
// and exported for tests.
import { ACTION_LABELS, box, choosePlacement, currentTip, errorTarget, overlaps, selectChapter, tipDone, visitOutcome } from "./guide.js";
import { createMascot, setMascotPose } from "./mascot.js";
import { contextualHelp, HELP_FAQ, HELP_TERMS, helpState, UNKNOWN_HELP, validHelpMetadata } from "./guide/context.js";
const FOCUSABLE = "a[href], button, input, select, textarea, summary, [tabindex]";
// The rail column: the rail and the blocks the grid places under it, such as
// the safety notes. Their children are its content.
const RAIL = ".rm-split > :not(.rm-split__content, .rm-panel)";
// Page content the dock should avoid covering when it has a choice; covering
// a control costs three times as much as covering text.
const SOFT = `${RAIL} > *, .rm-panel__heading, ${RAIL} :is(button:not(:disabled), a[href], summary),` +
" .rm-panel__body :is(.rm-field, .rm-check, .rm-choice, .rm-callout, .rm-notice, .rm-card, .rm-dl, .rm-disclosure," +
" .rm-terminal, .rm-table-wrap, p, h2, h3, li, button, a, label)";
const CONTROLS = "button, a, label, .rm-field, .rm-check, .rm-choice, .rm-disclosure";
// Safety the dock keeps clear: the rail column's content apart from the quest
// track (intros, notes, status and safety lists) and every warning.
const SAFETY = `${RAIL} > *, [data-tone='warning'], .rm-callout--warning, .rm-notice`;
// Also kept clear at first: the page and step titles and the quest track.
const LEAD = "h1, .rm-panel__heading :is(h2, h3), .rm-stepper--track";
// What the person works with in the step: fields, choices, buttons, links,
// disclosures and commands. A dock nobody opened never covers them.
const WORK = ".rm-panel__body :is(input, select, textarea, button, summary, a[href], .rm-field, .rm-check, .rm-choice, .rm-terminal)";
// Text the pointer's action label never covers: choice labels and summaries,
// then every line of text and every control near the target (see beside()).
const LABELS = ":is(.rm-check, .rm-choice) > :not(input), summary";
const CHOICE = "input[type=checkbox], input[type=radio]";
const PRESSABLE = "button, a[href], input, select, textarea, summary";
// What the folded chip keeps clear wherever it sits on the page: every link,
// button, field control, choice and disclosure summary, and every heading.
// With no slot clear of them all, it covers a heading first.
const TAPS = `${PRESSABLE}, .rm-check, .rm-choice`;
const HEADINGS = "h1, h2, h3, h4, h5, h6";
// A dock nobody opened may shrink into a clear stretch of the rail column
// this tall before it folds into the chip.
const SHRUNK = 200;
// Wide screens: the rail column's foot, level with the panel or under its
// content (12 px below it, or as low as the window allows down to 8 px), or
// its top, then corners of the panel above its footer or under its header.
// Narrow screens: a sheet at the bottom of the window, above the panel
// footer, or under the top bar.
export function candidates(view, measure) {
const list = [];
if (view.wide && view.rail) {
const { left, top, width } = view.rail;
const height = measure(width);
const foot = Math.min(view.railEnd + 12, view.vh - 8 - height);
list.push(box(left, view.columnBottom - height, width, height));
if (foot >= view.railEnd + 8) list.push(box(left, foot, width, height));
list.push(box(left, top, width, height));
}
if (view.wide && view.panel) {
const width = Math.min(352, view.panel.width - 32);
const height = measure(width);
const left = view.panel.right - 16 - width;
const above = view.footerTop - 12 - height;
list.push(box(left, above, width, height), box(view.panel.left + 16, above, width, height),
box(left, view.headerBottom + 8, width, height));
}
if (!view.wide) {
const width = Math.min(view.vw - 16, 512);
const height = measure(width);
const left = (view.vw - width) / 2;
list.push(box(left, view.vh - 8 - height, width, height));
if (view.bottom < view.vh - 1) list.push(box(left, view.bottom - 8 - height, width, height));
list.push(box(left, view.top, width, height));
}
return list.filter((rect) => rect.top >= view.top - 8 && rect.bottom <= view.vh && rect.left >= 0 && rect.right <= view.vw);
}
// The dock's spot, strictest rule first; null folds it into the chip. It never
// covers the hard boxes (the focused element, the primary action, alerts,
// footer actions and kept controls) where a spot keeps them clear. Until the
// person opens it, it also keeps safety, the step's working controls, the
// titles, the quest track and the tip's target clear; a spare spot cut into a
// clear stretch of the rail column comes before folding. Opened, it gives up
// the target, titles and track first, then the working controls, and safety
// last. At each of those steps a spare spot cut to the window comes before
// giving up more, and on a tiny window it covers the fewest hard boxes.
// Fixed boxes (error-help working controls) are never given up, even there.
export function placeDock(list, { hard, safety, lead, aim, work = [], fixed = [] }, soft, opened = false, spare = []) {
const required = [...hard, ...fixed];
const tiers = [[...required, ...safety, ...work, ...lead, ...aim]];
if (opened) tiers.push([...required, ...safety, ...work], [...required, ...safety], required);
for (const rects of tiers) {
for (const spots of [list, spare]) {
const spot = choosePlacement(spots, rects, soft);
if (spot) return spot;
}
}
return opened ? choosePlacement(spare, fixed, hard) : null;
}
// The stretches of a column, from `top` down to `floor`, that no stop reaches:
// [top, bottom] pairs in order. `air` keeps that many px clear of each stop.
function clearStretches(left, width, top, floor, stops, air = 0) {
const found = [];
const inColumn = stops.filter((rect) => rect.left < left + width && left < rect.right).sort((a, b) => a.top - b.top);
for (const stop of [...inColumn, box(left, floor + air, width, 0)]) {
const bottom = Math.min(stop.top - air, floor);
if (bottom > top) found.push([top, bottom]);
top = Math.max(top, stop.bottom + air);
}
return found;
}
// Spare spots for a dock that fits no full-height spot: its columns cut to
// the window under the top bar, where its body scrolls. A narrow window with
// room for two 272 px docks also tries each half. Each stretch between the
// boxes to keep clear (`stops`) holding at least `least` px comes first, then
// the whole column. An opened dock passes the hard boxes; a dock nobody opened
// passes everything it keeps clear and a view without the panel, so it only
// shrinks into the rail column.
export function boundedSpots(view, measure, stops, least = 160) {
const columns = [];
if (view.wide && view.rail) columns.push([view.rail.left, view.rail.width]);
if (view.wide && view.panel) {
const width = Math.min(352, view.panel.width - 32);
columns.push([view.panel.right - 16 - width, width], [view.panel.left + 16, width]);
}
if (!columns.length) {
const width = Math.min(view.vw - 16, 512);
const half = Math.min(352, (view.vw - 24) / 2);
columns.push([(view.vw - width) / 2, width]);
if (half >= 272) columns.push([8, half], [view.vw - 8 - half, half]);
}
const floor = view.vh - 8;
const spots = [];
for (const [left, width] of columns) {
const full = measure(width);
const fit = (top, bottom) => {
const height = Math.min(full, bottom - top);
return [box(left, top, width, height), box(left, bottom - height, width, height)];
};
for (const [top, bottom] of clearStretches(left, width, view.top, floor, stops)) {
if (bottom - top >= Math.min(full, least)) spots.push(...fit(top, bottom));
}
spots.push(...fit(view.top, floor));
}
return spots;
}
// The chip takes the slot nearest its usual place that keeps the most clear.
// Its usual place is the rail column's foot on wide windows, else the right
// edge above the panel footer, at the window's foot or under the top bar.
// Slots are those corners, the left edge's corners and both ends of every
// clear stretch down the window's left and right edges, always inside the
// window under the top bar. Plain text may sit under it. It keeps everything
// else clear, then gives up the tip's target and the quest track, then safety
// notes, then a field's label or hint. A link, a button, a field's control, a
// choice (`taps`) or a heading is covered only when no slot on either edge
// keeps them all clear: a heading first, then the least of the rest, and a
// hard box last of all.
export function chipSpot(view, width, height, { hard, safety, lead, aim = [], work = [], taps = [], headings = [] }, soft = []) {
const gap = view.wide ? 16 : 8;
const at = (left, top) => box(left, top, width, height);
const right = view.vw - gap - width;
const left = view.rail?.left ?? gap;
const floor = view.vh - gap;
const inside = (spots) => spots.filter((rect) =>
rect.top >= view.top && rect.bottom <= view.vh && rect.left >= 0 && rect.right <= view.vw);
const corners = inside([...(view.rail ? [at(left, view.columnBottom - height)] : []),
at(right, view.bottom - gap - height), at(right, floor - height), at(right, view.top),
at(left, floor - height), at(left, view.top)]);
const home = corners[0] ?? at(right, view.top);
// The corners, then both ends of each stretch of an edge that the boxes leave clear by 4 px.
const slots = (rects) => [...corners, ...inside([...new Set([right, left])].flatMap((edge) =>
clearStretches(edge, width, view.top, floor, rects, 4).flatMap(([top, bottom]) =>
(bottom - top < height ? [] : [at(edge, top), at(edge, bottom - height)]))))];
const base = [...hard, ...taps, ...headings];
const tiers = [
[...base, ...work, ...safety, ...lead, ...aim],
[...base, ...work, ...safety],
[...base, ...work],
base,
[...hard, ...taps],
];
for (const rects of tiers) {
let best = null;
let least = Infinity;
for (const spot of slots(rects)) {
if (rects.some((rect) => overlaps(spot, rect))) continue;
const far = Math.hypot(spot.left - home.left, spot.top - home.top);
if (far < least) [best, least] = [spot, far];
}
if (best) return best;
}
const last = slots(hard);
const weighed = [...taps.map((rect) => ({ ...box(rect.left, rect.top, rect.width, rect.height), weight: 4 })), ...headings, ...soft];
return choosePlacement(last, hard, weighed) ?? choosePlacement(last, [], hard) ?? home;
}
// The part of a box inside every clip box, or null when none of it shows.
export function visiblePart(rect, clips) {
let { left, top, right, bottom } = rect;
for (const clip of clips) {
[left, top] = [Math.max(left, clip.left), Math.max(top, clip.top)];
[right, bottom] = [Math.min(right, clip.right), Math.min(bottom, clip.bottom)];
}
return right > left && bottom > top ? box(left, top, right - left, bottom - top) : null;
}
// The pointer's cursor: its tip on the target's bottom right, or on a
// checkbox or radio's left edge so the action label points away from its
// text. The label sits under the target beside the arrow. When it would leave
// the window or cover an avoided box it takes the other side, then the same
// two sides above the target with the cursor on the top edge (`raise`). It is
// left out when no side is clear.
export function cursorSpot(goal, { arrow, width, height, top }, view, avoid, control = false) {
const x = control ? goal.left + 4 : Math.min(goal.right - 12, view.vw - 24);
const low = Math.min(goal.bottom - 6, view.vh - 32);
const sides = control ? [true, false] : [false, true];
for (const raise of [false, true]) {
const y = raise ? goal.top + 6 : low;
for (const flip of sides) {
const label = box(flip ? x - arrow - width : x + arrow, raise ? y - top - height : y + top, width, height);
if (label.left >= 8 && label.right <= view.vw - 8 && label.top >= (view.top ?? 0) && label.bottom <= view.vh &&
!avoid.some((rect) => overlaps(label, rect))) return { x, y, flip, raise, label: true };
}
}
return { x, y: low, flip: sides[0], raise: false, label: false };
}
// Show me markers under reduced motion: each starts above its target and moves
// right past a marker it would overlap, or under it when the row is full.
export function stackMarkers(boxes, width) {
const placed = [];
for (const start of boxes) {
let spot = start;
for (let hit; (hit = placed.find((other) => overlaps(spot, other)));) {
spot = hit.right + 4 + spot.width <= width ? box(hit.right + 4, spot.top, spot.width, spot.height)
: box(start.left, hit.bottom + 4, spot.width, spot.height);
}
placed.push(spot);
}
return placed;
}
export function mountGuide(host) {
const doc = document;
const { page, toggles } = host;
const reduced = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
let active = -1; let tipIndex = -1; let viewTip = null; let finished = false; let happyUntil = 0;
// viewTip was chosen by focus, not by Back or Next tip: the dock shows the
// tip of the control the person is using while that control keeps focus.
let followed = false;
let visitShown = new Set(); let visitDone = new Set();
const marks = new Set(); const completed = new Set(); const earned = [];
// A press counts once its operation ends without a new failure: each pressed
// tip waits here with the failure that was showing at the press. A chapter
// left while a press waits or the page is busy earns its badge afterwards.
const pending = new Map(); const departed = new Map();
let hidden = false; let seenPref; let returnFocus = null; let restored = false;
let demo = null; let demoTip = null; let markers = []; let look = {};
let frame = 0; let placeFrame = 0; let shownKey = ""; let observedTarget = null;
// The next placement follows a scroll or a resize, so nothing fades in.
let drift = false;
// The dock content the person asked to see (the chip, Start the guide, Back
// or Next tip); "next" until it renders. Only that content, or a dock holding
// focus, may cover the page past the strict rules.
let openedKey = null;
let helpOpen = false; let helpSelection = "";
const PROGRESS = `relmio-guide-progress:${page}`;
const busy = () => doc.body.dataset.operationBusy === "true";
const all = (selector) => { try { return [...doc.querySelectorAll(selector)]; } catch { return []; } };
const owned = (node) => toggles.includes(node) || ui.layer.contains(node) || ui.track.contains(node) ||
Boolean(ui.finish?.contains(node)) || node === ui.jump;
const showing = (node) => (node.checkVisibility ? node.checkVisibility() : node.getClientRects().length > 0);
const visible = (node) => Boolean(node) && !ui.layer.contains(node) && showing(node);
const first = (selector) => (typeof selector === "string" ? all(selector).find(visible) ?? null : null);
const shown = (selector) => first(selector) !== null;
// Boxes of the nodes on screen, leaving out visually hidden ones.
const boxes = (nodes) => nodes.filter(visible).map((node) => node.getBoundingClientRect())
.filter((rect) => rect.width > 1 && rect.height > 1);
const matches = (node, selector) => { try { return Boolean(node?.closest?.(selector)); } catch { return false; } };
// A copy button: a page's copy target or a button with the kit's copy icon.
const copyButton = (node) => (node.matches("[data-copy-target]") || node.querySelector(".rm-icon--copy") ? node : null);
const attr = (node, name, value) => { if (node.getAttribute(name) !== value) node.setAttribute(name, value); };
const make = (tag, className = "", text = "") => {
const node = doc.createElement(tag);
if (className) node.className = className;
if (text) node.textContent = text;
return node;
};
const textButton = (className, text = "") => Object.assign(make("button", className, text), { type: "button" });
const iconButton = (icon, label, variant = "") => {
const node = textButton(`rm-icon-button rm-icon-button--sm ${variant}`.trim());
node.setAttribute("aria-label", label);
node.title = label;
const glyph = make("span", `rm-icon rm-icon--${icon} rm-icon--sm`);
glyph.setAttribute("aria-hidden", "true");
node.append(glyph);
return node;
};
const probe = (chapter) => ({
shown,
// Read validity first: invalid inputs must not fire the page's "invalid"
// handler merely because the guide evaluated them.
filled: (selector) => all(selector).some((node) => visible(node) && String(node.value ?? "").trim() !== "" &&
node.validity?.valid !== false && node.checkValidity?.() !== false),
checked: (selector) => all(selector).some((node) => node.checked === true || Boolean(node.querySelector?.("input:checked"))),
marked: (tip, kind) => marks.has(`${kind}|${chapter.id}/${tip.id}`),
});
const chapterNow = () => host.content?.chapters[active];
const ui = build();
function build() {
const layer = make("div", "rm-guide-layer");
const dock = make("aside", "rm-guide");
dock.setAttribute("aria-label", "Setup guide");
dock.id = "relmio-help-dock";
dock.tabIndex = -1;
const head = make("div", "rm-guide__head");
const face = make("span", "rm-guide__mascot");
const mascot = createMascot();
face.append(mascot);
const quest = make("div", "rm-guide__quest");
const eyebrow = make("p", "rm-guide__eyebrow");
const progress = make("div", "rm-guide__progress");
const dots = make("ol", "rm-guide__dots");
dots.setAttribute("aria-hidden", "true");
progress.append(dots);
quest.append(eyebrow, progress);
const hide = iconButton("chevron-down", "Hide guide");
const close = iconButton("x", "Skip guide");
head.append(face, quest, hide, close);
const live = make("div", "rm-guide__live");
const title = make("p", "rm-guide__title");
const say = make("p", "rm-guide__say");
const steps = make("ol", "rm-guide__steps");
steps.setAttribute("aria-label", "Next steps");
live.append(title, say, steps);
const example = make("p", "rm-guide__example", "Example ");
const code = make("code");
example.append(code);
const find = make("details", "rm-disclosure rm-disclosure--plain rm-guide__find");
const findBody = make("div", "rm-disclosure__body");
const findText = make("p");
findBody.append(findText);
find.append(make("summary", "", "Where do I find this?"), findBody);
const next = make("ul", "rm-guide__next");
const topics = make("label", "rm-field rm-guide__topics");
const topicLabel = make("span", "rm-field__label", "Help topic");
const topic = make("select", "rm-select");
topics.append(topicLabel, topic);
const availability = make("p", "rm-small rm-muted rm-guide__availability");
const about = make("details", "rm-disclosure rm-disclosure--plain rm-guide__find");
const aboutBody = make("div", "rm-disclosure__body");
const source = make("p", "rm-small rm-muted");
aboutBody.append(source);
about.append(make("summary", "", "About this help"), aboutBody);
const actions = make("div", "rm-guide__actions");
const back = iconButton("arrow-left", "Back", "rm-icon-button--outline");
const forward = textButton("rm-button rm-button--sm", "Next tip");
const show = textButton("rm-button rm-button--sm", "Show me");
const start = textButton("rm-button rm-button--sm", "Start the guide");
const skip = textButton("rm-button rm-button--ghost rm-button--sm", "Skip guide");
const help = textButton("rm-button rm-button--ghost rm-button--sm", "Help");
actions.append(back, forward, show, help, start, skip);
// Cut to a short window, only the body scrolls; the head and actions stay.
const body = make("div", "rm-guide__body");
body.append(topics, live, example, find, next, availability, about);
dock.append(head, body, actions);
const chip = textButton("rm-guide-chip");
const chipFace = make("span", "rm-guide-chip__mascot");
const chipMascot = createMascot();
chipFace.append(chipMascot);
const chipText = make("span");
chip.append(chipFace, chipText);
chip.setAttribute("aria-controls", "relmio-help-dock");
chip.setAttribute("aria-expanded", "false");
const pointer = make("div", "rm-guide-pointer");
pointer.setAttribute("aria-hidden", "true");
const ring = make("div", "rm-guide-pointer__ring");
const cursor = make("div", "rm-guide-pointer__cursor");
const label = make("span", "rm-guide-pointer__label");
cursor.append(make("span", "rm-guide-pointer__arrow"), label);
pointer.append(ring, cursor);
// One polite status for every tip, outside the dock, so tips are also
// announced while the dock is folded into the chip.
const announce = make("p", "rm-visually-hidden");
announce.setAttribute("role", "status");
dock.hidden = chip.hidden = pointer.hidden = true;
layer.append(pointer, dock, chip, announce);
doc.body.append(layer);
// The dock is last in the page, so a second skip link reaches it first.
const jump = Object.assign(make("a", "rm-skip-link", "Skip to setup guide"), { href: "#relmio-help-dock" });
jump.setAttribute("data-guide-help", "");
jump.addEventListener("click", (event) => { event.preventDefault(); focusDock(); });
doc.querySelector(".rm-skip-link")?.after(jump);
// A tighter view box for the quest track; the cap and warning mark may spill.
const track = createMascot();
track.setAttribute("viewBox", "-6 -6 108 138");
const finish = doc.querySelector("[data-guide-finish]");
const collapse = () => {
hidden = true; helpOpen = false; openedKey = null;
stopDemo(); clearMarkers(); refresh();
};
// Focus moves to the visible guide toggle, or the menu that holds it.
const turnOff = () => {
helpOpen = false;
stopDemo(); clearMarkers();
host.choose("off");
(toggles.find((node) => node.checkVisibility?.()) ?? doc.querySelector(".rm-menu > summary"))?.focus();
};
hide.addEventListener("click", () => { collapse(); chip.focus(); });
close.addEventListener("click", turnOff);
skip.addEventListener("click", turnOff);
start.addEventListener("click", () => {
helpOpen = false; openedKey = "next";
if (host.pref !== "on") host.choose("on");
refresh(); focusDock();
});
back.addEventListener("click", () => { if (back.getAttribute("aria-disabled") !== "true") step(-1); });
forward.addEventListener("click", () => { if (forward.getAttribute("aria-disabled") !== "true") step(1); });
show.addEventListener("click", showMe);
// With the walkthrough on, the chip is the folded guide: it carries the
// current tip's title and opens that tip. Otherwise it opens Help.
chip.addEventListener("click", () => {
if (!walk()) return openHelp();
returnFocus = doc.activeElement;
hidden = false; openedKey = "next";
refresh(); focusDock();
});
help.addEventListener("click", openHelp);
topic.addEventListener("change", () => {
helpSelection = topic.value; followed = false;
stopDemo(); clearMarkers(); openedKey = "next"; refresh();
});
dock.addEventListener("focusin", (event) => { if (!dock.contains(event.relatedTarget)) returnFocus = event.relatedTarget; });
dock.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return;
event.preventDefault();
const origin = returnFocus;
collapse();
(origin?.isConnected && visible(origin) ? origin : chip).focus();
});
return { layer, dock, mascot, eyebrow, progress, dots, close, title, say, steps, example, code, find, findText,
next, topics, topic, availability, about, source, actions, back, forward, show, help, start, skip,
chip, chipMascot, chipText, pointer, ring, cursor, label, track, finish, announce, jump };
}
function openHelp() {
returnFocus = doc.activeElement;
hidden = false; helpOpen = true; helpSelection = ""; openedKey = "next"; followed = false;
stopDemo(); clearMarkers(); refresh(); focusDock();
}
function helpEntry() {
if (!validHelpMetadata(host.content?.metadata)) return UNKNOWN_HELP;
const tips = chapterNow()?.tips ?? [];
if (helpSelection.startsWith("tip:")) return tips.find((tip) => `tip:${tip.id}` === helpSelection) ?? UNKNOWN_HELP;
if (helpSelection.startsWith("faq:")) return HELP_FAQ.find((item) => `faq:${item.id}` === helpSelection) ?? UNKNOWN_HELP;
if (helpSelection.startsWith("term:")) return HELP_TERMS.find((item) => `term:${item.id}` === helpSelection) ?? UNKNOWN_HELP;
if (host.failure?.entry) return { ...host.failure.entry, id: "error", target: errorTarget(host.failure.entry, host.failure.info, page) };
const root = all(".siwc-controls").find(visible);
// A causal permission rule applies only while its account controls are on screen.
const cause = contextualHelp(helpState(root, doc.body), host.content.metadata);
return cause ?? tips[tipIndex] ?? UNKNOWN_HELP;
}
function focusDock() {
(ui.dock.hidden ? ui.chip : ui.dock).focus();
}
// The walkthrough view the chip stands for: null with the guide off, with no
// content or while an error needs help.
function walk() {
const { pref, content, failure } = host;
return failure?.entry || !content || pref === "off" ? null : pref === null ? "welcome" : finished ? "finish" : "guide";
}
function mode() {
if (host.pref !== seenPref) [seenPref, hidden] = [host.pref, false];
if (helpOpen) return "help";
if (hidden) return "chip";
if (host.failure?.entry) return host.pref === "off" ? "chip" : "error";
return walk() ?? "chip";
}
// Reading tips passed with Next tip and the badges earned stay for this tab,
// so a reload does not bring them back. Only ids this page's content knows
// are taken. Presses and field values are page state, read from the page.
function restore() {
if (restored) return;
restored = true;
let saved = null;
try { saved = JSON.parse(sessionStorage.getItem(PROGRESS)); } catch {}
const read = Array.isArray(saved?.read) ? saved.read : [];
const badges = Array.isArray(saved?.badges) ? saved.badges : [];
for (const chapter of host.content.chapters) {
for (const tip of chapter.tips) {
if (tip.done === "manual" && read.includes(`${chapter.id}/${tip.id}`)) marks.add(`manual|${chapter.id}/${tip.id}`);
}
if (!badges.includes(chapter.id) || completed.has(chapter.id)) continue;
completed.add(chapter.id);
if (chapter.badge) earned.push(chapter.badge);
}
}
function keep() {
try {
sessionStorage.setItem(PROGRESS, JSON.stringify({
read: [...marks].filter((key) => key.startsWith("manual|")).map((key) => key.slice(7)),
badges: [...completed],
}));
} catch {}
}
// One hop of the mascot.
function cheer() {
happyUntil = performance.now() + 320;
setTimeout(schedule, 350);
}
function award(chapter) {
if (!chapter || completed.has(chapter.id)) return;
completed.add(chapter.id);
if (chapter.badge) earned.push(chapter.badge);
keep();
cheer();
}
function evaluate() {
const failure = host.failure;
if (failure?.surfaces.length && !failure.surfaces.some(visible)) host.failure = null;
const chapters = host.content?.chapters;
if (!chapters) return;
restore();
// A press counts once the page is idle again and reported no new failure.
// A copy button's press counts once the page marks a successful copy with
// data-copied, so a copy the browser is still asking about or refused
// leaves its tip open.
let installed = false;
for (const [key, press] of pending) {
if ((host.failureCount ?? 0) !== press.failureCount || (host.failure && host.failure !== press.failure)) pending.delete(key);
else if (press.copy ? press.copy.hasAttribute("data-copied") : !busy()) {
marks.add(key);
pending.delete(key);
installed ||= press.tip.installs === true;
}
}
// A newly issued key needs its own copy, even after an earlier key's tip
// was completed. Forget that press when its fresh-key control leaves view.
for (const chapter of chapters) for (const tip of chapter.tips) {
if (tip.done === "clicked" && tip.target.includes("[data-guide-state=fresh]") && !shown(tip.target)) {
marks.delete(`clicked|${chapter.id}/${tip.id}`);
}
}
const outcome = (chapter, visit, current) => {
const check = probe(chapter);
return visitOutcome([...visit.shown], (tip) => visit.done.has(tip) || tipDone(tip, check),
{ last: chapter === chapters.at(-1), current });
};
const index = selectChapter(chapters, shown);
if (index !== active) {
if (chapters[active]) departed.set(chapters[active], { shown: visitShown, done: visitDone });
[active, viewTip, followed, finished, visitShown, visitDone] = [index, null, false, false, new Set(), new Set()];
stopDemo();
clearMarkers();
helpSelection = "";
}
// A chapter left behind earns its badge once the page is idle and no
// press there still waits for its result.
for (const [chapter, visit] of departed) {
if (busy() || [...pending.values()].some((press) => press.chapter === chapter)) continue;
departed.delete(chapter);
if (outcome(chapter, visit).badge) award(chapter);
}
// A real install that reaches the Ready step gets one hop, badge or not.
if (installed && active === chapters.length - 1) cheer();
const chapter = chapters[active];
if (!chapter) { tipIndex = -1; return; }
const check = probe(chapter);
for (const tip of chapter.tips) {
if (!shown(tip.target)) continue;
visitShown.add(tip);
if (tipDone(tip, check)) visitDone.add(tip);
}
const current = currentTip(chapter.tips, check);
if (current >= 0) finished = false;
if (viewTip && viewTip.base !== current) {
// A tip chosen by focus stays while its control keeps focus.
if (followed && matches(doc.activeElement, chapter.tips[viewTip.index]?.target)) viewTip.base = current;
else [viewTip, followed] = [null, false];
}
tipIndex = viewTip?.index ?? (current >= 0 ? current : chapter.tips.findLastIndex((tip) => shown(tip.target)));
const result = finished ? null : outcome(chapter, { shown: visitShown, done: visitDone }, current);
if (result?.finished) {
finished = true;
if (result.badge) award(chapter);
}
}
function step(delta) {
const chapter = chapterNow();
const tips = chapter?.tips ?? [];
const tip = tips[tipIndex];
if (!tip) return;
if (delta > 0 && tip.done === "manual") {
marks.add(`manual|${chapter.id}/${tip.id}`);
keep();
}
let index = tipIndex + delta;
while (tips[index] && !shown(tips[index].target)) index += delta;
viewTip = tips[index] ? { index, base: currentTip(tips, probe(chapter)) } : null;
followed = false;
openedKey = "next";
refresh();
}
// The dock follows the control the person is using: its tip shows while it
// keeps focus, and Help switches to that tip's topic.
function follow(event) {
placeSoon();
if (owned(event.target) || demo) return;
const chapter = chapterNow();
const tips = chapter?.tips ?? [];
const index = event.type === "focusin" ? tips.findIndex((tip) => shown(tip.target) && matches(event.target, tip.target)) : -1;
if (index >= 0 && helpOpen) [helpSelection, viewTip, followed] = [`tip:${tips[index].id}`, null, true];
else if (index >= 0) [viewTip, followed] = [{ index, base: currentTip(tips, probe(chapter)) }, true];
else if (followed) [viewTip, followed, helpSelection] = [null, false, ""];
else return;
schedule();
}
const sentence = (text) => (/[.?]$/u.test(text) ? text : `${text}.`);
function render() {
const current = mode();
const content = host.content;
const chapter = chapterNow();
// Hide and automatic folding keep the walkthrough available to the one
// status region; switching it off stops walkthrough announcements.
const display = current === "chip" && host.pref === "on"
? host.failure?.entry ? "error" : walk() ?? "chip" : current;
const tip = display === "guide" ? chapter?.tips[tipIndex] : null;
const entry = current === "help" ? helpEntry() : host.failure?.entry;
const goal = target(current);
const missing = current === "help" && entry.target && !goal;
const availability = missing ? "This control is not visible in this view. Choose Current step or open the relevant section to find it." : "";
const key = `${display}|${display === "guide" ? `${chapter?.id}/${tip?.id}` : ["error", "help"].includes(display) ? `${entry?.id}/${entry?.title}/${entry?.say}/${Boolean(missing)}` : ""}`;
if (openedKey === "next" && current !== "chip") openedKey = key;
const focusInside = ui.dock.contains(doc.activeElement);
// A Ready screen with the badges block lists them, so the dock does not repeat them.
const listed = onPage(content);
if (key !== shownKey && display !== "chip") {
shownKey = key;
const [title, say] = ["error", "help"].includes(display) ? [entry.title, entry.say]
: display === "finish" ? [content.finish?.title ?? "Setup complete", content.finish?.say]
: tip ? [tip.title, tip.say] : [content.title, content.intro];
const steps = ["error", "help"].includes(display) ? entry.steps ?? [] : [];
ui.title.textContent = title ?? "";
ui.say.textContent = say ?? "";
ui.steps.replaceChildren(...steps.map((text) => make("li", "", text)));
// Announced once from the status region; a change that follows focus
// announces the tip's title only.
ui.announce.textContent = followed && ["guide", "help"].includes(display) ? title ?? ""
: [title, say, ...steps, availability].filter(Boolean).map(sentence).join(" ");
ui.code.textContent = (current === "help" ? entry : tip)?.example ?? "";
ui.findText.textContent = (current === "help" ? entry : tip)?.find ?? "";
ui.find.open = false;
ui.next.replaceChildren(...(current === "finish" && !listed ? content.finish?.next ?? [] : [])
.map((text) => make("li", "", text)));
}
// A quest takes the number of the page's current step, so the dock agrees
// with the quest track; a page without a track counts its chapters.
const item = content ? first(".rm-stepper__item[aria-current='step']") : null;
const quest = item ? [...item.parentElement.children].indexOf(item) + 1 : active + 1;
const eyebrow = current === "help" ? "Help for this view" : current === "error" ? "Error help" : current === "guide" && chapter
? `Quest ${quest} · ${chapter.label}` : "Setup guide";
if (current !== "chip" && ui.eyebrow.textContent !== eyebrow) ui.eyebrow.textContent = eyebrow;
const chapters = current === "error" ? [] : content?.chapters ?? [];
if (ui.dots.children.length !== chapters.length) {
ui.dots.replaceChildren(...chapters.map(() => {
const dot = make("li");
dot.addEventListener("animationend", () => dot.classList.remove("is-new"));
return dot;
}));
}
chapters.forEach((item, index) => {
const dot = ui.dots.children[index];
const state = completed.has(item.id) ? "done" : index === active && current === "guide" ? "current" : "todo";
// A dot that turns done while the guide runs draws its check once.
if (state === "done" && !["done", null].includes(dot.getAttribute("data-state"))) dot.classList.add("is-new");
attr(dot, "data-state", state);
});
const badges = current === "finish" ? (listed ? [] : earned) : current === "guide" ? earned.slice(-1) : [];
const chips = [...ui.progress.children].slice(1);
if (chips.length !== badges.length || chips.some((node, index) => node.textContent !== badges[index])) {
ui.progress.replaceChildren(ui.dots, ...badges.map((text) => make("span", "rm-badge rm-badge--success", text)));
}
const tips = tip ? chapter.tips : [];
const before = tips.slice(0, tipIndex).some((item) => shown(item.target));
const after = tips.slice(tipIndex + 1).some((item) => shown(item.target)) ||
(tip?.done === "manual" && !marks.has(`manual|${chapter.id}/${tip.id}`));
ui.steps.hidden = !ui.steps.children.length;
ui.example.hidden = !ui.code.textContent;
ui.find.hidden = !ui.findText.textContent;
ui.next.hidden = !ui.next.children.length;
ui.progress.hidden = current === "error" || current === "help";
ui.topics.hidden = ui.about.hidden = current !== "help";
if (current === "help") {
// Current step, then the visible tips, the questions and the words.
const groups = [
["This step", (chapter?.tips ?? []).filter((item) => shown(item.target)).map((item) => [`tip:${item.id}`, item.title])],
["Questions", HELP_FAQ.map((item) => [`faq:${item.id}`, item.title])],
["Words", HELP_TERMS.map((item) => [`term:${item.id}`, item.title])],
].filter(([, options]) => options.length);
const option = ([value, title]) => Object.assign(make("option", "", title), { value });
const signature = groups.flatMap(([, options]) => options.map(([value]) => value)).join("|");
if (ui.topic.dataset.options !== signature) {
ui.topic.replaceChildren(option(["", "Current step"]), ...groups.map(([label, options]) => {
const group = Object.assign(make("optgroup"), { label });
group.append(...options.map(option));
return group;
}));
ui.topic.dataset.options = signature;
}
ui.topic.value = helpSelection;
const meta = host.content?.metadata;
ui.source.textContent = validHelpMetadata(meta)
? `Bundled help revision ${meta.revision}, source Relmio ${meta.sourceVersion}, reviewed ${meta.reviewedOn}. Sources: ${meta.sources.join(", ")}. Help runs on this computer without model requests or a question history.`
: "Help metadata is unavailable or unsupported. Follow the current view's instructions and documentation.";
}
attr(ui.dock, "aria-label", current === "help" ? "Help" : current === "error" ? "Error help" : "Setup guide");
ui.back.hidden = ui.forward.hidden = ui.close.hidden = current !== "guide";
attr(ui.back, "aria-disabled", String(!before));
attr(ui.forward, "aria-disabled", String(!after));
ui.availability.textContent = availability;
ui.availability.hidden = !missing;
ui.show.hidden = !goal;
ui.help.hidden = current === "help" || current === "chip";
ui.start.hidden = current !== "welcome" && current !== "help";
ui.start.textContent = current === "help" && host.pref === "on" ? "Return to guide" : "Start the guide";
ui.skip.hidden = current !== "welcome";
ui.actions.hidden = [...ui.actions.children].every((node) => node.hidden);
// While the walkthrough is on, the chip is the folded guide and carries
// the current tip's title; the skip link names where it leads.
const resume = walk();
const chipTitle = host.failure?.entry ? "Need help with this error?"
: resume === "guide" ? chapter?.tips[tipIndex]?.title ?? "Setup guide"
: resume === "finish" ? content.finish?.title ?? "Setup complete" : resume ? "Setup guide" : "Help";
if (ui.chipText.textContent !== chipTitle) ui.chipText.textContent = chipTitle;
attr(ui.chip, "aria-label", host.failure?.entry ? chipTitle : !resume ? "Open help for this view"
: chipTitle === "Setup guide" ? "Open the setup guide" : `Setup guide: ${chipTitle}`);
attr(ui.chip, "aria-expanded", String(current === "help"));
const jump = content && host.pref !== "off" ? "Skip to setup guide" : "Skip to help";
if (ui.jump.textContent !== jump) ui.jump.textContent = jump;
const happy = performance.now() < happyUntil;
setMascotPose(ui.chipMascot, happy ? "happy" : host.failure?.entry ? "worried" : "idle");
const pose = happy ? "happy" : current === "error" ? "worried"
: tip?.action === "wait" || entry?.id === "busy" ? "waiting" : tip?.action === "read" ? "thinking"
: tip || (current === "help" && goal) ? "look" : "idle";
setMascotPose(ui.mascot, pose, look);
// Relmio stands in the quest track's current step while the guide is on.
const step = host.pref === "on" ? item?.querySelector(".rm-stepper__marker") : null;
if (!step) ui.track.remove();
else if (ui.track.parentNode !== step) step.append(ui.track);
setMascotPose(ui.track, pose === "look" ? "idle" : pose);
renderFinish(content);
if (focusInside && !showing(doc.activeElement)) ui.dock.focus();
}
// Ready screens list the badges earned so far and the page's next steps
// while the guide is on and the last chapter is on screen.
function onPage(content) {
const chapters = content?.chapters ?? [];
return Boolean(ui.finish) && host.pref === "on" && chapters.length > 0 && active === chapters.length - 1;
}
function renderFinish(content) {
const block = ui.finish;
const show = onPage(content);
if (block && block.hidden !== !show) block.hidden = !show;
if (!show) return;
const badges = block.querySelector("[data-guide-badges]");
if (badges && [...badges.children].map((node) => node.textContent).join("\n") !== earned.join("\n")) {
badges.replaceChildren(...earned.map((text) => make("li", "rm-badge rm-badge--success", text)));
}
if (badges?.parentElement) badges.parentElement.hidden = !earned.length;
const next = block.querySelector("[data-guide-next]");
if (next && !next.children.length) next.replaceChildren(...(content.finish?.next ?? []).map((text) => make("li", "", text)));
}
function target(current = mode()) {
if (current === "help") return first(helpEntry().target);
if (current === "error") {
return first(errorTarget(host.failure.entry, host.failure.info, page)) ?? first("[aria-invalid='true']");
}
const tip = demoTip ?? (current === "guide" ? chapterNow()?.tips[tipIndex] : null);
return tip ? first(tip.target) : null;
}
function layout() {
const vw = doc.documentElement.clientWidth;
const vh = innerHeight;
const wide = matchMedia("(min-width: 64rem)").matches;
const top = (doc.querySelector(".rm-topbar")?.getBoundingClientRect().bottom ?? 0) + 8;
const rail = wide ? first(".rm-split__rail")?.getBoundingClientRect() : null;
const [panelNode, panel] = all(".rm-panel").filter(visible).map((node) => [node, node.getBoundingClientRect()])
.sort((a, b) => b[1].width * b[1].height - a[1].width * a[1].height)[0] ?? [];
const footerNode = panelNode && [...panelNode.querySelectorAll(".rm-panel__footer")].filter(visible).pop();
const footer = footerNode?.getBoundingClientRect();
const header = panelNode && [...panelNode.querySelectorAll(".rm-panel__header")].find(visible)?.getBoundingClientRect();
return {
vw, vh, wide, top, rail, panel, footerNode,
// Where the rail column's content ends, so the dock can sit under it.
railEnd: rail ? Math.max(rail.top, ...all(`${RAIL} > *`).filter(visible).map((node) => node.getBoundingClientRect().bottom)) : 0,
columnBottom: panel?.bottom ?? vh - 16,
footerTop: footer && footer.top > (panel?.top ?? 0) ? footer.top : (panel?.bottom ?? vh) - 16,
headerBottom: header?.bottom ?? (panel?.top ?? top) + 16,
bottom: footer && footer.top < vh ? footer.top : vh,
};
}
// A node's box inside the window below the top bar and inside every
// ancestor that clips it, such as a scrolled panel body; null when hidden.
function seen(node, clipTop) {
const clips = [box(0, clipTop, doc.documentElement.clientWidth, innerHeight - clipTop)];
for (let parent = node.parentElement; parent && parent !== doc.body; parent = parent.parentElement) {
const style = getComputedStyle(parent);
if (style.overflowX !== "visible" || style.overflowY !== "visible") clips.push(parent.getBoundingClientRect());
}
return visiblePart(node.getBoundingClientRect(), clips);
}
// A wrapper with display: contents has no box of its own, so it never counts
// as showing, but its children have theirs. It shows nothing as the folded
// part of a closed disclosure.
const boxless = (item) => !item.hidden && getComputedStyle(item).display === "contents" &&
!(item.parentElement?.matches("details:not([open])") && !item.matches("summary"));
// What the action label must not cover near its target: every line of text
// and every control within reach, apart from the target's own. Hidden
// branches and branches out of reach are skipped whole; a boxless wrapper
// is passed through to its children.
function beside(node, anchor) {
if (!doc.createTreeWalker || !doc.createRange) return [];
const near = (rect) => rect.bottom > anchor.top - 48 && rect.top < anchor.bottom + 48;
const found = [];
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, (item) => {
if (item.nodeType !== 1) return item.nodeValue.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
if (item === node || owned(item) || item.matches(".rm-visually-hidden")) return NodeFilter.FILTER_REJECT;
if (!showing(item)) return boxless(item) ? NodeFilter.FILTER_SKIP : NodeFilter.FILTER_REJECT;
const rect = item.getBoundingClientRect();
if ((rect.width || rect.height) && !near(rect)) return NodeFilter.FILTER_REJECT;
if (!item.matches(PRESSABLE) || item.contains(node)) return NodeFilter.FILTER_SKIP;
found.push(rect);
return NodeFilter.FILTER_REJECT;
});
const range = doc.createRange();
while (walker.nextNode()) {
range.selectNodeContents(walker.currentNode);
for (const rect of range.getClientRects()) if (rect.width > 1 && near(rect)) found.push(rect);
}
return found;
}
function place() {
const current = mode();
const node = target(current);
const errorHelp = current === "error" || (current === "help" && helpEntry().id === "error");
if (node !== observedTarget) {
if (observedTarget) sizes.unobserve(observedTarget);
if (node) sizes.observe(node);
observedTarget = node;
}
const view = layout();
const goal = node && seen(node, view.top - 8);
const was = { dock: !ui.dock.hidden, chip: !ui.chip.hidden };
// Never covered: the focused element, the primary action, alerts, the
// panel footer's actions and consents, the controls of a region the page
// keeps clear with data-guide-keep, such as the selected connection, and,
// while error help shows, the field the failure names or the page marked.
const hard = [];
const focused = doc.activeElement;
if (focused && !owned(focused)) {
const rect = focused.getBoundingClientRect();
if (rect.width * rect.height < (view.vw * view.vh) / 3) hard.push(rect);
}
const urgent = boxes([...all(".rm-button--primary, [role='alert'], .rm-panel__footer :is(button, a[href], label)"),
...all("[data-guide-keep]").flatMap((region) => [...region.querySelectorAll("button, a[href]")]),
...(errorHelp ? [node, ...all("[aria-invalid='true']")] : [])]);
hard.push(...urgent);
const track = first(".rm-stepper--track");
const safety = boxes(all(SAFETY).filter((item) => !item.contains(track)));
// Boxes of the nodes on screen, each cut to the panel body that scrolls it.
const bodies = new Map();
const onScreen = (nodes) => nodes.flatMap((item) => {
if (!visible(item)) return [];
const body = item.closest(".rm-panel__body");
if (body && !bodies.has(body)) bodies.set(body, body.getBoundingClientRect());
const rect = item.getBoundingClientRect();
const part = body ? visiblePart(rect, [bodies.get(body)]) : rect;
return part && part.width > 1 && part.height > 1 ? [part] : [];
});
// The step's working controls, as far as their panel body shows them.
const work = onScreen(all(WORK).filter((item) => item.closest(".rm-panel__body")));
// The cursor and its action label are measured on screen before placing.
const tip = demoTip ?? chapterNow()?.tips[tipIndex];
const text = current === "help" ? inferAction(node) : demoTip ? demoTip.title : current !== "error" && ACTION_LABELS[tip?.action] || inferAction(node);
if (ui.label.textContent !== text) ui.label.textContent = text;
ui.pointer.hidden = false;
const [tall, labelHeight] = [ui.cursor.offsetHeight, ui.label.offsetHeight];
const arrow = ui.cursor.offsetWidth - ui.label.offsetWidth;
const control = node?.matches(CHOICE) ? node : node?.matches("label") ? node.querySelector(CHOICE) : null;
const anchor = (control && seen(control, view.top - 8)) || goal;
// The label sits `tall - labelHeight` under the cursor's top, or that far
// above its foot when raised.
const cursor = anchor && cursorSpot(anchor, {
arrow, width: ui.label.offsetWidth, height: labelHeight, top: tall - labelHeight,
}, view, [...urgent, ...safety, ...boxes(all(LABELS)), ...beside(node, anchor)], Boolean(control));
// Prefer spots that also keep the pointed target and the cursor in view; a
// target taller than half the window is only highlighted.
const aim = [];
if (goal && goal.height < (view.vh - view.top) / 2) {
const width = cursor.label ? ui.cursor.offsetWidth : arrow;
aim.push(box(goal.left - 10, goal.top - 10, goal.width + 20, goal.height + 20),
box(cursor.flip ? cursor.x - width : cursor.x, cursor.raise ? cursor.y - tall : cursor.y, width, tall));
}
const soft = [];
for (const item of all(SOFT)) {
const rect = item.getBoundingClientRect();
if (rect.width > 0 && rect.bottom > 0 && rect.top < view.vh && !owned(item) && visible(item)) {
soft.push({ ...box(rect.left, rect.top, rect.width, rect.height), weight: item.matches(CONTROLS) ? 3 : 1 });
}
}
// On phones the walkthrough plays on a clear screen: the dock fades out but
// keeps its place and any focus inside it, and returns when the demo ends.
const clear = Boolean(demo) && !view.wide;
ui.dock.classList.toggle("is-clear", clear);
const sets = { hard, safety, lead: boxes(all(LEAD)), aim, work, fixed: errorHelp ? work : [] };
// The rail column alone, and everything a dock nobody opened keeps clear
// with 8 px of air, to find the stretches of that column still free.
const column = view.wide && view.rail ? { ...view, panel: null } : null;
const keep = [...hard, ...safety, ...work, ...sets.lead, ...aim]
.map((rect) => box(rect.left, rect.top - 8, rect.width, rect.height + 16));
let spot = null;
if (current !== "chip") {
ui.dock.hidden = false;
const opened = openedKey === shownKey || ui.dock.contains(doc.activeElement);
spot = placeDock(candidates(view, natural), sets, soft, opened,
opened ? boundedSpots(view, natural, [...hard, ...sets.fixed]) : column ? boundedSpots(column, natural, keep, SHRUNK) : []);
}
// A dock that folds into the chip hands its focus to the chip.
const refocus = !spot && ui.dock.contains(doc.activeElement);
ui.dock.hidden = !spot;
ui.chip.hidden = Boolean(spot);
if (spot) move(ui.dock, spot, was.dock);
else {
// The chip also keeps every link, button, choice and heading on the page clear.
move(ui.chip, chipSpot(view, ui.chip.offsetWidth, ui.chip.offsetHeight,
{ ...sets, taps: onScreen(all(TAPS)), headings: onScreen(all(HEADINGS)) }, soft), was.chip);
if (refocus) ui.chip.focus();
}
drift = false;
const shield = spot ?? ui.chip.getBoundingClientRect();
const off = !goal || current === "chip" || (!view.wide && ((!clear && overlaps(goal, shield)) ||
(goal.top >= view.bottom && !view.footerNode?.contains(node))));
ui.pointer.hidden = off;
if (off) return;
const pad = 4;
ui.ring.style.width = `${Math.round(goal.width + 2 * pad)}px`;
ui.ring.style.height = `${Math.round(goal.height + 2 * pad)}px`;
ui.ring.style.transform = `translate(${Math.round(goal.left - pad)}px, ${Math.round(goal.top - pad)}px)`;
ui.cursor.classList.toggle("is-flipped", cursor.flip);
ui.cursor.classList.toggle("is-raised", cursor.raise);
ui.cursor.classList.toggle("is-bare", !cursor.label);
ui.cursor.style.transform = `translate(${Math.round(cursor.x)}px, ${Math.round(cursor.y)}px)`;
if (spot) {
const dx = goal.left + goal.width / 2 - (spot.left + 32);
const dy = goal.top + goal.height / 2 - (spot.top + 36);
const length = Math.hypot(dx, dy) || 1;
look = { dx: (dx / length) * 4, dy: (dy / length) * 4 };
if (ui.mascot.getAttribute("data-pose") === "look") setMascotPose(ui.mascot, "look", look);
}
}
// The dock's full height at a width.
function natural(width) {
ui.dock.style.width = `${width}px`;
ui.dock.style.maxHeight = "";
ui.dock.classList.remove("is-cut");
return ui.dock.offsetHeight;
}
// Puts the dock or the chip at its spot. It moves at once, so it never
// trails over page controls. At a new spot it fades in, except while the
// page scrolls or resizes under it.
function move(node, rect, shown) {
// A dock cut shorter than its content scrolls its body.
if (node === ui.dock && rect.height < natural(rect.width)) {
node.style.maxHeight = `${rect.height}px`;
node.classList.add("is-cut");
}
const value = `translate(${Math.round(rect.left)}px, ${Math.round(rect.top)}px)`;
if (shown && node.style.transform === value) return;
node.style.transform = value;
if (!drift) node.dataset.arrive = node.dataset.arrive === "a" ? "b" : "a";
}
function inferAction(node) {
if (node?.matches?.("input[type=checkbox], input[type=radio]")) return ACTION_LABELS.check;
if (node?.matches?.("select")) return ACTION_LABELS.choose;
if (node?.matches?.("input, textarea")) return ACTION_LABELS.type;
return node?.matches?.("button, a, summary") ? ACTION_LABELS.press : ACTION_LABELS.read;
}
// Show me: play the chapter's tips in order, then bring the current target
// into view and focus it. Reduced motion numbers the targets instead.
function showMe() {
stopDemo();
clearMarkers();
const current = mode();
const goal = target(current);
const chapter = chapterNow();
const tips = current === "guide" && chapter ? chapter.tips.filter((tip) => shown(tip.target)) : [];
if (tips.length < 2) return reveal(goal);
if (reduced()) {
// The current target comes into view first, so each marker lands on its
// target; then the markers are numbered without overlapping.
reveal(goal);
const spots = tips.map((tip, index) => {
const marker = make("div", "rm-guide-marker");
marker.append(make("span", "rm-guide-marker__number", String(index + 1)), make("span", "", tip.title));
ui.layer.append(marker);
markers.push(marker);
const rect = first(tip.target).getBoundingClientRect();
return box(rect.left, Math.max(0, rect.top - 30), marker.offsetWidth, marker.offsetHeight);
});
stackMarkers(spots, doc.documentElement.clientWidth).forEach((spot, index) => {
markers[index].style.transform = `translate(${Math.round(spot.left)}px, ${Math.round(spot.top)}px)`;
});
return;
}
const wait = Math.min(900, 4200 / tips.length);
let index = 0;
const next = () => {
demoTip = tips[index++] ?? null;
place();
if (demoTip) demo = setTimeout(next, wait);
else {
demo = null;
place();
reveal(goal);
}
};
demo = setTimeout(next, 0);
}
function stopDemo() {
if (!demo) return;
clearTimeout(demo);
demo = demoTip = null;
placeSoon();
}
function clearMarkers() {
for (const marker of markers) marker.remove();
markers = [];
}
// Scroll the target into the part of the window the dock leaves free.
function reveal(node) {
if (!node) return;
const view = layout();
const rect = node.getBoundingClientRect();
const behavior = reduced() ? "auto" : "smooth";
let [low, high] = [view.top, view.bottom];
if (!view.wide && !ui.dock.hidden) {
const sheet = ui.dock.getBoundingClientRect();
if (sheet.top - low >= high - sheet.bottom) high = Math.min(high, sheet.top);
else low = Math.max(low, sheet.bottom);
}
if (rect.top < low || rect.bottom > high) {
if (view.wide) node.scrollIntoView({ block: "center", behavior });
else scrollBy({ top: rect.top - low - Math.max(0, (high - low - rect.height) / 2), behavior });
}
let focusable = node.matches(FOCUSABLE) ? node : node.querySelector(FOCUSABLE);
if (!focusable) {
focusable = node;
node.tabIndex = -1;
node.addEventListener("blur", () => node.removeAttribute("tabindex"), { once: true });
}
if (!focusable.disabled) focusable.focus({ preventScroll: true });
ui.pointer.classList.remove("is-pulsing");
void ui.pointer.offsetWidth;
ui.pointer.classList.add("is-pulsing");
setTimeout(() => ui.pointer.classList.remove("is-pulsing"), 1300);
}
function refresh() {
evaluate();
render();
place();
}
function schedule() {
frame ||= requestAnimationFrame(() => {
frame = 0;
refresh();
});
}
function placeSoon() {
placeFrame ||= requestAnimationFrame(() => {
placeFrame = 0;
place();
});
}
const sizes = new ResizeObserver(placeSoon);
sizes.observe(ui.dock);
// Ignore the guide's own changes, including moving the mascot along the track.
new MutationObserver((records) => {
if (records.some((record) => !owned(record.target) && (record.type === "childList"
? [...record.addedNodes, ...record.removedNodes].some((node) => node !== ui.track)
: /^(?:hidden|class|open|disabled|checked|value|data-|aria-)/u.test(record.attributeName)))) schedule();
}).observe(doc.body, { subtree: true, childList: true, attributes: true });
for (const type of ["input", "change", "click"]) {
doc.addEventListener(type, (event) => {
if (owned(event.target)) return;
const chapter = chapterNow();
const kind = { click: "clicked", change: "changed" }[type];
for (const tip of kind && chapter ? chapter.tips : []) {
if (tip.done !== kind || !matches(event.target, tip.target)) continue;
const key = `${kind}|${chapter.id}/${tip.id}`;
// A press waits for its result; a changed choice counts at once.
if (kind === "clicked" && !marks.has(key)) pending.set(key, { failure: host.failure, failureCount: host.failureCount ?? 0, tip, chapter, copy: copyButton(event.target.closest(tip.target)) });
else marks.add(key);
}
schedule();
}, true);
}
for (const type of ["pointerdown", "keydown", "wheel", "touchstart"]) {
doc.addEventListener(type, () => { stopDemo(); clearMarkers(); }, { capture: true, passive: true });
}
const drifted = () => { drift = true; placeSoon(); };
addEventListener("resize", drifted, { passive: true });
doc.addEventListener("scroll", drifted, { capture: true, passive: true });
doc.addEventListener("focusin", follow, true);
doc.addEventListener("focusout", (event) => { if (event.relatedTarget) placeSoon(); else follow(event); }, true);
return { refresh: schedule, openHelp };
}