エクスクルーシブ
Env Marker <mv-env-marker>
非本番環境を本番環境と取り違えることを不可能にする環境インジケーター。
文化的リファレンス
インセプション、クリストファー・ノーラン(2010年、映画)。夢を見る者はそれぞれトーテムを持っています。それは小さな私物で、その振る舞いによって夢の中か現実かがわかります。夢の中ではコマが永遠に回り続け、現実では倒れます。UI では、現実ではないすべての環境(ステージング、プレビュー、テストモード、なりすまし)で小さなステータスビーコンが脈打ち続け、本番環境でだけ静止したソリッドなドットに落ち着くため、次のクリックがどの世界に着地するかがひと目でわかります。
仕組み
非本番環境を本番環境と取り違えることを不可能にする環境インジケーター。小さなステータスビーコン(柔らかな波を放つ、リング付きのドット)が、現実ではないすべての環境(local、preview、staging、sandbox、QA、またはテストモードやなりすましなどのモードが有効な本番環境)で脈打ち続けます。本番環境ではリングが閉じてドットが落ち着き、ソリッドで静止した、控えめでニュートラルな状態になり、ホバーで名前がスライドして現れるグリフにまで縮小します。環境は env 属性、または順序付きのホスト名ルール(.vercel.app, staging.、localhost:6006 のようなグロブや /regex/。妥当なデフォルトとフォールバック付き)から決まり、それぞれに独自のラベルと色味があります(環境ごとに --mv-env-marker-tint で上書き可能)。クリックまたは Enter で、コンパクトな非モーダルのポップオーバーが開きます。表示されるのは環境、モード、ホスト、ビルド、コミット、API ベース、データソース、開始時刻(en-US 形式の日付と相対時間)、検出に使われたルール、バグ報告用の Copy details ボタン、独自のアクション用のスロット(Open production、Stop impersonating)です。本番環境でこれを開くと、シグナルが一度だけ再生されます。ping が 1 回鳴り、再び静止します。オプションで、ラベル付きタブのあるページ端のフレーム(ビューポートまたは任意のコンテナ)と、ドキュメントタイトルの接頭辞([staging] Orders…)を追加でき、接頭辞はルーターによるタイトル変更後も維持されます。どちらも本番環境以外でのみ表示されます。デフォルトではタブのセッションごとに一度だけ落ち着くため、本番環境でしつこく注意を引くことはありません。
| カテゴリー | フィードバック |
|---|---|
| タイプ | Web Component(<mv-env-marker>) |
| ステータス | 安定版 |
| キット | 信頼とプライバシー |
| 同時にインストールされるもの | button |
| Keywords | exclusive, culture, environment, env-indicator, staging, production, preview-deploy, sandbox, test-mode, impersonation, devtools, deployment, build-info, safety, hostname, badge, frame, document-title, status-dot |
When to use
- Staff switch between staging, preview deploys and production in the same admin tool and must never act on the wrong one
- Support agents impersonate customers or turn on test mode and the app must keep showing that this is not their own live session
- Preview and review deployments on generated hostnames should announce themselves without any build-time configuration
- Bug reports from testers should carry the exact environment, build and commit with one click
Avoid when
- The message is an incident, maintenance or outage notice that every production user must read → use Alert instead
- A static status label inside content (order paid, plan active) with no environment logic → use Badge instead
- End users of production should never see any indicator at all: render nothing in production instead of a resting marker
インストール
node scripts/add.mjs env-marker --out ./src/marvelousMarvelous UI MCP サーバーを使う AI エージェント:install_components({ slugs: ["env-marker"], 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/position.js, components/env-marker/env-marker.js, components/env-marker/env-marker.css。
使い方
クイックスタート、動作する最小限のマークアップ:
<mv-env-marker env="staging" build="2026.09.22-3" commit="8f3c2a1"></mv-env-marker>基本となるマークアップです。ここから属性、data-*、CSS 変数でカスタマイズしてください:
<div id="em-demo" style="width:min(100%,56rem);margin-inline:auto">
<style>
#em-demo { display:grid; gap:1rem; align-content:start }
#em-demo .em-app { position:relative; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden }
#em-demo .em-url { display:flex; align-items:center; gap:.5rem; height:2.25rem; padding:0 .875rem; border-bottom:1px solid var(--mv-border); background:var(--mv-bg-subtle); color:var(--mv-fg-muted); font:.75rem/1 var(--mv-font-mono) }
#em-demo .em-url svg { width:.8125rem; height:.8125rem; flex:none; color:var(--mv-fg-subtle) }
#em-demo .em-url span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#em-demo .em-bar { display:flex; align-items:center; gap:1rem; height:3.5rem; padding:0 1rem 0 1.125rem; border-bottom:1px solid var(--mv-border) }
#em-demo .em-brand { display:flex; align-items:center; gap:.5rem; font-weight:650; font-size:.9375rem; letter-spacing:-.01em }
#em-demo .em-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) }
#em-demo .em-logo svg { width:1rem; height:1rem }
#em-demo .em-nav { display:flex; gap:.25rem; margin:0; padding:0; list-style:none }
#em-demo .em-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 }
#em-demo .em-nav a[aria-current] { background:var(--mv-bg-muted); color:var(--mv-fg) }
#em-demo .em-end { display:flex; align-items:center; gap:.75rem; margin-inline-start:auto }
#em-demo .em-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 }
#em-demo .em-body { display:grid; grid-template-columns:minmax(0,1fr) 14rem; gap:1rem; padding:1.125rem }
#em-demo .em-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) }
#em-demo .em-card h4 { display:flex; align-items:center; justify-content:space-between; gap:.5rem; margin:0 0 .25rem; font-size:.875rem }
#em-demo .em-card p { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.5 }
#em-demo .em-rows { display:grid; gap:.5rem; margin-top:.875rem }
#em-demo .em-row { display:flex; align-items:center; justify-content:space-between; gap:.75rem; padding:.5rem .625rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); background:var(--mv-surface); font-size:.8125rem }
#em-demo .em-row span:last-child { color:var(--mv-fg-muted); font-variant-numeric:tabular-nums }
#em-demo .em-side { display:grid; gap:.75rem; align-content:start }
#em-demo .em-side .mv-button { width:100% }
#em-demo .em-log { min-height:2.5rem; margin:0; color:var(--mv-fg-subtle); font:.6875rem/1.5 var(--mv-font-mono); overflow-wrap:anywhere }
#em-demo .em-controls { display:flex; align-items:center; gap:.75rem 1.25rem; flex-wrap:wrap; padding:.875rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface) }
#em-demo .em-field { display:grid; gap:.375rem }
#em-demo .em-k { color:var(--mv-fg-muted); font-size:.6875rem; font-weight:600; letter-spacing:.04em; text-transform:uppercase }
#em-demo .em-switches { display:flex; gap:.5rem 1.25rem; flex-wrap:wrap; margin-inline-start:auto }
#em-demo .mv-choice { font-size:.8125rem }
#em-demo .em-variants { display:grid; gap:.75rem; padding:1rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface) }
#em-demo .em-variants h4 { margin:0; font-size:.8125rem; font-weight:600 }
#em-demo .em-variants p { margin:0; color:var(--mv-fg-muted); font-size:.75rem }
#em-demo .em-chips { display:flex; align-items:center; gap:.625rem; flex-wrap:wrap }
@media (max-width:44rem) {
#em-demo .em-nav { display:none }
#em-demo .em-url { height:auto; padding:1.125rem .875rem .5rem } /* clear of the frame tab */
#em-demo .em-body { grid-template-columns:minmax(0,1fr) }
#em-demo .em-switches { margin-inline-start:0 }
}
</style>
<!-- Main: the same refund screen on four hosts. The marker says which one is real. -->
<section class="em-app" id="em-app" aria-label="Order console">
<div class="em-url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>
<span id="em-url">https://staging.kestrel.example/orders/KS-48213</span>
</div>
<header class="em-bar">
<span class="em-brand">
<span class="em-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 7l9-4 9 4-9 4z"/><path d="M3 7v10l9 4 9-4V7"/><path d="M12 11v10"/></svg></span>
Kestrel
</span>
<ul class="em-nav">
<li><a href="#" aria-current="page">Orders</a></li>
<li><a href="#">Customers</a></li>
<li><a href="#">Payouts</a></li>
</ul>
<span class="em-end">
<mv-env-marker id="em-main" hostname="staging.kestrel.example" frame="#em-app" settle="always"
build="2026.09.22-3" commit="8f3c2a1" api="https://api.staging.kestrel.example/v2"
source="Nightly snapshot, anonymized" since="2026-09-22T16:12:00-07:00">
<a slot="actions" class="mv-button" data-variant="ghost" data-size="sm" href="#" id="em-goprod">Open production</a>
</mv-env-marker>
<span class="em-me" aria-hidden="true">MR</span>
</span>
</header>
<div class="em-body">
<div class="em-card">
<h4>Order KS-48213 <span class="mv-badge" data-variant="success" data-shape="pill">Paid</span></h4>
<p>Aiko Tanaka · Osaka, Japan · paid Sep 21, 2026 with the card ending 4242</p>
<div class="em-rows">
<div class="em-row"><span>Linen overshirt, sand (M)</span><span>$148.00</span></div>
<div class="em-row"><span>Merino crew, charcoal (M) × 2</span><span>$236.00</span></div>
<div class="em-row"><span>Waxed field jacket (L)</span><span>$856.00</span></div>
</div>
</div>
<div class="em-side">
<button type="button" class="mv-button" data-variant="destructive" id="em-refund">Refund $1,240.00</button>
<button type="button" class="mv-button" data-variant="outline">Resend receipt</button>
<p class="em-log" id="em-log" aria-live="off">Resolved staging from host rule staging.*</p>
</div>
</div>
</section>
<div class="em-controls">
<div class="em-field">
<span class="em-k" id="em-host-label">Simulated host</span>
<mv-segmented id="em-host" aria-labelledby="em-host-label" value="staging">
<button value="local">localhost</button>
<button value="preview">Preview deploy</button>
<button value="staging">Staging</button>
<button value="production">Production</button>
</mv-segmented>
</div>
<div class="em-switches">
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="em-imp"> Impersonate the customer</label>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="em-frame" checked> Frame the edges</label>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="em-title"> Prefix the tab title</label>
</div>
</div>
<div class="em-variants">
<h4>Every environment at a glance</h4>
<div class="em-chips">
<mv-env-marker env="local" hostname="localhost:3000" build="dev server" api="http://localhost:8787"></mv-env-marker>
<mv-env-marker env="preview" hostname="checkout-git-tax-rules.preview.kestrel.example" commit="c41d9e2"></mv-env-marker>
<mv-env-marker env="staging" hostname="staging.kestrel.example" build="2026.09.22-3"></mv-env-marker>
<mv-env-marker env="sandbox" hostname="sandbox.kestrel.example" source="Test cards only"></mv-env-marker>
<mv-env-marker env="production" mode="Test mode" tone="warning" hostname="app.kestrel.example" source="Test data, no real charges"></mv-env-marker>
<mv-env-marker env="production" hostname="app.kestrel.example" settle="always" compact="never" build="2026.09.21-1"></mv-env-marker>
<mv-env-marker env="production" hostname="app.kestrel.example" settle="always" build="2026.09.21-1"></mv-env-marker>
</div>
<p>Anything that is not production keeps pulsing. Production settles once into a solid, still dot, tiny and quiet (last one: compact, the name slides out on hover). Click any of them for the details.</p>
</div>
<script type="module">
const $ = (id) => document.getElementById(id);
const marker = $("em-main");
const log = $("em-log");
const ago = (min) => new Date(Date.now() - min * 60_000).toISOString();
const HOSTS = {
local: { host: "localhost:5173", build: "dev server", commit: "working tree", api: "http://localhost:8787/v2", source: "Seeded fixtures (42 orders)", since: ago(12) },
preview: { host: "checkout-git-tax-rules.preview.kestrel.example", build: "Preview #1184", commit: "c41d9e2", api: "https://api.staging.kestrel.example/v2", source: "Staging replica", since: ago(95) },
staging: { host: "staging.kestrel.example", build: "2026.09.22-3", commit: "8f3c2a1", api: "https://api.staging.kestrel.example/v2", source: "Nightly snapshot, anonymized", since: ago(60 * 19) },
production: { host: "app.kestrel.example", build: "2026.09.21-1", commit: "7ab03f5", api: "https://api.kestrel.example/v2", source: "Primary database (us-east-1)", since: ago(60 * 42) },
};
const apply = (key) => {
const h = HOSTS[key];
$("em-url").textContent = `https://${h.host}/orders/KS-48213`;
marker.hostname = h.host;
marker.build = h.build;
marker.commit = h.commit;
marker.api = h.api;
marker.source = h.source;
marker.since = h.since;
$("em-goprod").hidden = key === "production";
};
const showTitle = () => requestAnimationFrame(() => {
if ($("em-title").checked) log.textContent = `Tab title: ${document.title}`;
});
marker.addEventListener("mv-resolve", (e) => {
const { env, source, rule, real, mode } = e.detail;
const how = source === "attribute" ? "the env attribute" : source === "rule" ? `host rule ${rule}` : "the fallback (no rule matched)";
log.textContent = `Resolved ${env}${mode ? ` + ${mode}` : ""} from ${how} · ${real ? "real: the marker settles" : "not real: it pulses"}`;
showTitle();
});
$("em-host").addEventListener("mv-change", (e) => apply(e.detail.value));
$("em-imp").addEventListener("change", (e) => { marker.mode = e.target.checked ? "Viewing as Aiko Tanaka" : null; });
$("em-frame").addEventListener("change", (e) => { marker.frame = e.target.checked ? "#em-app" : null; });
$("em-title").addEventListener("change", (e) => { marker.titlePrefix = e.target.checked ? "" : null; showTitle(); });
$("em-goprod").addEventListener("click", (e) => {
e.preventDefault();
$("em-host").value = "production";
apply("production");
marker.toggle(false);
});
$("em-refund").addEventListener("click", () => {
const r = marker.resolved;
log.textContent = r.production
? `Refund of $1,240.00 sent to the card ending 4242. This was real${r.mode ? `, even ${r.mode.toLowerCase()}` : ""}.`
: `Refund simulated on ${r.label.toLowerCase()}: no money moved.`;
});
apply("staging");
</script>
</div>API
Attributes
| Name | タイプ | Default | Description |
|---|---|---|---|
env | string | The environment, as your build knows it ("staging", "preview", "local", "production", or any name). Wins over hostname rules. Known names get a label and a tone: production / prod / live (neutral), staging / uat (warning), preview / demo (accent), development / dev / local (info), sandbox / test / qa (success); other names are capitalized with the accent tone. | |
rules | string | Ordered hostname rules used when env is absent: "localhost|127.*=local, *.vercel.app=preview, staging.*=staging". Separate rules with commas (or ; / new lines when a pattern contains a comma), alternatives with |, and the env with =, -> or →. * matches anything (dots included); a pattern with a port (localhost:6006) is matched against host:port; /…/flags is a regular expression. Replaces the defaults (localhost and private IPs → local; .vercel.app, .netlify.app, .pages.dev, preview. → preview; staging.*, stage.*, -staging. → staging; dev.* → development; sandbox.*, qa.*, uat.*, test.*). | |
hostname | string | location.host | Host to resolve instead of the current one ("staging.acme.io", "localhost:5173" or a full URL). For previews, tests and demos. |
fallback | string | production | Environment used when no rule matches the host. |
production | comma-separated list | production, prod, live | Environment names that count as real: only those settle into the still, solid dot. |
mode | string | A state layered on the environment that makes it not real even in production: "Test mode", "Viewing as Aiko Tanaka", "Read-only replica". Shown as the chip label, with the danger tone by default; the beacon keeps pulsing. | |
label | string | Visible name of the environment (default from env: "Staging", "Preview"…). | |
tone | neutral | accent | info | success | warning | danger | CSS color | Tint override. Per environment in CSS: mv-env-marker[data-env="qa"] { --mv-env-marker-tint: … }. | |
description | string | Sentence under the popover title (defaults explain whether changes reach real customers). | |
build / commit / api / source | string | Detail rows of the popover: build or release, commit hash, API base URL, data source ("Nightly snapshot, anonymized"). Rows without a value are omitted. | |
since | ISO date-time | epoch ms | Since when this environment runs (deploy time, impersonation start). Shown as an en-US date with a relative time ("19 hours ago"), recomputed each time the popover opens. | |
locale | BCP 47 | en-US | Locale of the date and relative time. |
dock | bottom-start | bottom-end | top-start | top-end | Fixes the marker in a corner of the viewport (or of the nearest ancestor that contains fixed elements), with a raised shadow. Without it the marker is inline (a header, a toolbar). | |
placement | top | bottom (-start | -end) | bottom-end, or away from the docked edge | Preferred side of the popover (flips and shifts to stay in the viewport). |
frame | "" | "viewport" | CSS selector | Outside production, draws a tinted border with a small labeled tab around the viewport (empty value, top layer) or around the matched element (closest ancestor first, made position: relative if it was static, restored afterwards). Never shown in production. | |
title-prefix | "" | template | "[{env}] " | Outside production, prefixes document.title ("[staging] Orders · Kestrel") and puts the prefix back when a router rewrites the title. Template placeholders: {env}, {label}, {mode}. Removed in production and when the element goes away. One instance owns the title at a time. |
compact | auto | always | never | auto | Glyph-only chip whose name slides out on hover and keyboard focus. auto = compact in production only. |
settle | session | always | never | session | When the settle animation is played on load in production: once per tab session (then the dot is already still), on every load, or never. A live switch to production always plays it; reduced motion never does. |
open | boolean | Opens the popover (reflected). | |
data-env / data-tone / data-real / data-mode / data-glyph / data-compact | set by the component | Styling hooks: normalized env, tone, real (production without mode), mode present, glyph state (pulse | settle | check | rest), compact. |
Properties
| Name | タイプ | Description |
|---|---|---|
resolved | { env, label, tone, real, production, mode, name, source, rule, host } | Current resolution (read-only). source: "attribute" | "rule" | "fallback"; rule: the pattern that matched; production: the env is a production name (true even with a mode on); name: accessible name ("Staging environment"). |
real | boolean | True in production with no mode on (read-only). |
rules | string | Array<[pattern, env]> | Array<{ match, env }> | Record<pattern, env> | Hostname rules as data; patterns may be strings (globs, |), RegExp or functions (host, hostWithPort) => boolean. A string is reflected to the attribute. |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (name, nameMode, realText, unrealText, modeText, rowEnv, rowMode, rowHost, rowBuild, rowCommit, rowApi, rowSource, rowSince, rowFrom, fromAttribute, fromRule, fromFallback, copy, copied, copyFailed, announce, announceMode, announceCopied, titlePrefix). English defaults. |
env / hostname / mode / build / commit / api / source / since / frame / titlePrefix / … | reflected | Mirror the attributes; any change re-resolves live. |
Methods
| Name | Description |
|---|---|
toggle(force?) | Opens or closes the popover. |
check() | Replays the production signal: the still dot sends one ping and settles again (nothing under reduced motion or outside production). |
MvEnvMarker.resolve(hostname, { rules?, fallback? }) | Static: resolves a hostname with the same rules, without an element (tests, server rendering). Returns { env, source, rule, host }. |
Events
| Name | Description |
|---|---|
mv-resolve | The environment was resolved (on connection and on every change). detail: the resolved object. |
mv-toggle | The popover opens (cancelable: preventDefault() keeps it closed) or closes. detail: { open }. |
mv-copy | Copy details was pressed (cancelable: preventDefault() and send detail.text to your own bug reporter instead of the clipboard). detail: { text }. |
Content structure
| Name | Description |
|---|---|
actions | Children with slot="actions" (a link to production, Stop impersonating…) go in the popover footer, next to Copy details. |
details | Children with slot="details" go under the detail rows (a note, a link to the release notes). Children added later are adopted too. |
CSS classes
| Name | Description |
|---|---|
mv-env-marker-chip | The trigger <button>: .mv-env-marker-glyph + .mv-env-marker-label (.mv-env-marker-label-text). |
mv-env-marker-glyph | The status beacon (aria-hidden): -wave (the outgoing pulse), -ring (outline, shown outside production), -dot (the core). --_g sets its size. |
mv-env-marker-panel | The popover (Popover API top layer): -head, -emblem, -title, -text, -details (.mv-env-marker-row, dt/dd, dd[data-mono]), -slot, -foot, -actions, -copy. |
mv-env-marker-frame | The page-edge frame (aria-hidden, pointer-events: none) and its -frame-tab. data-scope="viewport | element". |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-env-marker-tint | tone color | Environment tint (chip, glyph, frame, popover stripe). |
--mv-env-marker-pulse | 1.8s | Duration of one outgoing wave of the beacon outside production. |
--mv-env-marker-frame-width | 3px | Frame thickness. |
--mv-env-marker-frame-ink | near-black | Text color of the frame tab on the tint. |
--mv-env-marker-inset | 12px | Distance from the corner when docked. |
Accessibility
The chip is a real <button> (aria-haspopup="dialog", aria-expanded, aria-controls) whose accessible name always spells out the environment (“Staging environment”, “Viewing as Aiko Tanaka, Production environment”) and starts with the visible text, so voice control works; the glyph is aria-hidden, the color is never the only signal (the visible name and the shape, a ringed dot or a solid dot, say it too). Enter or Space opens a labelled, described non-modal dialog placed right after the chip in the DOM, focus moves to it, Tab reaches Copy details and the slotted actions, Escape closes it and returns focus to the chip, an outside click or focus leaving the component closes it without moving focus. The details are a real description list. A live change of environment or mode after load is announced once in a polite live region (“Now in Production environment.”), and Copy details announces its result. The title prefix also reaches screen reader users, who hear “[staging]” in the tab name. Compact chips keep the full accessible name and reveal the visible name on hover and keyboard focus. Reduced motion (OS setting or data-motion="reduce"): nothing pulses or settles; outside production the glyph is a tinted dot inside a ring, in production a solid dot with no ring, so the two states stay unmistakable without motion. Forced colors: the glyph uses Highlight (not real) or CanvasText (real), the frame Highlight, the chip ButtonText. The frame is aria-hidden with pointer-events: none and never intercepts clicks.