Exclusivebeta
Mast — <mv-mast>
Asymmetric setting for self-imposed or security-sensitive limits: tightening is instant, loosening waits out a cooling-off delay. Wrap any control (number field, slider, select, segmented control, radios, switch) and say which way is safer with safer="lower | higher | on | off" or an ordered value list ("private, internal, public"). A safer change applies at once (mv-apply) and cancels any pending loosening; a riskier one opens an inline review (“Raise your daily limit to $250?”, optional or required reason), then becomes a single pending request with a countdown ring, the deadline in absolute en-US time plus the time left (“Your daily limit will rise to $250 on Sep 24, 10:12 AM · 19 h 42 min left · $100 stays in force until then · Cancel request”). The value in force never moves meanwhile: the control snaps back to it. At the deadline the request applies on its own or, with confirm-after, waits for one more explicit confirmation (optionally lapsing after confirm-window). A new riskier request restarts the timer, a smaller one keeps it, and state can come from your server (value, pending, until, pending-reason). mv-request is cancelable with waitUntil(promise) so the app persists the request before the countdown starts; time is derived from an absolute deadline, so background tabs never drift.
| Category | Forms |
|---|---|
| Type | Web Component (<mv-mast>) |
| Status | beta |
| Also installs | button, input |
| Keywords | exclusive, culture, settings, limits, cooling-off, cooldown, pending-change, delay, pre-commitment, responsible-gambling, spending-limit, screen-time, security, 2fa, retention, countdown, form |
When to use
- A user raises a self-imposed spending, deposit, loss or screen-time limit and the increase must wait out a cooling-off period
- Responsible gambling or trading rules require limit increases to be delayed and confirmed, while decreases apply immediately
- An admin weakens a security setting (turning off 2FA, making a repository public, removing a recovery method) and a delay adds a chance to react
- Lowering a data-retention or backup policy would delete data for good, so the change should only take effect after a grace period
Avoid when
- Both directions are equally safe: a plain control that saves on change is simpler and less frustrating
- The risky action is a one-off operation to confirm now, not a setting that stays in force → use Two-Key instead
- The delay must hold against a determined user: the server has to enforce it, the component only presents and orchestrates it
Install
node scripts/add.mjs mast --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["mast"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, components/mast/mast.js, components/mast/mast.css, components/button/button.css, components/input/input.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<div id="ms-demo" style="width:min(100%,62rem);margin-inline:auto">
<style>
#ms-demo { display:grid; gap:1rem; align-content:start }
#ms-demo .ms-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,24rem),1fr)); gap:1rem; align-items:start }
#ms-demo .ms-panel { display:grid; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden }
#ms-demo .ms-head { display:flex; align-items:center; gap:.75rem; padding:.875rem 1.125rem; border-bottom:1px solid var(--mv-border) }
#ms-demo .ms-logo { display:grid; place-items:center; flex:none; width:2rem; height:2rem; border-radius:var(--mv-radius-md); background:var(--mv-accent); color:var(--mv-fg-on-accent) }
#ms-demo .ms-logo svg { width:1.0625rem; height:1.0625rem }
#ms-demo .ms-title { display:grid; gap:.0625rem; min-width:0; flex:1 }
#ms-demo .ms-title h3 { margin:0; font-size:.9375rem; letter-spacing:-.01em }
#ms-demo .ms-title p { margin:0; color:var(--mv-fg-muted); font-size:.75rem }
#ms-demo .ms-row { padding:1rem 1.125rem }
#ms-demo .ms-row + .ms-row { border-top:1px solid var(--mv-border) }
#ms-demo .ms-field { display:flex; align-items:center; justify-content:space-between; gap:.5rem 1rem; flex-wrap:wrap }
#ms-demo .ms-copy { display:grid; gap:.125rem; min-width:0; flex:1 1 11rem }
#ms-demo .ms-label { font-size:.875rem; font-weight:560; color:var(--mv-fg) }
#ms-demo .ms-sub { color:var(--mv-fg-muted); font-size:.75rem }
#ms-demo .ms-range { display:grid; gap:.5rem; flex:1 1 100% }
#ms-demo .ms-range-top { display:flex; justify-content:space-between; align-items:baseline; gap:1rem }
#ms-demo .ms-range output { font-size:.8125rem; font-weight:600; font-variant-numeric:tabular-nums }
#ms-demo .ms-range input { width:100% }
#ms-demo mv-number-field { --mv-number-field-width:9.5rem }
#ms-demo .ms-select { width:auto; min-width:9.5rem; flex:none }
#ms-demo .ms-log { display:grid; gap:.5rem; padding:.875rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-bg-subtle) }
#ms-demo .ms-log h4 { display:flex; align-items:center; justify-content:space-between; margin:0; font-size:.75rem; font-weight:600; color:var(--mv-fg-muted); letter-spacing:.04em; text-transform:uppercase }
#ms-demo .ms-log ol { display:grid; gap:.25rem; margin:0; padding:0; list-style:none; font:.75rem/1.45 var(--mv-font-mono); color:var(--mv-fg-muted) }
#ms-demo .ms-log li { display:flex; gap:.625rem; min-width:0 }
#ms-demo .ms-log time { flex:none; color:var(--mv-fg-subtle) }
#ms-demo .ms-log b { flex:none; font-weight:600; color:var(--mv-fg) }
#ms-demo .ms-log span { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
</style>
<div class="ms-grid">
<!-- Personal finance: self-imposed spending and time limits -->
<section class="ms-panel" aria-labelledby="ms-h-play">
<header class="ms-head">
<span class="ms-logo" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 17l5-5 4 4 8-8"/><path d="M15 8h5v5"/></svg></span>
<span class="ms-title">
<h3 id="ms-h-play">Trading limits</h3>
<p>Harborline · limits you set for yourself</p>
</span>
<span class="mv-badge" data-variant="success"><span class="mv-badge-dot"></span>Protected</span>
</header>
<mv-mast id="ms-deposit" class="ms-row" safer="lower" delay="24h" confirm-after reason="optional" label="daily deposit limit" pending-reason="Moving savings for the quarterly rebalance">
<div class="ms-field">
<span class="ms-copy">
<span class="ms-label" id="ms-deposit-l">Daily deposit limit</span>
<span class="ms-sub">Across cards and bank transfers</span>
</span>
<mv-number-field id="ms-deposit-field" value="100" min="0" max="5000" step="25" format="currency" currency="USD"></mv-number-field>
</div>
</mv-mast>
<mv-mast id="ms-time" class="ms-row" safer="lower" delay="20s" label="daily session time" unit="minute">
<div class="ms-range">
<span class="ms-range-top">
<span class="ms-copy">
<label class="ms-label" for="ms-time-input">Daily session time</label>
<span class="ms-sub">Demo speed: 20-second cooling-off, applies on its own</span>
</span>
<output id="ms-time-out" for="ms-time-input">60 min</output>
</span>
<input id="ms-time-input" type="range" class="mv-slider" min="15" max="240" step="15" value="60">
</div>
</mv-mast>
</section>
<!-- Team workspace: security-sensitive settings -->
<section class="ms-panel" aria-labelledby="ms-h-sec">
<header class="ms-head">
<span class="ms-logo" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v12"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg></span>
<span class="ms-title">
<h3 id="ms-h-sec">Repository settings</h3>
<p>acme-labs / design-tokens</p>
</span>
<span class="mv-badge" data-variant="outline">Admin</span>
</header>
<mv-mast id="ms-visibility" class="ms-row" safer="private, internal, public" delay="72h" confirm-after confirm-window="7d" reason="required" label="repository visibility" pending-reason="Share the tokens with every product team in the company">
<div class="ms-field">
<span class="ms-copy">
<span class="ms-label" id="ms-vis-l">Visibility</span>
<span class="ms-sub">Who can see the code and its history</span>
</span>
<mv-segmented aria-labelledby="ms-vis-l" value="private">
<button value="private">Private</button>
<button value="internal">Internal</button>
<button value="public">Public</button>
</mv-segmented>
</div>
</mv-mast>
<mv-mast id="ms-2fa" class="ms-row" safer="on" delay="48h" label="two-factor sign-in for all members">
<div class="ms-field">
<span class="ms-copy">
<label class="ms-label" for="ms-2fa-input">Require two-factor sign-in</label>
<span class="ms-sub">Members without it lose access until they enroll</span>
</span>
<input id="ms-2fa-input" type="checkbox" role="switch" class="mv-switch" checked>
</div>
</mv-mast>
<mv-mast id="ms-retention" class="ms-row" safer="higher" delay="7d" unit="day" label="audit log retention">
<div class="ms-field">
<span class="ms-copy">
<label class="ms-label" for="ms-retention-input">Audit log retention</label>
<span class="ms-sub">Older events are deleted for good</span>
</span>
<select id="ms-retention-input" class="mv-select ms-select">
<option value="365" selected>365 days</option>
<option value="180">180 days</option>
<option value="90">90 days</option>
<option value="30">30 days</option>
</select>
</div>
</mv-mast>
</section>
</div>
<section class="ms-log" aria-labelledby="ms-log-h">
<h4 id="ms-log-h">Server log <span style="text-transform:none;letter-spacing:0;font-weight:500">mv-request · mv-apply · mv-cancel</span></h4>
<ol id="ms-log" aria-live="off"></ol>
</section>
<script type="module">
const root = document.getElementById("ms-demo");
const log = document.getElementById("ms-log");
const deposit = document.getElementById("ms-deposit");
const visibility = document.getElementById("ms-visibility");
const timeInput = document.getElementById("ms-time-input");
const timeOut = document.getElementById("ms-time-out");
const now = Date.now();
const clock = () => new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" });
const labelOf = (el) => el.getAttribute("label");
// State coming from the server: a raise requested earlier, and a change that finished cooling off.
deposit.setAttribute("until", new Date(now + (19 * 60 + 42) * 60_000 + 30_000).toISOString());
deposit.setAttribute("pending", "250");
visibility.setAttribute("until", new Date(now - 3 * 3_600_000).toISOString());
visibility.setAttribute("pending", "internal");
const say = (event, text) => {
const li = document.createElement("li");
const t = document.createElement("time");
t.textContent = clock();
const b = document.createElement("b");
b.textContent = event;
const s = document.createElement("span");
s.textContent = text;
li.append(t, b, s);
log.prepend(li);
while (log.children.length > 4) log.lastElementChild.remove();
};
// The number field has no built-in label here: name its inner input after the row title.
customElements.whenDefined("mv-number-field").then(() => setTimeout(() => {
document.getElementById("ms-deposit-field").input?.setAttribute("aria-labelledby", "ms-deposit-l");
}));
say("sync", "Loaded 1 pending raise and 1 change ready to confirm.");
const fmt = (el, v) => {
if (el.id === "ms-deposit") return `$${v}`;
if (el.id === "ms-time") return `${v} min`;
if (el.id === "ms-retention") return `${v} days`;
if (typeof v === "boolean") return v ? "on" : "off";
return String(v);
};
// The app persists each request (a fake 500 ms save) before the countdown starts.
root.addEventListener("mv-request", (e) => {
const { value, until } = e.detail;
const when = new Date(until).toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
e.detail.waitUntil(new Promise((r) => setTimeout(r, 500)).then(() => say("mv-request", `${labelOf(e.target)} → ${fmt(e.target, value)}, applies ${when}`)));
});
root.addEventListener("mv-apply", (e) => say("mv-apply", `${labelOf(e.target)} = ${fmt(e.target, e.detail.value)} (${e.detail.source})`));
root.addEventListener("mv-cancel", (e) => say("mv-cancel", `${labelOf(e.target)}: request for ${fmt(e.target, e.detail.value)} ${e.detail.reason}`));
// Keep the slider's readout in step with what the control shows.
const syncTime = () => { timeOut.textContent = `${timeInput.value} min`; };
for (const type of ["input", "change", "mv-draft", "mv-apply", "mv-request", "mv-cancel"]) {
document.getElementById("ms-time").addEventListener(type, () => queueMicrotask(syncTime));
}
</script>
</div>Cultural reference
The Odyssey — Homer (c. 8th century BC, epic poem). Odysseus has himself bound to the mast and orders his crew to ignore his pleas while they sail past the Sirens: a commitment made with a clear head that cannot be undone in the moment of temptation. In the UI, a limit set in a calm moment can be tightened at once but only loosened after a cooling-off delay, so a riskier change requested in the heat of the moment waits, stays cancelable and may need to be confirmed again.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
safer | lower | higher | on | off | comma-separated values (safest first) | lower | Which way is safer. lower / higher compare numbers (a limit, a retention period), on / off a switch or checkbox, and a list orders the control's values from safest to riskiest ("private, internal, public"). Values missing from the list count as the riskiest. |
delay | time ("24h", "7d", "72h", "30m", "20s", "2w", ms number) | 24h | Cooling-off period before a riskier request becomes applicable. Shown in the rule as “a 24-hour cooling-off period”. |
confirm-after | boolean | At the end of the delay the request does not apply on its own: it turns into a “ready” card that needs one more explicit confirmation (as responsible-gambling rules often require). Without it the request applies automatically (mv-apply with source "timer"). | |
confirm-window | time | With confirm-after: how long a ready request stays available. After that it lapses (mv-cancel with reason "lapsed") and the value in force stays. | |
reason | optional | required | Adds a reason field to the review step. required blocks the request until a reason is typed (announced error, aria-invalid). The reason travels in mv-request and is shown on the pending card. | |
label | string | Noun phrase used in every sentence and announcement (“daily deposit limit” → “Your daily deposit limit will rise to $250”). Defaults to the control's label text, first letter lowercased. | |
currency / unit / locale | string | Number formatting for lower / higher values (Intl currency code, Intl unit such as minute or day, locale; default en-US). Read from the wrapped control when it carries the same attributes (mv-number-field). | |
value | string | The value in force, when your server knows better than the control's markup. Changing it later syncs the control without any event; a pending request that is no longer riskier is dropped. | |
pending | string | A request already stored by your server (the value asked for). Ignored if it isn't riskier than the value in force. Removing it drops the request silently. | |
until | ISO date | epoch ms | When the pending request becomes applicable. Without it, now + delay. A past date means it is ready (confirm-after) or applies at once. | |
pending-reason | string | Reason stored with a server-provided request, shown on the pending card. | |
data-mast-control | marker attribute | On a descendant: the element to treat as the control. Otherwise the first mv-number-field, mv-slider, mv-segmented, mv-scrub-field, mv-toggle-group, mv-wheel-picker, mv-combobox, input, select or textarea inside (a radio group is taken as a whole by name). | |
data-state / data-safer / data-busy | set by the component | data-state: idle | draft | cooling | ready. data-safer: the resolved mode (lower, higher, on, off, list). data-busy while a request waits for its waitUntil promises. |
Properties
| Name | Type | Description |
|---|---|---|
value | number | boolean | string | null | The value in force. Set it from your server (no event). |
pending | number | boolean | string | null | The value asked for by the pending request, or null. Setting it creates or drops a request without emitting events (server sync). |
until | number (ms epoch) | Deadline of the pending request, or null. Accepts a Date, ISO string or epoch ms. |
remaining | number (ms) | Time left before the pending request is applicable, recomputed from until on every read (read-only). |
state | "idle" | "draft" | "cooling" | "ready" | draft: a riskier change is under review, not sent yet; cooling: a request is waiting; ready: the delay is over and a confirmation is expected (read-only). |
busy | boolean | True while mv-request waits for the promises passed to waitUntil() (read-only). |
formatValue | (value) => string | Formats values everywhere they are shown or announced (default: Intl number formatting, the option / segment / radio text, or On / Off). |
strings | Partial<Record<string, string>> & { verbs? } | Overrides for every visible text and announcement (rule, ruleConfirm (rule used with confirm-after), draftText, draftAmend, draftReplace, draftConfirm, reasonLabel, reasonOptional, reasonPlaceholder, reasonMissing, keep, pendingMeta, readyText, readyLapse, reasonShown, cancel, error, summaryPending, summaryReady, announceDraft, announceRequested, announceReady, announceTightened, announceApplied, announceCanceled, announceLapsed, on, off, fallbackLabel). strings.verbs overrides the direction wording: tighten, loosen, ask, will, stays, readyTitle, now, tightened, request, confirm, keep. English defaults, placeholders such as {label}, {value}, {current}, {date}, {delay}. |
Methods
| Name | Description |
|---|---|
request(value, { reason? }) | Changes the value through the same rule as the control: safer applies now, riskier sends a request (mv-request with source "api"). Returns Promise<boolean>. |
cancelRequest() | Withdraws the pending request (mv-cancel with reason "api"). Always allowed: it is the safer way. |
confirm() | Applies a request whose delay is over (mv-apply with source "api"). Returns false if nothing is ready or the apply was vetoed. |
discard() | Throws away the change under review and puts the control back to the value in force. |
Events
| Name | Description |
|---|---|
mv-request | Cancelable, when the user sends a riskier change. detail: { value, previous, until, delay, reason, replaces (value of the request it replaces, or null), source: "user" | "api", waitUntil(promise) }. Persist it and call waitUntil() synchronously: the button shows a busy state, a rejection keeps the review open with an error, a promise resolving to a Date or epoch ms sets the deadline from your server. preventDefault() refuses it: the review closes and the control returns to the value in force. |
mv-request-error | A waitUntil promise rejected. detail: { value, error }. |
mv-apply | Cancelable, a value takes effect. detail: { value, previous, source: "instant" (safer change) | "timer" (delay over, no confirm-after) | "confirm" (user confirmed) | "api" }. preventDefault() vetoes it: an instant change snaps back, a pending one stays ready for an explicit confirmation. |
mv-cancel | The pending request is gone. detail: { value, reason: "user" | "superseded" (a safer change applied) | "lapsed" (confirm-window elapsed) | "api" }. |
mv-draft | A riskier change opened or updated the review (detail.value), or the review closed, whether sent, discarded or superseded (detail.value null). detail.previous is the value in force. Handy to sync UI tied to the control. |
Content structure
| Name | Description |
|---|---|
(content) | Your label and control, left as they are. The component appends one .mv-mast-ui block after them. Listen to mv-apply / mv-request rather than to the control's own change events: the control briefly shows riskier values while they are under review. |
CSS classes
| Name | Description |
|---|---|
mv-mast-rule | The rule next to the control (“Lowering applies immediately. Raising takes effect after a 24-hour cooling-off period.”), linked to the control with aria-describedby. |
mv-mast-card | Review and request cards; data-kind="draft | cooling | ready". Parts: -head, -badge, -ring (conic countdown, --_p = elapsed 0..1), -body, -title, -text, -meta, -left, -quote, -reason, -error, -actions. |
mv-mast-status | Short-lived confirmation pill after a value took effect (“Lowered to $50”). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-mast-draft | var(--mv-warning) | Tone of the review card (a riskier change about to be requested). |
--mv-mast-pending | var(--mv-accent) | Tone of the cooling-off card and its countdown ring. |
--mv-mast-ready | var(--mv-success) | Tone of the ready card and of the confirmation pill. |
Accessibility
The control keeps its own role, name and keyboard behavior; the component adds two ids to its aria-describedby (the focusable part: the inner input of a number field, the radiogroup of a segmented control, every radio of a group): the rule (“Lowering applies immediately. Raising takes effect after a 24-hour cooling-off period.”) and a visually hidden summary of the current request (“Change pending: your daily deposit limit will rise to $250 on Sep 24, 10:12 AM.”), so the asymmetry is known before acting and the pending state is heard on every focus. A riskier change never moves focus: the review card appears right after the control in the DOM and a polite announcement says how long it will take and where to review it; Tab reaches its reason field and buttons, Enter in the reason field sends the request and Escape anywhere in the component throws the change away and returns focus to the control. Once sent, focus moves to “Cancel request” (the result of the action) and the request is announced politely with the full deadline (“Thursday, September 24 at 10:12 AM”); readiness, instant tightening, automatic applies, cancellations and lapses are announced politely too, saving errors assertively. The countdown text has role="timer" (implicitly aria-live off) with a spoken aria-label (“19 hours 42 minutes”), so it never chatters; the ring is decorative. A required reason uses a real label, aria-invalid and an error linked with aria-describedby. While a request is being saved the button is aria-busy and aria-disabled. States are never conveyed by color alone (icon, title and words: “will rise to”, “is ready”, “stays in force”). Deadlines are rendered as <time datetime> in absolute en-US time plus the time left. Reduced motion (OS or data-motion="reduce"): cards and the pill appear without sliding. Forced colors: cards keep a visible border and the ring uses Highlight on GrayText.