Exclusivobeta
Mast <mv-mast>
Ajuste assimétrico para limites autoimpostos ou sensíveis à segurança: apertar é imediato, afrouxar exige esperar um período de reflexão. Envolva qualquer controle (campo numérico, slider, select, controle segmentado, rádios, switch) e diga qual sentido é o mais seguro com safer="lower | higher | on | off" ou uma lista ordenada de valores ("private, internal, public"). Uma mudança mais segura é aplicada na hora (mv-apply) e cancela qualquer afrouxamento pendente; uma mais arriscada abre uma revisão inline (“Raise your daily limit to $250?”, com motivo opcional ou obrigatório) e depois vira uma única solicitação pendente com um anel de contagem regressiva, o prazo em horário absoluto en-US e o tempo restante (“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”). Enquanto isso, o valor em vigor nunca se mexe: o controle volta para ele na hora. No prazo, a solicitação se aplica sozinha ou, com confirm-after, espera mais uma confirmação explícita (que pode expirar depois de confirm-window). Uma nova solicitação mais arriscada reinicia o timer, uma menor o mantém, e o estado pode vir do seu servidor (value, pending, until, pending-reason). mv-request é cancelável com waitUntil(promise) para que o app salve a solicitação antes de a contagem regressiva começar; o tempo é derivado de um prazo absoluto, então abas em segundo plano nunca se desregulam.
| Categoria | Formulários |
|---|---|
| Tipo | Web Component (<mv-mast>) |
| Status | beta |
| Kit | Ações destrutivas e segredos com segurança |
| Também instala | 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
Instalação
node scripts/add.mjs mast --out ./src/marvelousAgente de IA com o servidor MCP do Marvelous UI: install_components({ slugs: ["mast"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Arquivos copiados (dependências incluídas): 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.
Uso
Início rápido, a menor marcação que funciona:
<mv-mast safer="lower" delay="24h">
<label>Daily deposit limit <input type="number" value="100" min="0"></label>
</mv-mast>Marcação de referência, para usar como ponto de partida e personalizar com atributos, data-* e variáveis CSS:
<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>Referência cultural
Odisseia, Homero (c. século VIII a.C., poema épico). Odisseu se faz amarrar ao mastro e ordena à tripulação que ignore suas súplicas enquanto passam pelas sereias: um compromisso assumido com a cabeça fria que não pode ser desfeito no momento da tentação. Na interface, um limite definido em um momento de calma pode ser apertado na hora, mas só afrouxado depois de um período de reflexão, então uma mudança mais arriscada pedida no calor do momento espera, continua cancelável e pode precisar ser confirmada de novo.
API
Attributes
| Name | Tipo | 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 | Tipo | 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.