Ctrl K
ComunidadGuías…

src/ui/guide-dock.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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 };
}