エクスクルーシブ

Two-Key <mv-two-key>

重大な操作(本番環境の削除、送金、デプロイ)のためのガードで、意図的かつ同期した 2 つの「キー」によってのみ作動可能になります。2 つの回転式キースイッチはスプリングでシリンダーが 4 分の 1 回転し、保護されたアクションボタンはすりガラスのふたの下に隠れています。

文化的リファレンス

ウォー・ゲーム、ジョン・バダム(1983年、映画)。ミサイルの発射には 2 人の士官が 2 つのキーを同時に回す必要があり、キーは 1 人では両方に手が届かないほど離れて配置されています。UI では、重大な操作は意図的に同期された 2 回のキー操作、または 2 人目の承認者が回したキーによってのみロック解除されます。

仕組み

重大な操作(本番環境の削除、送金、デプロイ)を守るガードで、意図的に同期された 2 つの「キー」によってのみ作動します。バネ仕掛けでシリンダーが 4 分の 1 回転する 2 つのロータリーキースイッチと、すりガラスの蓋の下に隠されたガード付きアクションボタンで構成されます。sync モード:2 つのキーを短い時間差の範囲内で回す必要があります。最初のキーはカウントダウンリングとともにラッチされ、2 本の指またはキーボードの 2 キー同時押し(F + J)で両方を一度に回せます。時間内に回せなければ「Out of sync」と表示されてすべてリセットされます。two-person モード:自分のキーで 2 人目の承認者に依頼し(mv-request → approve(name) / reject())、「Waiting for a second approver…」と表示します。両方が回されると、数秒間アクションの蓋が開きます(減っていく導火線)。Escape またはタイムアウトで解除されます。キーのロック解除前にリソース名の入力を求めることもできます。

カテゴリーフォーム
タイプWeb Component(<mv-two-key>)
ステータス安定版
キット破壊的な操作と機密情報を安全に
Keywordsexclusive, culture, confirm, confirmation, destructive, dangerous-action, two-person-rule, four-eyes, approval, dual-control, arming, key-switch, rotary, chord, multi-touch, guard, safety

When to use

  • An irreversible operation such as deleting a production database needs more than a single confirm click
  • A payment or payout above a threshold must be approved by a second person before it can be sent
  • A deploy, rollback or failover should require a deliberate two-handed gesture that can't happen by accident
  • An admin console needs a typed resource name plus an explicit arming step before a destructive action

Avoid when

  • The action is reversible or low-stakes: a plain confirmation is enough and less tiring → use Dialog instead
  • One deliberate gesture is enough and there's no second person or key involved → use Hold Button instead
  • The two-person rule must be enforced for real: the approval has to be checked on the server, the component only orchestrates the UI

インストール

node scripts/add.mjs two-key --out ./src/marvelous

Marvelous UI MCP サーバーを使う AI エージェント:install_components({ slugs: ["two-key"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。

コピーされるファイル(依存関係を含む):tokens/tokens.css、core/base.css、core/dom.js、core/element.js、core/motion.js、components/two-key/two-key.js、components/two-key/two-key.css。

使い方

クイックスタート、動作する最小限のマークアップ:

<mv-two-key confirm-text="orders-prod-db">
  <button slot="action">Delete database</button>
</mv-two-key>

基本となるマークアップです。ここから属性、data-*、CSS 変数でカスタマイズしてください:

<div id="tk-demo" style="width:min(100%,62rem);margin-inline:auto">
  <style>
    #tk-demo { display:grid; grid-template-columns:repeat(auto-fit, minmax(min(20rem, 100%), 1fr)); gap:1.25rem; align-items:start }
    #tk-demo .tk-card { display:grid; grid-template-columns:minmax(0, 1fr); gap:1rem; padding:1.25rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
    #tk-demo .tk-head { display:flex; align-items:flex-start; gap:.75rem }
    #tk-demo .tk-icon { display:grid; place-items:center; width:2.25rem; height:2.25rem; flex:none; border-radius:var(--mv-radius-lg); background:color-mix(in oklch, var(--mv-danger) 12%, transparent); color:var(--mv-danger) }
    #tk-demo .tk-icon[data-tone="accent"] { background:var(--mv-accent-subtle); color:var(--mv-accent-fg) }
    #tk-demo .tk-icon svg { width:1.125rem; height:1.125rem }
    #tk-demo .tk-title { display:grid; gap:.2rem; min-width:0 }
    #tk-demo .tk-title h3 { margin:0; font-size:.9375rem; letter-spacing:-.01em; line-height:1.3 }
    #tk-demo .tk-title p { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.45 }
    #tk-demo .tk-facts { display:grid; grid-template-columns:auto 1fr; gap:.3rem 1rem; margin:0; padding:.75rem .875rem; border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle); font-size:.8125rem }
    #tk-demo .tk-facts dt { color:var(--mv-fg-muted) }
    #tk-demo .tk-facts dd { margin:0; text-align:end; font-variant-numeric:tabular-nums; font-weight:500 }
    #tk-demo .tk-foot { display:flex; align-items:center; justify-content:space-between; gap:.5rem 1rem; flex-wrap:wrap; padding-top:.75rem; border-top:1px solid var(--mv-border) }
    #tk-demo .tk-log { margin:0; min-width:0; color:var(--mv-fg-subtle); font:400 .75rem/1.5 var(--mv-font-mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1 1 12rem }
    #tk-demo .tk-log b { color:var(--mv-fg-muted); font-weight:600 }
    #tk-demo .mv-choice { font-size:.8125rem }
  </style>

  <!-- 1 · Sync mode + typed confirmation -->
  <section class="tk-card" aria-labelledby="tk-db-title">
    <div class="tk-head">
      <span class="tk-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5.5" rx="7.5" ry="2.8"/><path d="M4.5 5.5v6c0 1.5 3.4 2.8 7.5 2.8"/><path d="M19.5 5.5v4"/><path d="M4.5 11.5v6c0 1.5 3.4 2.8 7.5 2.8"/><path d="m15.5 14.5 5 5"/><path d="m20.5 14.5-5 5"/></svg></span>
      <div class="tk-title">
        <h3 id="tk-db-title">Delete production database</h3>
        <p>Permanently deletes <b>orders-prod-db</b>, its 14 replicas and 30 days of snapshots.</p>
      </div>
    </div>
    <mv-two-key id="tk-db" mode="sync" window="3000" armed="5000" chord="F J" confirm-text="orders-prod-db" done-label="Deletion scheduled. You have 24 hours to restore it.">
      <button slot="action" class="mv-button" data-variant="destructive" type="button">Delete orders-prod-db</button>
    </mv-two-key>
    <div class="tk-foot">
      <p class="tk-log" id="tk-db-log">Type the name, then turn both keys (or hold F + J)</p>
      <button class="mv-button" data-variant="ghost" data-size="sm" type="button" id="tk-db-fill">Fill name</button>
    </div>
  </section>

  <!-- 2 · Two-person approval -->
  <section class="tk-card" aria-labelledby="tk-wire-title">
    <div class="tk-head">
      <span class="tk-icon" data-tone="accent" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M3 10h18"/><path d="M5 10v8"/><path d="M9.7 10v8"/><path d="M14.3 10v8"/><path d="M19 10v8"/><path d="M3 20.5h18"/><path d="M12 3.5 21 8H3z"/></svg></span>
      <div class="tk-title">
        <h3 id="tk-wire-title">Transfer $48,200 to vendor</h3>
        <p>Payments over $25,000 need a second approver from Finance.</p>
      </div>
    </div>
    <dl class="tk-facts">
      <dt>Payee</dt><dd>Halden &amp; Rowe Logistics</dd>
      <dt>Invoice</dt><dd>INV-20931 · due Sep 30, 2026</dd>
      <dt>From</dt><dd>Operating account ••4417</dd>
    </dl>
    <mv-two-key id="tk-wire" mode="two-person" armed="8000" label-a="Your key" label-b="Finance approver" done-label="Transfer sent. Confirmation #TX-58213.">
      <button slot="action" class="mv-button" type="button">Send $48,200.00</button>
    </mv-two-key>
    <div class="tk-foot">
      <p class="tk-log" id="tk-wire-log">Priya Raman (Finance) answers in about 2s</p>
      <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="tk-decline"><span class="mv-choice-text"><span class="mv-choice-title">Approver declines</span></span></label>
    </div>
  </section>

  <script type="module">
    await customElements.whenDefined("mv-two-key");
    const time = () => new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" });
    const logger = (id) => {
      const el = document.getElementById(id);
      return (text) => {
        const b = document.createElement("b");
        b.textContent = text;
        el.replaceChildren(b, ` · ${time()}`);
      };
    };
    const wire = (el, log) => {
      el.addEventListener("mv-turn", (e) => log(`mv-turn { key: "${e.detail.key}", turned: ${e.detail.turned} }`));
      el.addEventListener("mv-arm", (e) => log(`mv-arm { expires: ${e.detail.expires} }`));
      el.addEventListener("mv-launch", (e) => log(`mv-launch { approver: ${e.detail.approver ? `"${e.detail.approver}"` : "null"} }`));
      el.addEventListener("mv-abort", (e) => log(`mv-abort { reason: "${e.detail.reason}" }`));
    };

    const db = document.getElementById("tk-db");
    wire(db, logger("tk-db-log"));
    document.getElementById("tk-db-fill").addEventListener("click", () => {
      const input = db.querySelector(".mv-two-key-input");
      input.value = "orders-prod-db";
      input.dispatchEvent(new Event("input", { bubbles: true }));
    });

    const wireEl = document.getElementById("tk-wire");
    const wireLog = logger("tk-wire-log");
    wire(wireEl, wireLog);
    let pending = 0;
    wireEl.addEventListener("mv-request", (e) => {
      wireLog("mv-request → notifying Priya Raman");
      clearTimeout(pending);
      const decline = document.getElementById("tk-decline").checked;
      pending = setTimeout(() => {
        if (decline) e.detail.reject("the invoice amount doesn't match the PO");
        else e.detail.resolve("Priya Raman");
      }, 2000);
    });
  </script>
</div>

API

Attributes

NameタイプDefaultDescription
modesync | two-personsyncsync: the user turns both keys within window ms. two-person: the user turns key A, key B belongs to a second approver (mv-request, approve(), reject()).
windownumber (ms)3000sync: maximum time between the two turns (min 300). The first key latches with a countdown ring; when it runs out both keys reset with “Out of sync”.
armednumber (ms)5000How long the action stays unlocked once both keys are turned (min 1000). Lapsing disarms (mv-abort reason "timeout").
chordstringF Jsync: the two keyboard keys (shown on the key caps) that turn key A and key B when held together while focus is inside the component. Letting go before the other key is down un-turns it. Ignored while typing in a field.
confirm-textstringAdds a “Type orders-prod-db to unlock the keys” field: the keys stay locked (aria-disabled) until the exact text is entered (case-sensitive, trimmed). The field is read-only while a sequence is in progress.
action-labelstringConfirmLabel of the built-in action button, used when no slot="action" is provided.
label-a / label-bstringKey 1 / Key 2 (two-person: Your key / Second approver)Visible labels (and accessible names) of the two keys.
done-labelstringDoneMessage shown (and announced) after the action fired.
disabledbooleanLocks both keys and the action. Setting it mid-sequence disarms (mv-abort reason "disabled").
data-stateidle | locked | latched | waiting | armed | fired | out-of-sync | rejectedSet by the component (fired, out-of-sync and rejected return to idle after ~1.8s).

Properties

NameタイプDescription
statestringRead-only: current state (see data-state).
turned{ a: boolean, b: boolean }Read-only: which keys are turned.
approverstring | nullRead-only (two-person): name given to approve().
stringsPartial<Record<string, string>>Overrides for every visible text and announcement, English defaults, {placeholders} filled in (confirmLabel, locked, deckSync, deckTwoPerson, key1, key2, keyYours, keySecond, turned, ready, approved, waitingShort, notRequested, statusIdle, statusIdleTwo, statusLocked, statusLatched, statusWaiting, statusArmed, approvedBy, outOfSync, declined, declinedWhy, timedOut, disarmed, cancelled, count). label-a, label-b, action-label and done-label still win over their defaults.

Methods

NameDescription
turn("a" | "b")Turns a key as if the user did (key B is refused in two-person mode). Returns false if not allowed (locked, already turned, armed…).
approve(name?)two-person, while waiting: key B turns, the name is shown under it and the action arms.
reject(reason?)two-person, while waiting: key A resets and “Declined: reason” is shown (mv-abort reason "rejected").
disarm(reason?)Cancels a latched key, a pending request or an armed action (mv-abort with that reason, default "manual").
reset()Silently returns to the initial state.

Events

NameDescription
mv-turnA key turned or un-turned. detail: { key: "a" | "b", turned, source?, approver? } (source: pointer | keyboard | chord | click | api).
mv-requesttwo-person: key A was turned and a second approval is needed. detail: { resolve(name), reject(reason) }, equivalent to approve() / reject(), ignored once the request is cancelled or superseded.
mv-armBoth keys are turned: the action is unlocked. detail: { approver, expires (ms) }.
mv-launchThe unlocked action was pressed. detail: { approver, mode }. Clicks on the action never get through while it is not armed (blocked in the capture phase).
mv-abortA sequence stopped. detail: { reason: "out-of-sync" | "timeout" | "escape" | "cancelled" | "released" | "rejected" | "disabled" | custom, detail? }.

Content structure

NameDescription
actionThe guarded button (e.g. <button slot="action" class="mv-button" data-variant="destructive">). It is moved under the lid, disabled until armed and stretched to full width. Without it, a built-in button labelled by action-label is rendered. Other children are not kept.

CSS classes

NameDescription
mv-two-key-confirm / -input / -needTyped confirmation: label, field, expected text.
mv-two-key-deckPanel holding the two keys and the link between them (role=group).
mv-two-key-keyOne key (data-key a | b, data-turned, data-latched, data-remote, data-waiting).
mv-two-key-switch / -dial / -barrel / -slot / -ring / -marksThe rotary switch: button, dial, rotating barrel with its key slot, countdown ring, off/on marks.
mv-two-key-caption / -label / -kbd / -whoKey caption: label, keyboard cap or approver name.
mv-two-key-linkConnector whose halves fill as each key turns; its node unlocks when armed.
mv-two-key-guard / -lid / -fuseThe guarded action, its hinged frosted lid and the armed-time fuse.
mv-two-key-status / -countStatus line (role=status inside, data-tone neutral | progress | armed | danger | success) and the ticking countdown chip (aria-hidden).

CSS variables

NameDefaultDescription
--mv-two-key-colorvar(--mv-accent)Turned keys, rings, link and fuse.
--mv-two-key-dangervar(--mv-danger)Out of sync / declined feedback.
--mv-two-key-size5.5remDiameter of each key dial.
--mv-two-key-durationvar(--mv-duration-slower)Duration of the quarter-turn spring.

Accessibility

Each key is a real <button role="switch"> with aria-checked, named by its visible label (“Key 1”), aria-keyshortcuts for its chord key, and aria-disabled (still focusable) while locked, armed or owned by the remote approver (the remote key is removed from the tab order). Keyboard: Tab to a key, Space or Enter turns it (again to turn it back while latched); holding the chord keys (F + J by default) turns both; Escape cancels a latched key, a pending request or an armed action. When the action arms, focus moves to it if focus was inside the component, and returns to key 1 after firing or disarming. Pointer: keys turn on pointerdown, so two fingers on two keys turn both at the same time (touch-action: none, no context menu); clicks synthesized by assistive technology (no pointer, no key) are honored too. The status line is a polite role=status (“Key 1 turned. Turn Key 2 within 3 seconds.”, “Waiting for a second approver…”, “Approved by Priya Raman. Armed: … is unlocked for 8 seconds. Press Escape to cancel.”, “Out of sync…”) and describes the action button; the ticking seconds are an aria-hidden chip so nothing chatters. The action is disabled until armed and its clicks are blocked in the capture phase. Color is never the only cue: barrel angle, marks, text and icons change together. Reduced motion (system or data-motion="reduce"): no rotation animation, lid, fills and messages change instantly, the waiting ring does not spin. Forced colors: bezels, barrels and links use CanvasText, a turned key and the armed link switch to Highlight, the marks and slot stay visible. Known limits: the component only orchestrates the UI, so dual control must be enforced on the server; the chord needs a physical keyboard that reports two keys held at once (some keyboards drop simultaneous keys), and the two-finger turn needs a multi-touch screen.

このページは AI で翻訳されています。翻訳の問題を報告