diff --git a/docs/superpowers/plans/2026-06-29-localization-language-dropdown.md b/docs/superpowers/plans/2026-06-29-localization-language-dropdown.md new file mode 100644 index 0000000..d2d0d6b --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-localization-language-dropdown.md @@ -0,0 +1,733 @@ +# 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 `` 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, / + + +``` + +2. Tag the loading title (line 12): + +```html +
Loading Universe
+``` + +3. Tag the header (line 16): + +```html +

Human Experience Filter — Alteration Preview

+``` + +4. In the **Output** fieldset (lines 33–44), tag the legend + video label, and **add the language select** as the first control: + +```html +
+ Output + + + +
+``` + +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. ✓ 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 ` + -