独家

Invite In <mv-invite-in>

隐私优先的嵌入,在用户主动邀请之前不向第三方发出任何请求:YouTube 和 Vimeo 视频、地图、社交媒体帖子、聊天小部件、大量统计脚本的 iframe、第三方字体样式表。

文化出处

德古拉,布拉姆·斯托克(1897 年,书籍)。在这部小说所普及的吸血鬼传说中,吸血鬼在屋内有人邀请之前无法跨过门槛,而一旦受邀,他就可以随意再来。在 UI 中,第三方嵌入内容在页面外等待,不发出任何请求,直到用户邀请它;“Always allow” 则让该提供方在每个页面上都能再次进入,直到邀请被撤回。

工作原理

隐私优先的嵌入组件,在用户主动邀请之前不向任何第三方发出请求:YouTube 和 Vimeo 视频、地图、社交帖子、聊天挂件、带大量统计脚本的 iframe、第三方字体样式表。获得同意之前,嵌入内容只以属性或惰性的 <template> 形式存在,绝不会是活动的 iframe 或脚本;占位符保持最终的宽高比,显示应用自己的封面图(或包含提供方、标题和详情的中性预览),用一行文字说明加载会共享什么(“Loading this video shares your IP address and cookies with YouTube.”),附上提供方的隐私政策和“Open on YouTube”这一退出途径,并提供“Load video”(仅这一个,自动播放)或“Always allow YouTube”。记住的同意按提供方区分(普通 iframe 按主机区分),只有在用户选择时才会保存(localStorage、会话或仅本页面),在多个标签页之间同步,并且可以通过 MvInviteIn.providers() 或配套的 <mv-invite-in-settings> 列表撤销:已允许的提供方在接近视口时延迟加载,被撤销的则立即断开。预设会把 YouTube 改写为 youtube-nocookie.com、为 Vimeo 加上 dnt=1,能解析 watch、short、embed 和 playlist 类型的 URL,可根据普通地址生成 Google Maps 嵌入,自定义提供方只需一次调用即可注册;mv-invite 可以取消,便于记录同意日志或接入你自己的 CMP,模板中的脚本会被重新创建(保留 nonce),因此挂件只会在被邀请后运行。

分类媒体
类型Web Component(<mv-invite-in>)
状态稳定版
套件信任与隐私
同时安装button, checkbox, switch
Keywordsexclusive, culture, privacy, consent, gdpr, embed, iframe, youtube, vimeo, maps, facade, lazy-load, click-to-load, third-party, cookies, performance, chat-widget, cmp

When to use

  • A marketing page, blog or course embeds YouTube, Vimeo or map iframes and must not contact those services before consent
  • A chat, social or booking widget should load its third-party script only when a visitor actually wants it
  • Visitors should be able to trust a provider once and later withdraw that choice from a settings or privacy page
  • A page full of video embeds loads slowly and each player should cost nothing until someone asks for it

Avoid when

  • You need a full cookie banner with purposes and vendor lists: this handles consent per embed, not site-wide categories
  • The media is self-hosted and contacts no third party: a native video element or a lazy iframe needs no consent gate
  • Sensitive first-party data must be revealed with an audit trail rather than a third party being loaded → use Tamper Seal instead

安装

node scripts/add.mjs invite-in --out ./src/marvelous

使用 Marvelous UI MCP 服务器的 AI 智能体:install_components({ slugs: ["invite-in"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。

复制的文件(含依赖):tokens/tokens.css, core/base.css, components/button/button.css, components/checkbox/checkbox.css, components/switch/switch.css, core/dom.js, core/element.js, core/motion.js, core/observe.js, components/invite-in/invite-in.js, components/invite-in/invite-in.css。

用法

快速开始,最小可运行标记:

<mv-invite-in src="https://www.youtube.com/watch?v=WhWc3b3KhnY" label="Product tour"></mv-invite-in>

标准标记,可在此基础上通过属性、data-* 和 CSS 变量进行定制:

<div id="ii-demo" style="width:min(100%,68rem);margin-inline:auto">
  <style>
    #ii-demo { display:grid; gap:1rem; align-content:start }
    #ii-demo .ii-head { display:flex; align-items:flex-end; justify-content:space-between; gap:.75rem 1.5rem; flex-wrap:wrap; padding:1rem 1.25rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
    #ii-demo .ii-title { display:grid; gap:.25rem; min-width:0 }
    #ii-demo .ii-crumbs { color:var(--mv-fg-muted); font-size:.75rem; font-weight:500 }
    #ii-demo .ii-title h3 { margin:0; font-size:1.125rem; letter-spacing:-.015em }
    #ii-demo .ii-by { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; color:var(--mv-fg-muted); font-size:.8125rem }
    #ii-demo .ii-controls { display:flex; align-items:center; gap:.5rem 1rem; flex-wrap:wrap }
    #ii-demo label.mv-choice { font-size:.8125rem }
    #ii-demo .ii-grid { display:grid; grid-template-columns:minmax(0,1fr) 20rem; gap:1rem; align-items:start }
    #ii-demo .ii-main { display:grid; gap:1rem; min-width:0 }
    #ii-demo .ii-text { margin:0; color:var(--mv-fg-muted); font-size:.875rem; line-height:1.6; max-width:62ch }
    #ii-demo .ii-text strong { color:var(--mv-fg); font-weight:600 }
    #ii-demo .ii-pair { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:1rem; align-items:start }
    #ii-demo figure { display:grid; gap:.5rem; margin:0; min-width:0 }
    #ii-demo figcaption { color:var(--mv-fg-muted); font-size:.75rem }
    #ii-demo .ii-side { display:grid; gap:1rem; min-width:0 }
    #ii-demo .ii-card { display:grid; gap:.75rem; padding:1rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs); min-width:0 }
    #ii-demo .ii-card h4 { margin:0; font-size:.875rem; letter-spacing:-.01em }
    #ii-demo .ii-card p { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.5 }
    #ii-demo .ii-card-head { display:grid; gap:.25rem }
    #ii-demo .ii-status { display:flex; align-items:center; justify-content:space-between; gap:.5rem }
    #ii-demo .ii-log { display:grid; gap:.375rem; margin:0; padding:0; list-style:none; font:.75rem/1.45 var(--mv-font-mono); color:var(--mv-fg-muted) }
    #ii-demo .ii-log li { display:grid; grid-template-columns:auto minmax(0,1fr); gap:.5rem }
    #ii-demo .ii-log time { color:var(--mv-fg-subtle) }
    #ii-demo .ii-log b { color:var(--mv-fg); font-weight:600 }
    /* The mentor chat below is a stand-in for a third-party widget script. */
    #ii-demo .ii-chat { display:grid; grid-template-rows:auto 1fr auto; min-height:15rem; background:var(--mv-surface) }
    #ii-demo .ii-chat-head { display:flex; align-items:center; gap:.625rem; padding:.75rem .875rem; border-bottom:1px solid var(--mv-border) }
    #ii-demo .ii-chat-avatar { display:grid; place-items:center; width:2rem; height:2rem; border-radius:50%; background:var(--mv-accent); color:var(--mv-fg-on-accent); font-size:.75rem; font-weight:600 }
    #ii-demo .ii-chat-who { display:grid; line-height:1.3 }
    #ii-demo .ii-chat-who b { font-size:.8125rem }
    #ii-demo .ii-chat-who span { color:var(--mv-fg-muted); font-size:.75rem }
    #ii-demo .ii-chat-body { display:grid; align-content:end; gap:.5rem; padding:.875rem }
    #ii-demo .ii-chat-msg { justify-self:start; max-width:85%; padding:.5rem .75rem; border-radius:var(--mv-radius-lg); border-end-start-radius:.25rem; background:var(--mv-bg-muted); font-size:.8125rem; line-height:1.45 }
    #ii-demo .ii-chat-form { display:flex; gap:.5rem; padding:.625rem .75rem; border-top:1px solid var(--mv-border) }
    #ii-demo .ii-chat-form input { flex:1; min-width:0; height:2rem; padding:0 .625rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); background:var(--mv-bg); color:var(--mv-fg); font:inherit; font-size:.8125rem }
    @media (max-width:60rem) { #ii-demo .ii-grid { grid-template-columns:minmax(0,1fr) } }
    @media (max-width:40rem) { #ii-demo .ii-pair { grid-template-columns:minmax(0,1fr) } }
  </style>

  <header class="ii-head">
    <span class="ii-title">
      <span class="ii-crumbs">Northlight Academy · Lighting for Animation</span>
      <h3>Lesson 3 · Rim light and silhouettes</h3>
      <span class="ii-by"><span class="mv-badge" data-variant="secondary" data-size="sm">Lesson 3 of 8</span> Amara Okafor · 25 min</span>
    </span>
    <span class="ii-controls">
      <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="ii-persist" checked> Remember choices across visits</label>
      <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="ii-reset">Reset demo</button>
    </span>
  </header>

  <div class="ii-grid">
    <div class="ii-main">
      <!-- YouTube (rewritten to youtube-nocookie.com), with a poster supplied by the app -->
      <mv-invite-in id="ii-video" src="https://www.youtube.com/watch?v=WhWc3b3KhnY"
        label="Spring, a Blender open movie" subtitle="Blender Studio · CC BY 4.0"
        poster="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1600 900' preserveAspectRatio='xMidYMid slice'%3E%3Cdefs%3E%3ClinearGradient id='s' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0' stop-color='%231b3347'/%3E%3Cstop offset='.5' stop-color='%2358808f'/%3E%3Cstop offset='1' stop-color='%23e9c7a2'/%3E%3C/linearGradient%3E%3CradialGradient id='g' cx='.7' cy='.56' r='.4'%3E%3Cstop offset='0' stop-color='%23ffe9c7' stop-opacity='.9'/%3E%3Cstop offset='1' stop-color='%23ffe9c7' stop-opacity='0'/%3E%3C/radialGradient%3E%3ClinearGradient id='f' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0' stop-color='%23fff' stop-opacity='.35'/%3E%3Cstop offset='1' stop-color='%23fff' stop-opacity='0'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='1600' height='900' fill='url(%23s)'/%3E%3Crect width='1600' height='900' fill='url(%23g)'/%3E%3Cpath d='M0 540 L170 455 L330 505 L520 370 L700 465 L860 395 L1040 490 L1230 360 L1420 455 L1600 405 V900 H0Z' fill='%2348707e' opacity='.7'/%3E%3Crect y='470' width='1600' height='160' fill='url(%23f)' opacity='.5'/%3E%3Cpath d='M0 650 L140 585 L300 632 L480 548 L680 625 L870 560 L1070 645 L1250 572 L1430 636 L1600 590 V900 H0Z' fill='%232a4855'/%3E%3Cpath d='M0 765 C200 722 380 786 600 742 C820 700 1000 772 1220 732 C1380 706 1500 742 1600 726 V900 H0Z' fill='%23132a34'/%3E%3C/svg%3E">
        <a href="https://www.youtube.com/watch?v=WhWc3b3KhnY">Watch “Spring” on YouTube</a>
      </mv-invite-in>

      <p class="ii-text">Pause on the forest shots: the <strong>rim light</strong> traces the character's outline against a darker background, so the silhouette reads even when the key light is low. Compare with the soft morning key light in the second reference.</p>

      <div class="ii-pair">
        <figure>
          <!-- Vimeo (dnt=1), neutral preview: no poster -->
          <mv-invite-in src="https://vimeo.com/1084537" label="Big Buck Bunny" subtitle="9:57 · Blender Foundation · CC BY 3.0"></mv-invite-in>
          <figcaption>Reference 2 · Soft morning key light</figcaption>
        </figure>
        <figure>
          <!-- Custom provider + <template>: the widget script only runs once invited -->
          <mv-invite-in provider="parleydesk" ratio="auto" label="Ask a mentor" subtitle="Amara usually replies within 10 minutes" style="--mv-invite-in-min-height:15rem">
            <template>
              <div class="ii-chat"></div>
              <script>
                (() => {
                  const root = document.currentScript.previousElementSibling;
                  const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text) n.textContent = text; return n; };
                  const head = el("div", "ii-chat-head");
                  const who = el("span", "ii-chat-who");
                  who.append(el("b", "", "Amara Okafor"), el("span", "", "Mentor · online"));
                  head.append(el("span", "ii-chat-avatar", "AO"), who);
                  const body = el("div", "ii-chat-body");
                  body.append(el("p", "ii-chat-msg", "Hi! Stuck on the rim light exercise? Share a frame and I'll take a look."));
                  const form = el("form", "ii-chat-form");
                  const input = el("input");
                  input.placeholder = "Write a message…";
                  input.setAttribute("aria-label", "Message to Amara");
                  const send = el("button", "mv-button", "Send");
                  send.dataset.size = "sm";
                  form.append(input, send);
                  form.addEventListener("submit", (e) => {
                    e.preventDefault();
                    if (!input.value.trim()) return;
                    const mine = el("p", "ii-chat-msg", input.value.trim());
                    mine.style.cssText = "justify-self:end;background:var(--mv-accent);color:var(--mv-fg-on-accent);border-radius:var(--mv-radius-lg);border-end-end-radius:.25rem";
                    body.append(mine);
                    input.value = "";
                  });
                  root.append(head, body, form);
                })();
              </script>
            </template>
          </mv-invite-in>
          <figcaption>Live help · third-party chat widget</figcaption>
        </figure>
      </div>
    </div>

    <aside class="ii-side">
      <section class="ii-card" aria-labelledby="ii-venue-title">
        <span class="ii-card-head">
          <h4 id="ii-venue-title">In-person session</h4>
          <p>Thursday, Oct 8 · 6:00 PM PDT · Pier 70, San Francisco</p>
        </span>
        <!-- Google Maps from a plain address -->
        <mv-invite-in provider="google-maps" ratio="5/4" src="Pier 70, San Francisco, CA" label="Pier 70, San Francisco" subtitle="420 22nd St, San Francisco, CA 94107"></mv-invite-in>
      </section>

      <section class="ii-card" aria-labelledby="ii-settings-title">
        <span class="ii-card-head">
          <h4 id="ii-settings-title">Embedded content</h4>
          <p>Services allowed to load without asking. Switch one off to disconnect it.</p>
        </span>
        <mv-invite-in-settings id="ii-settings"></mv-invite-in-settings>
      </section>

      <section class="ii-card" aria-labelledby="ii-log-title">
        <span class="ii-status">
          <h4 id="ii-log-title">Consent log</h4>
          <span class="mv-badge" data-variant="success" data-size="sm" id="ii-count">0 connected</span>
        </span>
        <ol class="ii-log" id="ii-log" aria-live="polite"></ol>
      </section>
    </aside>
  </div>

  <script type="module">
    await customElements.whenDefined("mv-invite-in");
    const MvInviteIn = customElements.get("mv-invite-in");
    const root = document.getElementById("ii-demo");
    const log = document.getElementById("ii-log");
    const count = document.getElementById("ii-count");
    const persist = document.getElementById("ii-persist");

    // A custom provider for a script-based widget (its markup lives in the element's <template>).
    MvInviteIn.register("parleydesk", {
      name: "Parleydesk",
      kind: "chat",
      note: "Loading chat shares your IP address and browser details with Parleydesk.",
    });

    const time = () => new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" });
    const add = (who, what) => {
      const li = document.createElement("li");
      const t = document.createElement("time");
      t.textContent = time();
      const text = document.createElement("span");
      if (who) { const b = document.createElement("b"); b.textContent = who; text.append(b, " "); }
      text.append(what);
      li.append(t, text);
      log.prepend(li);
      while (log.children.length > 6) log.lastElementChild.remove();
    };
    const connected = new Set();
    const render = () => {
      count.textContent = `${connected.size} connected`;
      count.dataset.variant = connected.size ? "warning" : "success";
    };
    add("", "Page ready. No request made to YouTube, Vimeo, Google or Parleydesk.");

    const how = { click: "loaded once", always: "always allowed", remembered: "loaded from a saved choice", settings: "allowed in settings", api: "loaded by the app" };
    root.addEventListener("mv-invite", (e) => add(e.detail.name, how[e.detail.reason] + (e.detail.scope === "session" ? " (this visit)" : "")));
    root.addEventListener("mv-revoke", (e) => add(e.detail.name, "revoked"));
    root.addEventListener("mv-load", (e) => { connected.add(e.target); render(); });
    root.addEventListener("mv-unload", (e) => { connected.delete(e.target); render(); add(e.detail.name, "disconnected"); });

    const embeds = () => root.querySelectorAll("mv-invite-in");
    persist.addEventListener("change", () => {
      for (const el of embeds()) el.remember = persist.checked ? "local" : "session";
      document.getElementById("ii-settings").scope = persist.checked ? "local" : "session";
    });
    document.getElementById("ii-reset").addEventListener("click", () => {
      MvInviteIn.revoke(null, { source: "silent" });
      for (const el of embeds()) el.unload();
      log.replaceChildren();
      add("", "Demo reset. Every service asks again.");
    });
  </script>
</div>

API

Attributes

Name类型DefaultDescription
provideryoutube | vimeo | google-maps (alias maps) | iframe | custom idProvider preset. Detected from the src host when omitted (youtube.com, youtu.be, vimeo.com, google.com/maps), otherwise a generic iframe whose consent is keyed per host (iframe:calendar.example.com), so allowing one site never allows another.
srcstringWhat to embed. YouTube: watch, youtu.be, shorts, embed or playlist URL (t= / start= kept) or a bare 11-character id. Vimeo: page or player URL (unlisted hash kept) or numeric id. Google Maps: an embed URL, a maps URL with q=, or a plain address. iframe: any http(s) URL. Only http(s) ever reaches the iframe. Ignored when a <template> child is present.
labelstringTitle of the content (“Product tour”). Shown on the placeholder, used in the button name (“Load video: Product tour, from YouTube”) and in the loaded iframe's title.
subtitlestringSecondary line under the title: duration, author, license, address…
posterURLPreview image supplied by your app (self-hosted: the component never fetches provider thumbnails, which would leak the visit). A child with slot="poster" (<img>, <picture>) works too. Without one, a neutral preview is drawn.
ratio"16/9" | "4:3" | number | "auto"provider preset (16/9, maps 4/3)Aspect ratio kept before and after loading, so nothing shifts. auto = natural height of the loaded content (chat widgets, posts), with --mv-invite-in-min-height for the placeholder.
kindstringprovider presetNoun used in the texts and icon: video, audio, map, post, chat, content (“Load map”, “Loading this map shares…”).
rememberlocal | session | memory | offlocalWhere “Always allow” stores the choice: this browser (“Always allow YouTube”), this visit (“Allow YouTube for this visit”), this page only (“Allow YouTube on this page”), or off to hide the option. Nothing is stored until the user chooses it; blocked storage falls back to memory.
autoplay"true" | "false"trueAdds autoplay when the user clicks to load (the click already expressed intent). Never applied to embeds loaded from a remembered choice.
lazy"true" | "false"trueEmbeds of an allowed provider load when they come within 240px of the viewport instead of all at once.
paramsquery stringExtra parameters set on the final embed URL ("cc_load_policy=1&hl=en", "z=13").
notestringOverrides the one-line privacy note ({name} and {kind} are replaced).
policyURLprovider presetPrivacy policy linked under the note (YouTube and Google Maps: policies.google.com, Vimeo: vimeo.com/privacy). Hidden when none.
allow / sandboxstringOverride the iframe's allow list and add a sandbox.
credentiallessbooleanLoads the iframe as credentialless where supported (Chromium): no cookies or storage shared with the provider's other sites.
data-statewaiting | loading | loadedSet by the component (styleable), with data-provider, data-kind, data-poster and data-ratio="auto". aria-busy while loading.

Properties

Name类型DefaultDescription
state"waiting" | "loading" | "loaded"Read-only.
urlstring | nullThe embed URL loading will request, before the autoplay flag a click adds (null for <template> content or an invalid src). Read-only.
consentKey / providerName / kindNounstringResolved consent key (youtube, iframe:host…), display name and kind. Read-only.
allowedbooleanWhether this embed's provider is currently remembered as allowed. Read-only.
stringsPartial<Record<string, string>>Overrides for every visible text (keys: load, loadLabel, loadLabelNoTitle, loading, idle, note, always, allowSession, allowPage, policy, open, frameTitle, frameTitleNoLabel, newTab, announceAllowed, announceDisconnected). English defaults; {kind}, {name}, {label} are replaced.
MvInviteIn.storageKeystring"mv-invite-in"Static: storage key used for remembered choices.

Methods

NameDescription
invite({ remember? })Loads the embed from code (reason "api"); remember also grants the provider. Emits mv-invite first. Returns false if canceled or nothing to load.
unload()Disconnects the embed (the iframe or widget is removed) and shows the placeholder again; focus returns to it if it was inside. Emits mv-unload.
MvInviteIn.providers()Static. [{ id, name, kind, policy, allowed, scope, since (Date), inUse }] for every preset, custom provider, remembered host and host on the page: build your own privacy settings from it.
MvInviteIn.allow(id, { scope? }) / MvInviteIn.revoke(id?) / MvInviteIn.isAllowed(id)Static. Grant from your own consent banner (all matching embeds then load), withdraw one provider or all of them (matching embeds are disconnected, mv-revoke fires on document with source "api"), or check.
MvInviteIn.register(id, def)Static. Adds or overrides a provider: { name, kind, ratio, hosts: ["example.com"], match(url), embed(src, { autoplay }) → URL, open(src) → URL, note, policy, allow, sandbox }. Without embed, the element's <template> is used. Instances already on the page re-render.

Events

NameDescription
mv-inviteCancelable, before anything is requested. detail: { provider, name, kind, url, reason: "click" | "always" | "remembered" | "settings" | "api", remember, scope }. Log consent here, or preventDefault() to route through your own consent platform. Also fired by <mv-invite-in-settings> when a provider is switched on.
mv-revokeConsent withdrawn. detail: { provider, name, source: "settings" | "api" | "storage" }. Fired by <mv-invite-in-settings>, or on document for MvInviteIn.revoke() and changes made in another tab (storage).
mv-loadThe embed finished loading (or 8 s passed). detail: { provider, name, url }.
mv-unloadThe embed was disconnected (unload() or revocation). detail: { provider, name }.

Content structure

NameDescription
posterAn <img> or <picture> used as the preview (moved into the placeholder, decorative).
<template>Markup of script-based embeds (social post, chat widget, font <link>…). Inert until invited, then cloned in; scripts are re-created in order, nonce kept.
(fallback)Other children (e.g. a plain “Watch on YouTube” link) show before the element upgrades or without JavaScript, then are hidden.

CSS classes

NameDescription
mv-invite-in-stageThe ratio box holding the placeholder (.mv-invite-in-cover) and the loaded embed (.mv-invite-in-content, iframe.mv-invite-in-frame).
mv-invite-in-loadThe placeholder's real <button>: -chip (provider · Not connected), -center, -icon, -action, -title, -subtitle.
mv-invite-in-footPrivacy footer: -shield, -note (aria-describedby target), -links (-link: policy, open on provider), -always (the remembered choice).
mv-invite-in-settings-*<mv-invite-in-settings>: -list, -item (data-allowed), -row (label), -glyph, -name, -status, -empty, -foot, -note, -forget. Attributes: providers (comma list, default: those on the page then the remembered ones), label (list name, default “Embedded content”), scope (where switching on stores: local | session | memory). strings property: label, switchLabel, ask, allowedLocal, allowedSession, allowedMemory, inUse, empty, footer, forgetAll, announce*. refresh() method.

CSS variables

NameDefaultDescription
--mv-invite-in-accentvar(--mv-accent)Hover and focus color of the load control, glow of the neutral preview.
--mv-invite-in-scrimoklch(0.14 0.01 260 / 0.62)Darkening over a poster behind the text.
--mv-invite-in-on-posteroklch(0.99 0 0)Text and icon color over a poster.
--mv-invite-in-radiusvar(--mv-radius-lg)Corner radius of the stage.
--mv-invite-in-min-height16remPlaceholder height with ratio="auto".

Accessibility

The placeholder is one real <button> with a complete name (“Load video: Spring, from YouTube”, the visible “Load video” text first so voice control works) and aria-describedby pointing at the privacy note, so the consequence is read before the choice; the provider chip and decorative preview are aria-hidden. Tab then reaches the privacy-policy and open-on-provider links (“opens in a new tab” announced) and the “Always allow” button, which shares the same description. Enter or Space loads; while loading, the element is aria-busy and the buttons aria-disabled. When a click loads the embed, focus moves into the new iframe (or the first focusable element of template content) instead of being lost with the removed button, and the iframe gets a meaningful title (“Spring (YouTube video)”). Granting “Always allow” is announced politely, as is a disconnection that happens while focus is inside the embed (focus then returns to the placeholder button). <mv-invite-in-settings> is a labelled list of native checkbox switches (role="switch", “Always allow YouTube”) whose status line (“Always allowed since Sep 24, 2026 · 1 on this page”) is linked with aria-describedby; changes are announced in a polite status region, and “Forget all” hands focus back to the list when it disappears. Reduced motion (OS or data-motion="reduce"): no poster zoom, no icon bounce, the loading ring becomes static and the reveal is instant. Forced colors: posters and patterns are hidden so text stays on Canvas, the icon uses ButtonFace/ButtonText and focus a Highlight outline.

本页面由 AI 翻译。报告翻译问题