独家

Sealed Until <mv-sealed-until>

一份现在写下、封存到未来某个精确时刻的消息列表:一个日期(data-opens,绝对时间,或相对写作时间,如“+1y”),或一个具名的应用条件(“when we launch publicly”、“when MRR passes $10k”),由你的应用通过 check(state) 或 flags 属性提供,可附带进度(0-1 或“$7.4k of $10k”)。

文化出处

《基地》,艾萨克·阿西莫夫(1951 年,书籍)。一位早已去世的科学家录下了信息,封存的穹顶只会在他预言的危机发生的那一刻播放,每条信息都恰好针对当时的局面,并揭示他预见得有多准确。在这里,现在写下的时间胶囊会一直封存,直到其日期到来或应用条件成立,然后打开并显示作者和写作时间,其中的预测会被判定为正确、部分正确或错误,从而积累一份校准记录。

工作原理

一份现在写下、封存到未来某个精确时刻的消息列表:一个日期(data-opens,绝对时间,或相对写作时间,如“+1y”),或一个具名的应用条件(“when we launch publicly”、“when MRR passes $10k”),由你的应用通过 check(state) 或 flags 属性提供,可附带进度(0-1 或“$7.4k of $10k”)。列表显示每个时间胶囊的标题、作者、触发条件,以及用文字表述的平静状态(Sealed、Due soon、Ready to open、Opened),围绕着一枚封印,其外环随着时刻临近逐渐填满,但从不显示内容。开启是一次安静的揭示(外环松开,信纸展开,文字逐行浮现),以出处开头(“Written 14 months ago by Amara Okafor, before launch”);其中的预测会得到一个 Yes / Partly / No 评判单选组,一条校准条会对整个列表中的所有评判进行评分。内置的撰写表单可以用日期或从应用提供的列表中选择的条件来封存新胶囊。mv-seal 和 mv-open 可以取消,并接受 waitUntil(promise),因此由服务器存储胶囊,并且只在自己核实触发条件后才交出内容:组件负责隐藏,而不负责安全。

分类数据展示
类型Web Component(<mv-sealed-until>)
状态稳定版
同时安装button, 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

  • 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

安装

node scripts/add.mjs sealed-until --out ./src/marvelous

使用 Marvelous UI MCP 服务器的 AI 智能体:install_components({ slugs: ["sealed-until"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。

复制的文件(含依赖):tokens/tokens.css, core/base.css, components/button/button.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/motion.js, components/sealed-until/sealed-until.js, components/sealed-until/sealed-until.css。

用法

标准标记,可在此基础上通过属性、data-* 和 CSS 变量进行定制:

<div id="su-demo" style="width:min(100%,64rem);margin-inline:auto">
  <style>
    #su-demo { display:grid; grid-template-columns:minmax(0,1fr) 16.5rem; gap:1.25rem; align-items:start }
    #su-demo .su-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 }
    #su-demo .su-panel h4 { margin:0; font-size:.875rem; letter-spacing:-.01em }
    #su-demo .su-panel p { margin:0; color:var(--mv-fg-muted); font-size:.75rem; line-height:1.5 }
    #su-demo .su-sep { height:1px; background:var(--mv-border) }
    #su-demo .su-row { display:flex; align-items:center; justify-content:space-between; gap:.75rem }
    #su-demo .su-mrr { display:grid; gap:.5rem }
    #su-demo .su-mrr output { color:var(--mv-fg); font-weight:600; font-variant-numeric:tabular-nums }
    #su-demo .su-clock { display:grid; gap:.5rem }
    #su-demo .su-actions { display:flex; gap:.5rem; flex-wrap:wrap }
    #su-demo .su-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) }
    #su-demo .su-log li:first-child { color:var(--mv-fg) }
    #su-demo .mv-choice { font-size:.8125rem }
    @media (max-width:52rem) { #su-demo { grid-template-columns:minmax(0,1fr) } }
  </style>

  <!-- A product team's time capsules. The clock is frozen on Sep 24, 2026 so the demo reads the same any day. -->
  <mv-sealed-until id="su-capsules" label="Team time capsules" 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-sealed-until>

  <aside class="su-panel" aria-labelledby="su-sim-h">
    <div style="display:grid;gap:.25rem">
      <h4 id="su-sim-h">Simulate the app</h4>
      <p>Your app tells the component 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="su-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="su-mrr">
      <div class="su-row"><label for="su-mrr">Monthly recurring revenue</label><output for="su-mrr" id="su-mrr-out">$7,400</output></div>
      <input type="range" class="mv-slider" id="su-mrr" min="0" max="12000" step="100" value="7400">
    </div>

    <div class="su-sep"></div>

    <div class="su-clock">
      <div class="su-row"><span>Today</span><span class="mv-badge" data-variant="secondary" id="su-today">Sep 24, 2026</span></div>
      <div class="su-actions">
        <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="su-jump">Jump 2 weeks ahead</button>
        <button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="su-reset">Reset</button>
      </div>
    </div>

    <div class="su-sep"></div>

    <div style="display:grid;gap:.5rem">
      <h4>Events</h4>
      <ul class="su-log" id="su-log" aria-live="polite"><li>Capsules loaded.</li></ul>
    </div>
  </aside>

  <script type="module">
    const sealed = document.getElementById("su-capsules");
    const launch = document.getElementById("su-launch");
    const mrr = document.getElementById("su-mrr");
    const mrrOut = document.getElementById("su-mrr-out");
    const today = document.getElementById("su-today");
    const log = document.getElementById("su-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);
      sealed.check({ "mrr-10k": { progress: v / 10000, text: `${short(v)} of $10k` } });
    };
    const syncClock = () => {
      today.textContent = sealed.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",
      ],
    };
    sealed.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; }));
    });
    sealed.addEventListener("mv-ready", (e) => say(`Ready: “${e.detail.capsule.title}” (${e.detail.reason}).`));
    sealed.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}”.`)));
    });
    sealed.addEventListener("mv-verdict", (e) => {
      const c = e.detail.calibration;
      say(`Verdict: ${e.detail.verdict}. Calibration ${Math.round(c.score * 100)}%.`);
    });

    launch.addEventListener("change", () => {
      sealed.flags = launch.checked ? "beta-ended launch" : "beta-ended";
      say(launch.checked ? "Launch flag set." : "Launch flag cleared.");
    });
    mrr.addEventListener("input", syncMrr);
    document.getElementById("su-jump").addEventListener("click", () => {
      sealed.now = sealed.now.getTime() + 14 * 864e5;
      syncClock();
      say(`Clock moved to ${today.textContent}.`);
    });
    document.getElementById("su-reset").addEventListener("click", () => {
      sealed.now = START;
      launch.checked = false;
      sealed.flags = "beta-ended";
      mrr.value = "7400";
      syncMrr();
      syncClock();
      say("Clock and flags reset.");
    });

    sealed.flags = "beta-ended";
    syncMrr();
  </script>
</div>

API

Attributes

Name类型DefaultDescription
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 capsulesVisible heading of the component (also names the list).
heading-levelnumber (1..5)3Level of the main 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-sealed-until-capsule, with data-trigger (date | condition), data-fresh (just unsealed), data-pending (content loading) and data-revealing.

Properties

Name类型Description
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.
removeItem(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-sealed-until-headHeader: .mv-sealed-until-mark, .mv-sealed-until-heading, .mv-sealed-until-summary (“3 sealed · 1 ready to open · 1 opened”), .mv-sealed-until-new.
mv-sealed-until-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-sealed-until-composeThe compose form: -field, -label, -hint, -pred-fields, -seg (trigger kind), -when-pane, -quick, -error, -compose-actions.
mv-sealed-until-capsuleOne list item: .mv-sealed-until-row (.mv-sealed-until-emblem with -track, -arc and -glyph, .mv-sealed-until-title, -byline, -when, .mv-sealed-until-status, .mv-sealed-until-action) and .mv-sealed-until-letter.
mv-sealed-until-letterThe opened content: -letter-head (-provenance, -details), -body, -predictions (-pred-head, -pred-list, .mv-sealed-until-prediction[data-verdict] with -claim and .mv-sealed-until-verdict radio group of .mv-sealed-until-choice).

CSS variables

NameDefaultDescription
--mv-sealed-until-sealvar(--mv-fg-subtle)Ring and seal color of sealed capsules.
--mv-sealed-until-soonvar(--mv-warning)Tone of “Due soon” capsules.
--mv-sealed-until-readyvar(--mv-accent)Tone of ready capsules, their edge, breathing seal and reveal.
--mv-sealed-until-yes / -partly / -novar(--mv-success) / var(--mv-warning) / var(--mv-danger)Verdict colors (choices, markers, calibration bar).
--mv-sealed-until-emblem-size2.5remDiameter of the seal emblem (the letter aligns with the text column after it).

Accessibility

The component 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.

本页面由 AI 翻译。报告翻译问题