From 43370292e309350bbd08ca05143e5bdc0d0d580c Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Mon, 29 Jun 2026 18:38:05 -0700 Subject: [PATCH 1/8] docs(i18n): localization + language-dropdown design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design for a language dropdown localizing the simulator UI controls and the Left/Right brain annotations. This pass: en/es/fr/ja (Hebrew + Chinese deferred — no RTL work in scope). English default, session-only (no persistence), English fallback on missing keys. Annotations extend the existing per-clip strings dict (no schema change); ~2,260 strings authored via a one-shot idempotent translate_manifest tool. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...9-localization-language-dropdown-design.md | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-29-localization-language-dropdown-design.md diff --git a/docs/superpowers/specs/2026-06-29-localization-language-dropdown-design.md b/docs/superpowers/specs/2026-06-29-localization-language-dropdown-design.md new file mode 100644 index 0000000..11041cb --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-localization-language-dropdown-design.md @@ -0,0 +1,125 @@ +# Localization & Language Dropdown — Design + +**Date:** 2026-06-29 +**Status:** Approved design, pre-plan +**Scope:** The `simulator/` web experience — localize all UI controls and the +Left/Right brain annotations, selectable via a language dropdown. + +--- + +## 1. Goal + +Let a visitor switch the experience's language from a dropdown. Switching +re-renders **both** the UI chrome (controls, labels, hints) **and** the +Left/Right brain annotation content (object labels, facts, emotion words) into +the chosen language, live, without reloading the page or interrupting playback. + +## 2. Languages (this pass) + +Four languages, English as the source/default: + +| code | native name | notes | +|------|-------------|-------| +| `en` | English | source of truth, fallback | +| `es` | Español | | +| `fr` | Français | | +| `ja` | 日本語 | CJK glyphs; no layout-direction change | + +Hebrew and Chinese were considered and **deliberately deferred** to a later +pass. Hebrew was the only right-to-left language, so **no RTL / UI-mirror work +is in scope here.** The language set is data-driven, so adding more later +(including an RTL pass) is additive. + +## 3. What needs translating — two very different costs + +1. **UI chrome** — ~20 fixed control strings (`Loading Universe`, `Output`, + `Video`, `Audio`, `Altitude`, `Think`, `Feel`, `Mood`, the six scale names, + hints, dev-mode labels). Small, static. +2. **Annotations** — the bulk. The manifest holds **41 clips / 753 authored + English string-variants** (Left labels + facts + Right emotions, many as + tier-lists). Authoring `es/fr/ja` = **753 × 3 ≈ 2,260 strings**. This is + *content*, and it is the dominant effort. + +## 4. Architecture — three isolated pieces + +### 4.1 Language registry +A single small module (`simulator/static/i18n.js` or a sibling) exporting the +ordered list `[{code, nativeName}]` for `en, es, fr, ja`. It is the **one source +of truth** consumed by the dropdown and ``. No `dir` field needed +this pass (no RTL). + +### 4.2 UI string catalog +A `{ key: {en, es, fr, ja} }` map for the ~20 control strings, living alongside +the registry. `index.html`'s static text is tagged with `data-i18n="key"` +attributes (removing hardcoded English from the markup); an +`applyUiStrings(lang)` function fills every tagged node on load and on every +language change. + +### 4.3 Annotation strings — no schema change +The manifest already stores `strings: {"en": {key: text}}` per clip. We add +`es / fr / ja` sibling keys to each clip's `strings` object. The two render +sites change from a hardcoded `.en`: + +- `simulator/static/app.js:505` (Left labels/facts) +- `simulator/static/app.js:556` (Right emotions) + +…to `clip.strings[activeLang] || clip.strings.en` — an **English fallback** so a +missing key never blanks the overlay. Tier-list structure (general→scientific, +emotion escalation) is preserved per language. + +## 5. The dropdown & selection behavior + +- A `` 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) `. 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 ` + + + + +``` + +5. Tag the **Altitude** legend (line 47) and hint (line 52): + +```html + Altitude +``` +```html +

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.

+``` + +6. Tag the **Experience knobs** fieldset (lines 56–59): + +```html + Experience knobs (0–4) + + + +``` + +7. Tag the **Dev Mode** label (line 66): + +```html + Dev Mode +``` + +- [ ] **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 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, 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. ✓ From 00534e116cfccea45b2a1e610bddf88dbe1530ea Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Mon, 29 Jun 2026 18:45:33 -0700 Subject: [PATCH 3/8] feat(i18n): language registry + UI-string catalog module Co-Authored-By: Claude Opus 4.8 (1M context) --- simulator/static/i18n.js | 53 +++++++++++++++++++++++++++++++++++++ simulator/unit/i18n.test.js | 35 ++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 simulator/static/i18n.js create mode 100644 simulator/unit/i18n.test.js diff --git a/simulator/static/i18n.js b/simulator/static/i18n.js new file mode 100644 index 0000000..a46f167 --- /dev/null +++ b/simulator/static/i18n.js @@ -0,0 +1,53 @@ +// 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 }; +}); diff --git a/simulator/unit/i18n.test.js b/simulator/unit/i18n.test.js new file mode 100644 index 0000000..be3be74 --- /dev/null +++ b/simulator/unit/i18n.test.js @@ -0,0 +1,35 @@ +"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"), {}); +}); From 49b77e0700ce4e6834578c57ce9a14f8e70974d5 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Mon, 29 Jun 2026 18:47:03 -0700 Subject: [PATCH 4/8] feat(i18n): tag controls with data-i18n + add language dropdown Co-Authored-By: Claude Opus 4.8 (1M context) --- simulator/static/index.html | 28 ++++++++++++++++------------ simulator/unit/index-i18n.test.js | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+), 12 deletions(-) create mode 100644 simulator/unit/index-i18n.test.js diff --git a/simulator/static/index.html b/simulator/static/index.html index f790bde..ce60563 100644 --- a/simulator/static/index.html +++ b/simulator/static/index.html @@ -9,11 +9,11 @@
-
Loading Universe
+
Loading Universe
-

Human Experience Filter — Alteration Preview

+

Human Experience Filter — Alteration Preview

@@ -31,39 +31,42 @@
- Output + Output + -
- Altitude + Altitude
-

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.

+

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.

- Experience knobs (0–4) - - - + Experience knobs (0–4) + + +
+ diff --git a/simulator/unit/index-i18n.test.js b/simulator/unit/index-i18n.test.js new file mode 100644 index 0000000..345c3c2 --- /dev/null +++ b/simulator/unit/index-i18n.test.js @@ -0,0 +1,19 @@ +"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, /