익스클루시브
Remembered <mv-remembered>
선택이 이루어지는 순간, 흐름을 방해하지 않고 그 선택의 지속적인 부수 효과를 알려 줍니다.
문화적 레퍼런스
워킹 데드, Telltale Games (2012년, 게임). 의미 있는 선택을 하고 나면 화면 구석의 은은한 알림이 장면을 멈추지 않은 채, 다른 캐릭터가 이를 기억했으며 그 결정이 앞으로 이어진다고 알려 주고, 이런 선택은 각 에피소드가 끝날 때 요약됩니다. UI에서는 흐름을 막지 않는 구석 알림이 지속되는 변경을 누가 보관하는지와 그것이 이제 무엇을 의미하는지 알려 준 뒤, 보관 주체별로 묶인 세션 결과 기록으로 접히며, 각 항목은 여전히 되돌릴 수 있습니다.
작동 방식
선택의 지속적인 부작용을 선택하는 바로 그 순간, 흐름을 끊지 않고 알려 줍니다. 선언된 컨트롤이 바뀌거나(select, 스위치, 라디오 그룹, 모든 form-associated 요소에 data-remember="Invoices from now on will be in {value}"), 선언된 버튼이 클릭되거나, 앱 코드가 remember({ who, what, why, undo })를 호출하면, 작은 구석 알림이 누가 변경을 보관하는지(Billing, Your team (8 people), Anyone with the link), 그것이 이제 무엇을 의미하고 왜 중요한지를 Undo, Details와 함께 알려 줍니다. 알림은 잠시 쌓였다가(호버나 포커스 중에는 일시 정지) 지속되는 “Consequences” 필로 접히며, 이 필의 기록에는 세션의 모든 지속적인 선택이 보관 주체별로 묶여 각각의 시각과 개별 Undo와 함께 나열됩니다. 같은 컨트롤을 다시 바꾸면 해당 항목이 제자리에서 갱신되고(“Updated · 2 changes”), 직접 원래대로 되돌리면 항목이 지워집니다. 폼 컨트롤의 Undo는 이전 값을 복원하고 input/change를 발생시켜 앱이 일반 사용자 편집처럼 저장하게 합니다. 액션과 API 항목은 앱이 waitUntil(promise)를 지원하는 취소 가능한 mv-undo로 되돌리며, 되돌릴 수 없는 효과(data-remember-undo="false")에는 “Can’t be undone” 표시가 붙습니다. 스위치용 상태별 메시지(data-remember-on / -off), {value} / {old} / {label} 플레이스홀더, 알림을 거부하거나 다시 쓸 수 있는 취소 가능한 mv-remember, 이전 선택을 복원하는 무음 항목, 톱 레이어의 뷰포트 구석 배치 또는 특정 영역 안 배치를 제공합니다.
| 카테고리 | 피드백 |
|---|---|
| 유형 | Web Component (<mv-remembered>) |
| 상태 | 안정 |
| 키트 | 안전한 파괴적 작업과 비밀 정보 |
| 함께 설치되는 항목 | button |
| Keywords | exclusive, culture, feedback, consequences, side-effects, undo, ledger, history, settings, disclosure, notification, audit, live-region, non-blocking |
When to use
- A settings change reaches beyond the current screen: teammates are notified, future invoices or payouts change, a link becomes public
- Users flip several account or workspace options in a row and need one place to review and revert what they changed this session
- An action has a consequence people rarely read in the fine print, such as leaving a team, muting someone or resending an invite
- Some effects cannot be undone and must be disclosed as such, right when they happen, without a blocking dialog
Avoid when
- The feedback is a passing confirmation (Saved, Copied, Upload complete) with no effect beyond the screen → use Toast instead
- The user must see what a change will affect before committing it, not after → use Butterfly instead
- The change needs explicit consent before anything happens (deleting a project, a charge) → use Dialog instead
설치
node scripts/add.mjs remembered --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["remembered"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/button/button.css, core/dismiss.js, core/dom.js, core/element.js, core/motion.js, components/remembered/remembered.js, components/remembered/remembered.css.
사용법
빠른 시작, 동작하는 가장 작은 마크업:
<mv-remembered>
<label>Invoice currency <select data-remember="Invoices from now on will be in {value}" data-remember-who="Billing"><option>USD</option><option>EUR</option></select></label>
</mv-remembered>기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<div id="rm-demo" style="width:min(100%,64rem);margin-inline:auto">
<style>
#rm-demo { display:grid; grid-template-columns:minmax(0,1fr); gap:.875rem; align-content:start }
#rm-demo .rm-app { display:grid; grid-template-columns:10rem minmax(0,1fr); min-height:36rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-bg); box-shadow:var(--mv-shadow-sm); overflow:hidden }
#rm-demo .rm-side { display:grid; align-content:start; gap:.125rem; padding:1rem .75rem; border-inline-end:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
#rm-demo .rm-brand { display:flex; align-items:center; gap:.5rem; margin:0 0 1rem .375rem; font-weight:650; font-size:.9375rem; letter-spacing:-.01em }
#rm-demo .rm-logo { display:grid; place-items:center; width:1.625rem; height:1.625rem; border-radius:var(--mv-radius-md); background:var(--mv-accent); color:var(--mv-fg-on-accent) }
#rm-demo .rm-logo svg { width:.9375rem; height:.9375rem }
#rm-demo .rm-side-label { margin:.5rem 0 .25rem .5rem; color:var(--mv-fg-subtle); font-size:.6875rem; font-weight:600; letter-spacing:.04em; text-transform:uppercase }
#rm-demo .rm-side a { display:block; padding:.375rem .5rem; border-radius:var(--mv-radius-md); color:var(--mv-fg-muted); font-size:.8125rem; font-weight:500; text-decoration:none }
#rm-demo .rm-side a[aria-current] { background:var(--mv-bg-emphasis); color:var(--mv-fg) }
#rm-demo .rm-main { display:grid; align-content:start; gap:1rem; max-width:30rem; padding:1.375rem 1.5rem 1.75rem }
#rm-demo .rm-main h3 { margin:0; font-size:1.0625rem; letter-spacing:-.01em }
#rm-demo .rm-lede { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#rm-demo .rm-card { border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-surface) }
#rm-demo .rm-card > h4 { margin:0; padding:.75rem 1rem; border-bottom:1px solid var(--mv-border); font-size:.8125rem; font-weight:600 }
#rm-demo .rm-row { display:flex; align-items:center; justify-content:space-between; gap:1rem; padding:.75rem 1rem }
#rm-demo .rm-row + .rm-row { border-top:1px solid var(--mv-border) }
#rm-demo .rm-row-text { display:grid; gap:.125rem; min-width:0 }
#rm-demo .rm-row-text b { font-size:.8125rem; font-weight:550 }
#rm-demo .rm-row-text span { color:var(--mv-fg-muted); font-size:.75rem }
#rm-demo .rm-row .mv-select { width:10.5rem; flex:none }
#rm-demo fieldset.rm-row { margin:0; border:0; border-top:1px solid var(--mv-border); min-inline-size:0; flex-wrap:wrap }
#rm-demo fieldset.rm-row legend { float:left; padding:0 }
#rm-demo .rm-radios { display:flex; gap:1rem; flex-wrap:wrap }
#rm-demo .rm-radios .mv-choice { font-size:.8125rem }
#rm-demo .rm-who { display:flex; align-items:center; gap:.625rem; min-width:0 }
#rm-demo .rm-face { display:grid; place-items:center; flex:none; width:1.875rem; height:1.875rem; border-radius:50%; background:var(--mv-bg-emphasis); font-size:.6875rem; font-weight:600 }
#rm-demo .rm-row .mv-button { flex:none }
#rm-demo .rm-controls { display:flex; align-items:center; justify-content:space-between; gap:.625rem 1rem; flex-wrap:wrap; padding:.75rem 1rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-surface) }
#rm-demo .rm-log { margin:0; flex:1 1 18rem; min-width:0; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#rm-demo .rm-actions { display:flex; align-items:center; gap:.75rem; flex-wrap:wrap }
#rm-demo .rm-actions .mv-choice { font-size:.8125rem }
@media (max-width:46rem) {
#rm-demo .rm-app { grid-template-columns:minmax(0,1fr); min-height:44rem }
#rm-demo .rm-side { display:none }
#rm-demo .rm-main { padding:1rem }
#rm-demo .rm-row { flex-wrap:wrap }
}
</style>
<mv-remembered id="rm-el" contained who="Your account" style="--mv-remembered-width:19.75rem;--mv-remembered-offset:.875rem">
<div class="rm-app">
<nav class="rm-side" aria-label="Settings">
<span class="rm-brand">
<span class="rm-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="M4 19V5l8 7 8-7v14"/></svg></span>
Meridian
</span>
<span class="rm-side-label">Workspace</span>
<a href="#" aria-current="page">General</a>
<a href="#">Members</a>
<a href="#">Billing</a>
<a href="#">Integrations</a>
<span class="rm-side-label">Account</span>
<a href="#">Profile</a>
<a href="#">Notifications</a>
</nav>
<div class="rm-main">
<div>
<h3>Workspace settings</h3>
<p class="rm-lede">Fernhollow Studio · Team plan · 8 members</p>
</div>
<section class="rm-card" aria-labelledby="rm-billing">
<h4 id="rm-billing">Billing</h4>
<div class="rm-row">
<label class="rm-row-text" for="rm-currency"><b>Default currency</b><span>Used for new invoices and payouts</span></label>
<select class="mv-select" id="rm-currency"
data-remember="Invoices from now on will be in {value}"
data-remember-who="Billing"
data-remember-why="Invoices already sent stay in {old}; your accountant’s exports switch too.">
<option value="USD" data-remember-value="USD" selected>USD · US dollar</option>
<option value="EUR" data-remember-value="EUR">EUR · Euro</option>
<option value="GBP" data-remember-value="GBP">GBP · Pound sterling</option>
<option value="JPY" data-remember-value="JPY">JPY · Japanese yen</option>
</select>
</div>
<label class="rm-row">
<span class="rm-row-text"><b>Auto-renew</b><span>Team plan · renews Oct 15, 2026</span></span>
<input type="checkbox" role="switch" class="mv-switch" checked
data-remember-on="Your Team plan renews automatically on Oct 15, 2026"
data-remember-why-on="The card ending in 4242 will be charged $1,188."
data-remember-off="Your Team plan ends on Oct 15, 2026"
data-remember-why-off="After that date the workspace turns read-only. Nothing is deleted."
data-remember-who="Billing">
</label>
</section>
<section class="rm-card" aria-labelledby="rm-sharing">
<h4 id="rm-sharing">Sharing & notifications</h4>
<label class="rm-row">
<span class="rm-row-text"><b>Weekly digest for the team</b><span>A Monday summary of every project</span></span>
<input type="checkbox" role="switch" class="mv-switch" checked
data-remember-off="Your team stops getting the Monday digest"
data-remember-why-off="Each member is told you turned it off, starting next Monday."
data-remember-on="Your team gets the Monday digest again"
data-remember-who="Your team (8 people)">
</label>
<fieldset class="rm-row"
data-remember="{value} can now open “Q4 roadmap”"
data-remember-who="Link sharing"
data-remember-why="Anyone who already has the link keeps it; forwarded copies work too.">
<legend class="rm-row-text"><b>Who can open “Q4 roadmap”</b><span>Link access for this document</span></legend>
<span class="rm-radios">
<label class="mv-choice"><input type="radio" class="mv-radio" name="rm-link" value="members" data-remember-value="Only workspace members" checked> Members</label>
<label class="mv-choice"><input type="radio" class="mv-radio" name="rm-link" value="anyone" data-remember-value="Anyone with the link"> Anyone with the link</label>
</span>
</fieldset>
</section>
<section class="rm-card" aria-labelledby="rm-members">
<h4 id="rm-members">Membership</h4>
<div class="rm-row">
<span class="rm-who"><span class="rm-face" aria-hidden="true">DT</span><span class="rm-row-text"><b>Design team</b><span>6 members · 14 shared files</span></span></span>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="rm-leave"
data-remember="Design team members will see that you left"
data-remember-who="Design team"
data-remember-why="You lose access to its 14 shared files until someone adds you back.">Leave team</button>
</div>
<div class="rm-row">
<span class="rm-who"><span class="rm-face" aria-hidden="true">LF</span><span class="rm-row-text"><b>Lena Fischer</b><span>Invited Sep 19 · not joined yet</span></span></span>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm"
data-remember="A new invitation is on its way to [email protected]"
data-remember-who="Lena Fischer"
data-remember-why="Emails can’t be recalled; the previous link stops working."
data-remember-undo="false">Resend invite</button>
</div>
</section>
</div>
</div>
</mv-remembered>
<div class="rm-controls">
<p class="rm-log" id="rm-log" aria-live="off">Change a setting: its lasting effect is noted, then kept in Consequences.</p>
<span class="rm-actions">
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="rm-keep"> Keep notes until dismissed</label>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="rm-reset">Clear ledger</button>
</span>
</div>
<script type="module">
await customElements.whenDefined("mv-remembered");
const el = document.getElementById("rm-el");
const log = document.getElementById("rm-log");
const leave = document.getElementById("rm-leave");
const currency = document.getElementById("rm-currency");
const time = () => new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" });
const say = (text) => { log.textContent = `${time()} · ${text}`; };
// Choices made earlier in the session (e.g. restored from the server): straight into the ledger.
const seed = () => {
const now = Date.now();
el.remember({ id: "private-default", who: "Your team (8 people)", what: "New projects are private by default", why: "Existing projects keep their visibility.", undoable: true, undo: () => new Promise((r) => setTimeout(r, 500)), silent: true, at: now - 26 * 60_000 });
el.remember({ id: "tax-id", who: "Billing", what: "Invoices now show VAT ID DE 811 907 980", why: "Required for EU reverse charge.", undoable: false, silent: true, at: now - 41 * 60_000 });
};
// The app owns the revert of an action: handle mv-undo (waitUntil for the server round trip).
leave.addEventListener("click", () => {
const left = leave.dataset.left === "true";
leave.dataset.left = String(!left);
leave.textContent = left ? "Leave team" : "Rejoin team";
if (!left) leave.setAttribute("data-remember", "Design team members will see that you left");
});
el.addEventListener("mv-undo", (e) => {
say(`mv-undo · ${e.detail.what}`);
if (e.detail.source === leave) {
e.detail.waitUntil(new Promise((r) => setTimeout(r, 450)).then(() => {
leave.dataset.left = "false";
leave.textContent = "Leave team";
}));
}
});
el.addEventListener("mv-remember", (e) => {
// Rejoining is the undo of leaving, not a new lasting change.
if (e.detail.source === leave && leave.dataset.left !== "true") { e.preventDefault(); const entry = el.entries.find((x) => x.source === leave && x.status === "active"); if (entry) el.undo(entry.id, "rejoin"); return; }
say(`mv-remember · ${e.detail.who}: ${e.detail.what}`);
});
el.addEventListener("mv-ledger", (e) => say(`mv-ledger · ${e.detail.open ? "opened" : "closed"}`));
document.getElementById("rm-keep").addEventListener("change", (e) => { el.duration = e.target.checked ? "none" : "6s"; });
document.getElementById("rm-reset").addEventListener("click", () => {
el.open = false;
el.clear();
seed();
say("Ledger cleared.");
});
seed();
// Show the mechanic right away: the currency was just switched to EUR.
currency.value = "EUR";
currency.dispatchEvent(new Event("change", { bubbles: true }));
</script>
</div>API
Attributes
| Name | 유형 | Default | Description |
|---|---|---|---|
who | string | Your account | Default “who keeps it” for entries that do not name one (ledger group and note eyebrow). |
duration | time ("6s", "4000") | "none" | 6s | How long a note stays before folding into the ledger (minimum 2 s). The clock pauses while any note is hovered or focused and while the tab is hidden; "none" keeps notes until dismissed. Changing it restarts the visible notes' clocks. |
max | number | 3 | Visible notes at once; older ones fold into the ledger early. |
placement | bottom-end | bottom-start | top-end | top-start | bottom-end | Corner of the viewport (or of the element when contained). Logical sides: end is right in LTR, left in RTL. |
contained | boolean | false | Keep notes and the ledger inside the element's box (position: relative) instead of the viewport corner. Without it, the layer is rendered in the top layer (popover="manual") so no stacking context can clip it. |
label | string | Consequences | Name of the pill and the ledger. |
hotkey | string | "none" | Alt+M | Moves focus to the newest note, or opens the ledger when no note is showing (press again to close and return). Matched on the physical key, so it works with any layout. Set "none" on secondary instances. |
locale | BCP 47 tag | en-US | Time format in the ledger. |
open | boolean | false | The ledger is open (reflected). Opening folds every visible note into it. |
data-remember | template | On a control, a container of controls (fieldset, radio group) or a button / link: the message shown when it changes or is clicked. Placeholders: {value} (new value's label), {old} (value before the first change), {label} (the control's label). Controls report through change (and mv-change for form-associated elements); buttons and links through click. | |
data-remember-on / data-remember-off | template | On a lone checkbox or switch: one message per state (fall back to data-remember). A state with no message is not remembered. | |
data-remember-who | template | Who keeps the change: a person, a team, a service (“Billing”), the public. Groups the ledger. | |
data-remember-why / -why-on / -why-off | template | One short line on why it matters (optional). | |
data-remember-href | URL | Adds a “Learn more” link to the ledger entry. | |
data-remember-undo | "false" | The effect can't be undone: no Undo button, flagged “Can’t be undone” in the note and the ledger. | |
data-remember-value | string | On an <option> or a radio: the label used for {value} / {old} (default: the option's label or the radio's label). | |
data-remember-label | string | On the declarer: the text used for {label}. |
Properties
| Name | 유형 | Description |
|---|---|---|
entries | Entry[] | Read-only, chronological: { id, who, what, why, href, source, value, oldValue, status ("active" | "pending" | "reverted"), undoable, changes, at, updatedAt }. |
reverting | boolean | True while Undo restores form controls, i.e. during the input/change events it dispatches. |
who / duration / max / placement / contained / label / hotkey / locale / open | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
remember({ who, what, why, href, undo, undoable, id, source, value, oldValue, silent, at }) → id | null | Records a lasting change from app code. undo(entry) may return a promise (pending state, error kept in place on rejection); undoable defaults to Boolean(undo). Same id = the entry is updated in place. silent: true adds it straight to the ledger without a note or announcement (restore earlier choices with at: timestamp). Returns null when mv-remember is canceled. Calls made before the element is connected are queued. |
remember(opts, { target? }) | Also exported from remembered.js (and MvRemembered.remember): uses the first page-level <mv-remembered>, or any, and creates one on <body> when there is none. |
undo(id, reason?) → Promise<boolean> | Reverts one entry, as its Undo button does. |
forget(id) / clear() | Removes one or every entry without reverting anything. |
showLedger(id?) / hideLedger() | Opens the ledger (focused on one entry, highlighted) or closes it and returns focus. |
Events
| Name | Description |
|---|---|
mv-remember | Before a note shows. detail: { id, who, what, why, href, source, value, oldValue, undoable, update (true when an existing entry changes again), silent }. Cancelable: preventDefault() vetoes the note and the entry; who, what and why can be rewritten on detail before it shows. |
mv-undo | Before an entry is reverted. detail: the entry plus reason ("note" | "ledger" | "api" | your own) and waitUntil(promise). Cancelable: preventDefault() keeps the change. For buttons and links the revert is yours: do it here (async work through waitUntil); form controls are restored by the component once every promise resolves. |
mv-undo-error | A waitUntil promise or the undo function rejected. detail: the entry, reason and error. The entry stays active and shows the failure. |
mv-ledger | The ledger opened or closed. detail: { open }. |
Content structure
| Name | Description |
|---|---|
(content) | The region whose controls are watched (a settings page, a form, a whole app). An empty <mv-remembered> watches the whole document, minus regions that have their own <mv-remembered>. The notes, pill and ledger are appended in a layer the component owns. |
CSS classes
| Name | Description |
|---|---|
mv-remembered-layer | Corner layer (popover="manual" unless contained); data-placement, data-empty. |
mv-remembered-note / -who / -tag / -what / -why / -actions / -timer | A note; data-state="active | pending | undone", data-persistent when it has no clock. |
mv-remembered-pill / -count | The folded ledger trigger (aria-expanded, aria-keyshortcuts). |
mv-remembered-ledger / -group / -group-title / -list / -entry | The ledger panel; entries carry data-status and data-flash while highlighted. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-remembered-accent | var(--mv-accent) | Rail, timer, dots and count. |
--mv-remembered-ink | var(--mv-accent-fg) | Accent-colored text and icons. |
--mv-remembered-width | 23rem | Width of notes and ledger (capped by the viewport or the element). |
--mv-remembered-offset | var(--mv-space-4) | Distance from the corner. |
Accessibility
Nothing is ever interrupted: a note is not a dialog, never takes focus and never traps it; its content is announced once through a separate polite live region (“Billing: Invoices from now on will be in EUR. Invoices already sent stay in USD…”, plus “This can’t be undone.” when relevant and, the first time, the hotkey hint). Keyboard: Alt+M (configurable, exposed as aria-keyshortcuts on the pill) moves focus to the newest note, where Tab reaches Undo, Details and Dismiss and Escape folds the note into the ledger, moving focus to the next note or back where it was; with no note showing, the hotkey opens the ledger (and closes it again). The pill is a disclosure button (aria-expanded, aria-controls) and the ledger a labelled region with one heading per group and a real list of entries, each Undo button described by its entry; opening moves focus into the ledger, Escape or the close button closes it and returns focus to the pill, an outside click closes it without moving focus. Timing (WCAG 2.2.1): the clock pauses while any note is hovered or focused and while the tab is hidden, duration is adjustable and "none" disables it, and nothing is lost when a note folds, since the same entry stays in the ledger for the whole session. When a note holding focus leaves, focus goes to the next note, back to where it came from, or to the pill, never to <body>. Undo restores controls with real input/change events, so the app's own validation and saving run. Reduced motion (prefers-reduced-motion or data-motion="reduce"): no fly-to-pill, no bump, no timer sweep; notes simply appear and disappear. Forced colors: system borders, Highlight rail, timer and count, outline focus rings. Known limit: the built-in texts (Undo, Details, the pill and ledger wording, announcements) are English only and cannot be overridden yet; label and who are the only text props.