익스클루시브

Tamper Seal <mv-tamper-seal>

민감한 데이터(환자 기록, 전체 카드 번호나 주민번호, 급여, 고객의 비공개 메시지, 프로덕션 시크릿, 봉인된 입찰)에 대한 변조 확인형 “비상 유리 깨기” 접근으로, 열 수는 있지만 몰래 열 수는 없습니다.

문화적 레퍼런스

푸른 수염(La Barbe bleue), 샤를 페로(1697, 우화). 아내는 단 한 방을 제외한 모든 방을 열 수 있지만, 그 방의 작은 열쇠는 사용되는 순간 얼룩이 지고 그 얼룩은 결코 씻기지 않으므로 문을 열었다는 사실을 숨길 수 없습니다. UI에서는 민감한 콘텐츠를 사유를 밝히면 언제든 열 수 있지만, 열 때마다 영구적인 도장(누가, 언제, 왜)이 남고, 다시 봉인된 뒤에도 이후에 보는 모든 사람이 이를 볼 수 있습니다.

작동 방식

민감한 데이터(환자 기록, 전체 카드 번호나 SSN, 급여, 고객의 개인 메시지, 프로덕션 시크릿, 봉인 입찰)에 대한 변조 흔적이 남는 “break the glass” 방식의 접근으로, 열 수는 있지만 결코 몰래 열 수는 없습니다. 콘텐츠는 DOM에 민감한 정보가 전혀 없는 봉인 상태로 시작합니다: 가림 막대 또는 “•••-••-6789” 같은 마스크, 선택적인 비민감 미리 보기, 그리고 열면 영구적인 흔적이 남는다는 평이한 안내. 열 때는 사유를 묻고(설정 가능한 선택 목록과 메모, 메모는 “Other”일 때 또는 항상 필수), 자신의 코드로 공동 서명하는 두 번째 사람을 요구할 수 있으며, 누구의 이름이 도장에 남을지 문장으로 경고합니다. 이어서 취소 가능한 mv-unseal이 서버에 사유를 전달하고, 그 waitUntil(promise)은 승인 후에만 콘텐츠(텍스트, 노드 또는 { content, access })를 확정합니다. 그때부터 봉인은 보는 모든 사람에게 얼룩진 상태로 남습니다: 변조 방지 테이프 가장자리, 새 접근이 기록될 때 찍히는 잉크 도장 “Opened ×4”, 그리고 누가, 언제, 왜, 공동 서명자를 담은 전체 접근 기록 위의 요약 줄(“Opened 4 times · last by Marcus Webb, Sep 24, 2:31 PM · reason: Chargeback investigation”). 이 기록은 서버가 accesses로 제공하며 addAccess()로 실시간 업데이트됩니다. 다시 봉인하면(버튼, reseal-after, 또는 탭 이탈) 콘텐츠 노드는 제거되지만 도장은 절대 사라지지 않습니다. 두 가지 변형: 문서 섹션용 card, 기록 칩과 팝오버 폼을 갖춘 마스킹 필드용 inline.

카테고리데이터 표시
유형Web Component (<mv-tamper-seal>)
상태안정
키트안전한 파괴적 작업과 비밀 정보
함께 설치되는 항목button, field, input, select, textarea
Keywordsexclusive, culture, security, audit, audit-log, break-the-glass, sensitive-data, privacy, pii, masked, redaction, access-log, reason-for-access, compliance, hipaa, two-person, tamper-evident, reveal

When to use

  • Support or ops staff may open a customer’s private data only with a stated reason that later viewers can see
  • A clinician needs break-the-glass access to a restricted patient record, recorded for audit and visible on the record
  • A full card number, SSN or bank account should stay masked until someone explicitly opens it and signs for it
  • Opening payroll, a sealed bid or production secrets must need a second person and leave a permanent visible trace

Avoid when

  • A freshly generated secret should be shown exactly once and then destroyed, not reopened with a trail → use Self-Destruct instead
  • Someone gets elevated rights for a limited time rather than opening one specific piece of data → use Midnight instead
  • The audit trail must hold against tampering: the server has to authorize and store every access, the component only presents it

설치

node scripts/add.mjs tamper-seal --out ./src/marvelous

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

복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/button/button.css, components/field/field.css, components/input/input.css, components/select/select.css, core/dom.js, core/element.js, components/textarea/textarea.css, components/textarea/char-count.js, core/dismiss.js, core/motion.js, core/position.js, components/tamper-seal/tamper-seal.js, components/tamper-seal/tamper-seal.css.

사용법

빠른 시작, 동작하는 가장 작은 마크업:

<mv-tamper-seal label="Private messages" viewer="Marcus Webb">
  <p slot="preview">Conversation with Lina Haddad · 14 messages</p>
</mv-tamper-seal>
<script type="module">
  document.querySelector("mv-tamper-seal").addEventListener("mv-unseal", (e) => e.detail.waitUntil("Lina: The tent arrived torn. Can I get a refund?"));
</script>

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

<div id="ts-demo" style="width:min(100%,64rem);margin-inline:auto">
  <style>
    #ts-demo { display:grid; gap:1rem; align-content:start }
    #ts-demo .ts-top { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding:.875rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface) }
    #ts-demo .ts-case { display:grid; gap:.125rem; min-width:0 }
    #ts-demo .ts-case strong { display:flex; align-items:center; gap:.5rem; font-size:.9375rem; letter-spacing:-.01em }
    #ts-demo .ts-case span { color:var(--mv-fg-muted); font-size:.8125rem }
    #ts-demo .ts-controls { display:flex; align-items:center; gap:.5rem 1rem; flex-wrap:wrap }
    #ts-demo label.mv-choice { font-size:.8125rem }
    #ts-demo .ts-grid { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:1rem; align-items:start }
    #ts-demo .ts-profile { display:grid; gap:.25rem; padding:1rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
    #ts-demo .ts-profile h3 { margin:0 0 .5rem; font-size:.9375rem; letter-spacing:-.01em }
    #ts-demo .ts-profile dl { display:grid; grid-template-columns:auto minmax(0,1fr); margin:0 }
    #ts-demo .ts-profile dt, #ts-demo .ts-profile dd { display:flex; align-items:center; min-height:2.75rem; margin:0; padding-block:.375rem; border-top:1px solid var(--mv-border); font-size:.8125rem }
    #ts-demo .ts-profile dt { padding-inline-end:1.25rem; color:var(--mv-fg-muted) }
    #ts-demo .ts-profile dd { flex-wrap:wrap; gap:.25rem }
    #ts-demo .ts-msgs { display:grid; gap:.5rem; margin:0; padding:0; list-style:none }
    #ts-demo .ts-msg { display:grid; gap:.125rem; padding:.5rem .625rem; border-radius:var(--mv-radius-md); background:var(--mv-surface); border:1px solid var(--mv-border) }
    #ts-demo .ts-msg b { font-size:.75rem; font-weight:600 }
    #ts-demo .ts-msg b span { color:var(--mv-fg-muted); font-weight:400 }
    #ts-demo .ts-msg p { margin:0; font-size:.8125rem }
    #ts-demo .ts-bank { display:grid; grid-template-columns:auto 1fr; gap:.375rem 1rem; margin:0; font-size:.8125rem }
    #ts-demo .ts-bank dt { color:var(--mv-fg-muted) }
    #ts-demo .ts-bank dd { margin:0; font-family:var(--mv-font-mono) }
    #ts-demo .ts-log { margin:0; min-height:1.25rem; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono) }
    @media (max-width:46rem) { #ts-demo .ts-grid { grid-template-columns:minmax(0,1fr) } }
  </style>

  <div class="ts-top">
    <span class="ts-case">
      <strong>Dispute DSP-40817 <span class="mv-badge" data-variant="warning" data-size="sm">Chargeback</span></strong>
      <span>Lina Haddad · $1,284.00 at Aurora Outdoor Co. · you are signed in as Marcus Webb</span>
    </span>
    <span class="ts-controls">
      <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="ts-refuse"> Server refuses access</label>
      <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="ts-other">Simulate another opening</button>
    </span>
  </div>

  <!-- Card: private messages, already opened 3 times -->
  <mv-tamper-seal id="ts-messages" label="Private messages" viewer="Marcus Webb" reseal-on-hide
    reasons="Chargeback investigation, Fraud review, Customer asked us to look, Legal or law enforcement request, Other">
    <p slot="preview">Conversation between Lina Haddad and Aurora Outdoor Co. · 14 messages · Aug 2-Sep 18</p>
    <script type="application/json" data-accesses>
      [
        { "by": "Priya Raman", "role": "Disputes", "at": "2026-09-21T10:04:00", "reason": "Chargeback investigation", "note": "Merchant claims the item was delivered; checking the thread for proof." },
        { "by": "Daniel Okafor", "role": "Trust & Safety", "at": "2026-09-12T16:37:00", "reason": "Fraud review", "note": "Flagged by the risk model (score 0.91)." },
        { "by": "Sofia Lindqvist", "role": "Support lead", "at": "2026-08-30T09:12:00", "reason": "Customer asked us to look", "note": "Ticket #88213" }
      ]
    </script>
  </mv-tamper-seal>

  <div class="ts-grid">
    <!-- Inline fields: masked identity data -->
    <section class="ts-profile" aria-labelledby="ts-profile-title">
      <h3 id="ts-profile-title">Cardholder</h3>
      <dl>
        <dt>Name</dt><dd>Lina Haddad</dd>
        <dt>SSN</dt>
        <dd>
          <mv-tamper-seal id="ts-ssn" variant="inline" label="Social Security number" mask="•••-••-6789" viewer="Marcus Webb" reseal-after="1m"
            reasons="Identity verification, Chargeback investigation, Legal or law enforcement request, Other" note="off">
            <script type="application/json" data-accesses>
              [{ "by": "Priya Raman", "role": "Disputes", "at": "2026-09-21T10:06:00", "reason": "Identity verification" }]
            </script>
          </mv-tamper-seal>
        </dd>
        <dt>Card</dt>
        <dd>
          <mv-tamper-seal id="ts-pan" variant="inline" label="Full card number" mask="•••• •••• •••• 4242" viewer="Marcus Webb" reseal-after="1m"
            reasons="Chargeback investigation, Card replacement, Other" note="off"></mv-tamper-seal>
        </dd>
      </dl>
    </section>

    <!-- Card: payout details need a second person -->
    <mv-tamper-seal id="ts-bank" label="Payout bank account" viewer="Marcus Webb" witness note="required"
      witnesses="Priya Raman, Daniel Okafor, Sofia Lindqvist"
      reasons="Payout failed, Merchant changed banks, Fraud review, Other">
      <p slot="preview">Aurora Outdoor Co. · Checking account ending in 0931. Opening requires a second person.</p>
    </mv-tamper-seal>
  </div>

  <p class="ts-log" id="ts-log" aria-live="off">Every opening is sent to the audit log before anything is shown.</p>

  <script type="module">
    const $ = (id) => document.getElementById(id);
    const log = (text) => { $("ts-log").textContent = `${new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" })} · ${text}`; };
    const el = (tag, cls, ...kids) => { const n = document.createElement(tag); if (cls) n.className = cls; n.append(...kids); return n; };

    // Stand-in for your API: authorize, write the audit entry, then return the data.
    const server = (build) => new Promise((resolve, reject) => {
      setTimeout(() => ($("ts-refuse").checked
        ? reject("Your role can’t open this on a disputed account. Ask a Trust & Safety lead.")
        : resolve(build())), 700);
    });

    const messages = () => {
      const f = document.createDocumentFragment();
      const list = el("ul", "ts-msgs");
      for (const [who, when, text] of [
        ["Lina Haddad", "Sep 18, 9:42 AM", "The tent never arrived. The tracking page still says “label created”."],
        ["Aurora Outdoor Co.", "Sep 18, 2:15 PM", "Our carrier shows it was left at the front desk on Sep 9. Could a neighbor have it?"],
        ["Lina Haddad", "Sep 18, 3:01 PM", "I live alone in a house, there is no front desk. I’d like a refund please."],
      ]) list.append(el("li", "ts-msg", el("b", "", who, " ", el("span", "", `· ${when}`)), el("p", "", text)));
      f.append(list);
      return f;
    };

    $("ts-messages").addEventListener("mv-unseal", (e) => {
      log(`POST /disputes/DSP-40817/messages/unseal · reason: ${e.detail.reasonLabel}`);
      e.detail.waitUntil(server(messages));
    });
    for (const [id, value] of [["ts-ssn", "521-44-6789"], ["ts-pan", "4916 3385 0211 4242"]]) {
      $(id).addEventListener("mv-unseal", (e) => {
        log(`POST /cardholders/ch_7Q2k/${id === "ts-ssn" ? "ssn" : "pan"}/unseal · ${e.detail.reasonLabel}`);
        e.detail.waitUntil(server(() => value));
      });
    }
    $("ts-bank").addEventListener("mv-unseal", (e) => {
      const { witness } = e.detail;
      log(`POST /merchants/aurora/payout-account/unseal · co-signed by ${witness.name}`);
      e.detail.waitUntil(new Promise((resolve, reject) => setTimeout(() => {
        if ($("ts-refuse").checked) return reject("Your role can’t open payout details.");
        if (witness.code !== "4471") return reject(`${witness.name}’s approval code didn’t match. (Demo code: 4471)`);
        const dl = el("dl", "ts-bank");
        for (const [k, v] of [["Bank", "First Harbor Bank"], ["Routing", "021000089"], ["Account", "4410 2297 0931"], ["Holder", "Aurora Outdoor Co. LLC"]]) dl.append(el("dt", "", k), el("dd", "", v));
        resolve(dl);
      }, 700)));
    });

    document.addEventListener("mv-access", (e) => { if (e.target.closest("#ts-demo")) log(`Stamped: ${e.detail.access.by} · ${e.detail.access.reason}`); });
    document.addEventListener("mv-reseal", (e) => { if (e.target.closest("#ts-demo")) log(`Resealed (${e.detail.reason}). The stamp stays.`); });

    $("ts-other").addEventListener("click", () => {
      $("ts-messages").addAccess({ by: "Hannah Schmidt", role: "Compliance", at: new Date(), reason: "Legal or law enforcement request", note: "Subpoena 2026-CV-1142" });
    });
  </script>
</div>

API

Attributes

Name유형DefaultDescription
labelstringSealed contentName of the protected content (“Private messages”, “Social Security number”). Visible title of the card, accessible name of the group and of the content region, used in announcements.
variantcard | inlinecardcard: a document section with header, cover, in-flow reason form and access-history footer. inline: a masked value in running text or a table cell, with an Unseal button, an “Opened 2 times” chip and a popover for the form and the history.
reasonscomma-separated listCustomer request, Investigation, Legal or compliance request, Support escalation, OtherReasons offered in the form (commas inside parentheses are kept). The reasons property also accepts an array of strings or { value, label }. Choosing a reason whose value is “Other” makes the note required.
noteoptional | required | offoptionalThe free-text note under the reason (ticket, case number, context). required makes it mandatory for every reason; off removes it.
witnessbooleanOpening needs a second person: a co-signer (select from witnesses, or a text field) and their approval code (password field). Both are passed in mv-unseal for your server to verify; the code is cleared right after the event and never stored.
witnessescomma-separated listPeople who may co-sign, shown as a select. Without it the co-signer is a free text field.
viewerstringYouName of the signed-in user: written in the warning (“Marcus Webb, the time and this reason will be stamped…”), shown while open, and used for the local stamp when your server does not return its own access record.
maskstringPlaceholder shown while sealed (“•••-••-6789”, “•••• •••• •••• 4242”). Inline shows it in place of the value (default: bullets); card shows it above the redaction bars. Its letters and digits are read to screen readers as “Sealed, ending in 6789”.
reseal-aftertime ("90s", "5m", "1h", ms number)Reseals on its own this long after opening; the open bar says when (“reseals at 2:36 PM”). Survives throttled background tabs (checked against an absolute time).
reseal-on-hidebooleanReseals as soon as the tab is hidden (switching tabs, locking the screen), against shoulder surfing on shared screens.
history-openbooleanCard: the access history list starts expanded instead of behind the “Access history (n)” toggle.
localeBCP 47 tagen-USLocale of the dates and times in the summary and history.
data-statesealed | form | pending | openSet by the component (styleable). data-stained is added once at least one access exists, data-pending while waitUntil promises are in flight, data-variant mirrors the variant.

Properties

Name유형Description
accessesArray<{ by, at, reason, note?, role?, witness?, id? }>Access history from your server (at: Date, epoch ms or ISO string). Read it newest first. Can also be provided in HTML with a child <script type="application/json" data-accesses>. Replaced by a promise result carrying accesses.
reasonsArray<string | { value, label }>Reasons as an array (mirrors the attribute when set as a string).
state"sealed" | "form" | "pending" | "open"Current state (read-only). opened is true while the content is shown.
stringsPartial<Record<string, string>>Overrides for every visible text and announcement (unseal, reseal, submit, explain, formTitle, warning ({viewer}), summary ({count}, {by}, {when}, {reason}), chip, history ({n}), refused, error, announceOpen, announceReseal…). English defaults.

Methods

NameDescription
unseal()Starts the opening: shows the reason form and focuses it. The content still only arrives through mv-unseal.
reseal()Hides and drops the content (or cancels an open form). Emits mv-reseal with reason "api". The stamp stays.
addAccess(access)Adds an access recorded elsewhere (another viewer, a websocket push) to the stamp and history, with the stamp animation and a polite announcement. Returns false if the record is invalid.

Events

NameDescription
mv-unsealCancelable, on form submit after validation. detail: { reason, reasonLabel, note, witness: { name, code } | null, viewer, waitUntil(promise) }. Call waitUntil() synchronously with your authorize-and-log request. It may resolve a string, a Node or DocumentFragment, an array of them, or { content, access?, accesses? } (access: the server’s own record; accesses: the full history). Reject with a string to show your own message (“Approval code didn’t match”); any other rejection shows a generic error and nothing is stamped. preventDefault() refuses the opening.
mv-unseal-errorA waitUntil promise rejected. detail: { error }.
mv-accessThe content was opened and the access stamped. detail: { access, accesses }.
mv-resealThe content was hidden and dropped. detail: { reason: "button" | "timeout" | "hidden" | "api" }.

Content structure

NameDescription
previewChildren with slot="preview": non-sensitive context shown on the sealed cover (“Conversation between … · 14 messages”). Card only.

CSS classes

NameDescription
mv-tamper-seal-headCard header: -icon (lock / unlocked), -title, -status, and the -stamp (inked “Opened ×n”, data-fresh while it lands).
mv-tamper-seal-coverSealed state: -preview, -mask (-mask-text, -bars), -explain and the -unseal button.
mv-tamper-seal-openbar / mv-tamper-seal-contentOpen state: “Opened by … · reseals at …” (-viewing), the -reseal button and the content region (focusable, tabindex=-1).
mv-tamper-seal-formReason form: -form-title, -fields (mv-field / mv-label / mv-select / mv-textarea / mv-input), -witness, -warn, -form-error, -actions (-cancel, -submit).
mv-tamper-seal-footCard stain footer: -summary, -history-toggle and -history with the -list of -entry items (-avatar, -who, -role, time, -reason, -note, -cosign; data-new on a fresh one).
mv-tamper-seal-chip / mv-tamper-seal-popInline variant: the “Opened n” history chip and the popover (Popover API top layer) holding the form or the history.

CSS variables

NameDefaultDescription
--mv-tamper-seal-staincolor-mix(in oklab, var(--mv-warning), var(--mv-danger) 40%)Ink of the stain: tamper-tape edge, stamp, chip, open highlight and warning. Text uses it mixed with --mv-fg for contrast in both themes.
--mv-tamper-seal-tape4pxWidth of the striped tamper-tape edge on a stained card.

Accessibility

The element is a group named by its label. While sealed, nothing sensitive exists in the DOM; the mask is aria-hidden and replaced for screen readers by “Sealed, ending in 6789”, and the Unseal button is described by the sentence explaining that opening leaves a permanent, visible mark. The reason form is a real <form> named by its question, with <label>s, required markers, help text and per-field errors wired through aria-describedby and aria-invalid; submitting with errors moves focus to the first invalid field. The warning is linked to the submit button with aria-describedby, and server refusals are shown in a role="alert" message. Focus moves to the reason select when the form opens (card: in place; inline: in a labelled popover dialog, Escape or an outside click cancels and returns focus), and to the revealed content region (role=region, tabindex=-1) once opened; resealing returns focus to the Unseal button when it was inside. While the server answers, the submit button is aria-busy and the fields are inert. Opening, resealing and live accesses from others are announced politely. The access history is an ordered list, newest first, each entry with a machine-readable <time datetime> and a full date in its title; the card toggle uses aria-expanded / aria-controls and the inline chip has a spoken label (“Opened 2 times. Show access history”). The stain is never color-only: it always comes with words (“Opened 3 times”, the stamp text, “Open · your access is recorded”) and the lock / unlocked icons. Reduced motion (OS or data-motion="reduce"): no stamp landing, reveal blur or row highlight. Forced colors: the tape, stamp, chip and warning fall back to CanvasText borders.

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