nextmarte Claude Opus 4.8 commited on
Commit
dc59186
·
1 Parent(s): e62437d

feat: welcome popup on every open (reads pitch aloud, voice/tap close, video link)

Browse files

- modal shows on every open, narrates the proposal (browser TTS), big close button
- speak on first tap (mobile autoplay-safe); desktop tries on load
- close by voice (close/fechar + broad words), button, Esc, or auto-close after narration
- EN/PT toggle in the popup; tap the mark to replay; don't re-narrate on same-session reload
- dialog a11y: role=dialog, focus trap, focus close, return focus; reduced-motion honored
- replaces the old first-run onboarding; camera now starts when the popup closes
- styled with the taste-skill tokens (Outfit, one accent, soft shadow)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files changed (3) hide show
  1. frontend/app.js +123 -65
  2. frontend/index.html +22 -0
  3. frontend/style.css +54 -0
frontend/app.js CHANGED
@@ -6,6 +6,9 @@ const cam = $("cam"), canvas = $("canvas"), statusEl = $("status"),
6
  player = $("player"), stage = $("stage"),
7
  btnAsk = $("btn-ask"), btnDescribe = $("btn-describe"),
8
  btnLive = $("btn-live"), a11yBtn = $("a11y");
 
 
 
9
 
10
  function vibrate(ms) { try { navigator.vibrate && navigator.vibrate(ms); } catch (e) {} }
11
 
@@ -19,8 +22,8 @@ const T = {
19
  langListen: "Listening… say your language",
20
  welcome: "Welcome to Iris. Tap the screen to describe what is in front of you. Hold to ask a question. Double-tap to turn live mode on or off, which announces new things around you.",
21
  liveOn: "Live mode on.", liveOff: "Live mode off.", confLang: "English selected.",
22
- startTap: "Iris. Tap the screen to begin.",
23
- welcomeAsk: "Welcome to Iris, in English. Tap the screen to describe what is in front of you, hold to ask a question, double-tap for live mode. To use Portuguese instead, say 'português' now.",
24
  bAsk: "Ask", bDescribe: "Describe", bLive: "Live" },
25
  pt: { idle: "Iris", listening: "Ouvindo…", thinking: "Pensando…",
26
  hint: "Toque: descrever · Segurar: perguntar · Toque duplo: ao vivo",
@@ -28,8 +31,8 @@ const T = {
28
  langListen: "Ouvindo… diga seu idioma",
29
  welcome: "Bem-vindo ao Iris. Toque na tela para descrever o que está à sua frente. Segure para fazer uma pergunta. Toque duas vezes para ligar ou desligar o modo ao vivo, que avisa o que aparece de novo à sua volta.",
30
  liveOn: "Modo ao vivo ligado.", liveOff: "Modo ao vivo desligado.", confLang: "Português selecionado.",
31
- startTap: "Iris. Toque na tela para começar.",
32
- welcomeAsk: "Bem-vindo ao Iris, em português. Toque na tela para descrever o que está à sua frente, segure para perguntar, e toque duas vezes para o modo ao vivo. Para usar em inglês, diga 'inglês' agora.",
33
  bAsk: "Perguntar", bDescribe: "Descrever", bLive: "Ao vivo" },
34
  };
35
  const t = (k) => T[lang][k];
@@ -50,9 +53,7 @@ function speak(text) {
50
  }
51
 
52
  // ---- state ----
53
- let onboarded = false;
54
- try { onboarded = localStorage.getItem("iris_onboarded") === "1"; } catch (e) {}
55
- let mode = onboarded ? "normal" : "onboarding";
56
  let busy = false;
57
  let liveOn = false, liveTimer = null;
58
  let holding = false, recording = false, holdTimer = null;
@@ -63,9 +64,9 @@ langBtn.onclick = (e) => {
63
  lang = lang === "en" ? "pt" : "en";
64
  document.documentElement.lang = lang;
65
  langBtn.textContent = lang.toUpperCase();
66
- hintEl.textContent = onboarded ? t("hint") : "";
67
  updateLabels();
68
- if (!busy) setState("", onboarded ? t("idle") : t("startTap"));
69
  };
70
 
71
  // ---- unlock audio (autoplay) on the first tap ----
@@ -150,59 +151,121 @@ async function send(frame, audio, qtext = "") {
150
  function resetSoon() { setTimeout(() => { resumeSR(); if (!busy) setState("", t("idle")); }, 600); }
151
  player.addEventListener("ended", () => { setTimeout(resumeSR, 700); if (!busy) setState("", t("idle")); });
152
 
153
- // ---- first-run onboarding: default to the browser language, speak it, offer to switch ----
154
- function onboard() {
155
- if (onboarded) return;
156
- const u = new SpeechSynthesisUtterance(t("welcomeAsk"));
157
- u.lang = lang === "pt" ? "pt-BR" : "en-US";
158
- // mark onboarded only when speech actually STARTS — so if the browser blocks
159
- // autoplay on load, the mode stays "onboarding" and the first tap triggers it.
160
- u.onstart = () => {
161
- onboarded = true; mode = "normal";
162
- try { localStorage.setItem("iris_onboarded", "1"); } catch (e) {}
163
- hintEl.textContent = t("hint");
164
- updateLabels();
165
- setState("speaking", t("idle"));
166
- };
167
- u.onend = listenForLangChoice; // after the welcome, listen for a language switch
168
- try { speechSynthesis.cancel(); speechSynthesis.speak(u); }
169
- catch (e) { /* will retry on the first tap */ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  }
 
171
 
172
- function listenForLangChoice() {
 
173
  const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
174
- if (!SR) { setState("", t("idle")); return; }
175
- const r = new SR();
176
- r.lang = lang === "pt" ? "pt-BR" : "en-US";
177
- r.continuous = false; r.interimResults = false;
178
- let handled = false;
179
- const finish = () => { if (!handled) { handled = true; setState("", t("idle")); } };
180
- const to = setTimeout(() => { try { r.stop(); } catch (e) {} finish(); }, 5000);
181
- r.onresult = (e) => {
182
- handled = true; clearTimeout(to);
183
- const txt = (e.results[0][0].transcript || "").toLowerCase();
184
- if (txt.includes("ingl") || txt.includes("english")) switchLang("en");
185
- else if (txt.includes("portug") || txt.includes("brasil")) switchLang("pt");
186
- else setState("", t("idle"));
187
- try { r.stop(); } catch (e) {}
188
  };
189
- r.onerror = () => { clearTimeout(to); finish(); };
190
- setState("listening", t("listening"));
191
- try { r.start(); } catch (e) { finish(); }
192
- }
193
-
194
- function switchLang(newLang) {
195
- if (newLang !== lang) {
196
- lang = newLang;
197
- document.documentElement.lang = lang;
198
- langBtn.textContent = lang.toUpperCase();
199
- hintEl.textContent = t("hint");
200
- updateLabels();
201
- }
 
 
202
  setState("", t("idle"));
203
- speak(t("confLang") + " " + t("welcome"));
204
  }
205
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  // ---- live mode (toggled by voice command or double-tap) ----
207
  function handleCommand(cmd) {
208
  if (cmd === "live_on") setLive(true);
@@ -369,11 +432,7 @@ async function sendWatch(frame, hint = "") {
369
 
370
  // ---- interaction ----
371
  stage.addEventListener("pointerdown", async () => {
372
- if (mode === "onboarding") {
373
- unlockAudio();
374
- onboard(); // speak welcome in the browser language + offer to switch
375
- return;
376
- }
377
  if (liveOn) { speechSynthesis.cancel(); player.pause(); }
378
  if (busy) return;
379
  unlockAudio();
@@ -389,7 +448,7 @@ let lastTapTime = 0, tapTimer = null;
389
  const DOUBLE_MS = 320; // double-tap window
390
 
391
  async function endPress() {
392
- if (mode === "onboarding") return; // onboarding is handled on pointerdown
393
  if (!holding) return;
394
  holding = false;
395
  stage.classList.remove("armed");
@@ -483,10 +542,9 @@ function updateLabels() {
483
  document.documentElement.lang = lang;
484
  langBtn.textContent = lang.toUpperCase();
485
  updateLabels();
486
- hintEl.textContent = onboarded ? t("hint") : "";
487
- setState("", onboarded ? t("idle") : t("startTap"));
488
- if (!onboarded) onboard(); // try to speak the welcome on load (works where the browser allows it)
489
- await startCamera();
490
  loadDetector(); // preload the object detector in the background (non-blocking)
491
  try { client = await Client.connect(window.location.origin); console.log("Iris connected"); }
492
  catch (e) { console.error("connect:", e); setState("", t("err")); }
 
6
  player = $("player"), stage = $("stage"),
7
  btnAsk = $("btn-ask"), btnDescribe = $("btn-describe"),
8
  btnLive = $("btn-live"), a11yBtn = $("a11y");
9
+ const intro = $("intro"), introBodyEl = $("intro-body"), introCloseBtn = $("intro-close"),
10
+ introVideo = $("intro-video"), introEn = $("intro-en"), introPt = $("intro-pt"),
11
+ introReplay = $("intro-replay"), introHintEl = $("intro-hint");
12
 
13
  function vibrate(ms) { try { navigator.vibrate && navigator.vibrate(ms); } catch (e) {} }
14
 
 
22
  langListen: "Listening… say your language",
23
  welcome: "Welcome to Iris. Tap the screen to describe what is in front of you. Hold to ask a question. Double-tap to turn live mode on or off, which announces new things around you.",
24
  liveOn: "Live mode on.", liveOff: "Live mode off.", confLang: "English selected.",
25
+ introBody: "This is Iris, your eyes by voice. Point your phone and it tells you what is in front of you, and reads your money, your bills, and your medicine, out loud.",
26
+ introClose: "Close", introVideo: "Watch the demo", introHint: "Say \"close\" or tap the button",
27
  bAsk: "Ask", bDescribe: "Describe", bLive: "Live" },
28
  pt: { idle: "Iris", listening: "Ouvindo…", thinking: "Pensando…",
29
  hint: "Toque: descrever · Segurar: perguntar · Toque duplo: ao vivo",
 
31
  langListen: "Ouvindo… diga seu idioma",
32
  welcome: "Bem-vindo ao Iris. Toque na tela para descrever o que está à sua frente. Segure para fazer uma pergunta. Toque duas vezes para ligar ou desligar o modo ao vivo, que avisa o que aparece de novo à sua volta.",
33
  liveOn: "Modo ao vivo ligado.", liveOff: "Modo ao vivo desligado.", confLang: "Português selecionado.",
34
+ introBody: "Este é o Iris, os seus olhos por voz. Aponte o celular e ele diz o que está à sua frente, e lê o seu dinheiro, suas contas e seus remédios, em voz alta.",
35
+ introClose: "Fechar", introVideo: "Ver o vídeo", introHint: "Diga \"fechar\" ou toque no botão",
36
  bAsk: "Perguntar", bDescribe: "Descrever", bLive: "Ao vivo" },
37
  };
38
  const t = (k) => T[lang][k];
 
53
  }
54
 
55
  // ---- state ----
56
+ let mode = "intro"; // "intro" while the welcome popup is open, then "normal"
 
 
57
  let busy = false;
58
  let liveOn = false, liveTimer = null;
59
  let holding = false, recording = false, holdTimer = null;
 
64
  lang = lang === "en" ? "pt" : "en";
65
  document.documentElement.lang = lang;
66
  langBtn.textContent = lang.toUpperCase();
67
+ hintEl.textContent = t("hint");
68
  updateLabels();
69
+ if (!busy) setState("", t("idle"));
70
  };
71
 
72
  // ---- unlock audio (autoplay) on the first tap ----
 
151
  function resetSoon() { setTimeout(() => { resumeSR(); if (!busy) setState("", t("idle")); }, 600); }
152
  player.addEventListener("ended", () => { setTimeout(resumeSR, 700); if (!busy) setState("", t("idle")); });
153
 
154
+ // ---- welcome popup (shown on every open): pitch read aloud, closeable by voice/tap ----
155
+ const DEMO_VIDEO_URL = "https://huggingface.co/spaces/build-small-hackathon/iris"; // placeholder, swap for the demo video
156
+ let introOpen = true, autoCloseTimer = null, introRecog = null, introSpokenSession = false;
157
+ try { introSpokenSession = sessionStorage.getItem("iris_introSpoken") === "1"; } catch (e) {}
158
+ const CLOSE_WORDS = /\b(close|fechar|ok|okay|start|begin|continue|continuar|comecar|entendi|pode fechar|got it)\b/i;
159
+
160
+ function renderIntro() {
161
+ introBodyEl.textContent = t("introBody");
162
+ introCloseBtn.textContent = t("introClose");
163
+ introVideo.textContent = t("introVideo");
164
+ introVideo.href = DEMO_VIDEO_URL;
165
+ introHintEl.textContent = t("introHint");
166
+ introEn.setAttribute("aria-pressed", lang === "en" ? "true" : "false");
167
+ introPt.setAttribute("aria-pressed", lang === "pt" ? "true" : "false");
168
+ }
169
+
170
+ function showIntro() {
171
+ mode = "intro";
172
+ renderIntro();
173
+ intro.hidden = false;
174
+ try { introCloseBtn.focus(); } catch (e) {}
175
+ speakIntro(false); // narrate on load where the browser allows (desktop)
176
+ intro.addEventListener("pointerdown", onIntroFirstTap); // mobile: first tap unlocks audio + narrates
177
+ document.addEventListener("keydown", onIntroKey);
178
+ }
179
+
180
+ function onIntroFirstTap(e) {
181
+ unlockAudio();
182
+ if (e.target.closest("button, a")) return; // controls handle themselves
183
+ if (!introSpokenSession) speakIntro(false);
184
+ }
185
+
186
+ function onIntroKey(e) {
187
+ if (!introOpen) return;
188
+ if (e.key === "Escape") { closeIntro(); return; }
189
+ if (e.key === "Tab") { // simple focus trap inside the card
190
+ const f = intro.querySelectorAll("button, a[href]");
191
+ if (!f.length) return;
192
+ const first = f[0], last = f[f.length - 1];
193
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
194
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
195
+ }
196
+ }
197
+
198
+ function speakIntro(force) {
199
+ if (!introOpen) return;
200
+ if (introSpokenSession && !force) return; // don't re-narrate on a same-session reload
201
+ unlockAudio();
202
+ try {
203
+ speechSynthesis.cancel();
204
+ const u = new SpeechSynthesisUtterance(t("introBody") + " " + t("introHint") + ".");
205
+ u.lang = lang === "pt" ? "pt-BR" : "en-US";
206
+ u.onstart = () => {
207
+ introSpokenSession = true;
208
+ try { sessionStorage.setItem("iris_introSpoken", "1"); } catch (e) {}
209
+ armCloseListener();
210
+ };
211
+ u.onend = () => { if (introOpen) scheduleAutoClose(); };
212
+ speechSynthesis.speak(u);
213
+ } catch (e) { /* will retry on the first tap */ }
214
+ }
215
+
216
+ function scheduleAutoClose() {
217
+ cancelAutoClose();
218
+ autoCloseTimer = setTimeout(() => { if (introOpen) closeIntro(); }, 2500); // hands-free close after the narration
219
  }
220
+ function cancelAutoClose() { if (autoCloseTimer) { clearTimeout(autoCloseTimer); autoCloseTimer = null; } }
221
 
222
+ function armCloseListener() {
223
+ if (introRecog) return;
224
  const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
225
+ if (!SR) return;
226
+ introRecog = new SR();
227
+ introRecog.continuous = true; introRecog.interimResults = false;
228
+ introRecog.lang = lang === "pt" ? "pt-BR" : "en-US";
229
+ introRecog.onresult = (e) => {
230
+ const raw = (e.results[e.results.length - 1][0].transcript || "");
231
+ const txt = raw.normalize("NFD").replace(/[̀-ͯ]/g, ""); // strip accents
232
+ if (CLOSE_WORDS.test(txt)) closeIntro();
 
 
 
 
 
 
233
  };
234
+ introRecog.onend = () => { if (introOpen && introRecog) { try { introRecog.start(); } catch (e) {} } };
235
+ introRecog.onerror = () => {};
236
+ try { introRecog.start(); } catch (e) {}
237
+ }
238
+
239
+ function closeIntro() {
240
+ if (!introOpen) return;
241
+ introOpen = false;
242
+ cancelAutoClose();
243
+ if (introRecog) { try { introRecog.stop(); } catch (e) {} introRecog = null; }
244
+ try { speechSynthesis.cancel(); } catch (e) {}
245
+ intro.hidden = true;
246
+ document.removeEventListener("keydown", onIntroKey);
247
+ mode = "normal";
248
+ try { btnDescribe.focus(); } catch (e) {}
249
  setState("", t("idle"));
250
+ startCamera(); // start the app now (pitch first, camera permission after)
251
  }
252
 
253
+ function setIntroLang(l) {
254
+ cancelAutoClose();
255
+ if (l !== lang) {
256
+ lang = l; document.documentElement.lang = lang;
257
+ langBtn.textContent = lang.toUpperCase(); hintEl.textContent = t("hint"); updateLabels();
258
+ }
259
+ renderIntro();
260
+ speakIntro(true); // replay in the chosen language
261
+ }
262
+
263
+ introCloseBtn.addEventListener("click", (e) => { e.stopPropagation(); closeIntro(); });
264
+ introVideo.addEventListener("click", (e) => { e.stopPropagation(); cancelAutoClose(); }); // let them watch (opens a new tab)
265
+ introReplay.addEventListener("click", (e) => { e.stopPropagation(); cancelAutoClose(); speakIntro(true); });
266
+ introEn.addEventListener("click", (e) => { e.stopPropagation(); setIntroLang("en"); });
267
+ introPt.addEventListener("click", (e) => { e.stopPropagation(); setIntroLang("pt"); });
268
+
269
  // ---- live mode (toggled by voice command or double-tap) ----
270
  function handleCommand(cmd) {
271
  if (cmd === "live_on") setLive(true);
 
432
 
433
  // ---- interaction ----
434
  stage.addEventListener("pointerdown", async () => {
435
+ if (mode !== "normal") return; // welcome popup is open
 
 
 
 
436
  if (liveOn) { speechSynthesis.cancel(); player.pause(); }
437
  if (busy) return;
438
  unlockAudio();
 
448
  const DOUBLE_MS = 320; // double-tap window
449
 
450
  async function endPress() {
451
+ if (mode !== "normal") return; // welcome popup is open
452
  if (!holding) return;
453
  holding = false;
454
  stage.classList.remove("armed");
 
542
  document.documentElement.lang = lang;
543
  langBtn.textContent = lang.toUpperCase();
544
  updateLabels();
545
+ hintEl.textContent = t("hint");
546
+ setState("", t("idle"));
547
+ showIntro(); // welcome popup on every open (the camera starts when it closes)
 
548
  loadDetector(); // preload the object detector in the background (non-blocking)
549
  try { client = await Client.connect(window.location.origin); console.log("Iris connected"); }
550
  catch (e) { console.error("connect:", e); setState("", t("err")); }
frontend/index.html CHANGED
@@ -60,6 +60,28 @@
60
  </button>
61
  </nav>
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  <audio id="player" playsinline></audio>
64
 
65
  <!-- in-browser object detection (gates live mode); falls back to pixel-diff if unavailable -->
 
60
  </button>
61
  </nav>
62
 
63
+ <!-- welcome popup: shows on every open, reads the pitch aloud, closeable by voice/tap -->
64
+ <div id="intro" role="dialog" aria-modal="true" aria-labelledby="intro-title" aria-describedby="intro-body">
65
+ <div class="intro-card" role="document">
66
+ <button id="intro-replay" class="intro-mark" aria-label="Replay the introduction">
67
+ <svg viewBox="0 0 120 120" class="iris-mark" aria-hidden="true">
68
+ <path d="M14 60 Q60 26 106 60 Q60 94 14 60 Z" fill="none" stroke="#fff" stroke-width="3.2" stroke-linejoin="round" opacity=".92"/>
69
+ <circle cx="60" cy="60" r="17" fill="none" stroke="#fff" stroke-width="3.2" opacity=".92"/>
70
+ <circle cx="60" cy="60" r="6" fill="#ff7a18"/>
71
+ </svg>
72
+ </button>
73
+ <h2 id="intro-title">Iris</h2>
74
+ <p id="intro-body"></p>
75
+ <div class="intro-langs" role="group" aria-label="Language">
76
+ <button id="intro-en" class="intro-lang">EN</button>
77
+ <button id="intro-pt" class="intro-lang">PT</button>
78
+ </div>
79
+ <a id="intro-video" class="intro-video" href="#" target="_blank" rel="noopener"></a>
80
+ <button id="intro-close" class="intro-close"></button>
81
+ <p id="intro-hint" class="intro-hint"></p>
82
+ </div>
83
+ </div>
84
+
85
  <audio id="player" playsinline></audio>
86
 
87
  <!-- in-browser object detection (gates live mode); falls back to pixel-diff if unavailable -->
frontend/style.css CHANGED
@@ -175,6 +175,60 @@ body[data-state=""] #status, body:not([data-state]) #status { letter-spacing: .0
175
  }
176
  @keyframes livedot { 0%{box-shadow:0 0 0 0 rgba(255,59,48,.65)} 100%{box-shadow:0 0 0 11px rgba(255,59,48,0)} }
177
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  /* ===== accessible mode (max contrast + larger text) ===== */
179
  body.a11y-boost { --glass: rgba(0,0,0,.92); --stroke: #ffffff; }
180
  body.a11y-boost #cam { filter: brightness(.25); }
 
175
  }
176
  @keyframes livedot { 0%{box-shadow:0 0 0 0 rgba(255,59,48,.65)} 100%{box-shadow:0 0 0 11px rgba(255,59,48,0)} }
177
 
178
+ /* ===== welcome popup (shown on every open) ===== */
179
+ #intro {
180
+ position: fixed; inset: 0; z-index: 10;
181
+ display: grid; place-items: center; padding: 6vmin;
182
+ background: rgba(5,6,10,.72); backdrop-filter: blur(14px);
183
+ animation: introIn .3s ease;
184
+ }
185
+ #intro[hidden] { display: none; }
186
+ @keyframes introIn { from { opacity: 0 } to { opacity: 1 } }
187
+
188
+ .intro-card {
189
+ width: min(92vw, 460px); max-height: 88vh; overflow-y: auto;
190
+ display: flex; flex-direction: column; align-items: center; text-align: center; gap: 18px;
191
+ padding: clamp(24px, 6vmin, 40px) clamp(20px, 5vmin, 34px);
192
+ background: var(--glass); border: 1px solid var(--stroke); border-radius: var(--r-lg);
193
+ box-shadow: 0 24px 60px rgba(0,0,0,.55);
194
+ animation: introRise .42s cubic-bezier(.2,.7,.2,1);
195
+ }
196
+ @keyframes introRise { from { opacity: 0; transform: translateY(16px) } to { opacity: 1; transform: none } }
197
+
198
+ .intro-mark { background: none; border: 0; padding: 6px; cursor: pointer; filter: drop-shadow(0 6px 22px rgba(0,0,0,.5)); }
199
+ .intro-mark .iris-mark { width: 84px; height: auto; display: block; }
200
+
201
+ #intro-title { font-family: var(--font-display); font-weight: 700; font-size: clamp(28px, 7vmin, 40px); letter-spacing: .04em; }
202
+ #intro-body { font-family: var(--font-body); font-size: clamp(16px, 4vmin, 19px); line-height: 1.5; color: var(--fg); max-width: 34ch; }
203
+
204
+ .intro-langs { display: flex; gap: 10px; }
205
+ .intro-lang {
206
+ min-width: 56px; min-height: 44px; padding: 0 16px;
207
+ background: transparent; color: var(--fg); border: 2px solid var(--stroke); border-radius: var(--r-pill);
208
+ font-family: var(--font-display); font-weight: 700; font-size: 16px; cursor: pointer;
209
+ }
210
+ .intro-lang[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
211
+
212
+ .intro-video {
213
+ display: inline-flex; align-items: center; justify-content: center; gap: 8px;
214
+ min-height: 48px; padding: 0 22px;
215
+ border: 2px solid var(--accent); border-radius: var(--r-pill); color: var(--fg);
216
+ font-family: var(--font-display); font-weight: 600; font-size: 16px; text-decoration: none;
217
+ }
218
+
219
+ .intro-close {
220
+ width: 100%; min-height: 64px; margin-top: 4px; padding: 0 24px;
221
+ background: var(--accent); color: var(--accent-ink); border: 0; border-radius: var(--r-lg);
222
+ font-family: var(--font-display); font-weight: 700; font-size: 20px; cursor: pointer;
223
+ box-shadow: 0 10px 28px rgba(0,0,0,.34);
224
+ }
225
+ .intro-close:active { transform: scale(.98); }
226
+
227
+ .intro-hint { font-family: var(--font-body); font-size: 14px; color: var(--muted); }
228
+
229
+ body.a11y-boost .intro-card { background: rgba(0,0,0,.94); }
230
+ body.a11y-boost #intro-body { font-size: clamp(18px, 4.6vmin, 22px); font-weight: 700; }
231
+
232
  /* ===== accessible mode (max contrast + larger text) ===== */
233
  body.a11y-boost { --glass: rgba(0,0,0,.92); --stroke: #ffffff; }
234
  body.a11y-boost #cam { filter: brightness(.25); }