From 00534e116cfccea45b2a1e610bddf88dbe1530ea Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Mon, 29 Jun 2026 18:45:33 -0700 Subject: [PATCH] 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"), {}); +});