익스클루시브
Midnight <mv-midnight>
정확히 제시간에 끝나고 모든 것을 원래대로 되돌리는, 눈에 보이고 시간이 정해진 권한 상승 모드(sudo/관리자 재인증, “고객으로 보기”, 임시 공유 접근, 프리미엄 체험, 비상 접근, 편집 잠금)입니다.
문화적 레퍼런스
신데렐라(Cendrillon), 샤를 페로 (1697, 동화). 마법은 자정을 알리는 종이 칠 때까지만 유지되는 변신을 선사하고, 종소리는 시간이 다 되어 간다고 경고하며, 마지막 종이 울리면 모든 것이 원래대로 돌아갑니다. UI에서는 권한 상승 모드(관리자, 대리 접속, 임시 접근)가 정확한 마감 시각까지 부여되고, 정해진 시점마다 부드러운 차임이 경고하며, 끝나면 잠금 해제된 모든 컨트롤과 변경된 모든 콘텐츠가 저절로 되돌아갑니다.
작동 방식
정확한 시각에 끝나며 모든 것을 원래대로 되돌리는, 눈에 보이고 시간이 제한된 권한 상승 모드(sudo/관리자 재인증, “viewing as a customer”, 임시 공유 접근, 프리미엄 체험, 긴급 접근, 편집 잠금)입니다. 차분한 띠(또는 상세 패널이 있는 컴팩트한 칩)가 무엇이 누구에 의해 부여되었는지, 남은 시간을 고정폭 숫자로, 정확한 종료 시각을 사용자 로캘로, 그리고 요청 시 무엇이 되돌아갈지를 알려 줍니다. 가는 선이 끝을 향해 줄어들며 차임 시점마다 눈금이 있습니다. 각 시점(기본값 5분과 1분)마다 띠가 두 번 울리고, 아이콘이 흔들리며, 해당 시점이 polite 방식으로 안내되고, 선택적으로 부드러운 WebAudio 종소리가 재생됩니다. 마지막 구간에서는 Extend가 주요 동작이 됩니다. Extend는 취소 가능한 mv-extend를 거치며, 이 이벤트의 waitUntil(promise)은 앱이 재인증하는 동안 처리 중 상태를 유지하고(반환된 날짜가 새 종료 시각을 대체), 선택적으로 연장 한도를 둘 수 있습니다. 마감 시각에는 아무것도 기다리지 않습니다. data-midnight-only로 표시된 요소는 숨겨지거나 inert 상태가 되고, data-midnight-original 콘텐츠가 돌아오며(부여된 콘텐츠는 다음 시작을 위해 보관됨), mv-midnight가 앱에 권한을 내려놓으라고 알리고, 띠에는 “Admin mode ended at 3:42 PM”이 표시됩니다. 종료 시각은 절대 마감 시각에서 계산되므로 속도가 제한된 백그라운드 탭도 정확한 종료 시각을 보고합니다. until 속성(또는 persist)은 새로고침 후에도 유지되며, BroadcastChannel이 모든 탭을 같은 권한 부여 상태로 유지합니다.
| 카테고리 | 피드백 |
|---|---|
| 유형 | Web Component (<mv-midnight>) |
| 상태 | 안정 |
| 키트 | 안전한 파괴적 작업과 비밀 정보 |
| 함께 설치되는 항목 | button |
| Keywords | exclusive, culture, sudo, elevated-access, admin-mode, impersonation, view-as, temporary-access, break-glass, trial, countdown, timer, expiry, revert, permissions, security, broadcast-channel, multi-tab, banner |
When to use
- An admin re-authenticates for sensitive actions and those rights must visibly expire after a few minutes
- A support agent views or acts as a customer and must always see whose account it is and when it stops
- Temporary access (a share link, a break-glass role, a premium trial) must show its end time and revert the UI on expiry
- Several tabs share one elevated grant, and extending or ending it in one must apply to all of them
Avoid when
- The whole session expires and the user must prove they are still there to stay signed in → use Hatch instead
- A secret value must be shown once and then destroyed, rather than a mode being granted → use Self-Destruct instead
- The point is to tell which environment (staging, preview, test mode) the page runs in, with no expiry → use Env Marker instead
설치
node scripts/add.mjs midnight --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["midnight"], 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, core/observe.js, core/position.js, components/midnight/midnight.js, components/midnight/midnight.css.
사용법
빠른 시작, 동작하는 가장 작은 마크업:
<mv-midnight mode="Admin mode" grant="Refunds unlocked" duration="15m" autostart></mv-midnight>기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<div id="mn-demo" style="width:min(100%,56rem);margin-inline:auto">
<style>
#mn-demo { display:grid; gap:1.25rem; align-content:start }
#mn-demo .mn-app { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden }
#mn-demo .mn-top { display:flex; align-items:center; gap:1rem; height:3.25rem; padding:0 1rem 0 1.125rem; border-bottom:1px solid var(--mv-border) }
#mn-demo .mn-brand { display:flex; align-items:center; gap:.5rem; font-weight:650; font-size:.9375rem; letter-spacing:-.01em; white-space:nowrap }
#mn-demo .mn-logo { display:grid; place-items:center; width:1.75rem; height:1.75rem; border-radius:var(--mv-radius-md); background:var(--mv-fg); color:var(--mv-bg) }
#mn-demo .mn-logo svg { width:1rem; height:1rem }
#mn-demo .mn-nav { display:flex; gap:.25rem; margin:0; padding:0; list-style:none }
#mn-demo .mn-nav a { display:block; padding:.375rem .625rem; border-radius:var(--mv-radius-md); color:var(--mv-fg-muted); font-size:.8125rem; font-weight:500; text-decoration:none }
#mn-demo .mn-nav a[aria-current] { background:var(--mv-bg-muted); color:var(--mv-fg) }
#mn-demo .mn-end { display:flex; align-items:center; gap:.75rem; margin-inline-start:auto }
#mn-demo .mn-me { display:grid; place-items:center; width:2rem; height:2rem; border-radius:50%; background:var(--mv-bg-emphasis); color:var(--mv-fg); font-size:.75rem; font-weight:600 }
#mn-demo .mn-body { display:grid; gap:1rem; padding:1rem 1.125rem 1.125rem }
#mn-demo .mn-grid { display:grid; grid-template-columns:minmax(0,1fr) 16rem; gap:1rem }
#mn-demo .mn-card { min-width:0; padding:1rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle) }
#mn-demo .mn-card h4 { margin:0 0 .25rem; font-size:.875rem }
#mn-demo .mn-card p { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.5 }
#mn-demo .mn-dl { display:grid; grid-template-columns:auto minmax(0,1fr); gap:.5rem 1rem; margin:.875rem 0 0; font-size:.8125rem }
#mn-demo .mn-dl dt { color:var(--mv-fg-muted) }
#mn-demo .mn-dl dd { margin:0; font-variant-numeric:tabular-nums; overflow-wrap:anywhere }
#mn-demo [data-midnight-reverted] { color:var(--mv-fg-subtle) }
#mn-demo .mn-row { display:flex; flex-wrap:wrap; gap:.5rem; margin-top:1rem }
#mn-demo .mn-log { min-height:1.25rem; margin:0; color:var(--mv-fg-subtle); font:.75rem/1.45 var(--mv-font-mono) }
#mn-demo .mn-controls { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding:.875rem 1.125rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
#mn-demo .mn-controls .mv-choice { font-size:.8125rem }
#mn-demo .mn-actions { display:flex; align-items:center; gap:.5rem 1rem; flex-wrap:wrap }
#mn-demo .mn-list { display:grid; margin:.75rem 0 0; padding:0; list-style:none; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-surface); overflow:hidden }
#mn-demo .mn-list li { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:.125rem 1rem; padding:.625rem .875rem; font-size:.8125rem }
#mn-demo .mn-list li + li { border-top:1px solid var(--mv-border) }
#mn-demo .mn-list span:last-child { color:var(--mv-fg-subtle); font-variant-numeric:tabular-nums }
#mn-demo .mn-list small { grid-column:1 / -1; color:var(--mv-fg-muted); font-size:.75rem }
#mn-demo .mn-h { display:flex; align-items:center; justify-content:space-between; gap:.75rem; flex-wrap:wrap }
#mn-demo .mn-h h4 { margin:0; font-size:.9375rem }
@media (max-width:44rem) {
#mn-demo .mn-nav { display:none }
#mn-demo .mn-grid { grid-template-columns:minmax(0,1fr) }
}
</style>
<!-- 1. Band: sudo-style admin mode on a billing console (3 minutes here, chimes at 1 min and 15 s) -->
<section class="mn-app" aria-label="Billing admin console">
<header class="mn-top">
<span class="mn-brand">
<span class="mn-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 7h16v10H4z"/><path d="M4 11h16"/></svg></span>
Tallybook
</span>
<ul class="mn-nav">
<li><a href="#" aria-current="page">Customers</a></li>
<li><a href="#">Invoices</a></li>
<li><a href="#">Audit log</a></li>
</ul>
<span class="mn-end"><span class="mn-me" aria-hidden="true">JL</span></span>
</header>
<div class="mn-body">
<mv-midnight id="mn-admin" autostart duration="3m" extend-by="2m" chimes="1m, 15s" max-extensions="2"
mode="Admin mode" grant="Refunds, MFA resets and full customer data unlocked" granted-by="Priya Raman"
channel="mn-demo-admin">
<ul slot="reverts">
<li>Refunds and workspace deletion disappear</li>
<li>MFA reset is locked again</li>
<li>Emails and card details are masked again</li>
<li>Your normal role (Support agent) applies</li>
</ul>
<button slot="ended" type="button" class="mv-button" data-size="sm" data-variant="outline" id="mn-again">Enter admin mode again</button>
</mv-midnight>
<div class="mn-grid">
<div class="mn-card">
<h4>Alder Grove Traders · Pro plan</h4>
<p>Customer since March 2023 · 48 seats · billed annually</p>
<dl class="mn-dl">
<dt>Billing email</dt>
<dd data-midnight-original="d•••••@example.com">[email protected]</dd>
<dt>Card on file</dt>
<dd data-midnight-original="Credit card •••• ••••">Credit card 4242 · exp 08/28 · ZIP 94107</dd>
<dt>Last invoice</dt>
<dd>INV-2048 · $12,400.00 · paid Sep 1, 2026</dd>
</dl>
<div class="mn-row">
<button type="button" class="mv-button" data-size="sm" data-midnight-only>Issue refund</button>
<button type="button" class="mv-button" data-size="sm" data-variant="outline" data-midnight-only="inert">Reset MFA</button>
<button type="button" class="mv-button" data-size="sm" data-variant="outline">Send receipt</button>
<button type="button" class="mv-button" data-size="sm" data-variant="destructive" data-midnight-only>Delete workspace</button>
</div>
</div>
<div class="mn-card">
<h4>Activity</h4>
<p>What the mode reports to the app.</p>
<p class="mn-log" id="mn-log" style="margin-top:.75rem">Admin mode started.</p>
</div>
</div>
</div>
<div class="mn-controls">
<span class="mn-actions">
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="mn-reauth" checked> Extending asks for a passkey</label>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="mn-sound"> Soft chime sound</label>
</span>
<span class="mn-actions" style="gap:.5rem">
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="mn-ff">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 6 7 6-7 6zM13 6l7 6-7 6z"/></svg>
Jump to the last minute
</button>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="mn-skip">Skip to the end</button>
</span>
</div>
</section>
<!-- 2. Chip: support agent viewing the app as a customer (impersonation), 10 minutes -->
<section class="mn-app" aria-label="Support workspace">
<header class="mn-top">
<span class="mn-brand">
<span class="mn-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 6h16v10H8l-4 4z"/></svg></span>
Tidewell
</span>
<span class="mn-end">
<mv-midnight id="mn-view" variant="chip" name="view-as" autostart duration="10m" extend-by="5m" chimes="5m, 1m"
mode="Viewing as Alex Chen" grant="Read-only: you see exactly what Alex sees" granted-by="Marcus Webb">
<ul slot="reverts">
<li>You’re back in your own account</li>
<li>Alex’s inbox and settings are hidden again</li>
</ul>
</mv-midnight>
<span class="mn-me" aria-hidden="true">SK</span>
</span>
</header>
<div class="mn-body">
<div class="mn-h">
<h4 data-midnight-for="view-as" data-midnight-original="Your inbox">Alex Chen’s inbox · [email protected]</h4>
<button type="button" class="mv-button" data-size="sm" data-variant="outline" data-midnight-for="view-as" data-midnight-only>Copy what Alex sees</button>
</div>
<ul class="mn-list" data-midnight-for="view-as" data-midnight-only>
<li><span>Invoice INV-3310 failed to send</span><span>2:14 PM</span><small>“Your card was declined” shown to Alex, but the card was updated yesterday.</small></li>
<li><span>Welcome to Brightline Analytics</span><span>Sep 18</span><small>Onboarding checklist: 3 of 5 steps done.</small></li>
<li><span>Your trial ends in 3 days</span><span>Sep 17</span><small>Upgrade prompt with the old price ($29/month).</small></li>
</ul>
</div>
</section>
<script type="module">
const admin = document.getElementById("mn-admin");
const view = document.getElementById("mn-view");
const log = document.getElementById("mn-log");
const reauth = document.getElementById("mn-reauth");
const time = (d = new Date()) => d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" });
const say = (text) => { log.textContent = `${time()} · ${text}`; };
// Extending can require a fresh passkey check: the app answers through waitUntil().
admin.addEventListener("mv-extend", (e) => {
if (!reauth.checked) { say("Extended without re-authentication."); return; }
say("Waiting for passkey confirmation…");
e.detail.waitUntil(new Promise((resolve) => setTimeout(resolve, 900)).then(() => say(`Passkey confirmed. Admin mode extended to ${time(e.detail.until)}.`)));
});
admin.addEventListener("mv-chime", (e) => say(`Chime: ${Math.round(e.detail.remaining / 1000)} s left.`));
admin.addEventListener("mv-end", () => say("Ending admin mode early…"));
admin.addEventListener("mv-midnight", (e) => say(`Admin mode ended (${e.detail.reason}). Privileges dropped.`));
admin.addEventListener("mv-sync", () => say("Extended from another tab."));
document.getElementById("mn-sound").addEventListener("change", (e) => { admin.sound = e.target.checked; });
const ensureOn = () => { if (admin.state === "ended" || admin.state === "idle") admin.start(); };
// Demo shortcut: pretend two of the three minutes have already gone by.
document.getElementById("mn-ff").addEventListener("click", () => {
ensureOn();
admin.since = new Date(Date.now() - 119_000).toISOString();
admin.deadline = Date.now() + 61_000;
});
document.getElementById("mn-skip").addEventListener("click", () => { ensureOn(); admin.deadline = Date.now() + 3_000; });
document.getElementById("mn-again").addEventListener("click", () => {
say("Passkey confirmed. Admin mode started.");
admin.start({ duration: "3m" });
});
admin.addEventListener("mv-dismiss", () => {
say("Band dismissed. Signing back in to admin mode…");
setTimeout(() => admin.start({ duration: "3m" }), 1500);
});
view.addEventListener("mv-dismiss", () => setTimeout(() => view.start({ duration: "10m" }), 1500));
</script>
</div>API
Attributes
| Name | 유형 | Default | Description |
|---|---|---|---|
until | ISO 8601 date-time | epoch ms | Absolute end of the mode, typically rendered by your server from the real grant expiry, so a reload shows the same deadline. Reflected by the component whenever the end moves (start, extend, another tab). A value already in the past on load shows the ended state and emits mv-midnight with late > 0. | |
since | ISO 8601 date-time | epoch ms | When the grant started: shown as “Granted by … at 3:27 PM” and used as the full length of the draining line. Defaults to the start time (or the load time when only until is known). | |
duration | time ("15m", "90s", "1h", "2d", ms number) | 15m | Length used by start() and autostart when no end is given. |
extend-by | time | (duration) | Time added by Extend, counted from the current end (or from now if it has passed). Shown in the button (“Extend 15 min”). |
max-extensions | number | How many times Extend may be used per grant. Once reached, the button reads “No extensions left” and is disabled. | |
chimes | comma-separated times | 5m, 1m | Warning marks before the end. Each one rings once (pulse, polite announcement, optional bell, mv-chime); the first one switches the tone to closing. Marks already passed on load or after an extension never ring, and several marks missed by a sleeping tab ring once. |
mode | string | Elevated access | Short name of the mode (“Admin mode”, “Viewing as Alex Chen”), used in the band, the chip, the region label and every announcement. |
grant | string | What the mode unlocks (“Refunds and MFA resets unlocked”), shown next to the name. | |
granted-by | string | Who granted it; shown as “Granted by Priya Raman at 3:27 PM” (otherwise “Started at 3:27 PM”). | |
variant | band | chip | band | band: full-width inline bar with actions and a collapsible “What reverts” list. chip: compact pill (name + time left) that opens a non-modal panel with the details, a big countdown and the actions. |
autostart | boolean | Starts a grant of duration on first connection when no until (or persisted state) is present. For demos and client-only modes; pair it with persist to survive reloads. | |
sound | boolean | Soft sine bell via WebAudio at each chime (two strikes for the last mark). Off by default and never before a user gesture. | |
channel | string | BroadcastChannel name shared by the tabs of the same grant: start, extend and end apply to all of them, and a newly opened tab joins a running grant. Ignored where BroadcastChannel is unsupported. | |
persist | string | localStorage key under which the end, start and extension count are kept, so a reload without server-rendered until resumes (or reports the end). Cleared when the mode ends. | |
scope | CSS selector | (document) | Root in which data-midnight-only and data-midnight-original elements are managed (including elements added later), and which receives data-midnight="active | closing | ended". |
name | string | With several instances: this one only manages elements whose data-midnight-for matches; an instance without name manages elements without data-midnight-for. | |
linger | time | Hides the ended band automatically after this delay (mv-dismiss). By default it stays until Dismiss. | |
locale | BCP 47 tag | en-US | Locale of the end and start times (“3:42 PM”, “Sep 24 at 9:00 AM” when the end is on another day). |
placement | bottom-end | bottom-start | bottom | top-end | top-start | top | bottom-end | chip variant: preferred side of the panel (flips and shifts to stay in the viewport). |
data-midnight-only | "" | "hide" | "inert" | On any element in scope: only available while the mode is on. Otherwise it gets hidden + inert ("" / "hide", enforced with display: none) or stays visible but inert and dimmed ("inert"). data-midnight-locked is set while it is locked. | |
data-midnight-original | string | On any element in scope: text shown whenever the mode is off (masked email, “Your inbox”). The granted content is kept aside and put back on the next start; data-midnight-reverted is set while the original shows. | |
data-midnight-for | string | On a managed element: binds it to the instance with this name. | |
data-state | idle | active | closing | ended | Set by the component (idle renders nothing). data-chime is present during a chime pulse ("last" for the last mark), data-pending while an extension waits. |
Properties
| Name | 유형 | Description |
|---|---|---|
deadline | number (ms epoch) | Date | ISO string | Absolute end. Setting it moves a running grant (broadcast to other tabs) or starts one if it is in the future. |
remaining | number (ms) | Time left, recomputed from the deadline on every read; 0 when not on (read-only). |
state | "idle" | "active" | "closing" | "ended" | Current phase (read-only). |
extensions | number | Extensions used in the current grant (read-only). |
endedAt / endReason | Date | null / string | null | When and why the last grant ended (read-only). |
pending | boolean | True while an extension waits for waitUntil promises (read-only). |
reverts | string[] | What reverts at the end, as plain text items (alternative to slot="reverts"). |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (keys: mode, left, grantedBy, startedAt, endsAt, endsOn, extend ({by}), extendLimit, endNow, dismiss, reverts, reverted, revertsTitle, revertedTitle, endedTitle, endedChip, endedTimeout, endedManual, endedRemote, endedApi, error, timer, announceStart, announceChime, announceExtended, announceRemote, announceEnded). English defaults. |
Methods
| Name | Description |
|---|---|
start({ duration?, until?, since?, grant?, grantedBy? }) | Starts (or restarts) a grant: unlocks data-midnight-only elements, restores granted content, emits mv-start, announces the end time and broadcasts. Returns false if the end would be in the past. |
extend(by?) | Same as the Extend button (by defaults to extend-by). Returns Promise<boolean>: false if canceled, rejected, over the limit, or if the mode ended while waiting. |
end({ reason? }) | Ends now and reverts (reason defaults to "api"; "manual" emits the cancelable mv-end first, as the End now button does). |
dismiss() | Hides the ended band (state idle), returning focus to where it came from if it was inside. |
open() / close() | chip variant: opens or closes the details panel. |
Events
| Name | Description |
|---|---|
mv-start | A grant started. detail: { until, since, remote } (remote: joined from another tab). |
mv-chime | A warning mark was reached. detail: { mark (ms), remaining, until, last }. |
mv-extend | Cancelable, before an extension. detail: { by, until (proposed new end), remaining, extensions, waitUntil(promise) }. Call waitUntil() synchronously with your re-authentication: Extend shows a busy state, a rejection keeps the current end and shows an error, a promise resolving to a Date, ISO string or epoch ms uses it as the new end. The deadline is never held while you wait. |
mv-extend-error | A waitUntil promise rejected. detail: { error }. |
mv-end | Cancelable: End now was chosen. detail: { reason: "manual", remaining }. preventDefault() keeps the mode (e.g. to confirm first). |
mv-midnight | The mode ended and the UI reverted: drop the privileges now. detail: { reason: "timeout" | "manual" | "api" | "remote", endedAt (the exact deadline for timeouts), until, late (ms between the end and when this tab noticed), extensions }. |
mv-sync | Another tab moved the end. detail: { until, type }. |
mv-dismiss | The ended band was dismissed (button, dismiss() or linger). detail: { reason }. |
Content structure
| Name | Description |
|---|---|
reverts | A list (usually <ul>) of what reverts at the end, shown under “What reverts” in the band and always in the chip panel; after the end it becomes “Reverted at 3:42 PM”. |
actions | Extra actions shown while the mode is on (e.g. “Open audit log”), before End now / Extend. |
ended | Actions shown once it ended (e.g. “Enter admin mode again”, which calls start() after re-authentication), before Dismiss. |
CSS classes
| Name | Description |
|---|---|
mv-midnight-band / -bar | Band variant: labelled region with -icon, -text (-title with -mode and -grant, -meta with -by and -ends, -error), -clock (-digits, -caption), -actions and -track; -reverts below. |
mv-midnight-track / -fill / -tick | Draining line (--_p = remaining ratio) with one notch per chime mark (data-rung once passed). |
mv-midnight-chip / -panel | Chip variant: pill button (-chip-icon, -chip-label, -chip-digits) and its non-modal Popover API panel (-head, -clockblock, -reverts, -actions). |
mv-midnight-extend / -end / -toggle / -dismiss | Built-in actions (mv-button, size sm). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-midnight-tone | var(--mv-accent) | Tint of the active mode: band, icon, chip, draining line. |
--mv-midnight-closing | var(--mv-warning) | Tint once the first chime mark is passed. |
--mv-midnight-radius | var(--mv-radius-lg) | Corner radius of the band (0 for a full-bleed top bar). |
Accessibility
The band is a region labelled by the mode name (“Admin mode”), so it is listed among landmarks and can be jumped to; the chip is a real <button> (aria-expanded, aria-controls) opening a labelled non-modal dialog placed right after it in the DOM, closed with Escape (focus returns to the chip) or an outside click. Time left lives in a visually hidden role="timer" element (“12 minutes left in Admin mode, ends at 3:42 PM”, to the second under 5 minutes) whose implicit aria-live is off, and Extend and the chip are described by it: screen readers read it on demand and it never chatters. Only real events are announced, once, in a polite live region: the start (“Admin mode is on until 3:42 PM.”), each chime (“Admin mode ends in 1 minute, at 3:42 PM.”), an extension, a failed extension and the end (“Admin mode ended at 3:42 PM. Everything it unlocked has been reverted.”). Focus is never moved on a timer event; if it sat on Extend, End now or inside something the mode unlocked when the mode ends, it moves to Dismiss (to the chip while its panel is closed), and dismissing returns it to where it came from. Every action is a native button reachable with Tab; “What reverts” is a disclosure (aria-expanded, aria-controls); a pending extension sets aria-busy and aria-disabled; the extension limit sets aria-disabled with an explicit label (“No extensions left”), so the button keeps focus and stays readable instead of dropping focus to the page. Locked data-midnight-only elements are hidden or inert, so they leave the tab order and the accessibility tree instead of looking clickable. State is never color-only: each phase has its own icon (shield, bell, undo arrow) and words (time left, “ended at”). Reduced motion (OS or data-motion="reduce"): no pulse, sway or draining animation, a chime becomes a static ring. Forced colors: system borders, Highlight fill and outline. Sound is opt-in and never plays before a user gesture. Keep the element connected for the whole grant: removed while the mode is on, it stops ticking and the data-midnight-only elements elsewhere keep their current state (removed after the deadline, it locks them at once); connected again, it ends immediately if the deadline has passed. Call end() before removing it for good.