エクスクルーシブ
Sealed Until <mv-sealed-until>
今書いて、未来の正確な瞬間まで封印しておくメッセージのリスト:日付(data-opens。絶対指定、または書いた時点からの「+1y」のような相対指定)、またはアプリが check(state) や flags 属性で渡す名前付きの条件(「when we launch publicly」、「when MRR passes $10k」)で開封され、進捗(0-1 または「$7.4k of $10k」)を任意で表示できます。
文化的リファレンス
ファウンデーション、アイザック・アシモフ(1951年、書籍)。はるか昔に亡くなった科学者がメッセージを録音し、封印された保管庫は彼が予測した危機の瞬間にだけそれを再生します。各メッセージはまさにその状況に向けて語られ、彼の予見がどれほど正確だったかを明らかにします。ここでは、今書いたカプセルが、その日付またはアプリの条件が満たされるまで封印され、その後、誰がいつ書いたかとともに開かれます。予測は的中、部分的に的中、外れのいずれかで評価され、キャリブレーションの記録が積み上がります。
仕組み
今書いて、未来の正確な瞬間まで封印しておくメッセージのリスト:日付(data-opens。絶対指定、または書いた時点からの「+1y」のような相対指定)、またはアプリが check(state) や flags 属性で渡す名前付きの条件(「when we launch publicly」、「when MRR passes $10k」)で開封され、進捗(0-1 または「$7.4k of $10k」)を任意で表示できます。リストには各カプセルのタイトル、作成者、トリガー、落ち着いた言葉での状態(Sealed、Due soon、Ready to open、Opened)が、その瞬間が近づくにつれてリングが満ちていく封印の周りに表示され、内容は決して表示されません。開封は静かな演出で(リングが外れ、手紙が開き、行が 1 行ずつ現れます)、まず由来(「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 |
| 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
インストール
node scripts/add.mjs sealed-until --out ./src/marvelousMarvelous 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 | タイプ | 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 capsules | Visible heading of the component (also names the list). |
heading-level | number (1..5) | 3 | Level of the main 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-sealed-until-capsule, with data-trigger (date | condition), data-fresh (just unsealed), data-pending (content loading) and data-revealing. |
Properties
| Name | タイプ | 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. |
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
| 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-sealed-until-head | Header: .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-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-sealed-until-compose | The compose form: -field, -label, -hint, -pred-fields, -seg (trigger kind), -when-pane, -quick, -error, -compose-actions. |
mv-sealed-until-capsule | One 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-letter | The 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
| Name | Default | Description |
|---|---|---|
--mv-sealed-until-seal | var(--mv-fg-subtle) | Ring and seal color of sealed capsules. |
--mv-sealed-until-soon | var(--mv-warning) | Tone of “Due soon” capsules. |
--mv-sealed-until-ready | var(--mv-accent) | Tone of ready capsules, their edge, breathing seal and reveal. |
--mv-sealed-until-yes / -partly / -no | var(--mv-success) / var(--mv-warning) / var(--mv-danger) | Verdict colors (choices, markers, calibration bar). |
--mv-sealed-until-emblem-size | 2.5rem | Diameter 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.