익스클루시브
Mast <mv-mast>
스스로 정한 한도나 보안에 민감한 한도를 위한 비대칭 설정입니다. 강화는 즉시 적용되고, 완화는 숙려 기간이 지나야 적용됩니다.
문화적 레퍼런스
오디세이아, 호메로스 (기원전 8세기경, 서사시). 오디세우스는 세이렌 곁을 지나 항해하는 동안 자신을 돛대에 묶게 하고, 선원들에게 자신의 애원을 무시하라고 명령합니다. 맑은 정신으로 한 약속이 유혹의 순간에 번복될 수 없게 한 것입니다. UI에서는 차분한 순간에 설정한 한도를 즉시 강화할 수는 있지만 완화는 냉각 기간이 지난 뒤에만 가능하므로, 충동적으로 요청한 더 위험한 변경은 대기 상태가 되고, 취소할 수 있으며, 다시 확인이 필요할 수도 있습니다.
작동 방식
스스로 정한 한도나 보안상 민감한 한도를 위한 비대칭 설정입니다: 강화는 즉시, 완화는 냉각 기간을 기다립니다. 어떤 컨트롤이든(숫자 필드, 슬라이더, select, 세그먼트 컨트롤, 라디오, 스위치) 감싸고, safer="lower | higher | on | off" 또는 정렬된 값 목록("private, internal, public")으로 어느 방향이 더 안전한지 지정합니다. 더 안전한 변경은 즉시 적용되며(mv-apply) 대기 중인 완화를 취소합니다. 더 위험한 변경은 인라인 검토(“Raise your daily limit to $250?”, 선택 또는 필수 사유)를 연 다음, 카운트다운 링이 있는 단일 대기 요청이 되며, 마감 시각은 절대 en-US 시간과 남은 시간으로 표시됩니다(“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”). 그동안 현재 적용 중인 값은 절대 바뀌지 않으며, 컨트롤은 그 값으로 되돌아갑니다. 마감 시각이 되면 요청이 자동으로 적용되거나, confirm-after를 사용하면 한 번 더 명시적인 확인을 기다립니다(선택적으로 confirm-window 이후 만료). 더 위험한 새 요청은 타이머를 다시 시작하고, 더 작은 요청은 타이머를 유지하며, 상태는 서버에서 가져올 수 있습니다(value, pending, until, pending-reason). mv-request는 waitUntil(promise)과 함께 취소할 수 있어 카운트다운이 시작되기 전에 앱이 요청을 저장할 수 있으며, 시간은 절대 마감 시각에서 계산되므로 백그라운드 탭에서도 어긋나지 않습니다.
| 카테고리 | 폼 |
|---|---|
| 유형 | Web Component (<mv-mast>) |
| 상태 | 안정 |
| 키트 | 안전한 파괴적 작업과 비밀 정보 |
| 함께 설치되는 항목 | 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 buys time 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
설치
node scripts/add.mjs mast --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["mast"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/button/button.css, components/input/input.css, core/dom.js, core/element.js, components/mast/mast.js, components/mast/mast.css.
사용법
빠른 시작, 동작하는 가장 작은 마크업:
<mv-mast safer="lower" delay="24h">
<label>Daily deposit limit <input type="number" value="100" min="0"></label>
</mv-mast>기본 마크업입니다. 여기서 시작해 속성, data-*, 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>API
Attributes
| Name | 유형 | 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 | 유형 | 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.