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.

CategoryData display
TypeWeb Component (<mv-time-vault>)
Statusbeta
Also installsbutton, input, select, textarea
Keywordsexclusive, 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

Avoid when

Install

node scripts/add.mjs time-vault --out ./src/marvelous

AI 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

NameTypeDefaultDescription
authorstringName stamped on capsules sealed with the compose form. Without it the form shows a “From” field.
erastringPhrase 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”.
flagsspace- or comma-separated condition idsConditions that are currently true, for apps that prefer an attribute to check(). Adding an id unseals every capsule waiting on it (mv-ready).
nowISO date or datetimeFreezes 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.
soonperiod ("30d", "2w", "3mo")30dA dated capsule is “Due soon” when it opens within this window.
soon-progressnumber (0..1)0.8A condition capsule is “Due soon” once its reported progress reaches this share.
labelstringTime vaultVisible heading of the vault (also names the list).
heading-levelnumber (1..5)3Level of the vault heading; capsule titles and the compose title use the next level.
localeBCP 47 tagen-USLocale of dates and relative times (Intl.DateTimeFormat / RelativeTimeFormat).
sortauto | noneautoauto 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.
readonlybooleanHides “New capsule” and shows verdicts without letting them change (an archive, a shared read-only view).
<template data-capsule>markupOne 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>markupOne 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 | openedSet 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

NameTypeDescription
capsulesArray<{ 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.
conditionsArray<{ 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.
nowDate | number | string | nullClock override (same as the attribute; null returns to real time).
stringsPartial<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

NameDescription
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

NameDescription
mv-sealCancelable, 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-readyCancelable, 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-openCancelable, 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-verdictA prediction received a verdict. detail: { capsule, prediction, verdict: "yes" | "partly" | "no", calibration }.

Content structure

NameDescription
(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

NameDescription
mv-time-vault-headHeader: .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-calibrationCalibration 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-composeThe compose form: -field, -label, -hint, -pred-fields, -seg (trigger kind), -when-pane, -quick, -error, -compose-actions.
mv-time-vault-capsuleOne 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-letterThe 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

NameDefaultDescription
--mv-time-vault-sealvar(--mv-fg-subtle)Ring and seal color of sealed capsules.
--mv-time-vault-soonvar(--mv-warning)Tone of “Due soon” capsules.
--mv-time-vault-readyvar(--mv-accent)Tone of ready capsules, their edge, breathing seal and reveal.
--mv-time-vault-yes / -partly / -novar(--mv-success) / var(--mv-warning) / var(--mv-danger)Verdict colors (choices, markers, calibration bar).
--mv-time-vault-emblem-size2.5remDiameter 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.