From 88e7c100214d96dfba079173b00bb59315303af9 Mon Sep 17 00:00:00 2001 From: heznpc Date: Sun, 26 Apr 2026 22:43:42 +0900 Subject: [PATCH 1/2] refactor: extract banners.js from content.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit content.js was 1,280 lines after the 3.5.6 hotfix. Banner UI is one of the cleanest extraction boundaries — pure DOM, only depends on window._sb.t and window._sb.escapeHtml plus existing label constants already declared as ESLint globals. Moves out of content.js (~120 lines): - showOfflineBanner / hideOfflineBanner - skillbridge:bridgeunavailable listener (persistent banner) - skillbridge:storagequota listener (auto-dismiss banner) - showExamBanner - showTranslationProgress / updateTranslationProgress / hideTranslationProgress Stays in content.js because of state coupling: - online/offline event handlers (intertwined with translation pipeline) - showTermPreview / _renderTermPreview (uses translator, FLASHCARD_COURSE_SLUGS_SORTED, _termPreviewShown closure) Functions attach back onto window._sb so call sites only change shape, never semantics. Optional chaining (?.) on each call site protects against banners.js failing to load — a missing banner is a soft degradation, not a content.js crash. manifest.json content_scripts list updated to load banners.js immediately after content.js. Bundle and Firefox build paths read the manifest list directly, so no other config changes needed. Net change: -114 lines in content.js (1318 → 1204), +161 lines in new banners.js, +1 manifest entry. Bundle output grows ~2 KB pre-minify (unchanged after minify, 114.4 KB total — same as before). Co-Authored-By: Claude Opus 4.7 (1M context) --- manifest.json | 2 +- src/content/banners.js | 161 +++++++++++++++++++++++++++++++++++++++++ src/content/content.js | 136 +++------------------------------- 3 files changed, 173 insertions(+), 126 deletions(-) create mode 100644 src/content/banners.js diff --git a/manifest.json b/manifest.json index 7554e23..b4e7a84 100644 --- a/manifest.json +++ b/manifest.json @@ -27,7 +27,7 @@ "content_scripts": [ { "matches": ["https://*.skilljar.com/*"], - "js": ["src/lib/browser-polyfill.js", "src/lib/selectors.js", "src/lib/constants.js", "src/lib/translator.js", "src/lib/youtube-subtitles.js", "src/lib/protected-terms.js", "src/lib/gemini-block.js", "src/content/content.js", "src/content/code-comments.js", "src/content/header-controls.js", "src/content/text-selection.js", "src/content/sidebar-chat.js", "src/content/keyboard-shortcuts.js"], + "js": ["src/lib/browser-polyfill.js", "src/lib/selectors.js", "src/lib/constants.js", "src/lib/translator.js", "src/lib/youtube-subtitles.js", "src/lib/protected-terms.js", "src/lib/gemini-block.js", "src/content/content.js", "src/content/banners.js", "src/content/code-comments.js", "src/content/header-controls.js", "src/content/text-selection.js", "src/content/sidebar-chat.js", "src/content/keyboard-shortcuts.js"], "css": ["src/content/content.css"], "run_at": "document_idle" } diff --git a/src/content/banners.js b/src/content/banners.js new file mode 100644 index 0000000..ea18ced --- /dev/null +++ b/src/content/banners.js @@ -0,0 +1,161 @@ +/** + * SkillBridge — Banner UI + * + * Pure DOM banner registry split out of content.js. Loaded after content.js + * so it can read live language and helper state via `window._sb.t` / + * `window._sb.escapeHtml`. Functions attach back onto `window._sb` so + * content.js call sites only change the prefix. + * + * Covers: offline, bridge-unavailable, storage-quota, exam, translation + * progress. The term-preview card stays in content.js because it touches + * translator state and FLASHCARD_COURSE_SLUGS_SORTED resolution. + */ + +(function () { + const sb = window._sb; + if (!sb) { + console.warn('[SkillBridge] banners.js loaded before content.js — _sb namespace missing'); + return; + } + + // ============================================================ + // OFFLINE BANNER + // ============================================================ + + function showOfflineBanner() { + if (document.getElementById('si18n-offline-banner')) return; + const banner = document.createElement('div'); + banner.id = 'si18n-offline-banner'; + banner.className = 'si18n-offline-banner'; + banner.setAttribute('role', 'status'); + banner.setAttribute('aria-live', 'polite'); + banner.textContent = sb.t(OFFLINE_LABELS); + document.body.appendChild(banner); + requestAnimationFrame(() => banner.classList.add('visible')); + } + + function hideOfflineBanner() { + const banner = document.getElementById('si18n-offline-banner'); + if (banner) { + banner.classList.remove('visible'); + setTimeout(() => banner.remove(), 300); + } + } + + // ============================================================ + // BRIDGE UNAVAILABLE BANNER (persistent — refresh required) + // ============================================================ + + // Puter.js script never confirmed BRIDGE_READY; alert the user that AI + // features are off until they reload. No auto-dismiss because reloading + // is the only recovery path. + window.addEventListener('skillbridge:bridgeunavailable', () => { + if (document.getElementById('si18n-bridge-banner')) return; + const banner = document.createElement('div'); + banner.id = 'si18n-bridge-banner'; + banner.className = 'si18n-offline-banner si18n-storage-warn'; + banner.setAttribute('role', 'alert'); + banner.setAttribute('aria-live', 'assertive'); + banner.textContent = sb.t(BRIDGE_UNAVAILABLE_LABELS); + document.body.appendChild(banner); + requestAnimationFrame(() => banner.classList.add('visible')); + }); + + // ============================================================ + // STORAGE QUOTA BANNER (auto-dismiss after 8s) + // ============================================================ + + document.addEventListener('skillbridge:storagequota', () => { + if (document.getElementById('si18n-storage-banner')) return; + const banner = document.createElement('div'); + banner.id = 'si18n-storage-banner'; + banner.className = 'si18n-offline-banner si18n-storage-warn'; + banner.setAttribute('role', 'status'); + banner.setAttribute('aria-live', 'polite'); + banner.textContent = sb.t(STORAGE_WARNING_LABELS); + document.body.appendChild(banner); + requestAnimationFrame(() => banner.classList.add('visible')); + setTimeout(() => { + banner.classList.remove('visible'); + setTimeout(() => banner.remove(), 300); + }, 8000); + }); + + // ============================================================ + // EXAM BANNER + // ============================================================ + + function showExamBanner() { + if (document.getElementById('si18n-exam-banner')) return; + const banner = document.createElement('div'); + banner.id = 'si18n-exam-banner'; + banner.className = 'si18n-exam-banner'; + banner.setAttribute('role', 'alert'); + banner.textContent = sb.t(EXAM_BANNER_LABELS); + document.body.appendChild(banner); + requestAnimationFrame(() => banner.classList.add('visible')); + } + + // ============================================================ + // TRANSLATION PROGRESS + // ============================================================ + + function showTranslationProgress() { + let bar = document.getElementById('si18n-progress-bar'); + if (!bar) { + bar = document.createElement('div'); + bar.id = 'si18n-progress-bar'; + bar.innerHTML = '
'; + document.body.appendChild(bar); + } else { + const fill = bar.querySelector('.si18n-progress-fill'); + if (fill) fill.style.width = '15%'; + } + let toast = document.getElementById('si18n-progress-toast'); + const label = sb.t(PROGRESS_LABELS); + if (!toast) { + toast = document.createElement('div'); + toast.id = 'si18n-progress-toast'; + toast.setAttribute('role', 'status'); + toast.setAttribute('aria-live', 'polite'); + toast.innerHTML = `
${label}`; + document.body.appendChild(toast); + } else { + const span = toast.querySelector('span'); + if (span) span.textContent = label; + } + requestAnimationFrame(() => { + bar.classList.add('active'); + toast.classList.add('active'); + }); + } + + function updateTranslationProgress(pct) { + const fill = document.querySelector('#si18n-progress-bar .si18n-progress-fill'); + if (fill) fill.style.width = `${Math.min(pct, 95)}%`; + } + + function hideTranslationProgress() { + const fill = document.querySelector('#si18n-progress-bar .si18n-progress-fill'); + if (fill) fill.style.width = '100%'; + setTimeout(() => { + const bar = document.getElementById('si18n-progress-bar'); + const toast = document.getElementById('si18n-progress-toast'); + bar?.classList.remove('active'); + toast?.classList.remove('active'); + setTimeout(() => { + bar?.remove(); + toast?.remove(); + }, SKILLBRIDGE_DELAYS.PROGRESS_REMOVE); + }, SKILLBRIDGE_DELAYS.PROGRESS_HIDE); + } + + // Expose on the shared namespace so content.js call sites stay the same + // shape as other extracted modules (header-controls.js, text-selection.js). + sb.showOfflineBanner = showOfflineBanner; + sb.hideOfflineBanner = hideOfflineBanner; + sb.showExamBanner = showExamBanner; + sb.showTranslationProgress = showTranslationProgress; + sb.updateTranslationProgress = updateTranslationProgress; + sb.hideTranslationProgress = hideTranslationProgress; +})(); diff --git a/src/content/content.js b/src/content/content.js index 6c96ffb..d3524a5 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -100,7 +100,7 @@ window.addEventListener('online', () => { isOffline = false; - hideOfflineBanner(); + window._sb.hideOfflineBanner?.(); // Retry deferred offline items first, then re-apply if needed if (currentLang !== 'en' && translator && isReady) { if (_offlinePendingItems.length > 0) { @@ -119,60 +119,11 @@ window.addEventListener('offline', () => { isOffline = true; - if (currentLang !== 'en') showOfflineBanner(); + if (currentLang !== 'en') window._sb.showOfflineBanner?.(); }); - function showOfflineBanner() { - if (document.getElementById('si18n-offline-banner')) return; - const banner = document.createElement('div'); - banner.id = 'si18n-offline-banner'; - banner.className = 'si18n-offline-banner'; - banner.setAttribute('role', 'status'); - banner.setAttribute('aria-live', 'polite'); - banner.textContent = t(OFFLINE_LABELS); - document.body.appendChild(banner); - requestAnimationFrame(() => banner.classList.add('visible')); - } - - function hideOfflineBanner() { - const banner = document.getElementById('si18n-offline-banner'); - if (banner) { - banner.classList.remove('visible'); - setTimeout(() => banner.remove(), 300); - } - } - - // Bridge unavailable — Puter.js script never confirmed BRIDGE_READY. - // Persistent banner (no auto-dismiss) so users know AI features are off - // until they refresh the page. - window.addEventListener('skillbridge:bridgeunavailable', () => { - if (document.getElementById('si18n-bridge-banner')) return; - const banner = document.createElement('div'); - banner.id = 'si18n-bridge-banner'; - banner.className = 'si18n-offline-banner si18n-storage-warn'; - banner.setAttribute('role', 'alert'); - banner.setAttribute('aria-live', 'assertive'); - banner.textContent = t(BRIDGE_UNAVAILABLE_LABELS); - document.body.appendChild(banner); - requestAnimationFrame(() => banner.classList.add('visible')); - }); - - // Storage quota warning — auto-dismiss after 8 seconds - document.addEventListener('skillbridge:storagequota', () => { - if (document.getElementById('si18n-storage-banner')) return; - const banner = document.createElement('div'); - banner.id = 'si18n-storage-banner'; - banner.className = 'si18n-offline-banner si18n-storage-warn'; - banner.setAttribute('role', 'status'); - banner.setAttribute('aria-live', 'polite'); - banner.textContent = t(STORAGE_WARNING_LABELS); - document.body.appendChild(banner); - requestAnimationFrame(() => banner.classList.add('visible')); - setTimeout(() => { - banner.classList.remove('visible'); - setTimeout(() => banner.remove(), 300); - }, 8000); - }); + // Banner UI (offline / bridge-unavailable / storage / exam / progress) + // lives in src/content/banners.js and attaches to window._sb. // Lookup helper: returns map entry for given lang, falling back to 'en' function t(map, lang) { @@ -200,17 +151,6 @@ return false; } - function showExamBanner() { - if (document.getElementById('si18n-exam-banner')) return; - const banner = document.createElement('div'); - banner.id = 'si18n-exam-banner'; - banner.className = 'si18n-exam-banner'; - banner.setAttribute('role', 'alert'); - banner.textContent = t(EXAM_BANNER_LABELS); - document.body.appendChild(banner); - requestAnimationFrame(() => banner.classList.add('visible')); - } - window._sb = { get currentLang() { return currentLang; @@ -264,60 +204,6 @@ translateCodeComments: null, }; - // ============================================================ - // TRANSLATION PROGRESS INDICATOR - // ============================================================ - - function showTranslationProgress() { - let bar = document.getElementById('si18n-progress-bar'); - if (!bar) { - bar = document.createElement('div'); - bar.id = 'si18n-progress-bar'; - bar.innerHTML = '
'; - document.body.appendChild(bar); - } else { - const fill = bar.querySelector('.si18n-progress-fill'); - if (fill) fill.style.width = '15%'; - } - let toast = document.getElementById('si18n-progress-toast'); - const label = t(PROGRESS_LABELS); - if (!toast) { - toast = document.createElement('div'); - toast.id = 'si18n-progress-toast'; - toast.setAttribute('role', 'status'); - toast.setAttribute('aria-live', 'polite'); - toast.innerHTML = `
${label}`; - document.body.appendChild(toast); - } else { - const span = toast.querySelector('span'); - if (span) span.textContent = label; - } - requestAnimationFrame(() => { - bar.classList.add('active'); - toast.classList.add('active'); - }); - } - - function updateTranslationProgress(pct) { - const fill = document.querySelector('#si18n-progress-bar .si18n-progress-fill'); - if (fill) fill.style.width = `${Math.min(pct, 95)}%`; - } - - function hideTranslationProgress() { - const fill = document.querySelector('#si18n-progress-bar .si18n-progress-fill'); - if (fill) fill.style.width = '100%'; - setTimeout(() => { - const bar = document.getElementById('si18n-progress-bar'); - const toast = document.getElementById('si18n-progress-toast'); - bar?.classList.remove('active'); - toast?.classList.remove('active'); - setTimeout(() => { - bar?.remove(); - toast?.remove(); - }, SKILLBRIDGE_DELAYS.PROGRESS_REMOVE); - }, SKILLBRIDGE_DELAYS.PROGRESS_HIDE); - } - // ============================================================ // PER-LESSON TERM PREVIEW // ============================================================ @@ -669,7 +555,7 @@ updateLangClass(targetLang); // Re-detect exam page (DOM may have loaded since init) if (!isExamPage) isExamPage = detectExamPage(); - if (isExamPage && targetLang !== 'en') showExamBanner(); + if (isExamPage && targetLang !== 'en') window._sb.showExamBanner?.(); const elements = getTranslatableElements(); if (elements.length === 0) return; @@ -693,8 +579,8 @@ // Start GT for visible elements right away (skip redundant viewport check) if (gtCandidates.length > 0 && targetLang !== 'en') { - showTranslationProgress(); - updateTranslationProgress( + window._sb.showTranslationProgress?.(); + window._sb.updateTranslationProgress?.( Math.round((staticCount / (staticCount + gtCandidates.length + offscreen.length)) * 80), ); queueForGoogleTranslate(gtCandidates, targetLang, true); @@ -746,7 +632,7 @@ } else if (gtGeneration === myGeneration) { // All offscreen elements processed — queue GT candidates if (gtCandidates.length > 0 && targetLang !== 'en') { - if (prevGt === 0) showTranslationProgress(); + if (prevGt === 0) window._sb.showTranslationProgress?.(); queueForGoogleTranslate(gtCandidates, targetLang); } } @@ -888,7 +774,7 @@ } processedItems += batch.length; - updateTranslationProgress(80 + Math.round((processedItems / totalItems) * 15)); + window._sb.updateTranslationProgress?.(80 + Math.round((processedItems / totalItems) * 15)); if (gtTranslateQueue.length > 0) { await new Promise((r) => setTimeout(r, SKILLBRIDGE_DELAYS.GT_BATCH)); @@ -896,7 +782,7 @@ } } finally { gtProcessing = false; - hideTranslationProgress(); + window._sb.hideTranslationProgress?.(); pruneDetachedEntries(); // Term-preview only on full completion; on cancellation, the new @@ -1004,7 +890,7 @@ currentLang = 'en'; window._protectedTerms.resetProtectedTerms(); updateLangClass('en'); - hideTranslationProgress(); + window._sb.hideTranslationProgress?.(); originalComments.forEach((html, el) => { if (el && el.parentNode) el.innerHTML = html; }); From 47657db5854ac200b4d58cf7fd37eb923e29034e Mon Sep 17 00:00:00 2001 From: heznpc Date: Sun, 26 Apr 2026 22:49:31 +0900 Subject: [PATCH 2/2] chore: simplify review follow-ups on banners.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from /simplify on the just-pushed extraction: 1. CRITICAL — listener-leak guard. Without an idempotency marker the IIFE re-runs on extension auto-update / dev reload and adds duplicate listeners for skillbridge:bridgeunavailable and skillbridge:storagequota each time, so after N reloads N banners would render per fire. Mirrors the history.pushState __sb_wrapped__ guard from the v3.5.6 hotfix. Added an `sb.__bannersLoaded` flag. 2. Unified the five small banners (offline, bridge-unavailable, storage-quota, exam, plus offline's hide cousin) onto a single showSimpleBanner({...}) helper. Each call site is now 6-8 lines describing only the banner's identity rather than re-doing the create/setAttribute/append/raf-class dance. Translation progress stays separate — its two coordinated elements with dynamic content don't fit the helper shape. 3. Trimmed the breadcrumb comment from content.js that pointed at banners.js — the optional-chaining call sites already document the dependency, and the docblock list inside banners.js was a maintenance hazard (could go stale if banners are added). Reuse review came back clean (textContent-only, namespace pattern matches header-controls/text-selection). Tests 309/309 pass; lint, prettier, bundle build all green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/content/banners.js | 133 +++++++++++++++++++---------------------- src/content/content.js | 3 - 2 files changed, 62 insertions(+), 74 deletions(-) diff --git a/src/content/banners.js b/src/content/banners.js index ea18ced..861a629 100644 --- a/src/content/banners.js +++ b/src/content/banners.js @@ -2,13 +2,12 @@ * SkillBridge — Banner UI * * Pure DOM banner registry split out of content.js. Loaded after content.js - * so it can read live language and helper state via `window._sb.t` / - * `window._sb.escapeHtml`. Functions attach back onto `window._sb` so - * content.js call sites only change the prefix. + * so it can read live language and helper state via `window._sb.t`. + * Functions attach back onto `window._sb` so call sites only change shape, + * not semantics. * - * Covers: offline, bridge-unavailable, storage-quota, exam, translation - * progress. The term-preview card stays in content.js because it touches - * translator state and FLASHCARD_COURSE_SLUGS_SORTED resolution. + * Term-preview stays in content.js because it needs translator state and + * FLASHCARD_COURSE_SLUGS_SORTED resolution. */ (function () { @@ -18,88 +17,82 @@ return; } - // ============================================================ - // OFFLINE BANNER - // ============================================================ + // Guard: extension auto-update / dev reload re-runs content scripts. + // Without this marker we'd attach a second listener for each event each + // time, causing N banners per fire after N reloads (mirrors the + // history.pushState __sb_wrapped__ guard in content.js). + if (sb.__bannersLoaded) return; + sb.__bannersLoaded = true; - function showOfflineBanner() { - if (document.getElementById('si18n-offline-banner')) return; + // Build a transient banner element and animate it in. Used for the + // five "small toast" cases below; translation progress is its own + // shape (two coordinated elements, dynamic content) and doesn't fit. + function showSimpleBanner({ id, className, role, ariaLive, labels, autoDismissMs }) { + if (document.getElementById(id)) return; const banner = document.createElement('div'); - banner.id = 'si18n-offline-banner'; - banner.className = 'si18n-offline-banner'; - banner.setAttribute('role', 'status'); - banner.setAttribute('aria-live', 'polite'); - banner.textContent = sb.t(OFFLINE_LABELS); + banner.id = id; + banner.className = className; + banner.setAttribute('role', role); + if (ariaLive) banner.setAttribute('aria-live', ariaLive); + banner.textContent = sb.t(labels); document.body.appendChild(banner); requestAnimationFrame(() => banner.classList.add('visible')); + if (autoDismissMs) { + setTimeout(() => { + banner.classList.remove('visible'); + setTimeout(() => banner.remove(), 300); + }, autoDismissMs); + } + } + + function showOfflineBanner() { + showSimpleBanner({ + id: 'si18n-offline-banner', + className: 'si18n-offline-banner', + role: 'status', + ariaLive: 'polite', + labels: OFFLINE_LABELS, + }); } function hideOfflineBanner() { const banner = document.getElementById('si18n-offline-banner'); - if (banner) { - banner.classList.remove('visible'); - setTimeout(() => banner.remove(), 300); - } + if (!banner) return; + banner.classList.remove('visible'); + setTimeout(() => banner.remove(), 300); } - // ============================================================ - // BRIDGE UNAVAILABLE BANNER (persistent — refresh required) - // ============================================================ - - // Puter.js script never confirmed BRIDGE_READY; alert the user that AI - // features are off until they reload. No auto-dismiss because reloading - // is the only recovery path. + // No auto-dismiss: refresh is the only recovery, so keep the alert visible. window.addEventListener('skillbridge:bridgeunavailable', () => { - if (document.getElementById('si18n-bridge-banner')) return; - const banner = document.createElement('div'); - banner.id = 'si18n-bridge-banner'; - banner.className = 'si18n-offline-banner si18n-storage-warn'; - banner.setAttribute('role', 'alert'); - banner.setAttribute('aria-live', 'assertive'); - banner.textContent = sb.t(BRIDGE_UNAVAILABLE_LABELS); - document.body.appendChild(banner); - requestAnimationFrame(() => banner.classList.add('visible')); + showSimpleBanner({ + id: 'si18n-bridge-banner', + className: 'si18n-offline-banner si18n-storage-warn', + role: 'alert', + ariaLive: 'assertive', + labels: BRIDGE_UNAVAILABLE_LABELS, + }); }); - // ============================================================ - // STORAGE QUOTA BANNER (auto-dismiss after 8s) - // ============================================================ - document.addEventListener('skillbridge:storagequota', () => { - if (document.getElementById('si18n-storage-banner')) return; - const banner = document.createElement('div'); - banner.id = 'si18n-storage-banner'; - banner.className = 'si18n-offline-banner si18n-storage-warn'; - banner.setAttribute('role', 'status'); - banner.setAttribute('aria-live', 'polite'); - banner.textContent = sb.t(STORAGE_WARNING_LABELS); - document.body.appendChild(banner); - requestAnimationFrame(() => banner.classList.add('visible')); - setTimeout(() => { - banner.classList.remove('visible'); - setTimeout(() => banner.remove(), 300); - }, 8000); + showSimpleBanner({ + id: 'si18n-storage-banner', + className: 'si18n-offline-banner si18n-storage-warn', + role: 'status', + ariaLive: 'polite', + labels: STORAGE_WARNING_LABELS, + autoDismissMs: 8000, + }); }); - // ============================================================ - // EXAM BANNER - // ============================================================ - function showExamBanner() { - if (document.getElementById('si18n-exam-banner')) return; - const banner = document.createElement('div'); - banner.id = 'si18n-exam-banner'; - banner.className = 'si18n-exam-banner'; - banner.setAttribute('role', 'alert'); - banner.textContent = sb.t(EXAM_BANNER_LABELS); - document.body.appendChild(banner); - requestAnimationFrame(() => banner.classList.add('visible')); + showSimpleBanner({ + id: 'si18n-exam-banner', + className: 'si18n-exam-banner', + role: 'alert', + labels: EXAM_BANNER_LABELS, + }); } - // ============================================================ - // TRANSLATION PROGRESS - // ============================================================ - function showTranslationProgress() { let bar = document.getElementById('si18n-progress-bar'); if (!bar) { @@ -150,8 +143,6 @@ }, SKILLBRIDGE_DELAYS.PROGRESS_HIDE); } - // Expose on the shared namespace so content.js call sites stay the same - // shape as other extracted modules (header-controls.js, text-selection.js). sb.showOfflineBanner = showOfflineBanner; sb.hideOfflineBanner = hideOfflineBanner; sb.showExamBanner = showExamBanner; diff --git a/src/content/content.js b/src/content/content.js index d3524a5..f4b67d7 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -122,9 +122,6 @@ if (currentLang !== 'en') window._sb.showOfflineBanner?.(); }); - // Banner UI (offline / bridge-unavailable / storage / exam / progress) - // lives in src/content/banners.js and attaches to window._sb. - // Lookup helper: returns map entry for given lang, falling back to 'en' function t(map, lang) { return map[lang || currentLang] || map['en'];