diff --git a/tools/i18n/ARCHITECTURE.md b/tools/i18n/ARCHITECTURE.md
new file mode 100644
index 0000000000..ee4356cae7
--- /dev/null
+++ b/tools/i18n/ARCHITECTURE.md
@@ -0,0 +1,152 @@
+# WLED i18n Architecture
+
+## Overview
+
+Two-repository architecture separating **build toolchain** (core repo) from **translation files** (community repo).
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ Core Repo (WLED/tools/i18n/) │
+│ ├── extract.py # Extract translatable strings from HTML │
+│ ├── build.py # Apply translations at build time │
+│ └── locales/ # Locale configuration │
+└─────────────────────────────────────────────────────────────┘
+ ↓ calls
+┌─────────────────────────────────────────────────────────────┐
+│ Translation Repo (WLED-translations//) │
+│ ├── static.json # Layer 1: Static HTML (429 entries) │
+│ ├── js.json # Layer 2: JS strings (45 entries) │
+│ ├── effects.json # Layer 3: Effect names (216 entries) │
+│ ├── palettes.json # Layer 4: Palette names (72 entries) │
+│ └── metadata.json # Version, coverage, maintainer │
+└─────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## Four-Layer Translation Architecture
+
+| Layer | Content | File | Implementation | Coverage |
+|-------|---------|------|----------------|----------|
+| **L1** | Static HTML | `static.json` | Regex replacement in HTML text | 429 strings |
+| **L2** | JS strings | `js.json` | Replace JS string literals | 45 strings |
+| **L3** | Effect names | `effects.json` | C++ PROGMEM `#undef` + redefine | 216/216 (100%) |
+| **L4** | Palette names | `palettes.json` | C++ PROGMEM array replacement | 72/72 (100%) |
+
+---
+
+## Build Flow
+
+### PlatformIO Configuration
+
+```ini
+# platformio.ini
+[env:esp32dev_zh_CN]
+extends = env:esp32dev
+build_flags = ${env:esp32dev.build_flags} -D WLED_LOCALE=zh_CN
+extra_scripts =
+ ${env:esp32dev.extra_scripts}
+ pre:tools/i18n/build.py
+```
+
+### Build Steps
+
+1. `build.py` reads `wled00/data/*.htm` (English source)
+2. Applies L1/L2 translations via regex replacement
+3. Generates `i18n_effects.h` / `i18n_palettes.h` for L3/L4 (PROGMEM replacement)
+4. Output to `build/i18n//`
+
+---
+
+## How Dynamic Content Works
+
+The key insight: **PROGMEM replacement happens at compile time**, so JSON endpoints return translated strings automatically.
+
+```
+Browser ESP32 Firmware
+ │ │
+ ├─ GET /json/palettes ─────────→│ PROGMEM array was replaced at compile time
+ │ ← {"0":"默认","1":"* 随机循环",...} ↓
+ │ │ palettes.json → i18n_palettes.h
+ ├─ GET /json/effects ─────────→│ #undef _data_FX_MODE_STATIC
+ │ ← {"0":"常亮","1":"闪烁",...}│ #define _data_FX_MODE_STATIC "常亮"
+```
+
+No firmware code changes needed. The C++ PROGMEM strings are the single source of truth.
+
+---
+
+## Grammar and Word Order
+
+WLED UI uses **short labels**, not full sentences:
+
+| Pattern | Example | i18n Impact |
+|---------|---------|-------------|
+| Single word | "Brightness", "Speed" | ✅ No issue |
+| Label + value | "255 segments" | ✅ Works ("255 个段") |
+| Full sentences | Almost none | ✅ N/A |
+| Plural forms | Not used | ✅ N/A |
+| Date formats | Not used in UI | ✅ N/A |
+
+The architecture **intentionally avoids** complex i18n patterns (ICU MessageFormat, plural rules) because WLED's UI doesn't need them.
+
+---
+
+## What's NOT Translated (By Design)
+
+| Content | Reason |
+|---------|--------|
+| User-defined preset names | Belongs to user |
+| Usermod settings pages | Dynamic HTML from firmware, varies by hardware |
+| System info (IP, memory) | Universal data |
+| Effect slider tooltips | Generated from mode data arrays |
+
+---
+
+## Repository Structure
+
+### Core repo (WLED)
+
+```
+tools/i18n/
+├── extract.py # String extraction tool
+├── build.py # Build-time translation applicator
+├── ARCHITECTURE.md # This file
+├── README.md # Usage documentation
+└── locales/ # Locale configs
+```
+
+### Translation repo (WLED-translations)
+
+```
+/
+├── static.json # Layer 1: Static HTML text
+├── js.json # Layer 2: JavaScript strings
+├── effects.json # Layer 3: Effect names (PROGMEM)
+├── palettes.json # Layer 4: Palette names (PROGMEM)
+└── metadata.json # {"version":"1.0","coverage":"100%","maintainer":"..."}
+en_template/ # English template for translators
+```
+
+---
+
+## Adding a New Language
+
+1. Fork `WLED-translations`
+2. Copy `en_template/` to `/`
+3. Translate JSON files
+4. Submit PR to translation repo
+
+No changes to WLED core needed.
+
+---
+
+## Coverage Summary (zh_CN)
+
+| Layer | Content | Count | Status |
+|-------|---------|-------|--------|
+| L1 | Static HTML | 429 | ✅ Complete |
+| L2 | JS strings | 45 | ✅ Complete |
+| L3 | Effect names | 216/216 | ✅ 100% |
+| L4 | Palette names | 72/72 | ✅ 100% |
+| **Total** | | **762** | **100%** |
diff --git a/tools/i18n/README.md b/tools/i18n/README.md
new file mode 100644
index 0000000000..5a84ed75aa
--- /dev/null
+++ b/tools/i18n/README.md
@@ -0,0 +1,119 @@
+# WLED i18n Toolchain
+
+Build-time internationalization for WLED Web UI. Translates HTML/JS strings at compile time with **zero runtime overhead**.
+
+## Architecture
+
+```
+WLED (core repo) WLED-translations (community repo)
+tools/i18n/ /
+├── extract.py ──────────────────→ static.json (Layer 1: HTML)
+├── build.py ←─ applies ─────── js.json (Layer 2: JS)
+├── ARCHITECTURE.md effects.json (Layer 3: PROGMEM)
+└── README.md palettes.json(Layer 4: PROGMEM)
+```
+
+**Key principle:** Translations are maintained **out-of-tree** by community contributors, similar to usermods. Users build translated firmware locally.
+
+## Quick Start
+
+### 1. Clone both repos
+
+```bash
+git clone https://github.com/wled/WLED.git
+git clone https://github.com/wled/WLED-translations.git
+```
+
+### 2. Extract strings (optional, for translators)
+
+```bash
+cd WLED
+python3 tools/i18n/extract.py --stats
+# Output: tools/i18n/locales/_template.json
+```
+
+### 3. Validate translations
+
+```bash
+# Check coverage for a locale
+python3 tools/i18n/extract.py --validate zh_CN
+
+# Output:
+# Validating locale: zh_CN
+# ========================================
+# Coverage: 429/429 (100.0%)
+# PASSED: All strings translated
+```
+
+### 4. Build translated firmware
+
+```bash
+cd WLED
+
+# Apply translations (Layer 1 + 2: HTML/JS)
+python3 tools/i18n/build.py --locale zh_CN \
+ --source-dir wled00/data \
+ --translation-dir ../WLED-translations/zh_CN \
+ --output-dir build/i18n/zh_CN
+
+# Build web UI headers
+npm ci && npm run build
+
+# Build firmware
+pio run -e esp32dev
+```
+
+### 5. PlatformIO integration (automatic)
+
+Add to `platformio.ini`:
+
+```ini
+[env:esp32dev_zh_CN]
+extends = env:esp32dev
+build_flags = ${env:esp32dev.build_flags} -D WLED_LOCALE=zh_CN
+extra_scripts = pre:tools/i18n/build.py
+```
+
+Then just: `pio run -e esp32dev_zh_CN`
+
+## Four-Layer Translation System
+
+| Layer | Content | Source | Method |
+|-------|---------|--------|--------|
+| **L1** | Static HTML | `static.json` | Regex replacement |
+| **L2** | JS strings | `js.json` | Script block regex |
+| **L3** | Effect names | `effects.json` | PROGMEM `#undef` + redefine |
+| **L4** | Palette names | `palettes.json` | PROGMEM array replacement |
+
+## For Translators
+
+1. Fork [WLED-translations](https://github.com/wled/WLED-translations)
+2. Copy `en_template/` to `/`
+3. Edit JSON files — fill in `"translation"` fields
+4. Validate: `python3 tools/i18n/extract.py --validate `
+5. Submit PR to WLED-translations repo
+
+## For Maintainers
+
+### Adding this toolchain to WLED core
+
+This PR adds only `tools/i18n/` — no changes to existing build pipeline. The tool is a pre-build step that runs before `npm run build`.
+
+### CI/CD integration
+
+```yaml
+# .github/workflows/i18n-validate.yml
+- name: Validate translations
+ run: |
+ git clone https://github.com/wled/WLED-translations.git
+ for locale in WLED-translations/*/; do
+ python3 tools/i18n/extract.py --validate $(basename $locale)
+ done
+```
+
+## Limitations
+
+1. **No runtime language switching** — language is fixed at build time
+2. **External tools** (pixelforge, pixelmagic) — always English, downloaded on-the-fly
+3. **C++ server-side strings** — ~12 strings in `xml.cpp` need separate handling
+4. **User presets** — user-defined names are not translated (by design)
diff --git a/tools/i18n/build.py b/tools/i18n/build.py
new file mode 100644
index 0000000000..bf181d2304
--- /dev/null
+++ b/tools/i18n/build.py
@@ -0,0 +1,302 @@
+#!/usr/bin/env python3
+"""
+WLED i18n Build Script (v3 - fixes from coderabbitai review)
+Generates translated HTML files from English source + locale JSON.
+
+Uses raw string replacement instead of BeautifulSoup serialization
+to preserve original HTML formatting exactly (critical for ESP32 flash size).
+
+Fixes applied:
+1. File-scoped HTML replacement (no cross-file bleed)
+2. Script-block-aware HTML replacement (skip )', re.DOTALL | re.IGNORECASE)
+ last_end = 0
+
+ for match in pattern.finditer(content):
+ # Non-script content before this script block
+ before = content[last_end:match.start()]
+ if before:
+ segments.append((before, False))
+ # The script block itself (including tags)
+ segments.append((match.group(0), True))
+ last_end = match.end()
+
+ # Remaining non-script content after last script block
+ after = content[last_end:]
+ if after:
+ segments.append((after, False))
+
+ return segments
+
+
+def replace_html_text(content, original, translated):
+ """Replace HTML text content using exact string matching.
+ Handles:
+ - >original< (direct child text)
+ - > original < (with whitespace)
+ - >... original< (text after sibling element)
+
+ IMPORTANT: content must be non-script segments only.
+ """
+ escaped = re.escape(original)
+ total = 0
+
+ # Pattern 1: Text between > and or > and < (with optional whitespace)
+ p1 = re.compile(r'(>)\s*(' + escaped + r')\s*(?\w)')
+ content, n = p1.subn(r'\g<1>' + translated + r'\g<3>', content)
+ total += n
+
+ # Pattern 2: Text after a closing tag (e.g. Color palette
)
+ p2 = re.compile(r'(\w+>)\s*(' + escaped + r')\s*(?\w)')
+ content, n = p2.subn(r'\g<1>' + translated + r'\g<3>', content)
+ total += n
+
+ # Pattern 3: Standalone text line (with leading whitespace)
+ p3 = re.compile(r'^(\s*)(' + escaped + r')(\s*)$', re.MULTILINE)
+ content, n = p3.subn(r'\g<1>' + translated + r'\g<3>', content)
+ total += n
+
+ return content, total
+
+
+def replace_html_attr(content, attr, original, translated):
+ """Replace HTML attribute value using exact string matching."""
+ pattern = re.compile(
+ r'(' + re.escape(attr) + r'\s*=\s*")(' + re.escape(original) + r')(")',
+ re.IGNORECASE
+ )
+ new_content, count = pattern.subn(r'\g<1>' + translated + r'\g<3>', content)
+ return new_content, count
+
+
+def replace_js_in_block(script_block, original, translated):
+ """Replace a JS string literal within a single block.
+ Returns (new_block, count).
+ """
+ for quote in ['"', "'", '`']:
+ escaped = re.escape(original)
+ # Match quoted string within this single script block
+ pattern = re.compile(
+ r'([' + quote + r'])(' + escaped + r')([' + quote + r'])'
+ )
+ new_block, count = pattern.subn(
+ r'\g<1>' + translated + r'\g<3>',
+ script_block, count=1
+ )
+ if count > 0:
+ return new_block, count
+
+ return script_block, 0
+
+
+def apply_translations(content, file_key, translations, lang_code):
+ """Apply all translations to a file's content.
+ Uses script-block-aware processing to avoid cross-contamination.
+ """
+ total = 0
+
+ # 1. Update lang attribute
+ content = re.sub(
+ r'(]*lang\s*=\s*")([^"]+)(")',
+ r'\g<1>' + lang_code + r'\g<3>',
+ content, count=1
+ )
+
+ # 2. Split into script/non-script segments
+ segments = split_script_blocks(content)
+
+ # 3. Apply translations per-segment
+ file_translations = translations.get(file_key, {})
+ new_segments = []
+
+ for segment_text, is_script in segments:
+ if is_script:
+ # Apply JS translations to script blocks
+ for key, entry in file_translations.items():
+ if key.startswith('js:'):
+ segment_text, count = replace_js_in_block(
+ segment_text, entry['original'], entry['translation']
+ )
+ total += count
+ else:
+ # Apply HTML translations to non-script content only
+ for key, entry in file_translations.items():
+ if key.startswith('html:'):
+ parts = key.split(':')
+ attr_name = parts[-1]
+
+ if attr_name == 'text':
+ segment_text, count = replace_html_text(
+ segment_text, entry['original'], entry['translation']
+ )
+ total += count
+ elif attr_name in ('placeholder', 'title', 'alt', 'aria-label'):
+ segment_text, count = replace_html_attr(
+ segment_text, attr_name, entry['original'], entry['translation']
+ )
+ total += count
+
+ new_segments.append(segment_text)
+
+ return ''.join(new_segments), total
+
+
+def build_locale(locale, source_dir=None, output_dir=None):
+ """Build translated HTM files for a given locale."""
+ file_translations = load_translations(locale)
+ if not file_translations:
+ print(f"Warning: No translations found for {locale}")
+ return 0
+
+ lang_code = LOCALE_LANG.get(locale, locale.split('_')[0])
+ src_dir = Path(source_dir) if source_dir else DATA_DIR
+
+ htm_files = sorted(src_dir.glob('*.htm'))
+ if not htm_files:
+ print(f"Error: No .htm files found in {src_dir}", file=sys.stderr)
+ return 0
+
+ # BUG FIX #4: Never default to overwriting source files
+ if output_dir:
+ out_dir = Path(output_dir)
+ else:
+ out_dir = Path(tempfile.mkdtemp(prefix=f'wled_i18n_{locale}_'))
+ print(f"[i18n] No --output-dir specified, using temp: {out_dir}")
+
+ out_dir.mkdir(parents=True, exist_ok=True)
+
+ # Safety check: warn if output == source
+ if out_dir.resolve() == src_dir.resolve():
+ print(f"WARNING: Output dir equals source dir ({src_dir}).", file=sys.stderr)
+ print(f" English source files will be overwritten!", file=sys.stderr)
+ print(f" Pass --output-dir to a different location.", file=sys.stderr)
+
+ total_applied = 0
+
+ for filepath in htm_files:
+ file_key = filepath.name
+ with open(filepath, 'r', encoding='utf-8') as f:
+ content = f.read()
+
+ content, applied = apply_translations(content, file_key, file_translations, lang_code)
+
+ out_path = out_dir / file_key
+ with open(out_path, 'w', encoding='utf-8') as f:
+ f.write(content)
+
+ status = f"{applied} translations" if applied else "no changes"
+ print(f" {file_key}: {status}")
+ total_applied += applied
+
+ print(f"\nTotal: {total_applied} translations applied across {len(htm_files)} files")
+ print(f"Output: {out_dir}")
+ return total_applied
+
+
+def main():
+ import argparse
+ parser = argparse.ArgumentParser(description='Build translated WLED Web UI files')
+ parser.add_argument('--locale', required=True, help='Locale code (e.g. zh_CN)')
+ parser.add_argument('--source-dir', default=None, help='Source directory (default: wled00/data/)')
+ parser.add_argument('--output-dir', default=None, help='Output directory (default: temp dir)')
+ args = parser.parse_args()
+
+ print(f"WLED i18n Build — {args.locale}")
+ print("=" * 40)
+
+ count = build_locale(args.locale, args.source_dir, args.output_dir)
+ if count == 0:
+ print("\nWarning: No translations applied!")
+
+
+# PlatformIO pre-build integration
+def pre_build(source, target, env):
+ """PlatformIO pre-build script entry point."""
+ import re as _re
+ locale = None
+ for flag in env.get('BUILD_FLAGS', []):
+ m = _re.match(r'-D\s*WLED_LOCALE=(\S+)', flag)
+ if m:
+ locale = m.group(1).strip()
+ break
+
+ if not locale:
+ print("[i18n] No WLED_LOCALE set, skipping translation")
+ return
+
+ print(f"[i18n] Building with locale: {locale}")
+ # Use build directory for output, not source directory
+ build_dir = Path(env.subst('$BUILD_DIR')) / 'i18n' / locale
+ build_locale(locale, output_dir=build_dir)
+
+
+try:
+ Import("env")
+ env.AddPreAction("buildprog", pre_build)
+except NameError:
+ if __name__ == '__main__':
+ main()
diff --git a/tools/i18n/extract.py b/tools/i18n/extract.py
new file mode 100644
index 0000000000..3e656089ff
--- /dev/null
+++ b/tools/i18n/extract.py
@@ -0,0 +1,369 @@
+#!/usr/bin/env python3
+"""
+WLED i18n String Extractor
+Extracts translatable strings from WLED Web UI HTML files.
+
+Usage: python3 extract.py [--locale zh_CN]
+
+Outputs: locales/.json (or locales/_template.json if no locale specified)
+
+Handles three layers:
+1. Static HTML text (BeautifulSoup DOM parsing)
+2. JS strings in
@@ -252,61 +252,61 @@
-
- Pin Info
- Loading...
-
+
+ 引脚信息
+ 加载中...
+
diff --git a/wled00/data/settings_dmx.htm b/wled00/data/settings_dmx.htm
index 391c2bdc97..0e6e1a75ea 100644
--- a/wled00/data/settings_dmx.htm
+++ b/wled00/data/settings_dmx.htm
@@ -1,9 +1,9 @@
-
+