익스클루시브

Catch Loop <mv-catch-loop>

스스로를 순환 참조해서(“SSO를 쓰려면 인증된 도메인이 필요하고, 도메인을 인증하려면 SSO가 필요합니다”) 또는 서로 모순되어(암호화와 전문 검색을 동시에 요구하지만 둘이 양립할 수 없는 아카이브) 절대 충족될 수 없는 요구 사항을 찾아내고 설명합니다.

문화적 레퍼런스

캐치-22, 조지프 헬러(1961년, 소설). 조종사는 미쳤다는 이유로 비행에서 제외될 수 있지만 그러려면 직접 요청해야 하고, 요청한다는 것 자체가 정신이 멀쩡하다는 증거가 됩니다. 그래서 규칙은 스스로에게 되돌아가 결코 충족될 수 없습니다. 이 컴포넌트는 설정, 권한, 플랜에서 서로를 요구하는(또는 서로를 배제하는) 요구 사항을 감지해, 갇힌 컨트롤에 표시하고 탈출 방법과 함께 순환 고리를 그려 설명합니다.

작동 방식

스스로에게 되돌아가는 순환(“SSO에는 인증된 도메인이 필요하고, 도메인 인증에는 SSO가 필요함”)이나 서로 모순되는 조건(암호화와 전문 검색이 모두 필요하지만 두 기능이 서로를 배제하는 아카이브) 때문에 결코 충족될 수 없는 요구 사항을 감지하고 설명합니다. 요구 사항은 컨트롤에 선언하거나(data-requires="#domain", 대안은 |로 구분, data-conflicts), 규칙 그래프({ id, label, requires, conflictsWith, met, control, exits })로 선언하거나 둘 다 사용할 수 있으며, 설정, 권한, 기능 플래그, 양식 로직, 작업 의존성에 활용됩니다. 컴포넌트는 현재 상태에서 아직 도달할 수 있는 것을 계산하고(AND/OR 요구 사항, 이미 갖춰진 항목도 반영), 도달할 수 없는 것에 Tarjan의 강한 연결 요소 알고리즘을 실행해 단순히 충족되지 않은 요구 사항이 아니라 실제 교착 상태만 표시합니다. mode="all"은 이미 갖춰진 항목 덕분에 열려 있는 잠재적 순환도 보고합니다. 관련된 모든 컨트롤에는 “Blocked by a loop” 표시와 설명이 붙고, 단지 순환을 기다리는 하위 컨트롤에는 무엇이 필요한지 알려 줍니다. 설명 패널은 각 순환을 번호가 매겨진 단계로 된 작은 고리 다이어그램으로 그리고, 순서대로 된 경로를 텍스트로 나열하며(“Single sign-on → requires Verified domain → requires Single sign-on”), 그 순환이 막고 있는 다른 설정과 앱이 선언한 탈출 방법(취소 가능한 mv-loop-exit, 액션, 링크 또는 대상)을 보여 줍니다. 마크업, 컨트롤 또는 규칙이 바뀌면 실시간으로 다시 검증하고 mv-loop-found / mv-loop-resolved를 발생시킵니다. MvCatchLoop.analyze(rules)는 DOM 없이 같은 분석을 실행하므로 테스트나 서버에서 사용할 수 있습니다.

카테고리폼
유형Web Component (<mv-catch-loop>)
상태안정
함께 설치되는 항목button
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

  • A settings or permissions page has prerequisites that can end up requiring each other, leaving admins unable to turn anything on
  • Feature flags, plan entitlements or workflow steps depend on each other and a bad edit must be caught and explained right away
  • A project plan or form builder lets people add dependencies, and circular or contradictory ones must be pointed out with a way out
  • Support keeps hearing “this toggle is greyed out and I can’t tell why” about options with chained requirements

Avoid when

  • The need is to preview which values a change will ripple into, not why something cannot be enabled → use Butterfly instead
  • Requirements are a simple, linear list of missing fields to fill before submitting → use One at a Time instead
  • The dependency graph only exists on the server and cannot be sent to the page as ids or selectors

설치

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

Marvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["catch-loop"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/button/button.css, core/dom.js, core/element.js, core/motion.js, components/catch-loop/catch-loop.js, components/catch-loop/catch-loop.css.

사용법

기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:

<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>Tarnwick 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">Any SAML 2.0 identity provider</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 example.com</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 example.com, 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>

API

Attributes

Name유형DefaultDescription
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

Name유형Description
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.

이 페이지는 AI로 번역되었습니다. 번역 문제 신고