# Localization & Language Dropdown Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add a language dropdown to the simulator that localizes all visitor-facing UI controls and the Left/Right brain annotations into English, Spanish, French, and Japanese, switchable live without reload.

**Architecture:** A new UMD module `simulator/static/i18n.js` (dual-export like `scrub.js`) holds the language registry, the UI-string catalog, and pure helpers (`resolveStrings`, `pickUiString`). `index.html` static text is tagged with `data-i18n` attributes; `app.js` populates a `<select>` from the registry and on change swaps UI strings + re-renders annotations. Annotation content extends the existing per-clip `strings` dict (`{lang: {key: text|list}}`) with `es/fr/ja` keys — no schema change. A Python tool (`tools/i18n/translate_manifest.py`) extracts the English catalog and merges authored translation catalogs back into the manifest.

**Tech Stack:** Vanilla JS (UMD modules, `node:test`), FastAPI (existing, untouched), Python (stdlib `json` + pytest), Playwright (E2E).

## Global Constraints

- **Languages (exact codes & order):** `en` (English, default + fallback), `es` (Español), `fr` (Français), `ja` (日本語). No Hebrew, no Chinese, **no RTL work**.
- **Default & persistence:** `activeLang` defaults to `"en"` every load; **no localStorage** — selection is session-only.
- **Fallback:** every annotation lookup falls back to the `en` value when a key is missing in the active language; a missing language never blanks the overlay.
- **No schema change:** annotation translations are sibling keys inside each clip's existing `strings` object.
- **Manifest file:** `simulator/sample_media/manifest.json` is the live manifest (`DEFAULT_MANIFEST` in `simulator/app.py:47`). 41 clips, 753 English string-variants.
- **Tier preservation:** a string authored as a list (tiers) stays a list of identical length in every language; a plain string stays a plain string.
- **Scope out:** dev-panel diagnostics internals (`#dev-panel`, audio diagnostics, pool picker), `/author.html`, clip titles. Only visitor-facing chrome + annotations are localized.
- **Repo conventions:** SSH git; commit messages carry `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`. Run commands from repo root unless noted.

---

### Task 1: i18n module — registry, catalog, pure helpers

**Files:**
- Create: `simulator/static/i18n.js`
- Test: `simulator/unit/i18n.test.js`

**Interfaces:**
- Consumes: nothing.
- Produces (the `HEFi18n` global / `module.exports`):
  - `LANGUAGES` — `[{code:"en",nativeName:"English"}, {code:"es",nativeName:"Español"}, {code:"fr",nativeName:"Français"}, {code:"ja",nativeName:"日本語"}]`
  - `UI_STRINGS` — `{ key: {en, es, fr, ja} }` map (control chrome, all four languages)
  - `pickUiString(key, lang)` → string; the `lang` value, else the `en` value, else the `key` itself.
  - `resolveStrings(clipStrings, lang)` → object; `clipStrings[lang]` merged over `clipStrings.en` (so a missing key in `lang` falls back to `en`), else `clipStrings.en`, else `{}`.

- [ ] **Step 1: Write the failing test**

```js
// simulator/unit/i18n.test.js
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const I = require("../static/i18n.js");

test("LANGUAGES lists the four codes in order, English first", () => {
  assert.deepEqual(I.LANGUAGES.map((l) => l.code), ["en", "es", "fr", "ja"]);
  assert.equal(I.LANGUAGES[0].nativeName, "English");
  assert.equal(I.LANGUAGES.find((l) => l.code === "ja").nativeName, "日本語");
});

test("pickUiString returns the language value, falls back to en, then to key", () => {
  assert.equal(I.pickUiString("output.legend", "es"), I.UI_STRINGS["output.legend"].es);
  // a key present in en but (hypothetically) missing in fr falls back to en:
  const saved = I.UI_STRINGS["output.legend"].fr;
  delete I.UI_STRINGS["output.legend"].fr;
  assert.equal(I.pickUiString("output.legend", "fr"), I.UI_STRINGS["output.legend"].en);
  I.UI_STRINGS["output.legend"].fr = saved;
  assert.equal(I.pickUiString("totally.unknown.key", "en"), "totally.unknown.key");
});

test("UI_STRINGS has every language for every key", () => {
  for (const [key, vals] of Object.entries(I.UI_STRINGS)) {
    for (const code of ["en", "es", "fr", "ja"]) {
      assert.ok(typeof vals[code] === "string" && vals[code].length, `${key} missing ${code}`);
    }
  }
});

test("resolveStrings merges lang over en (per-key fallback)", () => {
  const cs = { en: { a: "A", b: "B" }, es: { a: "Aes" } };
  assert.deepEqual(I.resolveStrings(cs, "es"), { a: "Aes", b: "B" });
  assert.deepEqual(I.resolveStrings(cs, "fr"), { a: "A", b: "B" }); // no fr -> all en
  assert.deepEqual(I.resolveStrings({}, "es"), {});
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `node --test simulator/unit/i18n.test.js`
Expected: FAIL — `Cannot find module '../static/i18n.js'`.

- [ ] **Step 3: Write the module**

```js
// simulator/static/i18n.js
// UI/annotation localization — registry, control-string catalog, and pure
// lookup helpers. UMD so the browser gets a `HEFi18n` global and
// `node --test` can require() it. English is the source of truth & fallback.
(function (root, factory) {
  const api = factory();
  if (typeof module !== "undefined" && module.exports) module.exports = api;
  else root.HEFi18n = api;
})(typeof self !== "undefined" ? self : this, function () {
  "use strict";

  const LANGUAGES = [
    { code: "en", nativeName: "English" },
    { code: "es", nativeName: "Español" },
    { code: "fr", nativeName: "Français" },
    { code: "ja", nativeName: "日本語" },
  ];

  // Visitor-facing control chrome. Keys match `data-i18n` attributes in index.html.
  const UI_STRINGS = {
    "app.title":      { en: "Human Experience Filter — Alteration Preview", es: "Filtro de Experiencia Humana — Vista previa de alteración", fr: "Filtre d’Expérience Humaine — Aperçu d’altération", ja: "ヒューマン・エクスペリエンス・フィルター — 変容プレビュー" },
    "loading.title":  { en: "Loading Universe", es: "Cargando el universo", fr: "Chargement de l’univers", ja: "宇宙を読み込み中" },
    "output.legend":  { en: "Output", es: "Salida", fr: "Sortie", ja: "出力" },
    "output.video":   { en: "Video", es: "Vídeo", fr: "Vidéo", ja: "映像" },
    "output.audio":   { en: "Audio", es: "Audio", fr: "Audio", ja: "音声" },
    "altitude.legend":{ en: "Altitude", es: "Altitud", fr: "Altitude", ja: "高度" },
    "altitude.hint":  { en: "Turn the knob (drag it, or scroll) to change altitude — endless: past the deepest it wraps back up to the highest. Click a label to jump there.", es: "Gira el dial (arrástralo o desplázate) para cambiar la altitud — sin fin: tras lo más profundo vuelve a lo más alto. Haz clic en una etiqueta para saltar.", fr: "Tournez le cadran (glissez ou faites défiler) pour changer d’altitude — sans fin : après le plus profond, on revient au plus haut. Cliquez sur une étiquette pour y aller.", ja: "ダイヤルを回して（ドラッグまたはスクロール）高度を変えます — 無限ループ：最も深い先は最も高い所へ戻ります。ラベルをクリックでそこへ移動。" },
    "knobs.legend":   { en: "Experience knobs (0–4)", es: "Mandos de experiencia (0–4)", fr: "Boutons d’expérience (0–4)", ja: "体験つまみ（0〜4）" },
    "knobs.think":    { en: "Think", es: "Pensar", fr: "Penser", ja: "考える" },
    "knobs.feel":     { en: "Feel", es: "Sentir", fr: "Ressentir", ja: "感じる" },
    "knobs.mood":     { en: "Mood — dark ◀ 0 ▶ light", es: "Ánimo — oscuro ◀ 0 ▶ claro", fr: "Humeur — sombre ◀ 0 ▶ clair", ja: "ムード — 暗 ◀ 0 ▶ 明" },
    "devmode.label":  { en: "Dev Mode", es: "Modo desarrollo", fr: "Mode dév", ja: "開発モード" },
    "scale.cosmos":   { en: "cosmos", es: "cosmos", fr: "cosmos", ja: "宇宙" },
    "scale.orbit":    { en: "orbit", es: "órbita", fr: "orbite", ja: "軌道" },
    "scale.sky":      { en: "sky", es: "cielo", fr: "ciel", ja: "空" },
    "scale.coast":    { en: "coast", es: "costa", fr: "côte", ja: "海岸" },
    "scale.reef":     { en: "reef", es: "arrecife", fr: "récif", ja: "サンゴ礁" },
    "scale.abyss":    { en: "abyss", es: "abismo", fr: "abîme", ja: "深海" },
  };

  function pickUiString(key, lang) {
    const v = UI_STRINGS[key];
    if (!v) return key;
    return (v[lang] != null ? v[lang] : v.en) || key;
  }

  function resolveStrings(clipStrings, lang) {
    if (!clipStrings || !clipStrings.en) return (clipStrings && clipStrings[lang]) || {};
    if (lang === "en" || !clipStrings[lang]) return clipStrings.en;
    return Object.assign({}, clipStrings.en, clipStrings[lang]);
  }

  return { LANGUAGES, UI_STRINGS, pickUiString, resolveStrings };
});
```

- [ ] **Step 4: Run test to verify it passes**

Run: `node --test simulator/unit/i18n.test.js`
Expected: PASS (4 tests).

- [ ] **Step 5: Commit**

```bash
git add simulator/static/i18n.js simulator/unit/i18n.test.js
git commit -m "feat(i18n): language registry + UI-string catalog module"
```

---

### Task 2: Tag index.html + add the language dropdown

**Files:**
- Modify: `simulator/static/index.html`
- Test: `simulator/unit/index-i18n.test.js`

**Interfaces:**
- Consumes: `data-i18n` key names from Task 1's `UI_STRINGS`.
- Produces: a `<select id="lang-select">` and `data-i18n`-tagged nodes for Task 3 to drive.

- [ ] **Step 1: Write the failing test** (parses the HTML as text — no DOM lib needed)

```js
// simulator/unit/index-i18n.test.js
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const I = require("../static/i18n.js");

const html = fs.readFileSync(path.join(__dirname, "../static/index.html"), "utf8");

test("index.html includes the i18n script and a language select", () => {
  assert.match(html, /<script src="\/i18n\.js">/);
  assert.match(html, /id="lang-select"/);
});

test("every data-i18n key in index.html exists in UI_STRINGS", () => {
  const keys = [...html.matchAll(/data-i18n="([^"]+)"/g)].map((m) => m[1]);
  assert.ok(keys.length >= 12, `expected many tagged nodes, found ${keys.length}`);
  for (const k of keys) assert.ok(I.UI_STRINGS[k], `data-i18n key not in catalog: ${k}`);
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `node --test simulator/unit/index-i18n.test.js`
Expected: FAIL — no `i18n.js` script / no `lang-select` / no `data-i18n` attributes yet.

- [ ] **Step 3: Edit index.html**

Apply these exact edits:

1. Load the module before `app.js` (after the `scrub.js` script, line ~107):

```html
  <script src="/scrub.js"></script>
  <script src="/i18n.js"></script>
  <script src="/app.js"></script>
```

2. Tag the loading title (line 12):

```html
      <div class="loading-title"><span data-i18n="loading.title">Loading Universe</span><span class="loading-dots"></span></div>
```

3. Tag the header (line 16):

```html
  <header><h1 data-i18n="app.title">Human Experience Filter — Alteration Preview</h1></header>
```

4. In the **Output** fieldset (lines 33–44), tag the legend + video label, and **add the language select** as the first control:

```html
      <fieldset>
        <legend data-i18n="output.legend">Output</legend>
        <label class="lang-pick">🌐
          <select id="lang-select" aria-label="Language"></select>
        </label>
        <label class="dev-switch" for="visual">
          <input type="checkbox" id="visual" />
          <span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
          <span class="dev-switch-label" data-i18n="output.video">Video</span>
        </label>
        <label class="audio-level"><span data-i18n="output.audio">Audio</span>
          <input type="range" id="audio" min="0" max="10" value="0" step="1" />
          <span id="audio-level-val">0</span>/10
        </label>
      </fieldset>
```

5. Tag the **Altitude** legend (line 47) and hint (line 52):

```html
        <legend data-i18n="altitude.legend">Altitude</legend>
```
```html
        <p class="hint" data-i18n="altitude.hint">Turn the knob (drag it, or scroll) to change altitude — endless: past the deepest it wraps back up to the highest. Click a label to jump there.</p>
```

6. Tag the **Experience knobs** fieldset (lines 56–59):

```html
        <legend data-i18n="knobs.legend">Experience knobs (0–4)</legend>
        <label><span data-i18n="knobs.think">Think</span> <input type="range" id="left" min="0" max="4" value="0" /></label>
        <label><span data-i18n="knobs.feel">Feel</span> <input type="range" id="right" min="0" max="4" value="0" /></label>
        <label><span data-i18n="knobs.mood">Mood — dark ◀ 0 ▶ light</span> <input type="range" id="mood" min="-4" max="4" value="0" /></label>
```

7. Tag the **Dev Mode** label (line 66):

```html
          <span class="dev-switch-label" data-i18n="devmode.label">Dev Mode</span>
```

- [ ] **Step 4: Run test to verify it passes**

Run: `node --test simulator/unit/index-i18n.test.js`
Expected: PASS (2 tests).

- [ ] **Step 5: Commit**

```bash
git add simulator/static/index.html simulator/unit/index-i18n.test.js
git commit -m "feat(i18n): tag controls with data-i18n + add language dropdown"
```

---

### Task 3: Wire live language switching in app.js

**Files:**
- Modify: `simulator/static/app.js` (render sites at `:505` and `:556`; add init + handler)
- Test: covered by Task 1's `resolveStrings` unit test (pure logic) + Task 6 E2E (DOM behavior). No new unit file.

**Interfaces:**
- Consumes: `HEFi18n.LANGUAGES`, `HEFi18n.pickUiString`, `HEFi18n.resolveStrings`; existing `renderOverlay`, `renderAffect`, `renderScaleReadout`, `lastOverlay`.
- Produces: module-global `activeLang`; `lastAffect` cache for re-render.

- [ ] **Step 1: Add `activeLang` + `lastAffect` globals**

Near the other module globals (around `simulator/static/app.js:47–55`, after `let lastOverlay = null;`), add:

```js
let activeLang = "en";          // session-only; no persistence (resets to en each load)
let lastAffect = null;          // {strength, intensity, right} from the last renderAffect, for re-render on language switch
```

- [ ] **Step 2: Use `activeLang` at the two render sites**

In `renderOverlay` (line ~505) replace:

```js
  const strings = (clip.strings && clip.strings.en) || {};
```
with:
```js
  const strings = HEFi18n.resolveStrings(clip.strings, activeLang);
```

In `renderAffect` (line ~556) replace the same line with the same call, **and** cache the args at the top of `renderAffect` so a language switch can re-render without a server round-trip. Right after `function renderAffect(strength, intensity, right) {` add:

```js
  lastAffect = { strength, intensity, right };
```
and replace its `const strings = (clip.strings && clip.strings.en) || {};` with:
```js
  const strings = HEFi18n.resolveStrings(clip.strings, activeLang);
```

- [ ] **Step 3: Localize the scale-name readout**

In `renderScaleReadout` (line ~575) replace:

```js
  $("scale-name").textContent = `${s.id} · ${member}  (${ringIndex + 1}/${ring.scales.length}${poolTag})`;
```
with (localize only the scale id; `member` is a clip title and stays as-is):
```js
  const scaleName = HEFi18n.pickUiString("scale." + s.id, activeLang);
  $("scale-name").textContent = `${scaleName} · ${member}  (${ringIndex + 1}/${ring.scales.length}${poolTag})`;
```

- [ ] **Step 4: Add the init + change handler**

Add this function and call it once during startup. Place the function near the other `apply*` helpers, and invoke `initLanguage()` from the existing startup path (the same place that first calls `renderScaleReadout()` / builds the panel — search for the boot sequence after the manifest `fetch` in the init function around `simulator/static/app.js:58–104`; add `initLanguage();` there):

```js
// Populate the language dropdown from the registry and wire live switching.
// English default, session-only (no persistence). Switching swaps UI chrome
// and re-renders the current annotations/affect in place — no reload, no fetch.
function initLanguage() {
  const sel = $("lang-select");
  if (!sel) return;
  for (const { code, nativeName } of HEFi18n.LANGUAGES) {
    const o = document.createElement("option");
    o.value = code;
    o.textContent = nativeName;
    sel.appendChild(o);
  }
  sel.value = activeLang;
  applyUiStrings(activeLang);
  sel.addEventListener("change", () => setLanguage(sel.value));
}

// Fill every [data-i18n] node with its catalog string for `lang`.
function applyUiStrings(lang) {
  document.documentElement.lang = lang;
  for (const el of document.querySelectorAll("[data-i18n]")) {
    el.textContent = HEFi18n.pickUiString(el.getAttribute("data-i18n"), lang);
  }
}

function setLanguage(lang) {
  activeLang = lang;
  applyUiStrings(lang);
  renderScaleReadout();
  if (lastOverlay) renderOverlay(lastOverlay.level, lastOverlay.intensity);
  if (lastAffect) renderAffect(lastAffect.strength, lastAffect.intensity, lastAffect.right);
}
```

- [ ] **Step 5: Run the full node unit suite**

Run: `node --test simulator/unit/`
Expected: PASS — existing `scrub.test.js`, plus `i18n.test.js` and `index-i18n.test.js`.

- [ ] **Step 6: Manual smoke (optional but recommended)**

Run the app and confirm the dropdown lists English/Español/Français/日本語, switching changes the control labels and (with Think/Feel up) the annotations, and reload returns to English. (Server: the existing uvicorn launch — restart it with the venv python so it serves the new static files; a stale uvicorn is the recurring "no change" root cause.)

- [ ] **Step 7: Commit**

```bash
git add simulator/static/app.js
git commit -m "feat(i18n): live language switching for chrome + annotations"
```

---

### Task 4: Manifest translation tool (extract + merge)

**Files:**
- Create: `tools/i18n/translate_manifest.py`
- Create: `tools/i18n/__init__.py` (empty — `tools/` is a package; siblings `tools/pipeline/`, `tools/ingest/` each have one)
- Test: `tests/test_i18n_translate.py` (repo `tests/` is flat — match it)

**Interfaces:**
- Consumes: the manifest JSON shape — `{"clips": [ {"id", "strings": {"en": {key: str|list}}}, ... ]}`.
- Produces:
  - `extract_en_catalog(manifest) -> {clip_id: {key: str|list}}` — the English strings of every clip.
  - `merge_catalog(manifest, lang, catalog) -> manifest` — writes `catalog[clip_id]` into each clip's `strings[lang]`; **structure-validated** (raises `ValueError` if a key's type/list-length differs from `en`); **idempotent** (re-running with the same catalog is a no-op); leaves `en` untouched.

- [ ] **Step 1: Write the failing test**

```python
# tests/test_i18n_translate.py
import copy
import pytest
from tools.i18n.translate_manifest import extract_en_catalog, merge_catalog

MANIFEST = {
    "clips": [
        {"id": "cosmos", "strings": {"en": {
            "detected.star": ["star", "young star"],
            "measure.distance": "≈7,500 ly",
        }}},
    ]
}

def test_extract_pulls_every_en_string():
    cat = extract_en_catalog(MANIFEST)
    assert cat == {"cosmos": {"detected.star": ["star", "young star"], "measure.distance": "≈7,500 ly"}}

def test_merge_writes_lang_and_leaves_en():
    m = copy.deepcopy(MANIFEST)
    cat = {"cosmos": {"detected.star": ["estrella", "estrella joven"], "measure.distance": "≈7.500 al"}}
    merge_catalog(m, "es", cat)
    s = m["clips"][0]["strings"]
    assert s["en"]["measure.distance"] == "≈7,500 ly"   # en untouched
    assert s["es"]["detected.star"] == ["estrella", "estrella joven"]

def test_merge_is_idempotent():
    m = copy.deepcopy(MANIFEST)
    cat = {"cosmos": {"detected.star": ["estrella", "estrella joven"], "measure.distance": "≈7.500 al"}}
    merge_catalog(m, "es", cat)
    once = copy.deepcopy(m)
    merge_catalog(m, "es", cat)
    assert m == once

def test_merge_rejects_tier_length_mismatch():
    m = copy.deepcopy(MANIFEST)
    bad = {"cosmos": {"detected.star": ["estrella"], "measure.distance": "≈7.500 al"}}  # 1 tier vs en's 2
    with pytest.raises(ValueError):
        merge_catalog(m, "es", bad)

def test_merge_rejects_type_mismatch():
    m = copy.deepcopy(MANIFEST)
    bad = {"cosmos": {"detected.star": "estrella", "measure.distance": "≈7.500 al"}}  # str vs en's list
    with pytest.raises(ValueError):
        merge_catalog(m, "es", bad)
```

- [ ] **Step 2: Run test to verify it fails**

Run: `python -m pytest tests/test_i18n_translate.py -q`
Expected: FAIL — `ModuleNotFoundError: tools.i18n.translate_manifest`.

- [ ] **Step 3: Write the tool**

```python
# tools/i18n/translate_manifest.py
"""Extract a manifest's English annotation strings and merge authored
translations back into each clip's `strings` dict (no schema change).

Workflow:
  1. `extract` writes tools/i18n/catalogs/en.json — the English strings.
  2. A translator (human or LLM) authors es.json / fr.json / ja.json with the
     SAME structure (lists stay lists of the same length).
  3. `merge` validates structure and writes strings[lang] into the manifest.
"""
from __future__ import annotations

import argparse
import json
from pathlib import Path


def extract_en_catalog(manifest: dict) -> dict:
    out = {}
    for clip in manifest.get("clips", []):
        en = (clip.get("strings") or {}).get("en")
        if en:
            out[clip["id"]] = en
    return out


def _check_shape(key: str, en_val, tr_val):
    if isinstance(en_val, list):
        if not isinstance(tr_val, list) or len(tr_val) != len(en_val):
            raise ValueError(f"{key}: expected list of {len(en_val)} tiers, got {tr_val!r}")
    elif not isinstance(tr_val, str):
        raise ValueError(f"{key}: expected string, got {tr_val!r}")


def merge_catalog(manifest: dict, lang: str, catalog: dict) -> dict:
    if lang == "en":
        raise ValueError("refusing to overwrite the en source")
    for clip in manifest.get("clips", []):
        en = (clip.get("strings") or {}).get("en")
        tr = catalog.get(clip["id"])
        if not en or not tr:
            continue
        for key, en_val in en.items():
            if key not in tr:
                raise ValueError(f"{clip['id']}/{key}: missing in {lang} catalog")
            _check_shape(f"{clip['id']}/{key}", en_val, tr[key])
        clip["strings"][lang] = {k: tr[k] for k in en}  # en key order, lang values
    return manifest


def _main(argv=None):
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("command", choices=["extract", "merge"])
    ap.add_argument("--manifest", default="simulator/sample_media/manifest.json")
    ap.add_argument("--catalog-dir", default="tools/i18n/catalogs")
    ap.add_argument("--lang", help="merge: target language code")
    args = ap.parse_args(argv)

    mpath = Path(args.manifest)
    manifest = json.loads(mpath.read_text(encoding="utf-8"))
    cdir = Path(args.catalog_dir)

    if args.command == "extract":
        cdir.mkdir(parents=True, exist_ok=True)
        (cdir / "en.json").write_text(
            json.dumps(extract_en_catalog(manifest), ensure_ascii=False, indent=1) + "\n",
            encoding="utf-8",
        )
        print(f"wrote {cdir / 'en.json'}")
    else:
        if not args.lang:
            ap.error("merge requires --lang")
        catalog = json.loads((cdir / f"{args.lang}.json").read_text(encoding="utf-8"))
        merge_catalog(manifest, args.lang, catalog)
        mpath.write_text(json.dumps(manifest, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
        print(f"merged {args.lang} into {mpath}")


if __name__ == "__main__":
    _main()
```

Create empty `tools/i18n/__init__.py` (and confirm whether `tools/` and `tools/pipeline/` have `__init__.py`; match the existing pattern — `simulator/app.py:296` imports `tools.pipeline.manifest`, so `tools` is importable as a package).

- [ ] **Step 4: Run test to verify it passes**

Run: `python -m pytest tests/test_i18n_translate.py -q`
Expected: PASS (5 tests).

- [ ] **Step 5: Generate the English catalog**

Run: `python -m tools.i18n.translate_manifest extract`
Expected: writes `tools/i18n/catalogs/en.json` (41 clips).

- [ ] **Step 6: Commit**

```bash
git add tools/i18n/translate_manifest.py tools/i18n/__init__.py tests/test_i18n_translate.py tools/i18n/catalogs/en.json
git commit -m "feat(i18n): manifest translation extract/merge tool + en catalog"
```

---

### Task 5: Author es/fr/ja translations + merge into the manifest

**Files:**
- Create: `tools/i18n/catalogs/es.json`, `tools/i18n/catalogs/fr.json`, `tools/i18n/catalogs/ja.json`
- Modify: `simulator/sample_media/manifest.json` (via the merge tool)
- Test: `tests/test_i18n_manifest.py`

**Interfaces:**
- Consumes: `tools/i18n/catalogs/en.json` (Task 4), `merge_catalog` (Task 4).
- Produces: a manifest where every clip's `strings` has `en/es/fr/ja`.

- [ ] **Step 1: Write the failing invariant test**

```python
# tests/test_i18n_manifest.py
import json
from pathlib import Path

LANGS = ["en", "es", "fr", "ja"]
MANIFEST = Path("simulator/sample_media/manifest.json")

def _tier_len(v):
    return len(v) if isinstance(v, list) else 0  # 0 = plain string

def test_every_clip_has_all_languages_with_matching_shape():
    manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
    for clip in manifest["clips"]:
        s = clip.get("strings", {})
        en = s.get("en")
        if not en:
            continue
        for lang in LANGS:
            assert lang in s, f"{clip['id']} missing language {lang}"
            assert set(s[lang]) == set(en), f"{clip['id']}/{lang} key-set differs from en"
            for key, en_val in en.items():
                assert _tier_len(s[lang][key]) == _tier_len(en_val), (
                    f"{clip['id']}/{lang}/{key} tier-count differs from en"
                )
```

- [ ] **Step 2: Run test to verify it fails**

Run: `python -m pytest tests/test_i18n_manifest.py -q`
Expected: FAIL — clips have only `en`.

- [ ] **Step 3: Author the three translation catalogs**

Copy `tools/i18n/catalogs/en.json` to `es.json`, `fr.json`, `ja.json` and translate **every value**, preserving each key and every list's length. Guidance:
- Tier lists escalate (general → scientific+fact for `detected.*`; basic → compound emotion for `feel.*`) — preserve that escalation in the target language.
- `measure.*` are plain strings (units/quantities) — localize number formatting where natural (e.g. `7,500` → `7.500` in es/fr) but keep the value.
- These are LLM-authored; the Japanese pass gets a human spot-check before merge.

This is the bulk content step (~2,260 strings across the three files). It is data authoring, not code — produce the JSON, then validate with the merge tool's structure checks in the next step.

- [ ] **Step 4: Merge each language into the manifest**

```bash
python -m tools.i18n.translate_manifest merge --lang es
python -m tools.i18n.translate_manifest merge --lang fr
python -m tools.i18n.translate_manifest merge --lang ja
```
Expected: each prints `merged <lang> into simulator/sample_media/manifest.json`. Any structure error (missing key, wrong tier length) aborts that merge — fix the catalog and re-run (merge is idempotent).

- [ ] **Step 5: Run test to verify it passes**

Run: `python -m pytest tests/test_i18n_manifest.py -q`
Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add tools/i18n/catalogs/es.json tools/i18n/catalogs/fr.json tools/i18n/catalogs/ja.json simulator/sample_media/manifest.json tests/test_i18n_manifest.py
git commit -m "feat(i18n): author es/fr/ja annotation translations + merge into manifest"
```

---

### Task 6: E2E — dropdown switches UI and annotations

**Files:**
- Create: `simulator/e2e/tests/i18n.spec.ts` (match the existing `e2e/tests/*.spec.ts` location/pattern — confirm the exact tests dir, cf. `e2e/tests/altitude-lock.spec.ts`)
- Test: this IS the test.

**Interfaces:**
- Consumes: the running app with the language dropdown + localized manifest.

- [ ] **Step 1: Write the E2E test**

```ts
// simulator/e2e/tests/i18n.spec.ts
import { test, expect } from "@playwright/test";

test("language dropdown localizes chrome and resets to English on reload", async ({ page }) => {
  await page.goto("/");
  const sel = page.locator("#lang-select");
  await expect(sel).toBeVisible();

  // four languages, English selected by default
  await expect(sel.locator("option")).toHaveCount(4);
  await expect(sel).toHaveValue("en");
  await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Output");

  // switch to Spanish -> chrome changes, <html lang> updates
  await sel.selectOption("es");
  await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Salida");
  await expect(page.locator("html")).toHaveAttribute("lang", "es");

  // Japanese
  await sel.selectOption("ja");
  await expect(page.locator('[data-i18n="knobs.think"]')).toHaveText("考える");

  // reload returns to English (session-only, no persistence)
  await page.reload();
  await expect(page.locator("#lang-select")).toHaveValue("en");
  await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Output");
});
```

- [ ] **Step 2: Run the E2E test**

Run: the existing Playwright command for this repo (check `simulator/e2e/` for the runner — e.g. `npx playwright test i18n` from `simulator/e2e/`, with the app server running as the other specs expect).
Expected: PASS.

- [ ] **Step 3: Commit**

```bash
git add simulator/e2e/tests/i18n.spec.ts
git commit -m "test(i18n): e2e for language dropdown chrome + reset on reload"
```

---

### Task 7: Full verification + finish the branch

- [ ] **Step 1: Run every suite**

```bash
node --test simulator/unit/
python -m pytest tests/ -q
```
Expected: all green (node unit incl. i18n; pytest incl. translate-tool + manifest-language invariants; existing suites unaffected).

- [ ] **Step 2: Run the E2E suite**

Run the repo's full Playwright command (as other specs run it) and confirm the i18n spec plus the existing specs pass.

- [ ] **Step 3: Open the PR**

```bash
git push -u origin feat/localization-language-dropdown
```
Then open a PR to `main` via the Gitea flow, citing this plan and the design spec.

---

## Self-Review

**Spec coverage:**
- §2 languages (en/es/fr/ja, no RTL) → Task 1 registry + Global Constraints. ✓
- §3.1 UI chrome → Task 1 catalog + Task 2 tagging. ✓
- §3.2 / §4.3 annotations (extend strings dict, fallback) → Task 1 `resolveStrings`, Task 3 render sites, Tasks 4–5 content. ✓
- §4.1 registry → Task 1. ✓ §4.2 UI catalog + `applyUiStrings` + `data-i18n` → Tasks 1–3. ✓
- §5 dropdown, English default, session-only, live switch no reload → Task 2 (select) + Task 3 (init/handler). ✓
- §6 one-shot idempotent tool, tier-structure preservation → Task 4. ✓
- §7 tests: node unit (registry/swap/fallback/tier) → Tasks 1, 3; pytest manifest invariants → Task 5; Playwright per-language → Task 6. ✓
- §8 out-of-scope honored (no dev-panel internals, no author.html, no clip titles). ✓

**Placeholder scan:** Task 5 Step 3 is intentionally a content-authoring step (the 2,260 translations can't be inlined); its structure is fully constrained by the tool's validation and Task 5's invariant test. No code step is left as a placeholder.

**Type consistency:** `resolveStrings(clipStrings, lang)`, `pickUiString(key, lang)`, `LANGUAGES[].{code,nativeName}`, `extract_en_catalog`, `merge_catalog(manifest, lang, catalog)`, globals `activeLang`/`lastAffect`/`lastOverlay` — names used identically across Tasks 1, 3, 4, 5. ✓
