独家
Tamper Seal <mv-tamper-seal>
防篡改的“打破玻璃”式敏感数据访问(病历、完整卡号或社保号、薪资、客户私信、生产环境密钥、密封投标),可以打开,但绝不能悄悄打开。
文化出处
《蓝胡子》(La Barbe bleue),夏尔·佩罗(1697 年,寓言)。妻子可以打开除一间之外的所有房间,而那间房的小钥匙一经使用就会沾上污迹,而且永远也洗不掉,因此开过门这件事无法隐瞒。在界面中,敏感内容总能在给出理由后打开,但每次打开都会留下一个永久印记(谁、何时、为什么),之后查看它的每个人都能看到,即使内容已重新封存也一样。
工作原理
对敏感数据(病历、完整卡号或 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 |
| Keywords | exclusive, 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 | 类型 | Default | Description |
|---|---|---|---|
label | string | Sealed content | Name 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. |
variant | card | inline | card | card: 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. |
reasons | comma-separated list | Customer request, Investigation, Legal or compliance request, Support escalation, Other | Reasons 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. |
note | optional | required | off | optional | The free-text note under the reason (ticket, case number, context). required makes it mandatory for every reason; off removes it. |
witness | boolean | Opening 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. | |
witnesses | comma-separated list | People who may co-sign, shown as a select. Without it the co-signer is a free text field. | |
viewer | string | You | Name 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. |
mask | string | Placeholder 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-after | time ("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-hide | boolean | Reseals as soon as the tab is hidden (switching tabs, locking the screen), against shoulder surfing on shared screens. | |
history-open | boolean | Card: the access history list starts expanded instead of behind the “Access history (n)” toggle. | |
locale | BCP 47 tag | en-US | Locale of the dates and times in the summary and history. |
data-state | sealed | form | pending | open | Set 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 |
|---|---|---|
accesses | Array<{ 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. |
reasons | Array<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. |
strings | Partial<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
| Name | Description |
|---|---|
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
| Name | Description |
|---|---|
mv-unseal | Cancelable, 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-error | A waitUntil promise rejected. detail: { error }. |
mv-access | The content was opened and the access stamped. detail: { access, accesses }. |
mv-reseal | The content was hidden and dropped. detail: { reason: "button" | "timeout" | "hidden" | "api" }. |
Content structure
| Name | Description |
|---|---|
preview | Children with slot="preview": non-sensitive context shown on the sealed cover (“Conversation between … · 14 messages”). Card only. |
CSS classes
| Name | Description |
|---|---|
mv-tamper-seal-head | Card header: -icon (lock / unlocked), -title, -status, and the -stamp (inked “Opened ×n”, data-fresh while it lands). |
mv-tamper-seal-cover | Sealed state: -preview, -mask (-mask-text, -bars), -explain and the -unseal button. |
mv-tamper-seal-openbar / mv-tamper-seal-content | Open state: “Opened by … · reseals at …” (-viewing), the -reseal button and the content region (focusable, tabindex=-1). |
mv-tamper-seal-form | Reason form: -form-title, -fields (mv-field / mv-label / mv-select / mv-textarea / mv-input), -witness, -warn, -form-error, -actions (-cancel, -submit). |
mv-tamper-seal-foot | Card 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-pop | Inline variant: the “Opened n” history chip and the popover (Popover API top layer) holding the form or the history. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-tamper-seal-stain | color-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-tape | 4px | Width 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.