Exclusifbêta
Tamper Seal <mv-tamper-seal>
Accès « bris de glace » traçable aux données sensibles (dossier patient, numéro de carte complet ou de sécurité sociale, salaire, messages privés d’un client, secrets de production, offre sous pli scellé), qui peuvent être ouvertes, mais jamais en secret. Le contenu démarre scellé, sans rien de sensible dans le DOM : barres de caviardage ou masque comme « •••-••-6789 », un aperçu non sensible facultatif, et une mention claire indiquant que l’ouverture laisse une marque permanente. L’ouverture demande un motif (un select configurable plus une note, obligatoire pour « Autre » ou toujours), peut exiger une seconde personne qui contresigne avec son propre code, et indique en toutes lettres quel nom sera apposé ; ensuite, un mv-unseal annulable transmet le motif à votre serveur, et son waitUntil(promise) ne résout le contenu (texte, nœuds ou { content, access }) qu’après autorisation. Dès lors, le sceau reste taché pour tous ceux qui le consultent : un bord en ruban d’inviolabilité, un tampon encré « Ouvert ×4 » qui s’appose quand un nouvel accès est enregistré, et une ligne de synthèse (« Ouvert 4 fois · dernier accès par Marcus Webb, le 24 sept. à 14 h 31 · motif : Enquête sur une rétrofacturation ») au-dessus d’un historique complet des accès (qui, quand, pourquoi, cosignataire), fourni par le serveur via accesses et mis à jour en direct avec addAccess(). Resceller (un bouton, reseal-after, ou quitter l’onglet) supprime les nœuds du contenu, mais jamais le tampon. Deux variantes : card pour une section de document, et inline pour un champ masqué avec une pastille d’historique et un formulaire en popover.
| Catégorie | Affichage de données |
|---|---|
| Type | Web Component (<mv-tamper-seal>) |
| Statut | bêta |
| Kit | Actions destructives et secrets en sécurité |
| Installe aussi | 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
Installation
node scripts/add.mjs tamper-seal --out ./src/marvelousAgent IA avec le serveur MCP Marvelous UI : install_components({ slugs: ["tamper-seal"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Fichiers copiés (dépendances comprises) : 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.
Utilisation
Démarrage rapide, le balisage minimal qui fonctionne :
<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>Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables 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>Référence culturelle
La Barbe bleue, Charles Perrault (1697, fable). L’épouse peut ouvrir toutes les pièces sauf une, et la petite clé de cette pièce se tache dès qu’elle sert, d’une tache qui ne part jamais : l’ouverture ne peut pas être cachée. Dans l’interface, un contenu sensible peut toujours être ouvert avec un motif, mais chaque ouverture laisse un tampon permanent (qui, quand, pourquoi) que tous ceux qui le consultent ensuite peuvent voir, même après qu’il a été rescellé.
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.