diff --git a/README.md b/README.md index ec82075..918c12d 100644 --- a/README.md +++ b/README.md @@ -418,6 +418,54 @@ is built. Planning runs through [OpenSpec](openspec/): `openspec list` shows active changes, and `specs/roadmap.md` tracks milestone progress. +## Translating + +The interface text lives in `src/frontend/src/locales/`, one flat JSON file per +language. `en.json` is the source: every other language is translated from it, +and any string a language has not translated yet shows in English. + +Translations are done on [Hosted Weblate](https://hosted.weblate.org/). You don't +need to open a pull request to translate. Weblate commits the changes and opens +the pull request for you. To change the English wording or add a string, edit +`en.json` in a normal pull request. Weblate picks the change up once it merges. + +A string that depends on a number gets one key per plural form, named the i18next +v4 way: `"sentence.symbolCount_one"` and `"sentence.symbolCount_other"` in +`en.json`. Render it with `tPlural(key, count)` from `src/frontend/src/i18n.js`, +not `t`. The helper picks the form the active language needs, so on Weblate each +language gets its own plural forms, such as `_few` and `_many` for Russian. + +### Weblate component settings + +Only maintainers need these, when creating or repairing the component: + +| Setting | Value | +|---|---| +| Version control system | GitHub pull request | +| Source code repository | `https://github.com/Akay7/TypeLearn.git` | +| Repository push URL | `git@github.com:Akay7/TypeLearn.git` | +| Repository branch | `main` | +| File mask | `src/frontend/src/locales/*.json` | +| Monolingual base language file | `src/frontend/src/locales/en.json` | +| Edit base file | off (English changes go through code review) | +| File format | i18next JSON file v4 (keeps the flat keys; shows `key_one`, `key_few`, `key_other`… as one plural string) | +| File format parameters | `json_indent: 2`, `json_sort_keys` left unset (keeps `en.json`'s order) | +| Translation flags | `vue-format` (checks `{placeholder}` names against the source) | +| Adding new translation | Contact maintainers (see below) | + +### Adding a language + +A new JSON file alone does nothing. The app only loads the languages it lists. To +add one, open a pull request that: + +1. adds an empty `src/frontend/src/locales/.json` (`{}`), and imports it in + `src/frontend/src/i18n.js`, +2. adds the code to `SUPPORTED_LANGUAGES` in `i18n.js` and `stores/settings.js`, +3. adds the language's own name for itself (e.g. "Deutsch") to the Language + control in `components/SettingsMenu.vue`. + +Once it merges, Weblate lists the language for translators. + ## License AGPL-3.0-or-later — see [LICENSE](LICENSE). Running a modified copy of this diff --git a/openspec/changes/archive/2026-09-14-add-ui-localization/.openspec.yaml b/openspec/changes/archive/2026-09-14-add-ui-localization/.openspec.yaml new file mode 100644 index 0000000..c238415 --- /dev/null +++ b/openspec/changes/archive/2026-09-14-add-ui-localization/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-13 diff --git a/openspec/changes/archive/2026-09-14-add-ui-localization/design.md b/openspec/changes/archive/2026-09-14-add-ui-localization/design.md new file mode 100644 index 0000000..da0940a --- /dev/null +++ b/openspec/changes/archive/2026-09-14-add-ui-localization/design.md @@ -0,0 +1,139 @@ +## Context + +See proposal.md - Why. Today every string the frontend renders is a literal +in the `.vue` template: `SettingsMenu.vue` (menu titles, option labels and +descriptions, the "Settings" aria-label), `SentenceView.vue` +(loading/error/empty states, the character-count hint), `AnswerInput.vue` +(placeholder, Check button, verdicts), `AudioPlayer.vue` (aria-label), and +`OnScreenKeyboard.vue` (the finger legend). There is no i18n library in the +frontend today (`src/frontend/package.json` has none), and the two existing +settings (`virtualKeyboardOverride`, `onScreenKeyboardVisible`) each follow +the same small pattern in `stores/settings.js`: a `localStorage` key, a +best-effort load/save pair, and a `ref` wired to `watch(..., { flush: 'sync' +})`. + +## Goals / Non-Goals + +**Goals:** +- Translate the interface chrome into English, French, German, Thai, + Russian, and Hungarian, switchable from the Settings menu. +- Reuse the existing settings-persistence pattern rather than inventing a new + one. +- Keep exercise content (Thai sentences and audio) entirely outside this + change — only the app's own chrome is translated. + +**Non-Goals:** +- Translating exercise content or supporting non-Thai exercise corpora — out + of scope; the corpus is Thai regardless of interface language. +- A translation-management pipeline (e.g. pulling from a TMS) — six + hand-maintained catalogs are small enough to review as plain diffs. +- Localizing number/date formatting — the interface has none today (the only + dynamic value is a character count, which reads the same in all six + languages: as a number, no thousands separators at these string lengths). +- RTL layout — none of the six languages are RTL. + +## Decisions + +### Use vue-i18n +Vue's own ecosystem's standard choice; a hand-rolled `computed(() => +messages[locale.value][key])` store would need to reimplement pluralization, +interpolation, and Composition API ergonomics (`useI18n()`, `$t` in +templates) that vue-i18n already provides, for no benefit given the app has +no other i18n requirement pulling toward a lighter custom solution. +**Alternative considered**: a bespoke `translations.js` map — rejected, more +code to maintain for less capability. + +### One flat JSON catalog per language, keyed by short message IDs +`src/frontend/src/locales/{en,fr,de,th,ru,hu}.json`, each a flat object +(`{ "settings.title": "Settings", "sentence.loading": "Loading an +exercise…", ... }`) grouped by dot-prefixed component area. Flat with +namespacing prefixes rather than nested objects: easier to grep and diff +across six files, and vue-i18n accepts either. JSON rather than JS modules +so the catalogs can be translated on Hosted Weblate, which reads and writes +the files in the repository ("i18next JSON file v4" format, `en.json` as the +monolingual base). **Alternatives considered**: one JSON file per language +per component — rejected as more files than six languages' worth of strings +warrant; `.js` modules — rejected once the catalogs moved to Weblate, which +cannot parse them. + +### Count-dependent strings use CLDR plural keys, not vue-i18n's pipe syntax +Languages differ in how many plural forms they have: Russian has three for +whole numbers ("1 символ", "2 символа", "5 символов"), and Thai has none. Such +strings are stored as one key per CLDR category (`sentence.symbolCount_one`, +`_few`, `_many`, `_other`), the layout Weblate's i18next v4 format shows as +one plural field per form. A small `tPlural(key, count)` helper in `i18n.js` +picks the key with `Intl.PluralRules` for the active locale. If that form is +not translated, it uses the locale's `_other` form, then English. +**Alternative considered**: vue-i18n's `"a | b | c"` plurals with custom +`pluralRules`. Rejected because Weblate would show translators one raw string +full of pipes, with no hint of which form goes where. + +### Partial translations fall back to English +Volunteers on Weblate often translate part of a language. A key a locale +lacks, or holds as an empty string (how Weblate can write an untranslated +string), falls back to `en.json`: `i18n.js` drops empty strings before handing +the catalogs to vue-i18n, since vue-i18n would otherwise render them blank. +The catalog test therefore requires `en.json` to be complete, and for the +other locales rejects only what breaks at runtime: keys `en.json` lacks, and +`{placeholder}` names that differ from the English string. + +### Language names are hardcoded per-language, not translated +The Language control lists each option in its own language ("Français", +"Русский", "ไทย") rather than translating "French"/"Russian"/"Thai" into +whichever language is currently active. This is the universal convention +(every OS and browser language picker does this) and avoids the pitfall of a +name that reads as "unhelpful" to a learner who does not yet read the active +language at all — the whole point of the picker. + +### Locale detection reads `navigator.language`, matched by base subtag +`navigator.language` (e.g. `fr-CA`) is matched against the six supported +codes by its base subtag (`fr`) rather than requiring an exact match, since +none of the six catalogs are regional variants. Falls back to `'en'` when +the subtag isn't one of the six. + +### Settings-store integration follows the existing two settings verbatim +A third field, `interfaceLanguage`, added to `stores/settings.js` with its +own `localStorage` key (`typelearn.interfaceLanguage`), its own +load/save pair following the existing try/catch-and-fall-back-silently +shape, and a `watch(..., { flush: 'sync' })`. The store sets vue-i18n's +`locale` ref as a side effect of `interfaceLanguage` changing (via the same +watcher, or a computed passed to `createI18n`), so the rest of the app keeps +reading one store for every setting rather than importing vue-i18n's +composable directly in every component. **Alternative considered**: let +components call `useI18n()` and read `locale` directly, bypassing the +settings store — rejected, since it would split "where does the current +language live" across two places (the store for persistence, vue-i18n for +runtime) instead of one. + +### `SettingsMenu.vue`'s `GROUPS` data structure gains a language group built from `$t` +The `GROUPS` array's `title`/`label`/`description` strings become `$t(...)` +calls (or a computed that re-derives `GROUPS` when `locale` changes, since +vue-i18n messages are read reactively but a plain module-level constant +built once from them would not update). The Language control itself does not +fit the existing radiogroup-of-named-values shape as a third `GROUPS` entry +as cleanly as a `` bound to `settings.interfaceLanguage`, consistent with the +proposal's "selectbox" framing, rather than forcing it through the +radio-button rendering built for two or three short options. + +## Risks / Trade-offs + +- **Catalogs can drift out of sync as new strings are added** + → a new key only has to land in `en.json`; Weblate then offers it to + translators, and until they translate it every other locale shows English. + A test rejects keys in another locale that `en.json` no longer has, and + placeholders that differ from the English string. +- **Translation quality**: these catalogs are written without native-speaker + review in this change. → Acceptable for a first pass; flagged in + proposal.md as translatable content that can be refined later without a + spec or architecture change. +- **Bundle size**: six catalogs plus vue-i18n add to the JS bundle. → Small + in absolute terms (a few hundred short strings × 6); not worth lazy-loading + per-locale chunks at this scale. + +## Migration Plan +No data migration. A learner with no stored `interfaceLanguage` gets the +browser-language default the first time this ships; existing +`localStorage` keys for the other two settings are untouched. Rollback is a +plain revert — no persisted state this change introduces is read by any +other feature. diff --git a/openspec/changes/archive/2026-09-14-add-ui-localization/proposal.md b/openspec/changes/archive/2026-09-14-add-ui-localization/proposal.md new file mode 100644 index 0000000..8701eae --- /dev/null +++ b/openspec/changes/archive/2026-09-14-add-ui-localization/proposal.md @@ -0,0 +1,56 @@ +## Why + +TypeLearn's interface — settings, hints, status messages, button labels — is +English-only, regardless of the learner's own language. A learner who does not +read English fluently faces a language barrier before they even reach the Thai +they came to practice. The app already frames itself as language-agnostic (Thai +is "the first dataset"); its own chrome should not assume English is universal +either. + +## What Changes + +- Add a **Language** selector to the Settings menu (gear icon), alongside the + existing virtual-keyboard and on-screen-keyboard controls. +- Support six interface languages: **English, French, German, Thai, Russian, + Hungarian**. +- Translate the interface's own chrome — settings menu labels and + descriptions, loading/error/empty states, the character-count hint, the + Check button, the correct/incorrect verdicts, the on-screen keyboard's + legend and finger names, and control `aria-label`s. This does **not** + translate exercise content: the Thai target sentence and audio stay Thai no + matter which interface language is selected, since that content is the + thing being learned, not the chrome around it. +- Default the interface language to the browser's own language when it is one + of the six supported, otherwise English; let the learner override that + choice, and persist the override the same way other settings persist today + (`localStorage`, best-effort). +- Introduce an i18n library (`vue-i18n`) and one message catalog per + supported language. + +## Capabilities + +### New Capabilities +- `interface-localization`: the interface language setting, its default and + persistence, and the requirement that the app's own chrome (as opposed to + exercise content) render in the selected language. + +### Modified Capabilities +_None._ The existing `typing-practice` spec's "Latin text is unaffected" font +scenario is about typeface choice (looped Thai face vs. system sans-serif), +which holds regardless of which language that Latin (or Cyrillic) text is in — +no requirement there changes. The Settings menu's existing controls +(virtual-keyboard override, on-screen-keyboard visibility) are not currently +described by any spec; this change does not alter their behavior, only adds a +new control alongside them and translates all three controls' labels. + +## Impact + +- **Frontend only** (`src/frontend/`): no backend, API, or corpus changes. +- New dependency: `vue-i18n`. +- Touches every component that renders interface text: `SettingsMenu.vue`, + `SentenceView.vue`, `AnswerInput.vue`, `AudioPlayer.vue`, + `OnScreenKeyboard.vue`, `App.vue`. +- New `stores/settings.js` field (`interfaceLanguage`) and a new + `localStorage` key, following the existing pattern for the two settings + already there. +- New message-catalog files, one per supported language. diff --git a/openspec/changes/archive/2026-09-14-add-ui-localization/specs/interface-localization/spec.md b/openspec/changes/archive/2026-09-14-add-ui-localization/specs/interface-localization/spec.md new file mode 100644 index 0000000..3b46562 --- /dev/null +++ b/openspec/changes/archive/2026-09-14-add-ui-localization/specs/interface-localization/spec.md @@ -0,0 +1,86 @@ +## Purpose +Lets a learner see the application's own interface — its controls, hints, and +status messages — in a language they read, independent of Thai (or any other +language) being the thing they are practising. + +## ADDED Requirements + +### Requirement: Interface language selector +The Settings menu SHALL offer a Language control alongside its existing +controls, letting the learner choose the interface language from the full set +of supported languages: English, French, German, Thai, Russian, and +Hungarian. + +#### Scenario: Choosing a language +- **WHEN** the learner opens the Settings menu and selects a language from the + Language control +- **THEN** the interface's own text — Settings menu labels and descriptions, + loading/error/empty states, the character-count hint, the Check button, the + correct/incorrect verdicts, and the on-screen keyboard's legend — switches + to that language immediately, without a page reload + +#### Scenario: All six languages are offered +- **WHEN** the learner opens the Language control +- **THEN** English, French, German, Thai, Russian, and Hungarian are all + listed as choices, each identified by its own name (e.g. "Русский", not + "Russian" translated into the currently active language) + +#### Scenario: A string not yet translated +- **WHEN** the selected language has no translation (or an empty one) for a + piece of interface text +- **THEN** that text renders in English, never blank and never as its + internal message key + +### Requirement: Exercise content is unaffected by interface language +The interface language SHALL govern only the application's own chrome. It +SHALL NOT alter exercise content: the target sentence, its audio, and any +other data delivered by the exercise API render exactly as the API provides +them regardless of which interface language is selected. + +#### Scenario: Thai exercise content stays Thai +- **WHEN** the interface language is set to English (or French, German, + Russian, or Hungarian) and a Thai exercise is displayed +- **THEN** the target sentence is still rendered in Thai script, and the + audio clip is unchanged + +#### Scenario: Thai interface with Thai exercise content +- **WHEN** the interface language is set to Thai +- **THEN** the interface's own chrome (Settings menu, hints, messages) is + rendered in Thai, in addition to the exercise content that was already + Thai + +### Requirement: Default interface language +On first visit, the interface language SHALL default to the browser's +reported language when that language is one of the six supported languages, +and to English otherwise. The learner's own choice, once made, SHALL always +take precedence over this default. + +#### Scenario: Browser language is supported +- **WHEN** a learner with no stored language preference opens the + application, and their browser reports a language among the six supported + ones +- **THEN** the interface renders in that language without the learner + choosing anything + +#### Scenario: Browser language is unsupported +- **WHEN** a learner with no stored language preference opens the + application, and their browser reports a language outside the six + supported ones +- **THEN** the interface renders in English + +### Requirement: Interface language persists across visits +The learner's chosen interface language SHALL persist across page reloads and +future visits on the same browser, on a best-effort basis consistent with the +application's other settings: when persistence is unavailable, the choice +SHALL still apply for the current session. + +#### Scenario: Returning after choosing a language +- **WHEN** a learner selects a language, then reloads the page or returns in + a later visit +- **THEN** the interface renders in that previously chosen language, without + the learner selecting it again + +#### Scenario: Storage is unavailable +- **WHEN** the browser refuses persistent storage (e.g. private browsing) +- **THEN** a language choice made during the visit still applies for the rest + of that session, even though it will not survive a reload diff --git a/openspec/changes/archive/2026-09-14-add-ui-localization/tasks.md b/openspec/changes/archive/2026-09-14-add-ui-localization/tasks.md new file mode 100644 index 0000000..6772423 --- /dev/null +++ b/openspec/changes/archive/2026-09-14-add-ui-localization/tasks.md @@ -0,0 +1,47 @@ +## 1. Dependency and i18n setup + +- [x] 1.1 Add `vue-i18n` to `src/frontend/package.json` and verify `npm install` (or the project's package manager) succeeds with no peer-dependency warnings for Vue 3 +- [x] 1.2 Create `src/frontend/src/locales/en.js` as the reference catalog, with one flat, dot-prefixed key per interface string currently hardcoded in `SettingsMenu.vue`, `SentenceView.vue`, `AnswerInput.vue`, `AudioPlayer.vue`, and `OnScreenKeyboard.vue`, and verify the file exports a plain object +- [x] 1.3 Create `src/frontend/src/i18n.js` calling `createI18n` with `legacy: false`, `en` as `fallbackLocale`, and the six locale catalogs registered as messages, and verify the app still builds with it wired into `main.js` (`app.use(i18n)`) even before other locales exist, by temporarily pointing every locale at the English catalog + +## 2. Settings-store integration + +- [x] 2.1 Add `interfaceLanguage` to `stores/settings.js` following the existing `virtualKeyboardOverride`/`onScreenKeyboardVisible` pattern: a `SUPPORTED_LANGUAGES` set (`en`, `fr`, `de`, `th`, `ru`, `hu`), a `typelearn.interfaceLanguage` `localStorage` key, and a best-effort load/save pair, and verify a unit test covering an unset key, a valid stored value, and an invalid/foreign stored value +- [x] 2.2 Implement the browser-language default: when no stored value exists, derive the initial `interfaceLanguage` from `navigator.language`'s base subtag if it is in `SUPPORTED_LANGUAGES`, else `'en'`, and verify a unit test covering a supported subtag (e.g. `fr-CA` → `fr`), an unsupported one, and a missing `navigator.language` +- [x] 2.3 Wire `interfaceLanguage` to vue-i18n's `locale` (e.g. via a `watch(interfaceLanguage, ..., { flush: 'sync' })` that sets the i18n instance's `locale.value`, mirroring the store's existing `flush: 'sync'` persistence watchers) and verify a unit/component test that changing `settings.interfaceLanguage` changes what `useI18n().locale.value` reads + +## 3. Translate the interface chrome + +- [x] 3.1 Replace the hardcoded strings in `SettingsMenu.vue` (`GROUPS` titles/labels/descriptions, the `Settings` aria-label) with `$t(...)`/`t(...)` calls against the keys from 1.2, and verify the menu still renders with English active and no key falls back to a raw key string +- [x] 3.2 Replace the hardcoded strings in `SentenceView.vue` (loading/error/empty states, the character-count hint) with translation calls, handling the character-count hint's interpolated number via vue-i18n's interpolation syntax, and verify each of the three states and the hint render correctly with English active +- [x] 3.3 Replace the hardcoded strings in `AnswerInput.vue` (placeholder, Check button, correct/incorrect verdicts) with translation calls, and verify with English active +- [x] 3.4 Replace the hardcoded aria-label in `AudioPlayer.vue` with a translation call, and verify with English active +- [x] 3.5 Replace the hardcoded strings in `OnScreenKeyboard.vue` (the finger legend's finger names, and any other on-screen text — not the layout's own keycap characters, which stay as the language being typed) with translation calls, and verify with English active +- [x] 3.6 Confirm no remaining component hardcodes interface text by grepping the touched files for the string literals removed in 3.1–3.5, and verify the grep returns nothing outside test files and the locale catalogs themselves + +## 4. Language selector control + +- [x] 4.1 Add a Language block to `SettingsMenu.vue` — a native `