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>
- frontend/app.js +123 -65
- frontend/index.html +22 -0
- 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 |
-
|
| 23 |
-
|
| 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 |
-
|
| 32 |
-
|
| 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
|
| 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 =
|
| 67 |
updateLabels();
|
| 68 |
-
if (!busy) setState("",
|
| 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 |
-
// ----
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
}
|
|
|
|
| 171 |
|
| 172 |
-
function
|
|
|
|
| 173 |
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
| 174 |
-
if (!SR)
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 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 |
-
|
| 190 |
-
|
| 191 |
-
try {
|
| 192 |
-
}
|
| 193 |
-
|
| 194 |
-
function
|
| 195 |
-
if (
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
|
|
|
|
|
|
| 202 |
setState("", t("idle"));
|
| 203 |
-
|
| 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 ==
|
| 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 ==
|
| 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 =
|
| 487 |
-
setState("",
|
| 488 |
-
|
| 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); }
|