エクスクルーシブ

Tamper Seal <mv-tamper-seal>

機密データ(患者の記録、カード番号の全桁や社会保障番号、給与、顧客の非公開メッセージ、本番環境のシークレット、封印された入札)への、改ざんが明らかになる「ブレークグラス」アクセスです。開くことはできますが、こっそり開くことはできません。

文化的リファレンス

青ひげ(La Barbe bleue)、シャルル・ペロー(1697年、寓話)。妻はどの部屋を開けてもよいものの一部屋だけは禁じられており、その部屋の小さな鍵は使った瞬間に染みがつき、その染みは決して洗い落とせないため、開けたことを隠せません。UI では、機密コンテンツは理由を添えればいつでも開けますが、開けるたびに消えない刻印(誰が、いつ、なぜ)が残り、再び封印された後でも、後から閲覧するすべての人に見えます。

仕組み

改ざんの痕跡が残る「ブレークグラス」型の機密データアクセス(患者記録、カード番号の全桁や SSN、給与、顧客の非公開メッセージ、本番環境のシークレット、封印入札)。開くことはできても、こっそり開くことはできません。コンテンツは封印された状態で始まり、DOM には機密情報が一切含まれません:墨消しバーや「•••-••-6789」のようなマスク、任意の非機密プレビュー、そして開封すると消えない痕跡が残るという平易な説明だけです。開封時には理由の入力を求め(設定可能な選択肢とメモ。メモは「Other」の場合、または常に必須)、自分のコードで連署する 2 人目を必須にすることもでき、誰の名前が刻印されるかを言葉で警告します。その後、キャンセル可能な 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、またはタブを離れること)でコンテンツのノードは削除されますが、刻印は決して消えません。2 つのバリアント:文書セクション向けの 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 で翻訳されています。翻訳の問題を報告