Skip to content

feat: add web ui i18n#401

Open
meganetaaan wants to merge 2 commits intodev/v1.0from
feat/issue-400-web-ui-i18n
Open

feat: add web ui i18n#401
meganetaaan wants to merge 2 commits intodev/v1.0from
feat/issue-400-web-ui-i18n

Conversation

@meganetaaan
Copy link
Copy Markdown
Collaborator

@meganetaaan meganetaaan commented Apr 28, 2026

Summary

  • Add ADR 0001 documenting the lightweight Web UI i18n decision, scope boundaries, and tone/terminology guidance
  • Add a vanilla JS i18n module for ja, en, and zh-Hans with fallback behavior
  • Localize the preference and flash Web UI using data-i18n plus placeholder/title/aria-label attributes
  • Add Web UI tests for key coverage, locale resolution, fallback behavior, and attribute localization

Scope notes

  • Web UI only; firmware i18n is deferred
  • UI locale is kept separate from tts.*, voice, and conversation language settings
  • Future firmware-side UI i18n should prefer Moddable's built-in i18n support

Test plan

  • cd web && npm test
  • git diff --check

Closes #400

Summary by CodeRabbit

Release Notes

  • New Features

    • Added internationalization support with English, Japanese, and Simplified Chinese language options
    • Added a language selector to the UI with automatic preference persistence
  • Documentation

    • Added Architecture Decision Record documenting web UI internationalization standards

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 28, 2026

📝 Walkthrough

Walkthrough

This pull request adds lightweight client-side internationalization (i18n) support to the Web UI without external dependencies. It introduces an ADR documenting the approach, a new i18n module handling locale resolution and DOM translation application, updates to HTML pages integrating the i18n system, CSS styling for locale selectors, and comprehensive tests validating translation key consistency and locale resolution logic.

Changes

Cohort / File(s) Summary
Architecture & Documentation
docs/adr/0001-web-ui-i18n.md, docs/adr/README.md
Adds ADR 0001 specifying i18n scope (ja, en, zh-Hans), locale resolution order (URL query → localStorage → navigator.language → fallback), data-i18n markup patterns, strict separation from voice/TTS settings, and tooling requirements for translation key validation. README indexes the new ADR.
Core i18n Module
web/i18n.mjs
Implements lightweight i18n with constants for supported locales, translation catalogs, and utility functions for locale normalization, resolution, and DOM application. Handles attribute-specific translation markers (data-i18n-placeholder, data-i18n-title, data-i18n-aria-label) and initialization with persistence.
Web UI Integration
web/flash/index.html, web/preference/index.html
Refactors both pages to use i18n: adds data-i18n and attribute-specific markers to UI strings, includes locale selector dropdown, imports and initializes i18n module on load, and updates document title handling.
Styling & Configuration
web/global.css, web/package.json
Adds .locale-selector CSS rules for layout and spacing. Reformats existing selectors for consistency and adds node --test npm script for test execution.
Testing
web/test/i18n.test.mjs
Introduces Node test suite validating translation key consistency across locales, locale resolution precedence, translation fallback behavior, normalizeLocale handling of browser variants, and DOM application logic including element text, attributes, and locale selector updates.

Sequence Diagram(s)

sequenceDiagram
    participant Browser
    participant JavaScript
    participant LocalStorage
    participant DOM

    rect rgba(220, 150, 150, 0.5)
        Note over Browser,DOM: Locale Resolution Phase (DOMContentLoaded)
        JavaScript->>Browser: Check URL query parameter (?lang=)
        JavaScript->>LocalStorage: Query stored locale preference
        JavaScript->>Browser: Access navigator.language
        JavaScript->>JavaScript: Resolve to supported locale (ja/en/zh-Hans)
        JavaScript->>LocalStorage: Persist selected locale
    end

    rect rgba(150, 200, 150, 0.5)
        Note over JavaScript,DOM: DOM Translation Phase
        JavaScript->>DOM: Query elements with data-i18n
        JavaScript->>JavaScript: Fetch translation for (locale, key)
        JavaScript->>DOM: Set element.textContent from message catalog
    end

    rect rgba(150, 150, 200, 0.5)
        Note over JavaScript,DOM: Attribute Translation Phase
        JavaScript->>DOM: Query elements with data-i18n-placeholder
        JavaScript->>DOM: Set placeholder attribute
        JavaScript->>DOM: Query elements with data-i18n-title
        JavaScript->>DOM: Set title attribute
        JavaScript->>DOM: Query elements with data-i18n-aria-label
        JavaScript->>DOM: Set aria-label attribute
    end

    rect rgba(200, 200, 100, 0.5)
        Note over Browser,DOM: Locale Selector Change
        Browser->>JavaScript: User selects locale from dropdown
        JavaScript->>LocalStorage: Update stored preference
        JavaScript->>DOM: Re-apply translations for new locale
        JavaScript->>DOM: Update document.documentElement.lang
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 Hops with joy at translated pages bright,
Locales resolve left, localStorage, right!
From ja to en to Chinese so fair,
Now every visitor finds words they share!
One fallback, one truth—no bundle to bear!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add web ui i18n' is concise and clearly describes the main change: adding internationalization to the Web UI.
Linked Issues check ✅ Passed The PR fully addresses all coding requirements from issue #400: ADR created, i18n module implemented with supported locales and fallback, preference/flash pages localized, attribute localization supported, locale isolated from TTS settings, and tests added for key coverage and resolution.
Out of Scope Changes check ✅ Passed All changes are directly related to Web UI i18n implementation per issue #400 scope. No unrelated modifications to firmware, TTS/voice settings, or other areas outside the stated objectives were detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/issue-400-web-ui-i18n

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 550c679db0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread web/i18n.mjs

export function initI18n({
root = document,
storage = globalThis.localStorage,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle unavailable localStorage without crashing initI18n

initI18n() eagerly reads globalThis.localStorage in its default parameters, and that property access can throw SecurityError in sandboxed/blocked storage contexts (for example strict privacy settings or restricted iframes). When this happens the function throws before locale resolution, and callers like web/preference/index.html stop initializing the rest of their UI logic after initI18n() is invoked. Please guard storage acquisition with a try/catch (or defer access) so i18n can fall back gracefully.

Useful? React with 👍 / 👎.

@meganetaaan
Copy link
Copy Markdown
Collaborator Author

CI note

Local verification for this PR passed:

  • cd web && npm test
  • git diff --check

The failing GitHub Actions checks are firmware workflows:

  • Build Stack-chan Firmware
  • Bundle Stack-chan Firmware

Both fail with the existing firmware linker error:

undefined reference to `app_main`

This appears unrelated to the Web UI / ADR changes in this PR. The dev/v1.0 base branch is already failing the same firmware workflows on recent pushes, including the latest dev/v1.0 run after #398.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@web/flash/index.html`:
- Line 10: The script import is pinned to a floating major version
("esp-web-tools@9") — update the src attribute for the install-button module to
the exact stable release "[email protected]" (i.e., change the URL in the
<script> tag that loads install-button.js to use `@10.2.1`) so the dependency is
precisely pinned for reproducibility and security updates.

In `@web/global.css`:
- Around line 40-45: The grouped selectors in global.css are leaking styles
because only the first selector is scoped (e.g., ".card h1, h2, h3..."), so
change the grouped selectors to fully-qualified scoped selectors: replace
occurrences like ".card h1, h2, h3, h4, h5, h6" with ".card h1, .card h2, .card
h3, .card h4, .card h5, .card h6", and similarly scope form elements by
replacing "select, textarea" and " .card select, textarea" patterns with ".card
select, .card textarea" (and any other repeated groups) so all rules apply only
within the intended container selectors.

In `@web/i18n.mjs`:
- Line 205: The navigatorLanguages assignment can drop navigator.language when
navigator.languages exists but is an empty array; change the expression for
navigatorLanguages to explicitly check navigator.languages.length (e.g., use a
conditional that uses navigator.languages when it has length > 0, otherwise use
[navigator.language]) so that navigator.language is included when languages is
empty; update the navigatorLanguages property in web/i18n.mjs accordingly.

In `@web/package.json`:
- Line 7: Add an explicit "engines" entry to web/package.json to lock the Node
version so locals and CI match: update package.json (the same file containing
the "test": "node --test" script) to include an "engines" object, e.g.
"engines": {"node": "22.x"} (or another exact range your CI uses), and ensure
package.json's "engines" value reflects the CI Node 22 configuration so running
"node --test" behaves consistently across environments.

In `@web/preference/index.html`:
- Around line 142-148: The disconnect control currently implemented as a
clickable <div> with id "ble-disconnect-button" and class
"ble-disconnect-button" must be replaced with a real <button> element
(type="button") to restore native keyboard semantics; update the element to
preserve attributes like id, class, title and data-i18n-title, add an accessible
name via aria-label if title text is not sufficient, and ensure any event
handlers bound to "ble-disconnect-button" continue to work; make the same
replacement for the other instance using the same id/class so both controls are
keyboard and screen-reader accessible.
- Around line 96-99: The current lookup uses
document.querySelector(`[name="${prop}"]`) which can throw on unsafe characters
in prop; replace this with a safer lookup using document.getElementsByName(prop)
(e.g., const elems = document.getElementsByName(prop); if (elems &&
elems.length) elems[0].value = value) so that the code around prop/elem
assignment uses getElementsByName instead of querySelector to avoid CSS selector
parsing errors.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 47f726c0-261f-464e-8efe-dc9e482c7737

📥 Commits

Reviewing files that changed from the base of the PR and between caca377 and 550c679.

📒 Files selected for processing (8)
  • docs/adr/0001-web-ui-i18n.md
  • docs/adr/README.md
  • web/flash/index.html
  • web/global.css
  • web/i18n.mjs
  • web/package.json
  • web/preference/index.html
  • web/test/i18n.test.mjs

Comment thread web/flash/index.html
<title data-i18n-document-title="flash.documentTitle">Flash firmware</title>
<link rel="stylesheet" href="../global.css" />
<link rel="stylesheet" href="flash.css" />
<script type="module" src="https://unpkg.com/esp-web-tools@9/dist/web/install-button.js?module"></script>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

What is the recommended production integration for esp-web-tools install-button.js (exact version pinning, SRI/integrity usage, and whether self-hosting is recommended)?

💡 Result:

The recommended production integration for esp-web-tools install-button.js uses exact version pinning to the latest stable release (10.2.1 as of Jan 2026), loading from unpkg CDN without SRI/integrity attribute specified in official docs. Self-hosting is recommended as an option ("If you prefer to locally host the JavaScript, download it here" from official docs). Recommended script tag: <script type="module" src="https://unpkg.com/[email protected]/dist/web/install-button.js?module"&gt;&lt;/script> Official docs example uses @10 (older), but current latest is 10.2.1 per npm/GitHub. No SRI usage in docs or examples. Self-hosting via npm install or download for reliability/control.

Citations:


Update esp-web-tools to the latest stable version with exact pinning.

Line 10 uses esp-web-tools@9, but the latest stable release is 10.2.1 (as of Jan 2026). Update to exact version pinning (e.g., @10.2.1) to ensure reproducibility and receive security updates. While floating major versions are common, pinning to a specific release is the production best practice.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/flash/index.html` at line 10, The script import is pinned to a floating
major version ("esp-web-tools@9") — update the src attribute for the
install-button module to the exact stable release "[email protected]" (i.e.,
change the URL in the <script> tag that loads install-button.js to use `@10.2.1`)
so the dependency is precisely pinned for reproducibility and security updates.

Comment thread web/global.css
Comment on lines +40 to +45
.card h1,
h2,
h3,
h4,
h5,
h6 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix unintended global style leakage from grouped selectors.

At Line 40 and Lines 79/86, only the first selector is scoped; the rest (h2...h6, select, textarea) apply globally. This can override styles outside the target containers.

🎯 Proposed scoped selector fix
-.card h1,
-h2,
-h3,
-h4,
-h5,
-h6 {
+.card h1,
+.card h2,
+.card h3,
+.card h4,
+.card h5,
+.card h6 {
   margin: 0.3em 0;
 }

-.form-group input,
-select,
-textarea {
+.form-group input,
+.form-group select,
+.form-group textarea {
   flex: 2;
   padding: 0.5em;
 }

-.card input,
-select,
-textarea {
+.card input,
+.card select,
+.card textarea {
   border: 1px solid `#bdbdbd`;
   border-radius: 4px;
 }

Also applies to: 79-81, 86-88

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/global.css` around lines 40 - 45, The grouped selectors in global.css are
leaking styles because only the first selector is scoped (e.g., ".card h1, h2,
h3..."), so change the grouped selectors to fully-qualified scoped selectors:
replace occurrences like ".card h1, h2, h3, h4, h5, h6" with ".card h1, .card
h2, .card h3, .card h4, .card h5, .card h6", and similarly scope form elements
by replacing "select, textarea" and " .card select, textarea" patterns with
".card select, .card textarea" (and any other repeated groups) so all rules
apply only within the intended container selectors.

Comment thread web/i18n.mjs
let currentLocale = resolveLocale({
search: location?.search ?? '',
storedLocale: storage?.getItem?.(LOCALE_STORAGE_KEY),
navigatorLanguages: navigator?.languages ?? [navigator?.language],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Include navigator.language even when navigator.languages is present but empty.

Line 205 can drop navigator.language when navigator.languages is [], which can incorrectly force fallback to en.

✅ Suggested fallback-safe candidate list
-    navigatorLanguages: navigator?.languages ?? [navigator?.language],
+    navigatorLanguages: [...(navigator?.languages ?? []), navigator?.language],
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
navigatorLanguages: navigator?.languages ?? [navigator?.language],
navigatorLanguages: [...(navigator?.languages ?? []), navigator?.language],
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/i18n.mjs` at line 205, The navigatorLanguages assignment can drop
navigator.language when navigator.languages exists but is an empty array; change
the expression for navigatorLanguages to explicitly check
navigator.languages.length (e.g., use a conditional that uses
navigator.languages when it has length > 0, otherwise use [navigator.language])
so that navigator.language is included when languages is empty; update the
navigatorLanguages property in web/i18n.mjs accordingly.

Comment thread web/package.json
"scripts": {
"dev": "live-server --verbose"
"dev": "live-server --verbose",
"test": "node --test"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Node version constraints in repo =="
rg -n --iglob '**/package.json' '"engines"\s*:\s*\{|"node"\s*:\s*"[^\"]+"' 

echo
echo "== CI node-version settings =="
rg -n --iglob '*.yml' --iglob '*.yaml' 'setup-node|node-version|matrix:|NODE_VERSION'

echo
echo "== Version hint files =="
fd -HI '(^\.nvmrc$|^\.node-version$)' .

Repository: stack-chan/stack-chan

Length of output: 264


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Node version constraints in repo (package.json files) =="
find . -name "package.json" -type f | while read file; do
  if grep -q '"engines"' "$file"; then
    echo "Found engines in: $file"
    grep -A 3 '"engines"' "$file" | head -5
  fi
done

echo
echo "== Specific check: web/package.json =="
if [ -f "web/package.json" ]; then
  cat -n web/package.json | head -20
else
  echo "web/package.json not found"
fi

echo
echo "== CI node-version settings in yml/yaml files =="
find . -type f \( -name "*.yml" -o -name "*.yaml" \) | while read file; do
  if grep -iE 'node.version|setup.node|NODE_VERSION' "$file" > /dev/null 2>&1; then
    echo "=== $file ==="
    grep -iEn 'node.version|setup.node|NODE_VERSION' "$file"
  fi
done

echo
echo "== Version hint files (.nvmrc, .node-version) =="
find . -maxdepth 2 -type f \( -name ".nvmrc" -o -name ".node-version" \) 2>/dev/null && echo "Found" || echo "None found"

Repository: stack-chan/stack-chan

Length of output: 884


Add engines constraint to web/package.json to enforce Node version.

node --test requires Node 18+, but your CI pipeline is configured for Node 22. Add an explicit engines field to ensure local and CI environments are aligned and prevent inconsistencies.

🔧 Suggested package.json guard
 {
   "name": "stackchan-web",
   "version": "0.1.0",
   "description": "Stack-chan development server",
+  "engines": {
+    "node": ">=22.0.0"
+  },
   "scripts": {
     "dev": "live-server --verbose",
     "test": "node --test"
   },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"test": "node --test"
{
"name": "stackchan-web",
"version": "0.1.0",
"description": "Stack-chan development server",
"engines": {
"node": ">=22.0.0"
},
"scripts": {
"dev": "live-server --verbose",
"test": "node --test"
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/package.json` at line 7, Add an explicit "engines" entry to
web/package.json to lock the Node version so locals and CI match: update
package.json (the same file containing the "test": "node --test" script) to
include an "engines" object, e.g. "engines": {"node": "22.x"} (or another exact
range your CI uses), and ensure package.json's "engines" value reflects the CI
Node 22 configuration so running "node --test" behaves consistently across
environments.

Comment thread web/preference/index.html
Comment on lines +96 to +99
const elem = document.querySelector(`[name="${prop}"]`)
if (elem != null) {
elem.value = value
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid dynamic CSS selector parsing for BLE property names.

At Line 96, interpolating prop into querySelector can throw on unexpected characters and break update handling. Use getElementsByName instead.

🛡️ Safer element lookup
-          const elem = document.querySelector(`[name="${prop}"]`)
-          if (elem != null) {
-            elem.value = value
-          }
+          const elem = document.getElementsByName(prop).item(0)
+          if (elem != null && 'value' in elem) {
+            elem.value = value
+          }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const elem = document.querySelector(`[name="${prop}"]`)
if (elem != null) {
elem.value = value
}
const elem = document.getElementsByName(prop).item(0)
if (elem != null && 'value' in elem) {
elem.value = value
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/preference/index.html` around lines 96 - 99, The current lookup uses
document.querySelector(`[name="${prop}"]`) which can throw on unsafe characters
in prop; replace this with a safer lookup using document.getElementsByName(prop)
(e.g., const elems = document.getElementsByName(prop); if (elems &&
elems.length) elems[0].value = value) so that the code around prop/elem
assignment uses getElementsByName instead of querySelector to avoid CSS selector
parsing errors.

Comment thread web/preference/index.html
Comment on lines +142 to 148
class="ble-disconnect-button"
id="ble-disconnect-button"
title="Disconnect"
data-i18n-title="preference.disconnect.title"
>
×
</div>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use a real button for disconnect action (keyboard accessibility).

Line 142 uses a clickable <div> for an action control. This blocks native keyboard semantics and can prevent non-pointer users from disconnecting.

♿ Suggested semantic control fix
-        <div
+        <button
+          type="button"
           class="ble-disconnect-button"
           id="ble-disconnect-button"
           title="Disconnect"
           data-i18n-title="preference.disconnect.title"
+          aria-label="Disconnect"
+          data-i18n-aria-label="preference.disconnect.title"
         >
           ×
-        </div>
+        </button>

Also applies to: 117-117

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/preference/index.html` around lines 142 - 148, The disconnect control
currently implemented as a clickable <div> with id "ble-disconnect-button" and
class "ble-disconnect-button" must be replaced with a real <button> element
(type="button") to restore native keyboard semantics; update the element to
preserve attributes like id, class, title and data-i18n-title, add an accessible
name via aria-label if title text is not sufficient, and ensure any event
handlers bound to "ble-disconnect-button" continue to work; make the same
replacement for the other instance using the same id/class so both controls are
keyboard and screen-reader accessible.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add lightweight i18n support for Web UI

1 participant