Exclusivebeta
Tamper Seal — <mv-tamper-seal>
Tamper-evident “break the glass” access to sensitive data (a patient record, a full card number or SSN, a salary, a customer’s private messages, production secrets, a sealed bid) that can be opened but never secretly. The content starts sealed with nothing sensitive in the DOM: redaction bars or a mask such as “•••-••-6789”, an optional non-sensitive preview, and a plain statement that opening leaves a permanent mark. Opening asks for a reason (a configurable select plus a note, required for “Other” or always), can require a second person who co-signs with their own code, and warns in words who will be stamped; then a cancelable mv-unseal hands your server the reason, and its waitUntil(promise) resolves the content (text, nodes or { content, access }) only after authorization. From then on the seal is stained for everyone who views it: a tamper-tape edge, an inked “Opened ×4” stamp that lands when a new access is recorded, and a summary line (“Opened 4 times · last by Marcus Webb, Sep 24, 2:31 PM · reason: Chargeback investigation”) above a full access history of who, when, why and co-signer, supplied by the server through accesses and updated live with addAccess(). Resealing (a button, reseal-after, or leaving the tab) removes the content nodes but never the stamp. Two variants: card for a document section and inline for a masked field with a history chip and popover form.
| Category | Data display |
|---|---|
| Type | Web Component (<mv-tamper-seal>) |
| Status | beta |
| Also installs | 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
Install
node scripts/add.mjs tamper-seal --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["tamper-seal"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dismiss.js, core/dom.js, core/element.js, core/motion.js, core/position.js, components/tamper-seal/tamper-seal.js, components/tamper-seal/tamper-seal.css, components/button/button.css, components/field/field.css, components/input/input.css, components/select/select.css, components/textarea/textarea.css, components/textarea/char-count.js.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<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>Cultural reference
Bluebeard (La Barbe bleue) — Charles Perrault (1697, fable). The wife may open every room but one, and the little key to that room is stained the moment it is used, a stain that can never be washed off, so the opening cannot be hidden. In the UI, sensitive content can always be opened with a reason, but every opening leaves a permanent stamp (who, when, why) that everyone who views it later can see, even after it is sealed again.
API
Attributes
| Name | Type | 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 | Type | 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.