Exclusivebeta
Time Vault — <mv-time-vault>
A vault of messages written now and sealed until a precise future moment: a date (data-opens, absolute or relative like “+1y” after writing) or a named app condition (“when we launch publicly”, “when MRR passes $10k”) that your app feeds through vault.check(state) or the flags attribute, with optional progress (0–1 or “$7.4k of $10k”). The list shows each capsule’s title, author, trigger and a calm state in words (Sealed, Due soon, Ready to open, Opened) around a seal whose ring fills as the moment approaches, and never the content. Opening is a quiet reveal (the ring releases, the letter unfolds and its lines surface one by one) that starts with provenance (“Written 14 months ago by Amara Okafor, before launch”); predictions inside get a Yes / Partly / No verdict radio group, and a calibration strip scores every verdict across the vault. A built-in compose form seals new capsules with a date or a condition picked from the list your app provides. mv-seal and mv-open are cancelable and take waitUntil(promise), so the server stores the capsule and only hands over its content once it has checked the trigger itself: the component hides, it does not secure.
| Category | Data display |
|---|---|
| Type | Web Component (<mv-time-vault>) |
| Status | beta |
| Also installs | button, input, select, textarea |
| Keywords | exclusive, culture, time-capsule, sealed, future-self, letter, retrospective, pre-mortem, predictions, calibration, forecast-review, onboarding, milestone, unlock, scheduled-reveal, trigger, feature-flag, list |
When to use
- A team wants retro notes, pre-mortems or founder letters that only open at launch, a funding round or a year from now
- Predictions made today must be checked later and kept as a record of how well the team called it
- An onboarding letter or contract renewal note should surface exactly when it becomes relevant, with its original context
- A product needs “letters to your future self” that unlock on a date or when an app milestone is reached
Avoid when
- The content is a secret to show once and then destroy, not a message to open later → use Self-Destruct instead
- The point is to forecast a metric from its trend, not to store what people predicted → use Yet To Come instead
- The content must stay confidential until the date: never ship the body to the client, deliver it from mv-open’s waitUntil
Install
node scripts/add.mjs time-vault --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["time-vault"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, components/time-vault/time-vault.js, components/time-vault/time-vault.css, components/button/button.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="tv-demo" style="width:min(100%,64rem);margin-inline:auto">
<style>
#tv-demo { display:grid; grid-template-columns:minmax(0,1fr) 16.5rem; gap:1.25rem; align-items:start }
#tv-demo .tv-panel { display:grid; gap:1rem; padding:1rem 1.125rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); font-size:.8125rem }
#tv-demo .tv-panel h4 { margin:0; font-size:.875rem; letter-spacing:-.01em }
#tv-demo .tv-panel p { margin:0; color:var(--mv-fg-muted); font-size:.75rem; line-height:1.5 }
#tv-demo .tv-sep { height:1px; background:var(--mv-border) }
#tv-demo .tv-row { display:flex; align-items:center; justify-content:space-between; gap:.75rem }
#tv-demo .tv-mrr { display:grid; gap:.5rem }
#tv-demo .tv-mrr output { color:var(--mv-fg); font-weight:600; font-variant-numeric:tabular-nums }
#tv-demo .tv-clock { display:grid; gap:.5rem }
#tv-demo .tv-actions { display:flex; gap:.5rem; flex-wrap:wrap }
#tv-demo .tv-log { display:grid; gap:.25rem; min-height:4.5rem; margin:0; padding:0; list-style:none; color:var(--mv-fg-muted); font:.6875rem/1.45 var(--mv-font-mono) }
#tv-demo .tv-log li:first-child { color:var(--mv-fg) }
#tv-demo .mv-choice { font-size:.8125rem }
@media (max-width:52rem) { #tv-demo { grid-template-columns:minmax(0,1fr) } }
</style>
<!-- A product team's vault. The clock is frozen on Sep 24, 2026 so the demo reads the same any day. -->
<mv-time-vault id="tv-vault" label="Team time vault" author="Amara Okafor" era="before launch" now="2026-09-24T10:00">
<data data-condition value="launch">we launch publicly</data>
<data data-condition value="mrr-10k">MRR passes $10k</data>
<data data-condition value="beta-ended">the public beta ends</data>
<data data-condition value="series-a">we close the Series A</data>
<data data-condition value="legacy-api-off">the legacy API is switched off</data>
<template data-capsule data-id="premortem" data-title="Pre-mortem: how the beta could fail" data-author="Priya Raman" data-written="2025-03-10" data-context="during the private beta" data-when="beta-ended" data-opened="2026-08-18" data-expanded>
<p>It is the end of the beta and something went wrong. This is my honest guess at what it was, written while we still had eleven design partners and no pricing page.</p>
<p>If I am right about onboarding, the fix is a guided first project, not more features. If I am wrong, please tell me in the retro.</p>
<ol data-predictions>
<li data-verdict="yes">Onboarding, not pricing, will be the main reason teams churn</li>
<li data-verdict="no">We will need a Windows app before enterprise deals close</li>
<li data-verdict="partly">Support volume will double in the first month after the beta</li>
<li>At least three design partners will become paying customers</li>
</ol>
</template>
<template data-capsule data-id="sofia" data-title="To Sofia, one year after hire" data-author="Daniel Kim" data-written="2025-09-15" data-context="on your first day" data-opens="+1y">
<p>Sofia, welcome. Today you asked more good questions than most people ask in a quarter, so here is a note for the version of you who has been here a year.</p>
<p>I hope you shipped the offline mode you pitched in your interview. If not, I hope you know why, and that the reason taught you something about this team.</p>
<ol data-predictions>
<li>Offline mode will be live for every customer</li>
<li>You will be mentoring at least one new engineer</li>
</ol>
</template>
<template data-capsule data-id="brightwave" data-title="Renewal notes: Brightwave Media" data-author="Marcus Webb" data-written="2025-10-20" data-context="right after signing" data-opens="2026-10-05">
<p>We won Brightwave on the reporting exports, not on price. Their CFO, Hannah Lindqvist, pushed hard for net-60 terms; we gave net-45 and a named support contact.</p>
<ol data-predictions>
<li>They will ask for at least 15% off at renewal</li>
<li>Their usage will have grown past 40 seats</li>
</ol>
</template>
<template data-capsule data-id="mrr" data-title="When we pass $10k MRR" data-author="Amara Okafor" data-written="2025-11-03" data-context="at $2.1k MRR" data-when="mrr-10k">
<p>If you are reading this, the thing we built in a spare room pays for itself. Celebrate first. Then look at which plan got us here, because I bet it is not the one we designed the pricing page around.</p>
<ol data-predictions>
<li>Most of the revenue will come from the Team plan, not Pro</li>
<li>We will still be under 5% monthly churn</li>
</ol>
</template>
<!-- No content in the page: the server hands it over in mv-open, once it has checked the trigger itself. -->
<template data-capsule data-id="launch" data-title="Letter to the launch team" data-author="Amara Okafor" data-written="2025-07-22" data-context="before launch" data-when="launch"></template>
<template data-capsule data-id="2028" data-title="Where we will be in 2028" data-author="Leila Haddad" data-written="2026-01-12" data-context="during the seed round" data-opens="2028-01-01">
<p>A note on what I think the company looks like two years from now.</p>
</template>
</mv-time-vault>
<aside class="tv-panel" aria-labelledby="tv-sim-h">
<div style="display:grid;gap:.25rem">
<h4 id="tv-sim-h">Simulate the app</h4>
<p>Your app tells the vault what is true. Capsules open the moment their condition does.</p>
</div>
<label class="mv-choice" data-control="end">
<input type="checkbox" role="switch" class="mv-switch" id="tv-launch">
<span class="mv-choice-text"><span class="mv-choice-title">Public launch</span><span class="mv-choice-description">Sets the launch flag</span></span>
</label>
<div class="tv-mrr">
<div class="tv-row"><label for="tv-mrr">Monthly recurring revenue</label><output for="tv-mrr" id="tv-mrr-out">$7,400</output></div>
<input type="range" class="mv-slider" id="tv-mrr" min="0" max="12000" step="100" value="7400">
</div>
<div class="tv-sep"></div>
<div class="tv-clock">
<div class="tv-row"><span>Today</span><span class="mv-badge" data-variant="secondary" id="tv-today">Sep 24, 2026</span></div>
<div class="tv-actions">
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="tv-jump">Jump 2 weeks ahead</button>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="tv-reset">Reset</button>
</div>
</div>
<div class="tv-sep"></div>
<div style="display:grid;gap:.5rem">
<h4>Events</h4>
<ul class="tv-log" id="tv-log" aria-live="polite"><li>Vault loaded.</li></ul>
</div>
</aside>
<script type="module">
const vault = document.getElementById("tv-vault");
const launch = document.getElementById("tv-launch");
const mrr = document.getElementById("tv-mrr");
const mrrOut = document.getElementById("tv-mrr-out");
const today = document.getElementById("tv-today");
const log = document.getElementById("tv-log");
const START = new Date(2026, 8, 24, 10).getTime();
const usd = (n) => n.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
const short = (n) => `$${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
const say = (text) => {
log.prepend(Object.assign(document.createElement("li"), { textContent: text }));
while (log.children.length > 4) log.lastElementChild.remove();
};
const syncMrr = () => {
const v = Number(mrr.value);
mrrOut.textContent = usd(v);
vault.check({ "mrr-10k": { progress: v / 10000, text: `${short(v)} of $10k` } });
};
const syncClock = () => {
today.textContent = vault.now.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
};
// The "server": content for the launch letter is only released on open.
const LETTER = {
body: "To everyone who shipped this: I am writing from the week we almost postponed. We had no status page, two open security findings and a landing page nobody liked.\n\nWhatever launch day looked like, remember that the version we were scared of was the one people wanted. Thank you for betting your year on it.",
predictions: [
"The first week will bring more than 300 sign-ups",
"Someone will find a billing bug within 48 hours",
"Our first public review will mention the keyboard shortcuts",
],
};
vault.addEventListener("mv-open", (e) => {
const { id, title } = e.detail.capsule;
say(`Opening “${title}”…`);
const wait = new Promise((resolve) => setTimeout(resolve, 650));
e.detail.waitUntil((id === "launch" ? wait.then(() => LETTER) : wait).then((content) => { say(`Opened “${title}”.`); return content; }));
});
vault.addEventListener("mv-ready", (e) => say(`Ready: “${e.detail.capsule.title}” (${e.detail.reason}).`));
vault.addEventListener("mv-seal", (e) => {
say(`Sealing “${e.detail.capsule.title}”…`);
e.detail.waitUntil(new Promise((resolve) => setTimeout(resolve, 500)).then(() => say(`Sealed “${e.detail.capsule.title}”.`)));
});
vault.addEventListener("mv-verdict", (e) => {
const c = e.detail.calibration;
say(`Verdict: ${e.detail.verdict}. Calibration ${Math.round(c.score * 100)}%.`);
});
launch.addEventListener("change", () => {
vault.flags = launch.checked ? "beta-ended launch" : "beta-ended";
say(launch.checked ? "Launch flag set." : "Launch flag cleared.");
});
mrr.addEventListener("input", syncMrr);
document.getElementById("tv-jump").addEventListener("click", () => {
vault.now = vault.now.getTime() + 14 * 864e5;
syncClock();
say(`Clock moved to ${today.textContent}.`);
});
document.getElementById("tv-reset").addEventListener("click", () => {
vault.now = START;
launch.checked = false;
vault.flags = "beta-ended";
mrr.value = "7400";
syncMrr();
syncClock();
say("Clock and flags reset.");
});
vault.flags = "beta-ended";
syncMrr();
</script>
</div>Cultural reference
Foundation — Isaac Asimov (1951, book). A long-dead scientist records messages that a sealed vault plays back only at the precise moments of crisis he predicted, each one speaking to exactly that situation and revealing how well he foresaw it. Here capsules written now stay sealed until their date or app condition comes true, then open with who wrote them and when, and their predictions are judged right, partly right or wrong to build a calibration record.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
author | string | Name stamped on capsules sealed with the compose form. Without it the form shows a “From” field. | |
era | string | Phrase describing the present moment (“before launch”, “during the private beta”), stored as the context of every capsule sealed now and read back when it opens: “Written 14 months ago by Amara Okafor, before launch”. | |
flags | space- or comma-separated condition ids | Conditions that are currently true, for apps that prefer an attribute to check(). Adding an id unseals every capsule waiting on it (mv-ready). | |
now | ISO date or datetime | Freezes the clock used by date triggers and relative texts (previews, tests, server-rendered snapshots). Without it the real time is used and the component wakes itself at the next opening or due-soon boundary. | |
soon | period ("30d", "2w", "3mo") | 30d | A dated capsule is “Due soon” when it opens within this window. |
soon-progress | number (0..1) | 0.8 | A condition capsule is “Due soon” once its reported progress reaches this share. |
label | string | Time vault | Visible heading of the vault (also names the list). |
heading-level | number (1..5) | 3 | Level of the vault heading; capsule titles and the compose title use the next level. |
locale | BCP 47 tag | en-US | Locale of dates and relative times (Intl.DateTimeFormat / RelativeTimeFormat). |
sort | auto | none | auto | auto orders ready, due soon, sealed (soonest first, then conditions by progress) and opened (newest first), with a short FLIP when a capsule moves. none keeps your order. |
readonly | boolean | Hides “New capsule” and shows verdicts without letting them change (an archive, a shared read-only view). | |
<template data-capsule> | markup | One capsule per template (inert: nothing renders before opening). data-title, data-author, data-written, data-context, data-opens (date or "+1y" / "6mo" / "90d" after data-written) or data-when (condition id), data-opened (already opened on that date), data-expanded, data-id. Content = the letter; an inner <ol data-predictions> lists the predictions (li data-verdict="yes | partly | no"). Read once, then removed from the DOM. | |
<data data-condition value> | markup | One named condition offered by the compose form: value = id, text = label, phrased to follow “Opens when…” (“we launch publicly”). | |
data-state (on capsules) | sealed | soon | ready | opened | Set by the component on each .mv-time-vault-capsule, with data-trigger (date | condition), data-fresh (just unsealed), data-pending (content loading) and data-revealing. |
Properties
| Name | Type | Description |
|---|---|---|
capsules | Array<{ id?, title, author?, written?, context?, opens? | when?, body?: string | Node, predictions?: Array<string | { id?, text, verdict? }>, opened?, expanded?, data? }> | Read: every capsule as plain data with its current state. Write: replaces the list. body is never rendered before opening; omit it and deliver it from mv-open for real sealing. Strings are split into paragraphs on blank lines. |
conditions | Array<{ id, label }> | Named conditions the compose form offers and the rows use to phrase their trigger (“Opens when {label}”). |
calibration | { yes, partly, no, pending, judged, total, score } | Read-only score over the predictions of opened capsules; score = (yes + partly / 2) / judged, null before the first verdict. |
now | Date | number | string | null | Clock override (same as the attribute; null returns to real time). |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (states, triggers, provenance “Written {ago} by {author}{context}”, form labels and errors, verdict labels, calibration). English defaults. |
Methods
| Name | Description |
|---|---|
check(state) | Feeds condition triggers: { launch: true, "mrr-10k": 0.74, renewal: { progress: 0.5, text: "$4.1k of $8k" } }. true, progress ≥ 1 or met: true unseals; a number or progress below 1 drives the ring and “Due soon”. Returns the ids that became ready. |
add(capsule) | Adds a capsule without the form and without mv-seal (data coming from your server). Returns its public data. |
remove(id) | Removes a capsule. |
unseal(id) | Makes a capsule ready whatever its trigger says (your server decided). Emits mv-ready. |
open(id) | Opens a ready capsule with the reveal, as if “Open” was clicked (emits mv-open). Resolves true once revealed. |
toggle(id, force?) | Expands or collapses an opened capsule. |
showCompose() / hideCompose() | Shows or hides the compose form. |
Events
| Name | Description |
|---|---|
mv-seal | Cancelable, when the compose form is submitted and valid. detail: { capsule, waitUntil(promise) }. Persist it server-side inside waitUntil: the button shows “Sealing…”, a rejection keeps the form with an error, a resolved { id, data } replaces the local id. preventDefault() refuses the capsule. |
mv-ready | Cancelable, a capsule’s trigger just became true. detail: { capsule, reason: "date" | "condition" | "api" }. preventDefault() keeps it sealed (until the next check() of its condition, or unseal()). |
mv-open | Cancelable, a ready capsule is about to be revealed. detail: { capsule, waitUntil(promise) }. Fetch the content from your server here: resolve a string, a Node or { body, predictions }. The ring spins while it loads; a rejection keeps it ready and announces the failure. |
mv-verdict | A prediction received a verdict. detail: { capsule, prediction, verdict: "yes" | "partly" | "no", calibration }. |
Content structure
| Name | Description |
|---|---|
(content) | Optional <data data-condition> and <template data-capsule> children, read at first connection and removed. For framework-rendered lists prefer the capsules and conditions properties. |
CSS classes
| Name | Description |
|---|---|
mv-time-vault-head | Header: .mv-time-vault-mark, .mv-time-vault-heading, .mv-time-vault-summary (“3 sealed · 1 ready to open · 1 opened”), .mv-time-vault-new. |
mv-time-vault-calibration | Calibration strip: -cal-label, -cal-score, -cal-bar with -cal-seg[data-kind=yes|partly|no|pending], -cal-text. Hidden until an opened capsule has predictions. |
mv-time-vault-compose | The compose form: -field, -label, -hint, -pred-fields, -seg (trigger kind), -when-pane, -quick, -error, -compose-actions. |
mv-time-vault-capsule | One list item: .mv-time-vault-row (.mv-time-vault-emblem with -track, -arc and -glyph, .mv-time-vault-title, -byline, -when, .mv-time-vault-status, .mv-time-vault-action) and .mv-time-vault-letter. |
mv-time-vault-letter | The opened content: -letter-head (-provenance, -details), -body, -predictions (-pred-head, -pred-list, .mv-time-vault-prediction[data-verdict] with -claim and .mv-time-vault-verdict radio group of .mv-time-vault-choice). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-time-vault-seal | var(--mv-fg-subtle) | Ring and seal color of sealed capsules. |
--mv-time-vault-soon | var(--mv-warning) | Tone of “Due soon” capsules. |
--mv-time-vault-ready | var(--mv-accent) | Tone of ready capsules, their edge, breathing seal and reveal. |
--mv-time-vault-yes / -partly / -no | var(--mv-success) / var(--mv-warning) / var(--mv-danger) | Verdict colors (choices, markers, calibration bar). |
--mv-time-vault-emblem-size | 2.5rem | Diameter of the seal emblem (the letter aligns with the text column after it). |
Accessibility
The vault is a labelled list (the heading names it) and every capsule is a list item with a real heading, so screen-reader users can jump between capsules. State is always spelled out in text (“Sealed”, “Due soon”, “Ready to open”, “Opened”) next to the trigger in words (“Opens Oct 5, 2026 · in 11 days”, “Opens when MRR passes $10k · $7.4k of $10k”); the seal and its ring are aria-hidden decoration, and sealed content is simply not in the DOM (templates are inert and removed after reading). The Open / Read / Hide button names its capsule (“Open ‘To Sofia, one year after hire’”), is described by the trigger line, and as a disclosure carries aria-expanded and aria-controls pointing to the letter, a labelled region. A polite live region announces when a capsule becomes ready while the page is open, when one is revealed (“‘Letter to the launch team’ is open. Written 14 months ago by Amara Okafor, before launch.”), a failed opening and a new seal; focus stays on the button when opening and moves to the new capsule after sealing. Each prediction’s verdict is a native radio group in a fieldset whose legend asks “Was it right?” and whose description is the claim: Tab reaches it, arrow keys choose Yes / Partly / No, and the claim, not color alone, carries the result (a filled marker, a strike-through for “No”). The compose form uses real labels, a labelled radio group for the trigger kind, aria-invalid with an alert error message and focus on the first invalid field, and Escape closes it back to “New capsule”. Reduced motion (OS or data-motion="reduce"): no unfolding, no staggered lines, no breathing or spinning seal, no reordering glide; everything appears in place. Forced colors: rings, markers and the selected verdict use system colors.