익스클루시브
Posting As <mv-posting-as>
다중 계정 앱(브랜드 계정과 개인 계정, 공유 메일함과 본인, 대리 로그인, 고객 워크스페이스, 프로덕션과 샌드박스)의 작성기와 액션 영역을 위한 신원 보호 장치입니다. 행동하는 순간, 어떤 신원으로 행동하려는지 오해의 여지 없이 보여 줍니다.
문화적 레퍼런스
지킬 박사와 하이드 씨, 로버트 루이스 스티븐슨 (1886, 책). 한 남자가 두 정체성으로 살아가며, 위험은 자신이 다른 쪽이라고 믿거나 그런 척하면서 한쪽으로 행동하는 데 있습니다. UI에서는 지금 누구로서 행동하려는지가 버튼 자체에 적히고, 작성 영역 전체에 색과 질감을 입히며, 문구나 초안의 이력, 계정의 과거 기록이 실제로는 다른 사람이라고 말할 때 이의를 제기합니다.
작동 방식
다중 계정 앱의 작성 영역과 동작 영역을 위한 신원 보호 장치입니다(브랜드 대 개인, 공유 메일함 대 본인, 대리 접속, 클라이언트 워크스페이스, 프로덕션 대 샌드박스). 행동하는 순간, 지금 어떤 신원으로 행동하려는지 착각할 수 없게 만듭니다. 신원은 실행 버튼 자체에 적히고(“Post as @AcmeCorp”, 아바타 포함, 접근 가능한 이름에도 반영), 프레임은 그 신원의 색조와 패턴(줄무늬, 점, 교차 빗금, 격자)을 입고 계정 유형을 글자로 표시하며, 헤더의 전환기(listbox)로 작성 도중에도 텍스트를 잃지 않고 신원을 바꿀 수 있습니다. 가드 규칙은 한 신원으로 행동하면서 다른 신원이라고 믿는 순간을 잡아냅니다: 조직 계정에서의 개인적인 말투(1인칭, 가벼운 은어, 또는 다른 신원에 속하는 해시태그 같은 표지, 원클릭 “Switch to Maya Chen” 제공), 초안을 시작한 뒤 바뀐 신원, 한 번도 사용한 적 없거나 주로 팀원들이 운영하는 계정, 그리고 직접 정의한 규칙. 위험도가 높은 신원(기본값은 브랜드, 대리 접속, 프로덕션)과 해결되지 않은 경고가 있으면 한 단계 확인이나 길게 누르기가 필요합니다. mv-act는 취소할 수 있으며, waitUntil(promise)은 대기 상태를 보여 주고 실패 시 초안을 유지합니다. 이후 모든 행동은 그 행동이 이루어진 신원에 연결된 실행 취소 창을 엽니다(“Posted as @AcmeCorp · Undo”). 선택한 신원은 맥락별로(스레드별, 채널별, 클라이언트별) 기억할 수 있습니다.
| 카테고리 | 폼 |
|---|---|
| 유형 | Web Component (<mv-posting-as>) |
| 상태 | 안정 |
| 키트 | 신뢰와 개인정보 보호 |
| 함께 설치되는 항목 | button |
| Keywords | exclusive, culture, identity, multi-account, account, brand-account, shared-inbox, shared-mailbox, impersonation, view-as, workspace, agency, production, composer, send-as, post-as, guard, mismatch, confirm, hold-to-send, undo, remember |
When to use
- A social or community tool lets people post from brand accounts and their own account in the same composer
- Agents reply from a shared mailbox or as themselves, and the choice should be remembered per thread
- Support staff impersonate customers or act in a client's workspace and every write must show whose name it's under
- A console deploys or runs commands in production and sandbox orgs, and production needs a deliberate hold
Avoid when
- Users only need to see which environment the whole app runs in, not which identity a single action uses → use Env Marker instead
- The action is irreversible and needs two people or two deliberate keys, whoever it's made as → use Two-Key instead
- The app has one identity per user: a plain submit button and an account menu are enough → use Button instead
설치
node scripts/add.mjs posting-as --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["posting-as"], 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/focus.js, core/motion.js, core/position.js, components/posting-as/posting-as.js, components/posting-as/posting-as.css.
사용법
빠른 시작, 동작하는 가장 작은 마크업:
<mv-posting-as verb="Reply">
<data data-identity="priya" data-kind="work" data-handle="[email protected]">Priya Natarajan</data>
<data data-identity="support" data-kind="shared" data-handle="[email protected]">Acme Support</data>
<textarea aria-label="Reply text"></textarea>
</mv-posting-as>기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<div id="pa-demo" style="width:min(100%,58rem);margin-inline:auto">
<style>
#pa-demo { display:grid; gap:1.25rem; align-content:start }
#pa-demo .pa-app { display:grid; gap:.875rem; padding:1rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-bg-subtle) }
#pa-demo .pa-top { display:flex; align-items:center; gap:.75rem; flex-wrap:wrap }
#pa-demo .pa-title { display:grid; gap:.125rem; flex:1 1 16rem; min-width:0 }
#pa-demo .pa-title strong { font-size:.9375rem; letter-spacing:-.01em }
#pa-demo .pa-title span { color:var(--mv-fg-muted); font-size:.8125rem }
#pa-demo .pa-row { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,24rem),1fr)); gap:1.25rem; align-items:start }
#pa-demo .pa-controls { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap }
#pa-demo .pa-log { margin:0; min-height:1.25rem; color:var(--mv-fg-muted); font:.75rem/1.4 var(--mv-font-mono); flex:1 1 18rem }
#pa-demo .pa-actions { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap }
#pa-demo .mv-choice { font-size:.8125rem }
#pa-demo .pa-threads { display:flex; gap:.375rem; flex-wrap:wrap }
#pa-demo .pa-thread[aria-pressed="true"] { background:var(--mv-bg-emphasis); border-color:var(--mv-border-strong) }
#pa-demo .pa-quote { margin:0; padding:.625rem .75rem; border-left:3px solid var(--mv-border-strong); border-radius:var(--mv-radius-sm); background:var(--mv-surface); color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.5 }
#pa-demo .pa-quote b { color:var(--mv-fg); font-weight:600 }
#pa-demo input[data-posting-as-field] { min-height:0; padding-block:.875rem }
</style>
<!-- 1 · Social publishing: a personal-sounding draft on the brand account -->
<section class="pa-app" aria-label="Social publishing">
<div class="pa-top">
<span class="pa-title">
<strong>Launch week queue</strong>
<span>Three accounts share this composer. The draft below reads like Maya, but it’s about to go out as the brand.</span>
</span>
<span class="mv-badge" data-variant="secondary">4 scheduled</span>
</div>
<mv-posting-as id="pa-social" value="acme" verb="Post">
<data data-identity="acme" data-kind="brand" data-handle="@AcmeCorp" data-markers="#AcmeLaunch, our customers, our team, we’re hiring" data-last-self="2026-06-18" data-last-others="0">Acme Corp</data>
<data data-identity="maya" data-kind="personal" data-handle="@mayachen" data-markers="#weekendvibes, #trailrunning, my kid">Maya Chen</data>
<data data-identity="help" data-kind="shared" data-handle="@AcmeHelp" data-markers="#AcmeStatus, ticket" data-last-self="2026-02-09" data-last-others="12">Acme Support</data>
<span slot="context">Scheduled for Oct 7, 9:00 AM</span>
<textarea data-posting-as-field aria-label="Post text" rows="3" placeholder="Write a post…">Finally ran my first trail half marathon this morning, legs are toast lol #trailrunning #weekendvibes</textarea>
<button slot="actions" type="button" class="mv-button" data-variant="ghost" data-size="icon" aria-label="Add image"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="2"/><path d="m21 16-5-5-9 9"/></svg></button>
<button slot="actions" type="button" class="mv-button" data-variant="ghost" data-size="icon" aria-label="Add link"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></svg></button>
</mv-posting-as>
<div class="pa-controls">
<p class="pa-log" id="pa-log" aria-live="polite">Brand draft loaded.</p>
<span class="pa-actions">
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="pa-hold"> Hold to post on high-stakes accounts</label>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="pa-reset">Reset draft</button>
</span>
</div>
</section>
<div class="pa-row">
<!-- 2 · Shared inbox: the identity is remembered per thread -->
<section class="pa-app" aria-label="Support inbox">
<div class="pa-top">
<span class="pa-title">
<strong>Support inbox</strong>
<span>Each thread remembers who you reply as.</span>
</span>
<span class="pa-threads" role="group" aria-label="Thread">
<button type="button" class="mv-button pa-thread" data-variant="outline" data-size="sm" data-thread="2231" aria-pressed="true">#2231</button>
<button type="button" class="mv-button pa-thread" data-variant="outline" data-size="sm" data-thread="2232" aria-pressed="false">#2232</button>
</span>
</div>
<p class="pa-quote" id="pa-quote"><b>Lucía Fernández</b> · Refund for order 88213 hasn’t shown up on my card yet. Can you check?</p>
<mv-posting-as id="pa-inbox" verb="Reply" noun="reply">
<data data-identity="priya" data-kind="work" data-kind-label="You" data-handle="[email protected]" data-last-self="2026-09-23">Priya Natarajan</data>
<data data-identity="support" data-kind="shared" data-handle="[email protected]" data-last-self="2026-08-12" data-last-others="14">Acme Support</data>
<textarea data-posting-as-field aria-label="Reply text" rows="2" placeholder="Write a reply…">Hi Lucía, the refund was issued on Sep 21 and usually takes 3 to 5 business days to appear.</textarea>
</mv-posting-as>
</section>
<!-- 3 · Production deploy: hold to act as the high-stakes identity -->
<section class="pa-app" aria-label="Deploy">
<div class="pa-top">
<span class="pa-title">
<strong>Release v2.14.0</strong>
<span>Production needs a deliberate hold; staging deploys on click.</span>
</span>
</div>
<mv-posting-as id="pa-deploy" value="prod" verb="Deploy" noun="deploy" confirm="hold" hold="1.2s" guards="">
<data data-identity="prod" data-kind="production" data-handle="acme-prod" data-initials="P">Production · us-east-1</data>
<data data-identity="staging" data-kind="staging" data-kind-label="Staging" data-pattern="dots" data-tint="var(--mv-info)" data-handle="acme-staging" data-initials="S">Staging · us-east-1</data>
<input data-posting-as-field type="text" aria-label="Release note" value="Checkout: retry declined cards once before failing">
</mv-posting-as>
</section>
</div>
<script type="module">
const root = document.getElementById("pa-demo");
const social = document.getElementById("pa-social");
const inbox = document.getElementById("pa-inbox");
const deploy = document.getElementById("pa-deploy");
const log = document.getElementById("pa-log");
const say = (text) => { log.textContent = text; };
const DRAFT = "Finally ran my first trail half marathon this morning, legs are toast lol #trailrunning #weekendvibes";
social.addEventListener("mv-identity-mismatch", (e) => say(`mv-identity-mismatch · ${e.detail.rule}: ${e.detail.title}`));
social.addEventListener("mv-identity-change", (e) => say(`mv-identity-change · ${e.detail.previous?.handle} → ${e.detail.identity.handle} (${e.detail.reason}), draft kept`));
social.addEventListener("mv-act", (e) => {
say(`mv-act · publishing as ${e.detail.identity.handle}…`);
// A fake publishing endpoint: 500 ms round trip.
e.detail.waitUntil(new Promise((resolve) => setTimeout(resolve, 500)));
});
social.addEventListener("mv-undo", (e) => say(`mv-undo · post by ${e.detail.identity.handle} retracted`));
social.addEventListener("mv-commit", (e) => say(`mv-commit · post by ${e.detail.identity.handle} is final`));
const hold = document.getElementById("pa-hold");
hold.addEventListener("change", () => { social.confirm = hold.checked ? "hold" : "step"; });
document.getElementById("pa-reset").addEventListener("click", () => {
social.value = "acme";
const field = social.querySelector("[data-posting-as-field]");
// Emptying the field first starts a fresh draft: dismissed warnings can show again.
field.value = "";
field.dispatchEvent(new Event("input", { bubbles: true }));
field.value = DRAFT;
field.dispatchEvent(new Event("input", { bubbles: true }));
say("Brand draft loaded.");
});
// Inbox: one draft and one remembered identity per thread.
const threads = {
2231: { quote: ["Lucía Fernández", "Refund for order 88213 hasn’t shown up on my card yet. Can you check?"], draft: "Hi Lucía, the refund was issued on Sep 21 and usually takes 3 to 5 business days to appear.", as: "support" },
2232: { quote: ["Kenji Watanabe", "Thanks for the quick call earlier, can you send me the deck you mentioned?"], draft: "Hi Kenji, great talking to you! Here’s the deck from our call.", as: "priya" },
};
try { for (const [id, t] of Object.entries(threads)) localStorage.setItem(`mv-posting-as:demo-inbox-${id}`, t.as); } catch {}
const inboxField = inbox.querySelector("[data-posting-as-field]");
const quote = document.getElementById("pa-quote");
const openThread = (id) => {
const t = threads[id];
quote.replaceChildren(Object.assign(document.createElement("b"), { textContent: t.quote[0] }), ` · ${t.quote[1]}`);
inboxField.value = t.draft;
inbox.remember = `demo-inbox-${id}`;
inboxField.dispatchEvent(new Event("input", { bubbles: true }));
for (const b of root.querySelectorAll(".pa-thread")) b.setAttribute("aria-pressed", String(b.dataset.thread === id));
};
let current = "2231";
inboxField.addEventListener("input", () => { threads[current].draft = inboxField.value; });
for (const b of root.querySelectorAll(".pa-thread")) b.addEventListener("click", () => { current = b.dataset.thread; openThread(current); });
customElements.whenDefined("mv-posting-as").then(() => openThread(current));
deploy.addEventListener("mv-act", (e) => e.detail.waitUntil(new Promise((resolve) => setTimeout(resolve, 700))));
deploy.addEventListener("mv-commit", () => {
const f = deploy.querySelector("[data-posting-as-field]");
if (!f.value) f.value = "Checkout: retry declined cards once before failing";
f.dispatchEvent(new Event("input", { bubbles: true }));
});
</script>
</div>API
Attributes
| Name | 유형 | Default | Description |
|---|---|---|---|
value | string | Id of the identity you act as. Reflected by the component; setting it switches identity (reason "api", cancelable). A remembered choice wins over it at startup. | |
verb | string | Post | Action verb written on the button and in every message: Post, Reply, Send, Sign, Deploy, Run… |
done | string | Past tense used by the receipt and messages. Derived for common verbs (Posted, Replied, Sent, Deployed, Ran…), otherwise verb + "ed". | |
noun | string | post | What an act produces, used by the history rule (“Teammates made the last 14 replies from support@…”). |
confirm | step | hold | step | How high-stakes identities (and any open warning) are confirmed: step = the first press arms the button (“Confirm: post as @AcmeCorp”, a 5 s fuse drains, Escape cancels) and a second press acts; hold = press and hold the button (pointer, Space or Enter) until the fill completes. |
hold | time ("1s", "800ms") | 1s | Hold duration in hold mode (clamped between 300 ms and 5 s). |
undo | time | 8s | Undo window after each act. "0" shows the receipt for 4 s without Undo. |
remember | string | Context key (per thread, channel, client…): the chosen identity is stored in localStorage under it and restored when the key is set again. Changing the key switches to that context's identity (reason "restore") and starts a fresh draft origin. | |
guards | space-separated list | voice switch history | Built-in rules to run. voice: personal register on a "we" identity (first person singular, casual slang) or markers of another identity in the text. switch: the identity changed after the draft was started. history: first time acting as this identity, or teammates made the last 3+ acts. An empty value turns every built-in rule off (your rules still run). |
allow-empty | boolean | Allows acting with an empty field (default: the button is aria-disabled until the field has text). | |
keep-draft | boolean | Keeps the text in the field after a successful act (default: the field is cleared and Undo restores it). | |
no-shortcut | boolean | Disables Ctrl/⌘+Enter in the field (which goes through the same confirm step or moves focus to the button in hold mode). | |
data-identity (child) | string | Declares an identity: any direct child element carrying it (e.g. <data>), hidden by the component. Its text is the name; options as data-*: kind (personal | brand | shared | impersonating | client | production | work | any string), handle, avatar (image URL), initials, kind-label, tint (any CSS color), pattern (solid | stripes | dots | cross | grid), stakes (high | normal), confirm (step | hold | none), voice (we | me | any), markers (comma list of words, phrases, #tags or @handles typical of this identity), last-self (ISO date you last acted as it, or "never"), last-others (acts by teammates since), default. | |
data-posting-as-field (child) | boolean | Marks the field whose text is guarded and cleared (textarea, input or contenteditable) and gives it a borderless default style. Without it, the first text field inside is used. | |
data-kind / data-pattern / data-stakes / data-method / data-armed / data-pending / data-switched | state | Set on the element for styling: the current identity's kind, pattern and stakes, the confirm method in force (none | step | hold), armed (step confirm waiting), pending (waitUntil in flight), switched (one-shot, 700 ms after an identity change). |
Properties
| Name | 유형 | Description |
|---|---|---|
identities | Array<{ id, name, handle?, kind?, avatar?, initials?, kindLabel?, tint?, pattern?, stakes?, confirm?, voice?, markers?, lastSelf?, lastOthers?, default? }> | Read the normalized identities, or set them from data instead of child markup (overrides the children). |
identity | object | The identity you currently act as (read-only copy). |
notices | Array<{ rule, level, title, message, matches, suggest }> | Open (not acknowledged) warnings (read-only). |
rules | Array<(ctx) => notice | notice[] | null> | Your own guards. ctx: { identity, identities, text, draftIdentity, verb }. Return { title, message, suggest? (identity id), level? ("warning" forces a confirm, "info" doesn't), id?, matches? }. Re-run on every edit (debounced) and identity change. |
field | Element | The guarded field (set it to point at a field that isn't the first one). |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (act, actName, confirm, confirmName, hold, holdName, pending, voiceTitle, voiceText, markersTitle, markersText, switchedTitle, switchedText, firstTitle, firstText, othersTitle, othersText, errorTitle, errorText, switchTo, keep, receipt, undo, announce*…). Placeholders: {verb} {verbLower} {done} {doneLower} {name} {handle} {who} (handle + name) {kind} {kindLower} {nouns} {matches} {other}. |
pending / armed | boolean | Read-only state. |
value / verb / done / noun / confirm / hold / undo / remember / guards / allowEmpty / keepDraft / noShortcut | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
switchTo(id) | Switches identity and keeps the draft (emits the cancelable mv-identity-change with reason "api"). Returns false if refused or unknown. |
submit() | Acts now as the current identity, skipping the confirm step (for when your own UI already confirmed). Returns Promise<boolean>. |
undoLast() | Undoes the last act while its window is open (same as the Undo button). |
evaluate() | Re-runs the guards, e.g. after you updated an identity's history or your rules' inputs. |
acknowledge(rule?) | Acknowledges every open warning (or one rule's): they stay hidden for this draft and stop forcing a confirmation. |
openSwitcher() / closeSwitcher() | Opens or closes the identity listbox. |
Events
| Name | Description |
|---|---|
mv-identity-change | Before the identity changes. detail: { identity, previous, reason: "user" | "suggest" | "api" | "restore" | "undo", draft }. Cancelable except for "undo": preventDefault() keeps the current identity (and reverts value). |
mv-identity-mismatch | A guard raised a new warning (once per draft and warning). detail: { identity, rule: "voice" | "switch" | "history" | your rule, level, title, message, matches (the words that triggered it), suggest (identity or null) }. |
mv-act | Cancelable, when the act is confirmed. detail: { identity, text, field, via: "click" | "confirm" | "hold" | "keyboard" | "api", notices, waitUntil(promise) }. Call waitUntil() synchronously with your request: the button shows “Posting as @AcmeCorp…” (aria-busy); a rejection keeps the draft and shows an error. preventDefault() cancels the act. |
mv-act-error | A waitUntil promise rejected. detail: { identity, text, error }. |
mv-undo | Cancelable, when Undo is chosen during the window. detail: { identity, text }. Retract the act here; the draft and the identity are restored. |
mv-commit | The undo window ended without Undo: the act is final. detail: { identity, text }. |
Content structure
| Name | Description |
|---|---|
(content) | The app's field (textarea, input, contenteditable) and anything around it, left in place between the identity header and the footer. |
actions | Children with slot="actions" (attach, emoji, schedule buttons) are moved to the start of the footer. |
context | Children with slot="context" (channel, recipient, schedule) are moved to the end of the header. |
CSS classes
| Name | Description |
|---|---|
mv-posting-as-head / -who / -name / -handle / -kind / -swatch / -context | Header: the identity chip (button opening the listbox) with avatar, name, handle and the kind label with its pattern swatch. |
mv-posting-as-list / -option | The switcher (role=listbox, Popover API top layer when available), one option per identity with data-kind and data-pattern. |
mv-posting-as-notices / -notice | Warnings (data-rule, data-level="warning | info | error"): -notice-icon, -notice-title, -notice-text, -notice-actions. |
mv-posting-as-receipt | “Posted as @AcmeCorp · 2:41 PM · Undo” with a draining -receipt-meter. |
mv-posting-as-foot / -tools / -hint / -act | Footer: your actions, the Ctrl+Enter hint and the act button (data-state="ready | armed | hold | pending", data-holding while pressed; -act-fill, -act-timer, -act-handle). |
mv-posting-as-avatar | Image or initials avatar (square for brand, client and production kinds; data-size="sm | xs"). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-posting-as-personal / -brand / -shared / -impersonating / -client / -production / -work | fg-muted / accent / info / warning / success / danger / violet | Tint of each kind: frame outline, header texture, kind label, act button. |
--mv-posting-as-tint | Set by the component from an identity's data-tint (wins over its kind's tint). | |
--mv-posting-as-radius | var(--mv-radius-xl) | Frame corner radius. |
Accessibility
The act button's accessible name always carries the identity: “Post as @AcmeCorp, Acme Corp, Brand account” (the visible label comes first, so voice control matches it); armed it becomes “Confirm: post as @AcmeCorp… Press again to post.”, in hold mode “Hold to post as…”, pending it is aria-busy, and it is aria-disabled (still focusable) while the field is empty. Open warnings are real text with a title, an icon and actions, and are linked to the button with aria-describedby, so they are read before acting; a new warning is announced once, politely. Kinds are never told by color alone: the kind is spelled out (“Brand account”, “Impersonating”) and drawn as a pattern (stripes, dots, cross-hatch, grid), which becomes a border style (double, dotted, dashed) in forced-colors mode. The header chip is a button with aria-haspopup="listbox" and aria-expanded (name: “Acting as Acme Corp @AcmeCorp Brand account. Change identity”); ArrowDown/Up, Enter or Space open the listbox with focus on the current option, Arrow keys, Home/End and typeahead move, Enter/Space choose, Escape closes and returns focus, Tab closes; options expose aria-selected and say when they need confirmation. Switching keeps the draft and announces “Now acting as Maya Chen, Personal. Your draft is kept.” Ctrl/⌘+Enter in the field follows the same confirm rules. Hold works with the pointer, Space or Enter held down; a click with no press behind it (assistive technology) falls back to the two-step confirm, so the action never depends on a timed gesture alone. Arming, cancel (Escape), acting, errors (assertive), undo availability and undo are announced in visually hidden live regions. The undo button is named “Undo: post as @AcmeCorp”; after Undo, focus returns to the restored draft. Reduced motion (OS or data-motion="reduce"): no switch ring, no slide-ins; the hold fill and the draining fuses remain as progress indicators.