Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<code>.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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-13
139 changes: 139 additions & 0 deletions openspec/changes/archive/2026-09-14-add-ui-localization/design.md
Original file line number Diff line number Diff line change
@@ -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 `<select>` would — it is added as its own block using a
native `<select>` 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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
Loading