diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 41f20ee..7cc0aec 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,3 +31,19 @@ jobs:
run: pytest -q
- name: Demo
run: beear demo
+
+ web-i18n:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ - name: Unit tests (EN/VI chrome strings)
+ run: node --test packages/web/tests/i18n.test.mjs
+ - name: IIFE mirror present
+ run: |
+ test -f packages/web/assets/i18n.js
+ grep -q "BeeARI18n" packages/web/assets/i18n.js
+ grep -q "i18n.js" packages/web/index.html
+ grep -q "i18n.js" packages/web/studio3d.html
diff --git a/docs/evidence-issue-8-vi-ui.md b/docs/evidence-issue-8-vi-ui.md
new file mode 100644
index 0000000..a42a865
--- /dev/null
+++ b/docs/evidence-issue-8-vi-ui.md
@@ -0,0 +1,44 @@
+# Evidence: Vietnamese UI strings toggle (#8)
+
+## What
+
+EN ↔ VI chrome UI toggle for BeeAR web try-on (2D camera page + 3D studio).
+
+## How to verify
+
+```bash
+# unit tests
+node --test packages/web/tests/i18n.test.mjs
+
+# static demo
+node scripts/build-pages.mjs --no-minify
+npx --yes serve site -p 4173
+# open http://127.0.0.1:4173/
+# click the VI button → chrome strings switch to Vietnamese
+# click EN → back to English
+# reload → language persists via localStorage key `beear_lang`
+```
+
+## Acceptance mapping
+
+| Requirement | Implementation |
+| --- | --- |
+| EN/VI chrome strings | `packages/web/i18n.mjs` + `packages/web/assets/i18n.js` |
+| Toggle button | `#btn-lang` on `index.html` and `studio3d.html` |
+| Persist language | `localStorage['beear_lang']` via `BeeARI18n.loadLang/saveLang` |
+| Filter labels | All / Glasses / Accessories translated |
+| Empty states | Catalog empty + gallery empty translated |
+| 3D studio parity | Tagline, person, frames, auto-rotate, snapshot, filters |
+| Tests | `packages/web/tests/i18n.test.mjs` (CI job `web-i18n`) |
+
+## Screenshots
+
+- Existing VI chrome shot: `docs/screenshots/demo-vi-ui.png`
+- After toggle (EN header labels): Catalog / Compare / Snapshot / Camera
+- After toggle (VI header labels): Danh mục / So sánh / Chụp / Máy ảnh
+
+## Risk notes
+
+- Language pack is chrome UI only (SKU names remain catalog data).
+- No network calls; pure client-side `localStorage`.
+- Private mode: load/save lang fails soft and defaults to English.
diff --git a/packages/android/beear-webview/src/main/assets/beear/assets/i18n.js b/packages/android/beear-webview/src/main/assets/beear/assets/i18n.js
new file mode 100644
index 0000000..9b10524
--- /dev/null
+++ b/packages/android/beear-webview/src/main/assets/beear/assets/i18n.js
@@ -0,0 +1,154 @@
+/**
+ * BeeAR chrome UI strings (EN / VI) — browser IIFE.
+ * Keep in sync with packages/web/i18n.mjs (unit tests).
+ */
+(function (g) {
+ const I18N = {
+ en: {
+ tagline: "Virtual try-on · glasses & accessories",
+ catalog: "Catalog",
+ pd: "PD (mm)",
+ pdHint: "Calibrate pupil distance for better fit scale",
+ select: "Select a SKU",
+ cam: "📷 Camera",
+ demo: "Demo photo",
+ demoNext: "Next face",
+ compare: "Compare",
+ snap: "Snapshot",
+ gallery: "Gallery",
+ person3d: "🧑 3D Person",
+ consentTitle: "Camera consent",
+ consentBody:
+ "BeeAR uses the camera only in your browser for try-on. Video is not uploaded to the server.",
+ consentOk: "Allow & start",
+ consentDemo: "Use demo photo",
+ hintIdle: "Start camera or use Demo photo · pick a frame",
+ hintDemo: "Photoreal demo face · PD slider · Compare A/B · Next face",
+ hintCam: "Camera live",
+ trackGeo: "tracking: geometric",
+ trackMp: "tracking: mediapipe",
+ trackDemo: "tracking: demo photo",
+ cloudLocal: "🔒 Local Only",
+ cloudServer: "☁️ Cloud Enabled",
+ photoConsentLabel: "Allow face photo server upload & cloud features",
+ filterAll: "All",
+ filterGlasses: "Glasses",
+ filterAccessory: "Accessories",
+ emptyFilter: "No frames match the current filter.",
+ emptyFilterHint: "Try adjusting filters or check your connection.",
+ emptyGallery: "No snapshots yet",
+ loadingCatalog: "Loading catalog…",
+ studioTagline: "Real 3D characters · glasses GLB try-on",
+ studioBack: "← 2D / Camera",
+ autoRotate: "Auto-rotate",
+ autoRotateOn: "Auto-rotate ON",
+ autoRotateOff: "Auto-rotate OFF",
+ person: "Person",
+ character: "Character",
+ frames3d: "Frames (3D)",
+ fitScale: "Fit scale",
+ yOffset: "Y offset",
+ zDepth: "Z (depth)",
+ loadingStudio: "Loading 3D studio…",
+ hintStudio: "Drag to orbit · scroll to zoom · pick person + frames",
+ badgeStudio: "3D try-on · GLB glasses",
+ loadingPerson: "Loading 3D person…",
+ },
+ vi: {
+ tagline: "Thử kính & phụ kiện ảo",
+ catalog: "Danh mục",
+ pd: "Khoảng đồng tử PD (mm)",
+ pdHint: "Hiệu chỉnh PD để scale kính chính xác hơn",
+ select: "Chọn mẫu",
+ cam: "📷 Máy ảnh",
+ demo: "Ảnh demo",
+ demoNext: "Đổi mặt",
+ compare: "So sánh",
+ snap: "Chụp",
+ gallery: "Thư viện",
+ person3d: "🧑 Nhân vật 3D",
+ consentTitle: "Đồng ý camera",
+ consentBody:
+ "BeeAR chỉ dùng camera trên trình duyệt để thử đồ. Video không gửi lên server.",
+ consentOk: "Cho phép & bắt đầu",
+ consentDemo: "Dùng ảnh demo",
+ hintIdle: "Bật camera hoặc ảnh demo · chọn khung",
+ hintDemo: "Ảnh người demo · chỉnh PD · So sánh · Đổi mặt",
+ hintCam: "Camera đang bật",
+ trackGeo: "tracking: hình học",
+ trackMp: "tracking: mediapipe",
+ trackDemo: "tracking: ảnh demo",
+ cloudLocal: "🔒 Chỉ lưu cục bộ",
+ cloudServer: "☁️ Đã bật Cloud",
+ photoConsentLabel: "Cho phép tải ảnh mặt lên server & dùng tính năng đám mây",
+ filterAll: "Tất cả",
+ filterGlasses: "Kính",
+ filterAccessory: "Phụ kiện",
+ emptyFilter: "Không có khung khớp bộ lọc hiện tại.",
+ emptyFilterHint: "Thử đổi bộ lọc hoặc kiểm tra kết nối.",
+ emptyGallery: "Chưa có ảnh chụp",
+ loadingCatalog: "Đang tải danh mục…",
+ studioTagline: "Nhân vật 3D thật · thử kính GLB",
+ studioBack: "← 2D / Camera",
+ autoRotate: "Tự xoay",
+ autoRotateOn: "Tự xoay BẬT",
+ autoRotateOff: "Tự xoay TẮT",
+ person: "Nhân vật",
+ character: "Mẫu",
+ frames3d: "Khung (3D)",
+ fitScale: "Tỷ lệ vừa",
+ yOffset: "Lệch Y",
+ zDepth: "Z (độ sâu)",
+ loadingStudio: "Đang tải studio 3D…",
+ hintStudio: "Kéo để xoay · cuộn để zoom · chọn người + khung",
+ badgeStudio: "Thử 3D · kính GLB",
+ loadingPerson: "Đang tải nhân vật 3D…",
+ },
+ };
+
+ const LANG_KEY = "beear_lang";
+
+ function normalizeLang(value) {
+ return value === "vi" ? "vi" : "en";
+ }
+
+ function t(lang, key) {
+ const pack = I18N[normalizeLang(lang)] || I18N.en;
+ return pack[key] ?? I18N.en[key] ?? key;
+ }
+
+ function toggleLang(lang) {
+ return normalizeLang(lang) === "en" ? "vi" : "en";
+ }
+
+ function langButtonLabel(lang) {
+ return normalizeLang(lang) === "en" ? "VI" : "EN";
+ }
+
+ function loadLang() {
+ try {
+ return normalizeLang(localStorage.getItem(LANG_KEY));
+ } catch (_) {
+ return "en";
+ }
+ }
+
+ function saveLang(lang) {
+ try {
+ localStorage.setItem(LANG_KEY, normalizeLang(lang));
+ } catch (_) {
+ /* private mode */
+ }
+ }
+
+ g.BeeARI18n = {
+ I18N,
+ LANG_KEY,
+ normalizeLang,
+ t,
+ toggleLang,
+ langButtonLabel,
+ loadLang,
+ saveLang,
+ };
+})(typeof globalThis !== "undefined" ? globalThis : window);
diff --git a/packages/android/beear-webview/src/main/assets/beear/index.html b/packages/android/beear-webview/src/main/assets/beear/index.html
index d83d0ce..b014605 100644
--- a/packages/android/beear-webview/src/main/assets/beear/index.html
+++ b/packages/android/beear-webview/src/main/assets/beear/index.html
@@ -72,6 +72,7 @@
Catalog
+
diff --git a/packages/web/assets/app.js b/packages/web/assets/app.js
index 06f0731..9e86af4 100644
--- a/packages/web/assets/app.js
+++ b/packages/web/assets/app.js
@@ -23,7 +23,11 @@ let compareMode = false;
let svgCache = {};
let raf = 0;
let pdMm = TryOn?.DEFAULT_PD_MM || 64;
-let lang = "en";
+const I18n = globalThis.BeeARI18n;
+if (!I18n) {
+ console.error("BeeARI18n missing — load /assets/i18n.js before app.js");
+}
+let lang = I18n ? I18n.loadLang() : "en";
let tracking = "geometric"; // geometric | mediapipe
let faceMesh = null;
let meshBusy = false;
@@ -113,93 +117,70 @@ let demoFaceIndex = 0;
let demoImages = {}; // id -> HTMLImageElement
let demoReady = false;
-const I18N = {
- en: {
- tagline: "Virtual try-on · glasses & accessories",
- catalog: "Catalog",
- pd: "PD (mm)",
- pdHint: "Calibrate pupil distance for better fit scale",
- select: "Select a SKU",
- cam: "📷 Camera",
- demo: "Demo photo",
- demoNext: "Next face",
- compare: "Compare",
- snap: "Snapshot",
- gallery: "Gallery",
- consentTitle: "Camera consent",
- consentBody:
- "BeeAR uses the camera only in your browser for try-on. Video is not uploaded to the server.",
- consentOk: "Allow & start",
- consentDemo: "Use demo photo",
- hintIdle: "Start camera or use Demo photo · pick a frame",
- hintDemo: "Photoreal demo face · PD slider · Compare A/B · Next face",
- hintCam: "Camera live",
- trackGeo: "tracking: geometric",
- trackMp: "tracking: mediapipe",
- trackDemo: "tracking: demo photo",
- cloudLocal: "🔒 Local Only",
- cloudServer: "☁️ Cloud Enabled",
- photoConsentLabel: "Allow face photo server upload & cloud features",
- },
- vi: {
- tagline: "Thử kính & phụ kiện ảo",
- catalog: "Danh mục",
- pd: "Khoảng đồng tử PD (mm)",
- pdHint: "Hiệu chỉnh PD để scale kính chính xác hơn",
- select: "Chọn mẫu",
- cam: "📷 Camera",
- demo: "Ảnh demo",
- demoNext: "Đổi mặt",
- compare: "So sánh",
- snap: "Chụp",
- gallery: "Thư viện",
- consentTitle: "Đồng ý camera",
- consentBody:
- "BeeAR chỉ dùng camera trên trình duyệt để thử đồ. Video không gửi lên server.",
- consentOk: "Cho phép & bắt đầu",
- consentDemo: "Dùng ảnh demo",
- hintIdle: "Bật camera hoặc ảnh demo · chọn khung",
- hintDemo: "Ảnh người demo · chỉnh PD · So sánh · Đổi mặt",
- hintCam: "Camera đang bật",
- trackGeo: "tracking: hình học",
- trackMp: "tracking: mediapipe",
- trackDemo: "tracking: ảnh demo",
- cloudLocal: "🔒 Chỉ lưu cục bộ",
- cloudServer: "☁️ Đã bật Cloud",
- photoConsentLabel: "Cho phép tải ảnh mặt lên server & dùng tính năng đám mây",
- },
-};
-
function t(k) {
- return (I18N[lang] || I18N.en)[k] || k;
+ if (I18n) return I18n.t(lang, k);
+ return k;
}
function applyLang() {
- document.getElementById("tagline").textContent = t("tagline");
- document.getElementById("lbl-catalog").textContent = t("catalog");
- document.getElementById("lbl-pd").childNodes[0].textContent = t("pd") + " ";
- document.getElementById("pd-hint").textContent = t("pdHint");
- document.getElementById("btn-cam").textContent = t("cam");
- document.getElementById("btn-demo").textContent = t("demo");
- const btnNext = document.getElementById("btn-demo-next");
- if (btnNext) btnNext.textContent = t("demoNext");
- document.getElementById("btn-compare").textContent = t("compare");
- document.getElementById("btn-snap").textContent = t("snap");
- document.getElementById("btn-gallery").textContent = t("gallery");
- document.getElementById("btn-lang").textContent = lang === "en" ? "VI" : "EN";
- document.getElementById("consent-title").textContent = t("consentTitle");
- document.getElementById("consent-body").textContent = t("consentBody");
- document.getElementById("consent-ok").textContent = t("consentOk");
- document.getElementById("consent-demo").textContent = t("consentDemo");
-
+ document.documentElement.lang = lang === "vi" ? "vi" : "en";
+ if (I18n) I18n.saveLang(lang);
+
+ const setText = (id, key) => {
+ const el = document.getElementById(id);
+ if (el) el.textContent = t(key);
+ };
+
+ setText("tagline", "tagline");
+ setText("lbl-catalog", "catalog");
+ const lblPd = document.getElementById("lbl-pd");
+ if (lblPd && lblPd.childNodes[0]) {
+ lblPd.childNodes[0].textContent = t("pd") + " ";
+ }
+ setText("pd-hint", "pdHint");
+ setText("btn-cam", "cam");
+ setText("btn-demo", "demo");
+ setText("btn-demo-next", "demoNext");
+ setText("btn-compare", "compare");
+ setText("btn-snap", "snap");
+ setText("btn-gallery", "gallery");
+ setText("btn-3d", "person3d");
+ const btnLang = document.getElementById("btn-lang");
+ if (btnLang) {
+ btnLang.textContent = I18n ? I18n.langButtonLabel(lang) : lang === "en" ? "VI" : "EN";
+ btnLang.setAttribute("aria-label", lang === "en" ? "Switch to Vietnamese" : "Chuyển sang tiếng Anh");
+ btnLang.setAttribute("title", btnLang.getAttribute("aria-label"));
+ }
+ setText("consent-title", "consentTitle");
+ setText("consent-body", "consentBody");
+ setText("consent-ok", "consentOk");
+ setText("consent-demo", "consentDemo");
+
+ // Filter