익스클루시브
Two-Key <mv-two-key>
중요한 작업(프로덕션 삭제, 송금, 배포)을 위한 보호 장치로, 의도적이고 동기화된 두 개의 “열쇠”로만 활성화됩니다. 원통이 스프링과 함께 90도 돌아가는 회전식 키 스위치 두 개와, 반투명 덮개 아래에 숨겨진 보호된 액션 버튼으로 구성됩니다.
문화적 레퍼런스
위험한 게임, 존 바담 (1983, 영화). 미사일을 발사하려면 두 장교가 동시에 두 개의 키를 돌려야 하며, 키는 한 사람이 둘 다 닿을 수 없을 만큼 멀리 떨어져 있습니다. UI에서는 의도적이고 동기화된 두 번의 키 회전, 또는 두 번째 승인자가 돌린 키가 있어야만 중요한 작업이 잠금 해제됩니다.
작동 방식
중요한 작업(프로덕션 삭제, 송금, 배포)을 위한 보호 장치로, 의도적이고 동기화된 두 개의 “키”로만 활성화됩니다. 스프링으로 실린더가 4분의 1 회전하는 두 개의 회전식 키 스위치와, 반투명 덮개 아래 숨겨진 보호된 작업 버튼으로 구성됩니다. sync 모드: 두 키를 서로 짧은 시간 안에 돌려야 합니다. 먼저 돌린 키는 카운트다운 링과 함께 고정되고, 두 손가락이나 두 키 키보드 코드(F + J)로 둘을 한 번에 돌릴 수 있으며, 시간 안에 돌리지 못하면 “Out of sync”와 함께 모두 초기화됩니다. two-person 모드: 내 키가 두 번째 승인자를 요청하고(mv-request → approve(name) / reject()) “Waiting for a second approver…”를 표시합니다. 둘 다 돌려지면 덮개가 몇 초 동안 작업 버튼에서 들어 올려지며(줄어드는 도화선), Escape 또는 시간 초과 시 해제됩니다. 키 잠금을 풀기 전에 리소스 이름을 입력하도록 요구할 수도 있습니다.
| 카테고리 | 폼 |
|---|---|
| 유형 | Web Component (<mv-two-key>) |
| 상태 | 안정 |
| 키트 | 안전한 파괴적 작업과 비밀 정보 |
| Keywords | exclusive, 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/marvelousMarvelous 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 & 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 | 유형 | Default | Description |
|---|---|---|---|
mode | sync | two-person | sync | sync: 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()). |
window | number (ms) | 3000 | sync: 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”. |
armed | number (ms) | 5000 | How long the action stays unlocked once both keys are turned (min 1000). Lapsing disarms (mv-abort reason "timeout"). |
chord | string | F J | sync: 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-text | string | Adds 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-label | string | Confirm | Label of the built-in action button, used when no slot="action" is provided. |
label-a / label-b | string | Key 1 / Key 2 (two-person: Your key / Second approver) | Visible labels (and accessible names) of the two keys. |
done-label | string | Done | Message shown (and announced) after the action fired. |
disabled | boolean | Locks both keys and the action. Setting it mid-sequence disarms (mv-abort reason "disabled"). | |
data-state | idle | locked | latched | waiting | armed | fired | out-of-sync | rejected | Set by the component (fired, out-of-sync and rejected return to idle after ~1.8s). |
Properties
| Name | 유형 | Description |
|---|---|---|
state | string | Read-only: current state (see data-state). |
turned | { a: boolean, b: boolean } | Read-only: which keys are turned. |
approver | string | null | Read-only (two-person): name given to approve(). |
strings | Partial<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
| Name | Description |
|---|---|
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
| Name | Description |
|---|---|
mv-turn | A key turned or un-turned. detail: { key: "a" | "b", turned, source?, approver? } (source: pointer | keyboard | chord | click | api). |
mv-request | two-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-arm | Both keys are turned: the action is unlocked. detail: { approver, expires (ms) }. |
mv-launch | The 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-abort | A sequence stopped. detail: { reason: "out-of-sync" | "timeout" | "escape" | "cancelled" | "released" | "rejected" | "disabled" | custom, detail? }. |
Content structure
| Name | Description |
|---|---|
action | The 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
| Name | Description |
|---|---|
mv-two-key-confirm / -input / -need | Typed confirmation: label, field, expected text. |
mv-two-key-deck | Panel holding the two keys and the link between them (role=group). |
mv-two-key-key | One key (data-key a | b, data-turned, data-latched, data-remote, data-waiting). |
mv-two-key-switch / -dial / -barrel / -slot / -ring / -marks | The rotary switch: button, dial, rotating barrel with its key slot, countdown ring, off/on marks. |
mv-two-key-caption / -label / -kbd / -who | Key caption: label, keyboard cap or approver name. |
mv-two-key-link | Connector whose halves fill as each key turns; its node unlocks when armed. |
mv-two-key-guard / -lid / -fuse | The guarded action, its hinged frosted lid and the armed-time fuse. |
mv-two-key-status / -count | Status line (role=status inside, data-tone neutral | progress | armed | danger | success) and the ticking countdown chip (aria-hidden). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-two-key-color | var(--mv-accent) | Turned keys, rings, link and fuse. |
--mv-two-key-danger | var(--mv-danger) | Out of sync / declined feedback. |
--mv-two-key-size | 5.5rem | Diameter of each key dial. |
--mv-two-key-duration | var(--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.