익스클루시브
Reveal Once <mv-reveal-once>
한 번만 보여 줄 수 있는 비밀 값(API 키, 개인 액세스 토큰, 웹훅 서명 시크릿, 2FA 복구 코드, 임시 비밀번호, 지갑 시드 문구)을 전달하는 완전한 단계로, 타이머가 없습니다. 값은 사용자가 필요한 만큼 계속 표시되지만, 저장했음을 확인하지 않으면 다음으로 넘어갈 수 없습니다.
문화적 레퍼런스
일과 날(판도라의 항아리 신화), 헤시오도스 (기원전 700년경, 신화). 항아리가 한 번 열리면 나온 것은 결코 되돌려 넣을 수 없습니다. 여는 것은 되돌릴 수 없는 행위이므로 먼저 잠시 생각해 볼 가치가 있으며, 그 흔적만이 남습니다. UI에서는 일회성 비밀 값이 분명한 경고 뒤에만 공개되고, 저장했음을 확인하지 않으면 떠날 수 없으며, 이후에는 그 지문과 교체 수단만 남습니다.
작동 방식
한 번만 표시할 수 있는 비밀 값(API 키, 개인 액세스 토큰, 웹훅 서명 시크릿, 2FA 복구 코드, 임시 비밀번호, 지갑 시드 문구)을 넘겨주는 완전한 단계로, 타이머가 없습니다: 값은 사용자가 필요한 만큼 계속 표시되지만, 저장했음을 확인하지 않으면 다음으로 넘어갈 수 없습니다. 공개 전에는 DOM에 비밀이 전혀 없습니다: 값과 정확히 같은 형태의 점선 플레이스홀더, 한 번만 표시된다는 분명한 안내, “Have ready” 목록(비밀번호 관리자, 환경 변수 접근 권한), 그리고 Reveal 버튼 또는 막대가 차오르는 길게 누르기(키보드에서는 Space 길게 누르기, 스크린 리더에서는 두 번 활성화)가 있습니다. 공개되면 값은 고정폭 글꼴로 묶어서 표시되고(prc_live_ 같은 접두사는 따로 두고, 끝에서부터 4자씩 묶으며, 숫자와 기호에 색을 입혀 0/O와 1/l을 혼동하지 않게 하고, 스크린 리더에는 한 글자씩 읽어 줌), Copy(확인 표시, 선택적인 클립보드 자동 지우기), 복구 코드용 Download .txt와 Print, Hide 토글을 제공합니다. 게이트는 체크박스가 선택되거나, 고위험 모드에서는 값을 가린 채 마지막 4자(또는 무작위 코드나 단어)를 다시 입력할 때까지 Continue를 막습니다. 그사이 떠나려 하면 beforeunload, 취소 가능한 mv-leave, 앱 내 이동용 인라인 “Leave without saving?”이 막아 줍니다. 이후 값은 DOM과 메모리에서 지워지고 영구적인 지문만 남으며(“prc_live_…9f3a · Created Sep 24 by you”, 마지막 그룹이 미끄러지듯 자리 잡음), “Lost it? Rotate”는 확인 단계와 취소 가능한 mv-rotate를 거치고, 그 waitUntil로 새 비밀 값을 넘겨 다시 한 번 표시할 수 있습니다.
| 카테고리 | 데이터 표시 |
|---|---|
| 유형 | Web Component (<mv-reveal-once>) |
| 상태 | 안정 |
| 키트 | 안전한 파괴적 작업과 비밀 정보 |
| 함께 설치되는 항목 | button, checkbox, input |
| Keywords | exclusive, culture, secret, one-time, api-key, token, access-token, recovery-codes, backup-codes, seed-phrase, password, webhook-secret, reveal, copy, clipboard, download, print, fingerprint, rotate, acknowledge, confirmation, leave-guard, beforeunload, security, onboarding, hold-to-reveal |
When to use
- A freshly created API key or access token must be shown once, and users keep closing the page before saving it
- Two-factor recovery codes or a seed phrase must be downloaded, printed or copied and acknowledged before setup can finish
- A settings page lists existing keys by fingerprint only and offers to rotate one that was lost
- Compliance requires proof that the user acknowledged storing a credential that will never be displayed again
Avoid when
- The secret should disappear on its own after a short, timed read, even if the user walks away → use Self-Destruct instead
- Sensitive data stays retrievable but every opening must leave an audited trace → use Tamper Seal instead
- The value is not sensitive and should stay visible and copyable at any time → use Copy Button instead
설치
node scripts/add.mjs reveal-once --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["reveal-once"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/button/button.css, components/checkbox/checkbox.css, components/input/input.css, core/dom.js, core/element.js, core/motion.js, components/reveal-once/reveal-once.js, components/reveal-once/reveal-once.css.
사용법
빠른 시작, 동작하는 가장 작은 마크업:
<mv-reveal-once label="Production API key">
<template>prc_live_8f2c9a71d04be63c5e1a</template>
</mv-reveal-once>기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<div id="ro-demo" style="width:min(100%,66rem);margin-inline:auto">
<style>
#ro-demo { display:grid; gap:1.25rem }
#ro-demo .ro-app { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-bg-subtle); overflow:hidden }
#ro-demo .ro-bar { display:flex; align-items:center; gap:.75rem; height:3.25rem; padding:0 1.125rem; border-bottom:1px solid var(--mv-border); background:var(--mv-surface) }
#ro-demo .ro-brand { display:flex; align-items:center; gap:.5rem; font-weight:650; font-size:.9375rem; letter-spacing:-.01em }
#ro-demo .ro-logo { display:grid; place-items:center; width:1.75rem; height:1.75rem; border-radius:var(--mv-radius-md); background:var(--mv-accent); color:var(--mv-fg-on-accent) }
#ro-demo .ro-logo svg { width:1rem; height:1rem }
#ro-demo .ro-crumbs { display:flex; gap:.375rem; color:var(--mv-fg-muted); font-size:.8125rem }
#ro-demo .ro-crumbs b { color:var(--mv-fg); font-weight:500 }
#ro-demo .ro-body { display:grid; grid-template-columns:minmax(0,1fr) 16rem; gap:1.25rem; padding:1.25rem }
#ro-demo .ro-side { display:grid; gap:1rem; align-content:start }
#ro-demo .ro-panel { display:grid; gap:.75rem; padding:1rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-surface) }
#ro-demo .ro-panel h4 { margin:0; font-size:.8125rem; font-weight:600 }
#ro-demo .ro-panel p { margin:0; color:var(--mv-fg-muted); font-size:.75rem; line-height:1.5 }
#ro-demo .ro-row { display:flex; align-items:center; justify-content:space-between; gap:.75rem; font-size:.8125rem }
#ro-demo .ro-btns { display:flex; flex-wrap:wrap; gap:.5rem }
#ro-demo .ro-log { display:grid; gap:.25rem; margin:0; padding:0; list-style:none; min-height:4.5rem; color:var(--mv-fg-muted); font:.6875rem/1.45 var(--mv-font-mono) }
#ro-demo .ro-log li:first-child { color:var(--mv-fg) }
#ro-demo .ro-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,22rem),1fr)); gap:1.25rem; align-items:start }
#ro-demo .ro-cap { display:flex; align-items:center; gap:.5rem; margin:0 0 .5rem; color:var(--mv-fg-muted); font-size:.75rem; font-weight:500 }
@media (max-width:46rem) { #ro-demo .ro-body { grid-template-columns:minmax(0,1fr) } }
</style>
<!-- 1 · A key was just created in developer settings: the “before” state -->
<section class="ro-app" aria-label="Developer settings">
<header class="ro-bar">
<span class="ro-brand">
<span class="ro-logo" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 8 12 3.5 20.5 8v8L12 20.5 3.5 16z"/><path d="M3.5 8 12 12.5 20.5 8M12 12.5v8"/></svg></span>
Parcelo
</span>
<span class="ro-crumbs" aria-hidden="true">/ Developers / <b>API keys</b></span>
</header>
<div class="ro-body">
<mv-reveal-once id="ro-key" label="Production API key" created-by="you"
prepare="A password manager or secrets vault, Access to your server’s environment variables"></mv-reveal-once>
<div class="ro-side">
<div class="ro-panel">
<h4>Options</h4>
<label class="ro-row">Hold to reveal <input type="checkbox" role="switch" class="mv-switch" data-size="sm" id="ro-hold"></label>
<label class="ro-row">Retype to confirm <input type="checkbox" role="switch" class="mv-switch" data-size="sm" id="ro-retype"></label>
<div class="ro-btns">
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="ro-leave">Leave page</button>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="ro-new">New key</button>
</div>
</div>
<div class="ro-panel">
<h4>Events <span class="mv-badge" data-variant="secondary" data-size="sm">never the secret</span></h4>
<ul class="ro-log" id="ro-log" aria-live="off"><li>Key created. Waiting for reveal.</li></ul>
</div>
</div>
</div>
</section>
<div class="ro-grid">
<!-- 2 · Two-factor recovery codes: revealed, download + print, retype one code to confirm -->
<div>
<p class="ro-cap"><span class="mv-badge" data-variant="warning" data-size="sm">During</span> Recovery codes, retype a code to confirm</p>
<mv-reveal-once id="ro-codes" label="Recovery codes" confirm="retype" downloadable filename="parcelo-recovery-codes" printable clear-clipboard="60"></mv-reveal-once>
</div>
<!-- 3 · A webhook signing secret saved last month: only its fingerprint remains -->
<div>
<p class="ro-cap"><span class="mv-badge" data-variant="success" data-size="sm">After</span> Webhook signing secret, saved in August</p>
<mv-reveal-once id="ro-hook" label="Webhook signing secret" fingerprint="whk_…c81d" created="2026-08-03T10:20:00" created-by="Priya Nair"></mv-reveal-once>
</div>
</div>
<script type="module">
const $ = (id) => document.getElementById(id);
const key = $("ro-key"), codes = $("ro-codes"), hook = $("ro-hook"), log = $("ro-log");
const ABC = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789";
const rand = (n, set = ABC) => Array.from(crypto.getRandomValues(new Uint32Array(n)), (v) => set[v % set.length]).join("");
const newKey = () => `prc_live_${rand(32)}`;
const newCodes = () => Array.from({ length: 10 }, () => `${rand(4, "abcdefghjkmnpqrstuvwxyz23456789")}-${rand(4, "abcdefghjkmnpqrstuvwxyz23456789")}`);
const time = () => new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" });
const say = (text) => {
log.prepend(Object.assign(document.createElement("li"), { textContent: `${time()} ${text}` }));
while (log.children.length > 5) log.lastElementChild.remove();
};
await customElements.whenDefined("mv-reveal-once");
// Secrets are set from JS (never server-rendered into the page).
key.secret = newKey();
codes.secret = newCodes();
codes.reveal();
// Event details carry facts, never the value.
key.addEventListener("mv-reveal", () => say("mv-reveal"));
key.addEventListener("mv-copy", (e) => say(`mv-copy · ${e.detail.method}`));
key.addEventListener("mv-saved", (e) => say(`mv-saved · ${e.detail.method}${e.detail.copied ? " · copied" : ""} · ${e.detail.fingerprint}`));
key.addEventListener("mv-leave", (e) => say(`mv-leave · ${e.detail.reason} (${e.detail.state})`));
key.addEventListener("mv-rotate", (e) => {
say(`mv-rotate · revoking ${e.detail.fingerprint}`);
e.detail.waitUntil(new Promise((r) => setTimeout(() => r(newKey()), 700)));
});
$("ro-hold").addEventListener("change", (e) => { key.hold = e.target.checked; });
$("ro-retype").addEventListener("change", (e) => { key.confirm = e.target.checked ? "retype" : "checkbox"; });
$("ro-leave").addEventListener("click", async () => {
const ok = await key.leave();
say(ok ? "Navigation allowed" : "Stayed on the page");
});
$("ro-new").addEventListener("click", () => { key.secret = newKey(); say("New key created"); });
// Rotation: the server issues a new secret; the component shows it once again.
for (const el of [codes, hook]) {
el.addEventListener("mv-rotate", (e) => {
e.detail.waitUntil(new Promise((r) => setTimeout(() => r(el === codes ? newCodes() : `whk_${rand(32)}`), 700)));
});
}
</script>
</div>API
Attributes
| Name | 유형 | Default | Description |
|---|---|---|---|
label | string | Secret | What the secret is (“Production API key”, “Recovery codes”): title, accessible name, and the noun used in every sentence (“I’ve saved this production API key somewhere safe”). |
hold | boolean | Press-and-hold to reveal: a bar fills the button for hold-duration; releasing early rewinds it. Keyboard: hold Space or Enter. A click with no press behind it (screen reader, switch access) arms the button and a second activation within 6 s reveals. | |
hold-duration | number (ms) | 900 | How long the hold lasts (250-5000). The fill still runs under reduced motion because it carries the time left. |
confirm | checkbox | retype | checkbox | The gate before Continue. checkbox: “I’ve saved this … somewhere safe”. retype (high stakes): type the last 4 characters of a key, or a random code / word (#n) of a list; focusing the field hides the value so it has to come from the saved copy. Case, spaces and dashes are ignored. |
format | auto | key | list | words | auto | How the value is shown. auto: an array or several lines is a numbered list (codes), 12+ lowercase words a seed phrase (words, never spelled out), anything else a key. Read when the secret is loaded. |
group | number | 4 | Chunk size for keys and for list items longer than 8 characters, counted from the end so the last chunk is the fingerprint’s tail. 0 disables. Values that carry their own dashes or spaces (temporary passwords, dashed codes) keep them instead. |
key-prefix | string | "none" | Prefix kept apart and always visible (it is part of the fingerprint). Auto-detected when omitted: one or two lowercase segments ending with _ or - (prc_live_, ghp_, whk_). | |
tail | number | 4 | Trailing characters kept in the fingerprint (0-8). Use 0 for passwords: the fingerprint then shows only the prefix and ••••. |
downloadable | boolean | Shows Download .txt: a plain-text file with the label, creation line, the value (numbered for lists) and a safekeeping note. | |
filename | string | Download file name (.txt added if missing). Default: the label in kebab-case. | |
printable | boolean | Shows Print: prints only the value, label and creation line from a hidden, throwaway frame (never the rest of the page). | |
clear-clipboard | number (s) | After a copy, overwrites the clipboard with an empty string after this many seconds if the last copy in this document was the secret, and says so under the tools. Best effort: browsers refuse clipboard writes when the page is not focused. | |
masked | boolean | Reflects the Hide / Show toggle while revealed (set it to start hidden, e.g. while screen sharing). Masked, the characters leave the DOM and only dots of the same length remain; the prefix stays. | |
created | ISO date | Creation date shown as “Created Sep 24 by you” (year added when not the current one). Default: the moment the secret is loaded. Removed by a rotation that returns a new secret. | |
created-by | string | you | Who created it. Empty string: “Created Sep 24”. |
prepare | comma list | A password manager or secrets vault | The “Have ready” checklist shown before the reveal. Empty string hides it. |
fingerprint | string | Server-provided fingerprint (“whk_…c81d”). Without a secret, the component starts directly in the done state: a keys settings page listing fingerprints with Rotate. Also overrides the computed one. | |
no-guard | boolean | Disables the beforeunload prompt (mv-leave is then not emitted on unload; leave() still works). | |
data-state | empty | ready | revealed | done | Set by the component. Also data-holding, data-copied, data-downloaded, data-confirmed, data-masked-view, data-unconfirmed (done without confirmation), data-alert, data-pending (rotation in flight). |
Properties
| Name | 유형 | Description |
|---|---|---|
secret | string | string[] | Write-only: loads a secret in the ready state (a string, a multi-line string or an array). Reading it always returns null. Preferred over a <template> child, which is in the page source. Can be set before the element is defined. |
state | "empty" | "ready" | "revealed" | "done" | Current phase (read-only). |
fingerprint | string | null | The fingerprint kept after the wipe (the attribute when set, otherwise computed: prefix…last 4, “10 codes”, “24-word phrase”). |
guarded | boolean | True while leaving would lose a secret nobody confirmed as saved (ready or revealed). |
confirmed / copied / downloaded | boolean | Gate satisfied, value copied (button or manual selection), file downloaded (read-only). |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement ({label} = noun inside a sentence, {Label} = at the start). English defaults, e.g. noteTitle, noteText, prepare, reveal, holdReveal, holdHint, confirmCheck, retypeKey, retypeItem, retypeWord, continue, gateHint, doneText, lost, rotate, rotateTitle, rotateText, leaveTitle, leaveText, fingerprintList, fingerprintWords, announceReveal, announceCopied, announceSaved. |
Methods
| Name | Description |
|---|---|
reveal() | Shows the secret (ready → revealed), focuses it and emits mv-reveal. Returns false unless ready. |
copy() | Copies the value (Clipboard API, textarea fallback; lists one per line, words space-separated). Resolves to true on success. |
download() / print() | Same as the buttons; work even when the attributes hide them. Return false unless revealed. |
finish({ force? }) | Ends the step as Continue does (emits mv-saved). Requires the gate unless force is true (method "api"). |
leave({ reason? }) | Call before an in-app navigation or closing a dialog. Resolves true when nothing is at risk or the user chose “Leave anyway” (the secret is then wiped, state done with data-unconfirmed), false if they stayed or mv-leave was canceled. |
rotate({ confirm? }) | Opens the rotate confirmation (done state), or rotates right away with { confirm: false }. Resolves true once mv-rotate went through. |
Events
| Name | Description |
|---|---|
mv-reveal | The value was revealed. detail: { kind: "key" | "list" | "words", count }. |
mv-copy | The value was copied. detail: { method: "button" | "keyboard" | "api" }, never the value. |
mv-download / mv-print | The file was downloaded (detail: { filename }) or the print dialog opened. |
mv-clipboard-clear | The clipboard was overwritten by clear-clipboard. |
mv-saved | Cancelable. The user confirmed and pressed Continue. detail: { method: "checkbox" | "retype" | "api", copied, downloaded, fingerprint }. Record the acknowledgement on your server; preventDefault() keeps the secret on screen. |
mv-leave | Cancelable. The user is leaving with the secret not confirmed as saved. detail: { reason: "unload" | "navigate" | string, state }. preventDefault() suppresses the built-in warning (the beforeunload prompt or the inline “Leave without saving?”) so you can show your own; leave() then resolves false. |
mv-rotate | Cancelable. Rotation confirmed. detail: { fingerprint, waitUntil(promise) }. Pass your revoke-and-regenerate request to waitUntil: the button shows a busy state, a rejection shows an error, and a promise resolving to a string or array loads it as the new secret (ready state, created now). |
Content structure
| Name | Description |
|---|---|
<template> child | Alternative to the secret property, read once at mount then removed: text (lines = list items) or <li> elements. It is visible in the page source, so prefer the property. |
CSS classes
| Name | Description |
|---|---|
mv-reveal-once-head / -icon / -title / -meta / -badge | Header: state icon (key, shield when saved), label, creation line, “Shown once” / “Saved” / “Not confirmed” badge (data-tone). |
mv-reveal-once-stage | The value box (data-kind): -placeholder and -cover with -reveal (-hold-fill) and -hint when ready, -value when revealed, -fingerprint (-fp-prefix, -fp-ellipsis, -fp-tail, -fp-label) when done. |
mv-reveal-once-chunk / -glyphs / -list / -item / -num / -word | Rendered value: chunks carry data-prefix, data-sep, data-tail; digits and symbols are spans with data-k="d" | "s". |
mv-reveal-once-note / -prepare | Before the reveal: the once-only statement and the “Have ready” list. |
mv-reveal-once-tools / -btn / -cliphint | Copy, Download, Print and Hide / Show buttons (data-feedback="done" | "error" after an action). |
mv-reveal-once-gate / -check / -retype / -gate-hint / -continue | The saved-confirmation gate; Continue has data-locked until it is satisfied. |
mv-reveal-once-record / -rotate | After: what is kept and the “Lost it? Rotate” action. |
mv-reveal-once-alert | Inline confirmation (data-kind="leave" | "rotate"). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-reveal-once-accent | var(--mv-accent) | Revealed box ring and the dotted underline of the tail chunk. |
--mv-reveal-once-digit | info mixed with fg | Color of digits in the value. |
--mv-reveal-once-symbol | danger mixed with fg | Color of symbols in the value. |
--mv-reveal-once-ok | var(--mv-success) | Saved badge, copied feedback, retype match. |
--mv-reveal-once-warn | var(--mv-warning) | “Shown once” badge, leave warning, unconfirmed state. |
--mv-reveal-once-font-size | 0.9375rem | Size of the monospace value. |
--mv-reveal-once-radius | var(--mv-radius-xl) | Corner radius of the card. |
Accessibility
The element is a role=group named by its label. Before the reveal it holds no character of the secret: the dotted placeholder is aria-hidden and the Reveal button is described by a hint (“Press and hold, or hold Space. Make sure nobody can see your screen.”). Hold mode works with pointer, touch (no callout, no scroll) and Space or Enter held (key repeat ignored, blur releases); since assistive technologies activate buttons with a plain click and cannot hold, a click with no press behind it arms the button (“Activate again to reveal”, announced) and a second activation reveals. After the reveal, focus moves to the value (a labelled group, tabindex=-1) and a polite role=status region says it is shown only once. Keys are read character by character: each chunk has visually hidden spelled text (“9 f 3 a,”) while the visible glyphs are aria-hidden; the spelled text is excluded from selection so a manual copy stays exact. Codes are an ordered list, seed-phrase words are read as words. Copy, downloads, hiding and clearing the clipboard are announced; digits and symbols are tinted but never only by color (monospace, slashed zero). The gate is a real checkbox or a labelled text field with aria-invalid and a linked error; Continue stays focusable and, while locked, explains why and moves focus to the gate instead of doing nothing. The inline Leave / Rotate confirmation is a labelled group that receives focus on the safe choice (Stay / Cancel), Escape closes it and focus returns where it was. After saving, focus moves to the fingerprint, read as “Production API key ending in 9 f 3 a”. Reduced motion (OS or data-motion="reduce"): no unveil, no sliding fingerprint, no height animation; the hold fill still runs because it is the time left. Forced colors: system borders and a Highlight fill. Security: the value never appears in event details, logs or storage, the secret getter returns null, masking removes the characters from the DOM, the wipe happens before any animation, the page leaving (pagehide, including the back-forward cache) or the element being removed wipes an unconfirmed secret, and JavaScript strings cannot be zeroed, so “removed from memory” means every reference is dropped.