Exclusivebeta

Catch Loop — <mv-catch-loop>

Detector and explainer for requirements that can never be met because they loop back on themselves (“SSO needs a verified domain, verifying the domain needs SSO”) or contradict each other (an archive that needs both encryption and full-text search, which exclude each other). Requirements are declared on controls (data-requires="#domain", with | for alternatives, data-conflicts) and/or as a rules graph ({ id, label, requires, conflictsWith, met, control, exits }), for settings, permissions, feature flags, form logic or task dependencies. The component computes what can still be reached from the current state (AND/OR requirements, what is already in place counts), runs Tarjan’s strongly connected components on what cannot and flags only real deadlocks, not mere unmet requirements; mode="all" also reports latent loops held open by something already in place. Every control involved gets a “Blocked by a loop” flag and a description; downstream controls that merely wait on a loop are told what they need. An explanation panel draws each loop as a small ring diagram with numbered steps, lists the ordered path in text (“Single sign-on → requires Verified domain → requires Single sign-on”), the other settings it blocks and the ways out the app declares (a cancelable mv-loop-exit, an action, a link or a target). It re-validates live as the markup, the controls or the rules change and fires mv-loop-found / mv-loop-resolved; MvCatchLoop.analyze(rules) runs the same analysis without any DOM, for tests or a server.

CategoryForms
TypeWeb Component (<mv-catch-loop>)
Statusbeta
Also installsbutton
Keywordsexclusive, culture, dependencies, prerequisites, requirements, circular-dependency, cycle-detection, deadlock, conflict, contradiction, validation, settings, permissions, feature-flags, workflow, project-plan, graph, tarjan, explainer, disabled-reason, accessibility

When to use

Avoid when

Install

node scripts/add.mjs catch-loop --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["catch-loop"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, components/catch-loop/catch-loop.js, components/catch-loop/catch-loop.css, components/button/button.css.

Usage

Canonical markup — start from it and customize with attributes, data-* and CSS variables:

<div id="cl-demo" style="width:min(100%,50rem);margin-inline:auto">
  <style>
    #cl-demo { display:grid; gap:1.25rem; align-content:start }
    #cl-demo .cl-card { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden }
    #cl-demo .cl-top { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding:1rem 1.25rem; border-bottom:1px solid var(--mv-border) }
    #cl-demo .cl-top h3 { margin:0; font-size:1rem; letter-spacing:-.01em }
    #cl-demo .cl-top p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
    #cl-demo .cl-body { padding:1.125rem 1.25rem 1.25rem }
    #cl-demo .cl-group + .cl-group { margin-top:1.25rem }
    #cl-demo .cl-group h4 { margin:0 0 .5rem; color:var(--mv-fg-muted); font-size:.6875rem; font-weight:600; letter-spacing:.06em; text-transform:uppercase }
    #cl-demo .cl-rows { border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); overflow:hidden }
    #cl-demo .cl-row { padding:.8125rem 1rem }
    #cl-demo .cl-row + .cl-row { border-top:1px solid var(--mv-border) }
    #cl-demo .cl-row .mv-choice { width:100% }
    #cl-demo code { padding:.0625rem .3125rem; border-radius:var(--mv-radius-sm); background:var(--mv-bg-muted); font:.75rem var(--mv-font-mono) }
    #cl-demo .cl-bar { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding:.75rem 1.25rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
    #cl-demo .cl-log { flex:1 1 16rem; min-height:1.25rem; margin:0; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono) }
    #cl-demo .cl-actions { display:flex; align-items:center; gap:.75rem; flex-wrap:wrap }
    #cl-demo .cl-actions .mv-choice { font-size:.8125rem }
    /* Launch plan */
    #cl-demo .cl-tasks { display:grid; margin:0; padding:0; list-style:none; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); overflow:hidden }
    #cl-demo .cl-task { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:.25rem 1rem; align-items:center; padding:.6875rem 1rem }
    #cl-demo .cl-task + .cl-task { border-top:1px solid var(--mv-border) }
    #cl-demo .cl-task b { font-size:.875rem; font-weight:550 }
    #cl-demo .cl-meta { display:flex; align-items:center; gap:.5rem; color:var(--mv-fg-muted); font-size:.75rem; font-variant-numeric:tabular-nums }
    #cl-demo .cl-who { display:grid; place-items:center; width:1.5rem; height:1.5rem; border-radius:50%; background:var(--mv-bg-emphasis); color:var(--mv-fg); font-size:.625rem; font-weight:600 }
    #cl-demo .cl-task [data-loop-flag] { grid-column:1 / -1 }
    #cl-demo .cl-task [data-loop-flag]:empty { display:none }
    #cl-demo .cl-task [data-loop-flag] .mv-catch-loop-flag { margin-top:.125rem }
    @media (max-width:36rem) { #cl-demo .cl-task { grid-template-columns:minmax(0,1fr) } }
  </style>

  <!-- 1. Settings: a circular requirement and a contradiction, declared on the controls -->
  <section class="cl-card" aria-labelledby="cl-sec-title">
    <div class="cl-top">
      <div>
        <h3 id="cl-sec-title">Security &amp; compliance</h3>
        <p>Northwind Labs · Enterprise plan · 214 members</p>
      </div>
      <span class="mv-badge" data-variant="secondary" data-shape="pill">Workspace admin</span>
    </div>
    <div class="cl-body">
      <mv-catch-loop id="cl-settings">
        <div class="cl-group">
          <h4>Authentication</h4>
          <div class="cl-rows">
            <div class="cl-row" data-loop-field>
              <label class="mv-choice" data-control="end">
                <input type="checkbox" role="switch" class="mv-switch" id="cl-sso" data-requires="#cl-domain" data-loop-label="Single sign-on">
                <span class="mv-choice-text"><span class="mv-choice-title">Single sign-on (SAML)</span><span class="mv-choice-description">Okta, Microsoft Entra ID or Google Workspace</span></span>
              </label>
            </div>
            <div class="cl-row" data-loop-field>
              <label class="mv-choice" data-control="end">
                <input type="checkbox" role="switch" class="mv-switch" id="cl-domain" data-requires="#cl-sso" data-loop-label="Verified domain"
                       data-loop-exit="Verify by DNS record instead" data-loop-exit-hint="Publish a TXT record, no sign-in needed">
                <span class="mv-choice-text"><span class="mv-choice-title">Verify northwind.io</span><span class="mv-choice-description" id="cl-domain-how">Method: an admin signs in through your identity provider</span></span>
              </label>
            </div>
            <div class="cl-row" data-loop-field id="cl-dns-row" hidden>
              <label class="mv-choice" data-control="end">
                <input type="checkbox" role="switch" class="mv-switch" id="cl-dns" data-loop-label="DNS TXT record">
                <span class="mv-choice-text"><span class="mv-choice-title">TXT record published</span><span class="mv-choice-description">Add <code>nw-verify=8f3a21c7</code> to northwind.io, then turn this on</span></span>
              </label>
            </div>
            <div class="cl-row" data-loop-field>
              <label class="mv-choice" data-control="end">
                <input type="checkbox" role="switch" class="mv-switch" id="cl-enforce" data-requires="#cl-sso" data-loop-label="Enforce SSO for everyone">
                <span class="mv-choice-text"><span class="mv-choice-title">Enforce SSO for everyone</span><span class="mv-choice-description">Password sign-in is turned off for all members</span></span>
              </label>
            </div>
            <div class="cl-row" data-loop-field>
              <label class="mv-choice" data-control="end">
                <input type="checkbox" role="switch" class="mv-switch" id="cl-scim" data-requires="#cl-sso" data-loop-label="SCIM provisioning">
                <span class="mv-choice-text"><span class="mv-choice-title">SCIM provisioning</span><span class="mv-choice-description">Create and deactivate accounts from your directory</span></span>
              </label>
            </div>
          </div>
        </div>

        <div class="cl-group">
          <h4>Data &amp; compliance</h4>
          <div class="cl-rows">
            <div class="cl-row" data-loop-field>
              <label class="mv-choice" data-control="end">
                <input type="checkbox" role="switch" class="mv-switch" id="cl-fle" data-conflicts="#cl-fts" data-loop-label="Field-level encryption">
                <span class="mv-choice-text"><span class="mv-choice-title">Field-level encryption</span><span class="mv-choice-description">Customer records are encrypted with your own keys</span></span>
              </label>
            </div>
            <div class="cl-row" data-loop-field>
              <label class="mv-choice" data-control="end">
                <input type="checkbox" role="switch" class="mv-switch" id="cl-fts" data-loop-label="Full-text search">
                <span class="mv-choice-text"><span class="mv-choice-title">Full-text search</span><span class="mv-choice-description">Index every field so it can be searched</span></span>
              </label>
            </div>
            <div class="cl-row" data-loop-field>
              <label class="mv-choice" data-control="end">
                <input type="checkbox" role="switch" class="mv-switch" id="cl-archive" data-requires="#cl-fle, #cl-fts" data-loop-label="Compliance archive"
                       data-loop-exit="Archive with metadata search only" data-loop-exit-hint="Drops the full-text requirement">
                <span class="mv-choice-text"><span class="mv-choice-title">Compliance archive</span><span class="mv-choice-description">Seven-year retention, searchable by auditors</span></span>
              </label>
            </div>
          </div>
        </div>
      </mv-catch-loop>
    </div>
    <div class="cl-bar">
      <p class="cl-log" id="cl-log" aria-live="polite">Loaded: 2 problems found.</p>
      <span class="cl-actions">
        <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" data-size="sm" id="cl-flags" checked> Flags on controls</label>
        <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="cl-reset">Reset</button>
      </span>
    </div>
  </section>

  <!-- 2. Project plan: rules given as a graph, controls mapped by selector, latent loops on demand -->
  <section class="cl-card" aria-labelledby="cl-plan-title">
    <div class="cl-top">
      <div>
        <h3 id="cl-plan-title">Q4 launch plan</h3>
        <p>Atlas 3.0 · target Oct 7, 2026 · 7 tasks</p>
      </div>
      <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" data-size="sm" id="cl-latent"> Show latent loops</label>
    </div>
    <div class="cl-body">
      <mv-catch-loop id="cl-plan" panel="bottom" noun="task, tasks">
        <ul class="cl-tasks">
          <li class="cl-task" data-loop-field id="cl-t-kickoff"><b>Kickoff</b><span class="cl-meta"><span class="mv-badge" data-variant="success" data-shape="pill">Done</span><span class="cl-who" aria-hidden="true">AO</span></span><span data-loop-flag></span></li>
          <li class="cl-task" data-loop-field id="cl-t-pricing"><b>Final pricing</b><span class="cl-meta">Sep 30<span class="cl-who" aria-hidden="true">LM</span></span><span data-loop-flag></span></li>
          <li class="cl-task" data-loop-field id="cl-t-forecast"><b>Sales forecast</b><span class="cl-meta">Oct 1<span class="cl-who" aria-hidden="true">RK</span></span><span data-loop-flag></span></li>
          <li class="cl-task" data-loop-field id="cl-t-date"><b>Launch date</b><span class="cl-meta">Oct 2<span class="cl-who" aria-hidden="true">AO</span></span><span data-loop-flag></span></li>
          <li class="cl-task" data-loop-field id="cl-t-press"><b>Press kit</b><span class="cl-meta">Oct 3<span class="cl-who" aria-hidden="true">JS</span></span><span data-loop-flag></span></li>
          <li class="cl-task" data-loop-field id="cl-t-review"><b>Security review</b><span class="cl-meta"><span class="mv-badge" data-variant="success" data-shape="pill">Done</span><span class="cl-who" aria-hidden="true">PN</span></span><span data-loop-flag></span></li>
          <li class="cl-task" data-loop-field id="cl-t-pentest"><b>Pen test report</b><span class="cl-meta">Oct 4<span class="cl-who" aria-hidden="true">DV</span></span><span data-loop-flag></span></li>
        </ul>
      </mv-catch-loop>
    </div>
  </section>

  <script type="module">
    const root = document.getElementById("cl-demo");
    const settings = document.getElementById("cl-settings");
    const plan = document.getElementById("cl-plan");
    const log = document.getElementById("cl-log");
    const $ = (id) => document.getElementById(id);
    const say = (text) => { log.textContent = text; };

    // The app disables what can't be turned on and lets the flags say why.
    const syncDisabled = () => {
      for (const input of settings.querySelectorAll("input.mv-switch")) {
        input.disabled = ["cycle", "blocked", "conflict"].includes(input.dataset.loop);
      }
    };
    settings.addEventListener("mv-loop-found", (e) => { syncDisabled(); if (!e.detail.initial) say(`Found: ${e.detail.added[0].text}`); });
    settings.addEventListener("mv-loop-resolved", (e) => { syncDisabled(); say(`Resolved: ${e.detail.resolved.map((c) => c.path[0].label).join(", ")} can be set up now.`); });
    settings.addEventListener("mv-loop-exit", (e) => {
      const at = e.detail.exit.label;
      if (at.startsWith("Verify by DNS")) {
        $("cl-domain").dataset.requires = "#cl-dns";
        $("cl-domain-how").textContent = "Method: DNS TXT record";
        $("cl-dns-row").hidden = false;
      } else if (at.startsWith("Archive with metadata")) {
        $("cl-archive").dataset.requires = "#cl-fle";
        $("cl-archive").closest(".cl-row").querySelector(".mv-choice-description").textContent = "Seven-year retention, searchable by date, sender and tags";
      }
    });
    syncDisabled();

    $("cl-reset").addEventListener("click", () => {
      $("cl-domain").dataset.requires = "#cl-sso";
      $("cl-domain-how").textContent = "Method: an admin signs in through your identity provider";
      $("cl-dns-row").hidden = true;
      $("cl-dns").checked = false;
      $("cl-archive").dataset.requires = "#cl-fle, #cl-fts";
      $("cl-archive").closest(".cl-row").querySelector(".mv-choice-description").textContent = "Seven-year retention, searchable by auditors";
      for (const input of settings.querySelectorAll("input.mv-switch")) input.checked = false;
      settings.evaluate();
    });
    $("cl-flags").addEventListener("change", (e) => { settings.flags = e.target.checked ? "true" : "false"; });

    // Launch plan: dependencies come from a project graph, not from the markup.
    const tasks = () => [
      { id: "kickoff", label: "Kickoff", control: "#cl-t-kickoff", met: true },
      { id: "pricing", label: "Final pricing", control: "#cl-t-pricing", requires: ["forecast"] },
      { id: "forecast", label: "Sales forecast", control: "#cl-t-forecast", requires: ["launch-date"] },
      {
        id: "launch-date", label: "Launch date", control: "#cl-t-date", requires: provisional ? [] : ["pricing"],
        exits: [{ label: "Set a provisional launch date", hint: "Pick Oct 7, 2026 now and confirm it later", action: () => { provisional = true; plan.rules = tasks(); } }],
      },
      { id: "press", label: "Press kit", control: "#cl-t-press", requires: ["pricing"] },
      { id: "review", label: "Security review", control: "#cl-t-review", requires: ["pentest"], met: true },
      { id: "pentest", label: "Pen test report", control: "#cl-t-pentest", requires: ["review"] },
    ];
    let provisional = false;
    plan.strings = {
      relRequires: "waits for",
      relRequiredBy: "is awaited by",
      titlePair: "{a} and {b} wait for each other",
      titleMany: "{list} wait for each other",
      noteCycle: "Each task waits for the next one to finish, so none of them can ever start.",
      noteLatent: "This works today only because {held} is already done. If it is reopened, neither task can be finished again.",
      exitHint: "Breaks the loop at {at}",
      resolved: "Loop resolved: {list} can start now.",
    };
    plan.rules = tasks();
    $("cl-latent").addEventListener("change", (e) => { plan.mode = e.target.checked ? "all" : "blocking"; });
  </script>
</div>

Cultural reference

Catch-22 — Joseph Heller (1961, book). A pilot can be grounded for insanity only if he asks to be, but asking proves he is sane, so the rule loops back on itself and can never be satisfied. Here requirements that require each other (or exclude each other) are detected in settings, permissions and plans, flagged on the controls they trap and explained as a drawn loop with the ways out.

API

Attributes

NameTypeDefaultDescription
mode"blocking" | "all"blockingblocking reports only real deadlocks from the current state: loops whose members are all unmet, and contradictions on something not yet in place. all also reports latent loops, cycles that work today only because one member is already in place and would lock if it were undone (for rule authors and admins).
scopeCSS selectorRoot in which data-requires / data-conflicts carriers are collected and watched. Default: the element itself (wrap the settings, form or plan).
panel"top" | "bottom" | "none"topWhere the explanation panel goes inside the element. none renders no panel: flags and descriptions stay, flags emit mv-loop-explain for your own UI.
flags"true" | "false"true"false" inserts no flag buttons into the app’s markup (data-loop and aria-describedby are still set).
live"true" | "false"trueRe-validates on input and change events and on DOM mutations inside the scope (data-requires, data-conflicts, data-met, checked, disabled, added or removed nodes), coalesced to one pass per frame. "false": only evaluate() and setting rules re-validate.
noun"singular, plural"setting, settingsWord used in the panel summary (“Blocks 4 settings”), e.g. "task, tasks" or "permission, permissions".
data-requireslistOn any control or element in scope: what it needs, comma-separated (AND). "#a | #b" is one requirement met by either (OR). Tokens are #ids, bare rule ids or CSS selectors (each match becomes a requirement). Referenced elements without the attribute become nodes too.
data-conflictslistOn a control: what it cannot be combined with (symmetric). A node whose mandatory requirements include two conflicting nodes can never be met.
data-met"true" | "false"Overrides whether a node is in place. Default: checked for checkboxes, radios and switches, aria-checked / aria-pressed, a non-empty value for fields, false otherwise (status rows, list items).
data-loop-labelstringName used in flags, paths and announcements. Default: aria-label, aria-labelledby, the label’s .mv-choice-title or [data-loop-title], the label text, then the element text.
data-loop-exit / data-loop-exit-hint / data-loop-exit-target / data-loop-exit-hrefstringA way out offered when this node is part of a loop or a conflict: button label, secondary line (default “Breaks the loop at …”), a selector focused when chosen, or a link.
data-loop-fieldmarker attributeOn the row or field that holds a control: the flag is appended to it (or to its [data-loop-flag] child), it gets data-loop-state and a faint tint, and it is what name links scroll to.
data-loop-flagmarker attributeInside a data-loop-field or inside the flagged element itself (a list row mapped by a rule): exact place for the flag. Without any, the flag goes right after the control’s <label>, or after the control.
data-loop"cycle" | "blocked" | "conflict" | "involved" | "latent"Set by the component on each control involved: in a loop, waiting on a loop or a conflict, unsatisfiable because of a conflict, one side of a conflict (not blocked itself), in a latent loop. Removed when resolved. The host gets data-state="clear | blocked | latent" and data-loops="n".

Properties

NameTypeDescription
rulesArray<{ id, label?, requires?: (id | id[])[], conflictsWith?: id[], met?: boolean | () => boolean, control?: selector | Element, exits?: (string | { label, hint?, href?, target?, action?(ctx) })[] }>Requirement graph merged with the markup. An array inside requires is an OR group. control maps a rule to the element to flag (a rule id equal to an element id adopts it). Setting it re-validates; after mutating it in place, call evaluate().
cyclesCycle[]Current problems (read-only), blocking first. Cycle: { id, type: "cycle" | "conflict", severity: "blocking" | "latent", nodes, path: [{ id, label, relation: "requires" | "conflicts" | "requiredBy", met, element }], text, root?, pair?, lead, heldBy, blocked: [{ id, label, element, via }], exits: [{ label, hint, at, atLabel, href, target, action }] }.
stringsPartial<Record<string, string>>Overrides for every visible text and announcement ({placeholders}, “one|many” plurals): flag*, kind*, title*, head*, rel*, note*, exit*, resolved*, announce*, desc*… English defaults.
mode / scope / panel / flags / live / nounreflectedMirror the attributes.

Methods

NameDescription
evaluate()Re-reads the rules and the DOM now (rather than on the next frame) and returns { cycles, stuck (ids that can never be met), missing (referenced ids that exist nowhere; treated as satisfiable) }.
explain(target, { origin?, focus? })Opens the explanation for an issue id, a node id, a control or a cycle object: expands its card, scrolls it into view and focuses it; with origin, the card offers “Back to …”. Emits the cancelable mv-loop-explain. Returns false if nothing matches.
MvCatchLoop.analyze(rules, { mode? })Static and DOM-free: the same analysis on a rules array, returning { cycles, stuck, missing } (elements are null). For unit tests, a CI check of a permissions file, or server-side validation.

Events

NameDescription
mv-loop-foundNew problems appeared. detail: { cycles (all current), added, initial (true for what exists on load or when the first requirements arrive; not announced) }.
mv-loop-resolvedProblems disappeared (a rule changed, a way out was taken, something was put in place). detail: { resolved, cycles (remaining) }.
mv-loop-exitA way out was chosen in the panel. Cancelable: preventDefault() stops the default (following href, focusing target, calling action). detail: { cycle, exit, at (node id), element }. Change your rules or markup here: the component re-validates on its own.
mv-loop-explainA flag was activated or explain() was called. Cancelable: preventDefault() keeps the panel as is (show your own dialog). detail: { cycle, node, element }.

Content structure

NameDescription
(content)The settings, form, permission matrix or plan to watch. The panel is inserted as the first (or last) child; flags are inserted next to the controls involved; a visually hidden live region and description container are appended.

CSS classes

NameDescription
mv-catch-loop-flagFlag button next to a control (-icon, -text, a visually hidden “, show why”). data-kind mirrors data-loop: solid border for a loop, dashed when only waiting on one, dotted for latent.
mv-catch-loop-panelExplanation region (data-tone="cycle | conflict | latent"): -head (-icon, -title, -sub), -resolved (transient confirmation), -issues.
mv-catch-loop-issueOne problem (data-type, data-severity, data-kind, data-open): -toggle (-kind chip, -name, -chev) and -body with -figure (the ring) and -explain (-path of -step / -num / -step-name / -rel, -note, -lead, -also with -chip, -exits with -exit / -exit-label / -exit-hint, -return).
mv-catch-loop-ringThe SVG diagram (aria-hidden): -track, -pulse (travelling highlight on loops), -edge (data-rel), -arrowhead, -node (data-met, data-pair), -x (conflict mark), -glyph.

CSS variables

NameDefaultDescription
--mv-catch-loop-colorvar(--mv-warning)Tone of blocking loops: flags, ring, step numbers, panel accents (text uses a mix with the foreground for contrast).
--mv-catch-loop-conflictvar(--mv-danger)Tone of contradictions and of the conflict mark on the ring.
--mv-catch-loop-latentvar(--mv-info)Tone of latent loops (mode="all").

Accessibility

Nothing depends on color or on the diagram. Every control involved gets aria-describedby pointing to a hidden sentence kept up to date (“Blocked by a loop: Single sign-on → requires Verified domain → requires Single sign-on. 1 way out is listed in the explanation.”, or “it needs Single sign-on, which is caught in a loop”), so screen reader users hear the reason on focus, even on a control the app disabled. The flag is a real button whose name is its visible text plus “, show why”; it opens and focuses the matching card, which then offers “Back to Single sign-on”. The panel is a labelled region; each problem is a disclosure button (aria-expanded, aria-controls) whose body holds the path as an ordered list (“1. Single sign-on, requires. 2. Verified domain, requires. 3. Single sign-on, back to the start.”) — the text equivalent of the ring, which is aria-hidden — plus a plain-language note, the other items it blocks and the ways out as buttons or links. Names in the path and chips are buttons that scroll to the control, focus it (or its field when it is disabled) and flash it. New problems found after load and resolutions are announced once in a polite live region; problems present on load are shown but not announced. When the problem holding focus disappears (for instance after a way out), focus moves to the “Loop resolved” confirmation, which stays until focus leaves it. Loops, dependents and latent loops are told apart by words and border style (solid, dashed, dotted) as well as tone. Reduced motion (OS or data-motion="reduce"): no travelling highlight, pop-in or slide, scrolling is instant and the flash becomes a static outline. Forced colors: flags, chips and exits get CanvasText borders, the ring is drawn in CanvasText on Canvas.