익스클루시브

Env Marker <mv-env-marker>

비프로덕션 환경을 프로덕션으로 착각할 수 없게 만드는 환경 표시기입니다.

문화적 레퍼런스

인셉션, 크리스토퍼 놀란(2010년, 영화). 꿈을 꾸는 사람은 저마다 토템을 지닙니다. 토템은 작은 개인 물건으로, 그 움직임을 보면 꿈인지 현실인지 알 수 있습니다: 팽이는 꿈속에서는 영원히 돌고 현실에서는 쓰러집니다. UI에서는 실제가 아닌 모든 환경(스테이징, 프리뷰, 테스트 모드, 사용자 가장)에서 작은 상태 신호등이 계속 맥동하고, 프로덕션에서만 움직임 없는 단단한 점으로 가라앉으므로, 다음 클릭이 어느 세계에 닿을지 한눈에 알 수 있습니다.

작동 방식

비프로덕션 환경을 프로덕션으로 착각할 수 없게 만드는 환경 표시기입니다. 부드러운 파동을 내보내는 링 달린 점, 즉 작은 상태 신호등이 실제가 아닌 모든 환경(local, preview, staging, sandbox, QA, 또는 test mode나 impersonation 같은 모드가 켜진 production)에서 계속 맥동하고, 프로덕션에서는 링이 오므라들며 점이 가라앉습니다. 단단하고 정지된, 조용하고 중립적인 모습으로 글리프 크기까지 줄어들고, 호버하면 이름이 미끄러져 나옵니다. 환경은 env 속성이나 순서가 있는 호스트 이름 규칙(.vercel.app, staging., localhost:6006 같은 글롭 또는 /regex/, 합리적인 기본값과 폴백 포함)으로 결정되며, 각 환경은 고유한 레이블과 색조를 가집니다(환경별로 --mv-env-marker-tint로 재정의 가능). 클릭하거나 Enter를 누르면 간결한 비모달 팝오버가 열립니다: 환경, 모드, 호스트, 빌드, 커밋, API 베이스, 데이터 소스, 시작 시점(en-US 날짜와 상대 시간), 감지한 규칙, 버그 리포트용 Copy details 버튼, 그리고 직접 추가하는 액션용 슬롯(Open production, Stop impersonating). 프로덕션에서 팝오버를 열면 신호가 한 번 재생됩니다: 핑 한 번, 그리고 다시 정지. 선택적으로 레이블 탭이 달린 페이지 가장자리 프레임(뷰포트 또는 임의의 컨테이너)과 문서 제목 접두사([staging] Orders…)를 표시할 수 있으며, 접두사는 라우터가 제목을 바꿔도 유지되고, 둘 다 프로덕션 밖에서만 나타납니다. 기본적으로 탭 세션당 한 번만 가라앉으므로 프로덕션에서는 절대 거슬리지 않습니다.

카테고리피드백
유형Web Component (<mv-env-marker>)
상태안정
키트신뢰와 개인정보 보호
함께 설치되는 항목button
Keywordsexclusive, 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/marvelous

Marvelous 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유형DefaultDescription
envstringThe 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.
rulesstringOrdered 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.*).
hostnamestringlocation.hostHost to resolve instead of the current one ("staging.acme.io", "localhost:5173" or a full URL). For previews, tests and demos.
fallbackstringproductionEnvironment used when no rule matches the host.
productioncomma-separated listproduction, prod, liveEnvironment names that count as real: only those settle into the still, solid dot.
modestringA 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.
labelstringVisible name of the environment (default from env: "Staging", "Preview"…).
toneneutral | accent | info | success | warning | danger | CSS colorTint override. Per environment in CSS: mv-env-marker[data-env="qa"] { --mv-env-marker-tint: … }.
descriptionstringSentence under the popover title (defaults explain whether changes reach real customers).
build / commit / api / sourcestringDetail rows of the popover: build or release, commit hash, API base URL, data source ("Nightly snapshot, anonymized"). Rows without a value are omitted.
sinceISO date-time | epoch msSince 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.
localeBCP 47en-USLocale of the date and relative time.
dockbottom-start | bottom-end | top-start | top-endFixes 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).
placementtop | bottom (-start | -end)bottom-end, or away from the docked edgePreferred side of the popover (flips and shifts to stay in the viewport).
frame"" | "viewport" | CSS selectorOutside 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.
compactauto | always | neverautoGlyph-only chip whose name slides out on hover and keyboard focus. auto = compact in production only.
settlesession | always | neversessionWhen 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.
openbooleanOpens the popover (reflected).
data-env / data-tone / data-real / data-mode / data-glyph / data-compactset by the componentStyling 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").
realbooleanTrue in production with no mode on (read-only).
rulesstring | 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.
stringsPartial<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 / …reflectedMirror the attributes; any change re-resolves live.

Methods

NameDescription
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

NameDescription
mv-resolveThe environment was resolved (on connection and on every change). detail: the resolved object.
mv-toggleThe popover opens (cancelable: preventDefault() keeps it closed) or closes. detail: { open }.
mv-copyCopy details was pressed (cancelable: preventDefault() and send detail.text to your own bug reporter instead of the clipboard). detail: { text }.

Content structure

NameDescription
actionsChildren with slot="actions" (a link to production, Stop impersonating…) go in the popover footer, next to Copy details.
detailsChildren with slot="details" go under the detail rows (a note, a link to the release notes). Children added later are adopted too.

CSS classes

NameDescription
mv-env-marker-chipThe trigger <button>: .mv-env-marker-glyph + .mv-env-marker-label (.mv-env-marker-label-text).
mv-env-marker-glyphThe status beacon (aria-hidden): -wave (the outgoing pulse), -ring (outline, shown outside production), -dot (the core). --_g sets its size.
mv-env-marker-panelThe 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-frameThe page-edge frame (aria-hidden, pointer-events: none) and its -frame-tab. data-scope="viewport | element".

CSS variables

NameDefaultDescription
--mv-env-marker-tinttone colorEnvironment tint (chip, glyph, frame, popover stripe).
--mv-env-marker-pulse1.8sDuration of one outgoing wave of the beacon outside production.
--mv-env-marker-frame-width3pxFrame thickness.
--mv-env-marker-frame-inknear-blackText color of the frame tab on the tint.
--mv-env-marker-inset12pxDistance 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.

이 페이지는 AI로 번역되었습니다. 번역 문제 신고