From 9bfe6bce21b3d63182fb3b4f1825448eec242654 Mon Sep 17 00:00:00 2001 From: Wessel Verheij Date: Sat, 1 Aug 2026 17:47:26 +0200 Subject: [PATCH] feat(localisation): specify interface localisation and language selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interface has only ever been English. G5 rests on choosing the true word in the user's own vocabulary, and that argument does not survive the user not reading English. Nothing in the catalogue owned the language the interface runs in — only G5's Translation note, which declared interface translation out of scope and now no longer holds. G7 specifies it: English and Dutch to start with English as the source and fallback locale, a locale added by registration and translation rather than a rewrite, first-visit detection from the request's Accept-Language with English as the floor, and a per-user preference stored beside the theme it mirrors that beats detection on every later request. The switch lives in Settings in the Appearance family; every user-facing string is translatable; a missing key falls back to English rather than to a broken surface; the active locale is announced for assistive technology; and selection adds no outbound call. Fourteen requirements, every one marked (Open) — the feature is specified ahead of its implementation, which is the sequence GOV-R4 requires: the specification of a behavioural change merges before the code that implements it. Reconciled around it: G5's Translation section now points to G7 and keeps only the honesty rule that outlives any locale, G5-R14 is reworded to that same effect without renumbering, the data model and platform matrix gain the language preference and the per-platform language signal beside their theme counterparts, and the feature count moves to fifty-two. Signed-off-by: Wessel Verheij --- 10-functional/README.md | 2 +- 10-functional/features/README.md | 3 +- .../features/g-ux/g5-plain-language.md | 11 +- .../features/g-ux/g7-localisation.md | 158 ++++++++++++++++++ 20-architecture/data-model.md | 3 +- 20-architecture/platform-matrix.md | 1 + 30-repos/spec.md | 2 +- AGENTS.md | 2 +- README.md | 6 +- 9 files changed, 177 insertions(+), 11 deletions(-) create mode 100644 10-functional/features/g-ux/g7-localisation.md diff --git a/10-functional/README.md b/10-functional/README.md index b87a741..b0d5594 100644 --- a/10-functional/README.md +++ b/10-functional/README.md @@ -7,7 +7,7 @@ check. | Page | Contents | |------|----------| -| [features/](features/) | Fifty-one features across seven areas, each with numbered requirements | +| [features/](features/) | Fifty-two features across seven areas, each with numbered requirements | | [journeys/](journeys/) | Seven end-to-end paths, each naming the features it exercises | ## The contract diff --git a/10-functional/features/README.md b/10-functional/features/README.md index c1cb9ab..2205cf8 100644 --- a/10-functional/features/README.md +++ b/10-functional/features/README.md @@ -2,7 +2,7 @@ **Status:** Accepted -Fifty-one features across seven areas. This catalogue is the **contract the +Fifty-two features across seven areas. This catalogue is the **contract the technical spec is written against** — every architecture and implementation decision must trace to a feature requirement here, not the other way round. @@ -155,6 +155,7 @@ exhibit**. G1 in particular is the one the whole product is judged on. | [G4](g-ux/g4-pwa.md) | Responsive and installable PWA | | [G5](g-ux/g5-plain-language.md) | Plain language and in-product help | | [G6](g-ux/g6-keyboard.md) | Keyboard and command palette | +| [G7](g-ux/g7-localisation.md) | Interface localisation and language selection | --- diff --git a/10-functional/features/g-ux/g5-plain-language.md b/10-functional/features/g-ux/g5-plain-language.md index 154bbe6..123ac2a 100644 --- a/10-functional/features/g-ux/g5-plain-language.md +++ b/10-functional/features/g-ux/g5-plain-language.md @@ -84,8 +84,12 @@ comment policy applies to code ### Translation -The product has a translated readme and no translated interface. Interface -translation is not currently in scope, and this page does not imply otherwise. +Interface localisation is specified separately in +[G7](g7-localisation.md) — supported languages, detection, the per-user +preference, and full string coverage all live there. What this page owns is the +honesty rule that outlives any single locale: a string shown in English because +its translation is missing is **English**, and the interface must not present a +partially translated locale as though it were complete. ## Acceptance criteria @@ -104,11 +108,12 @@ translation is not currently in scope, and this page does not imply otherwise. | **G5-R11** | The product MUST use one voice, British-leaning, calm and precise. | | **G5-R12** | The interface MUST NOT contain placeholders, coming-soon copy, or controls that do nothing. | | **G5-R13** | A setting reserved for future behaviour MUST say so explicitly. | -| **G5-R14** | Interface translation MUST NOT be implied where it does not exist. | +| **G5-R14** | A locale's translation coverage MUST NOT be overstated; a string shown by English fallback MUST read as English, not as evidence of a complete translation. See [G7](g7-localisation.md). | ## Related - [G1 Privacy stance](g1-privacy.md) — the honesty requirements this enforces - [G2 Error and remedy model](g2-error-model.md) +- [G7 Interface localisation](g7-localisation.md) — supported languages, detection, and the per-user preference - [60-brand/brand-rules.md](../../../60-brand/brand-rules.md) — the voice - [ADR-0011](../../../00-overview/decisions/0011-code-comment-policy.md) — the same discipline in code diff --git a/10-functional/features/g-ux/g7-localisation.md b/10-functional/features/g-ux/g7-localisation.md new file mode 100644 index 0000000..79d83f6 --- /dev/null +++ b/10-functional/features/g-ux/g7-localisation.md @@ -0,0 +1,158 @@ +# G7 — Interface localisation and language selection + +**Status:** Accepted · **Area:** G — Cross-cutting UX + +> **Forthcoming.** This feature is specified ahead of its implementation, to +> unblock the app-side work that introduces it. Nothing here ships in `v1.3.0`; +> every requirement below is therefore marked *(Open)* until the behaviour it +> describes lands. This is the same discipline [G5](g5-plain-language.md) applies +> to deferred behaviour — a specification may run ahead of the product, but it +> must say so plainly rather than describe an interface that does not yet exist. + +--- + +## Purpose + +The interface has always been English, and only English. The words the product +chooses are load-bearing — the whole of [G5](g5-plain-language.md) is about +saying the true thing in the user's own vocabulary — and that argument does not +hold if the user does not read English. + +This feature makes the interface translatable and lets a household choose the +language it runs in. It starts with English and Dutch, the two the product's own +statement vocabulary already spans, and is built so a third costs a translation +rather than a rewrite. + +It deliberately mirrors the existing theme preference +([F1](../f-platform/f1-desktop-shell.md)): a signal the environment can offer, a +per-user choice that overrides it, and a switch in Settings that sits in the same +family as Appearance. + +## Behaviour + +### Two languages to start, more without a rewrite + +The interface ships in English (`en`) and Dutch (`nl`). English is the **source +locale**: it is the language keys are authored in, and the one every other locale +is measured against. + +Adding a further language is a matter of registering its locale and supplying its +translations. The mechanism that detects, selects, and applies a language does +not change when a locale is added — nothing about switching is wired to the fact +that there happen to be two. + +### Detection before choice + +On a first visit, before the household has expressed any preference, the +interface picks its language from the environment: the browser or system +`Accept-Language` preference, resolved to the **best supported match**. A request +that prefers `nl` gets Dutch; one that prefers a language the product does not +carry falls through. + +Where nothing in the request matches a supported locale, the interface uses +**English**. English is the floor, not an error state — an unmatched preference +is an ordinary outcome, not a failure to report. + +This mirrors the theme signal exactly: the environment offers a preference, its +absence is itself a defined outcome, and neither is ever the last word. + +### Choice beats detection + +Language is a **per-user preference**, stored on the user record next to the +theme it mirrors ([data model](../../../20-architecture/data-model.md)). Once a +user has set it, that choice wins — on this session and every one after it, +regardless of what the environment reports. Detection applies only until a +preference exists; it never overrides one. + +The preference persists the way every other user preference does. A user who +chose Dutch last month opens the application in Dutch, with no re-detection and +no drift back to the environment's language. + +### The switch lives in Settings + +The language switcher sits in Settings, in the **same family as Appearance** — +the surface that already owns the theme choice. It is an ordinary setting a user +can find where they would look for the theme, not a separate onboarding step or a +hidden flag. Changing it takes effect across the interface immediately, without +losing the user's place or data. + +### Everything user-facing is translatable + +Every string a user can read is translatable. No user-facing copy is pinned to a +single language in a way a locale cannot reach — the same completeness discipline +[G4](g4-pwa.md) applies to responsive layout, applied to language. + +Where a key has no value in the active locale, the interface falls back to its +**English** value rather than showing a raw key, a blank, or a placeholder. A +partially translated locale therefore degrades to English in the gaps, never to +something broken — which is exactly the honesty [G5-R14](g5-plain-language.md) +requires: an untranslated string reads as English, not as a translated interface +that silently isn't. + +### The active language is announced + +The active locale is reflected in the document's language attribute, so a screen +reader announces content in the language it is actually written in +([G3](g3-accessibility.md)). A Dutch interface that a screen reader pronounces as +English is a defect, not a cosmetic one. + +### It stays on the machine + +Detection reads a request header the browser already sends and a preference the +user already stored; neither is sent anywhere. Language selection adds no +outbound call and no third-party translation service — consistent with the +privacy stance ([G1](g1-privacy.md)), translation is a local concern like every +other. + +## States + +The active locale for a request resolves in a fixed order, first match wins: + +| Order | Source | When it applies | +|-------|--------|-----------------| +| 1 | The user's stored language preference | Whenever the user has set one. | +| 2 | The environment's `Accept-Language`, best supported match | First visit, before a preference exists, when the request names a supported locale. | +| 3 | English (`en`) | Whenever neither above yields a supported locale. | + +Setting a preference moves a user permanently from rows 2–3 to row 1. There is no +transition back short of clearing the preference. + +## Edge cases + +| Situation | Behaviour | +|-----------|-----------| +| `Accept-Language` prefers an unsupported language | Falls through to English; no error. | +| `Accept-Language` lists several languages | The best supported match is taken in the header's own priority order. | +| A supported locale with a region subtag (`nl-BE`) | Resolved to its base supported locale (`nl`). | +| A preference set, then the environment language changes | The preference still wins; detection does not re-run. | +| A key missing in the active locale | The English value is shown. | +| A key missing in English as well | A genuine defect — English coverage is the source locale's contract, not a fallback that may itself have holes. | +| Language changed mid-session | Applies immediately, without a reload that discards the user's place. | + +## Acceptance criteria + +| ID | Requirement | +|----|-------------| +| **G7-R1** | *(Open)* The interface MUST support English (`en`) and Dutch (`nl`), with English as the source and fallback locale. | +| **G7-R2** | *(Open)* Adding a further locale MUST require only its registration and its translations; the detect–select–apply mechanism MUST NOT change per locale. | +| **G7-R3** | *(Open)* On a first visit, before any preference exists, the active locale MUST be the best supported match against the request's `Accept-Language` preference. | +| **G7-R4** | *(Open)* Where `Accept-Language` yields no supported locale, the active locale MUST default to English, and this MUST NOT be treated as an error. | +| **G7-R5** | *(Open)* A language preference MUST be stored per user on the user record, alongside the existing theme preference. | +| **G7-R6** | *(Open)* Once a user language preference is set, it MUST take precedence over detection on every subsequent request. | +| **G7-R7** | *(Open)* Detection MUST apply only while no user preference exists; it MUST NOT override a stored preference. | +| **G7-R8** | *(Open)* The language preference MUST persist across sessions without re-detection. | +| **G7-R9** | *(Open)* A language switcher MUST live in Settings, in the same family as the Appearance/theme setting. | +| **G7-R10** | *(Open)* Changing the language MUST take effect across the interface immediately, without discarding the user's place or data. | +| **G7-R11** | *(Open)* Every user-facing string MUST be translatable; no user-facing copy may be pinned to a single language beyond a locale's reach. | +| **G7-R12** | *(Open)* A key absent in the active locale MUST fall back to its English value, never to a raw key, a blank, or a placeholder. | +| **G7-R13** | *(Open)* The active locale MUST be reflected in the document's language attribute for assistive technology. | +| **G7-R14** | *(Open)* Language detection and selection MUST NOT add an outbound call or send the user's locale off the machine. | + +## Related + +- [G5 Plain language and in-product help](g5-plain-language.md) — the voice these translations carry, and the honesty rule the English fallback satisfies +- [G3 Accessibility](g3-accessibility.md) — why the active locale must be announced +- [G1 Privacy stance](g1-privacy.md) — why selection stays on the machine +- [F1 Desktop shell](../f-platform/f1-desktop-shell.md) — the theme preference this mirrors +- [20-architecture/data-model.md](../../../20-architecture/data-model.md) — where the per-user preference lives +- [20-architecture/platform-matrix.md](../../../20-architecture/platform-matrix.md) — the language signal per platform diff --git a/20-architecture/data-model.md b/20-architecture/data-model.md index dc2b1cf..1d9472c 100644 --- a/20-architecture/data-model.md +++ b/20-architecture/data-model.md @@ -30,7 +30,8 @@ elsewhere is wrong by definition. Users; sessions; system alerts; user preferences. The user record carries the owner and developer flags, the forced-change flag, -and the per-user settings the product accumulated: theme, currency view, period +and the per-user settings the product accumulated: theme, language +([G7](../10-functional/features/g-ux/g7-localisation.md)), currency view, period start day, detection windows, thresholds, drop-folder behaviour, close behaviour, community settings, tax country, and the activation and backfill anchors. diff --git a/20-architecture/platform-matrix.md b/20-architecture/platform-matrix.md index d20b677..06b2b6c 100644 --- a/20-architecture/platform-matrix.md +++ b/20-architecture/platform-matrix.md @@ -34,6 +34,7 @@ documentation says so plainly rather than shipping something that will not run. | File-open intake | Native or process arguments | Not applicable | Not applicable | | Notifications | Operating-system delivery | In-application only | Platform local notifications | | Theme signal | Reported by the shell | Browser preference | Reported by the shell | +| Language signal | `Accept-Language` from the webview | `Accept-Language` from the browser | `Accept-Language` from the webview | Storage paths resolve through a **single path authority** ([ARCH-R8](README.md#the-arch-r-namespace)), which is what makes the diff --git a/30-repos/spec.md b/30-repos/spec.md index 920b4c5..7110631 100644 --- a/30-repos/spec.md +++ b/30-repos/spec.md @@ -16,7 +16,7 @@ enforcement in one place. ```text 00-overview/ vision, glossary, roadmap, decisions/ -10-functional/ features/ (7 areas, 51 features), journeys/ (7) +10-functional/ features/ (7 areas, 52 features), journeys/ (7) 20-architecture/ system context, components, data flow, data model, platform matrix, contracts/ 30-repos/ one page per repository diff --git a/AGENTS.md b/AGENTS.md index 3d3200c..ada2430 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,7 +57,7 @@ documentation build. ```text 00-overview/ vision, glossary, roadmap, 20 decision records -10-functional/ 51 features across 7 areas, 7 journeys — WHAT and WHY +10-functional/ 52 features across 7 areas, 7 journeys — WHAT and WHY 20-architecture/ system context, components, data flow, data model, platform matrix, 4 contracts 30-repos/ per-repository specifications diff --git a/README.md b/README.md index 376fc5e..e175af5 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ freely. | Section | Contents | Read this if… | |---------|----------|---------------| | **[00-overview](00-overview/)** | Vision, glossary, roadmap, and twenty Architecture Decision Records | …you want the *why* behind any choice | -| **[10-functional](10-functional/)** | Fifty-one features across seven areas, and seven user journeys | …you are deciding what to build, or verifying it got built | +| **[10-functional](10-functional/)** | Fifty-two features across seven areas, and seven user journeys | …you are deciding what to build, or verifying it got built | | **[20-architecture](20-architecture/)** | System context, component model, data flow, data model, platform matrix, contracts | …you are implementing across a seam | | **[30-repos](30-repos/)** | Per-repository specifications | …you are working inside one repository | | **[40-quality](40-quality/)** | Code standards, comment policy, testing, CI/CD, security, definition of done | …you are writing or reviewing a pull request | @@ -53,7 +53,7 @@ freely. ### Fast paths - **"I want to understand the product"** → [vision](00-overview/vision.md) → [journeys](10-functional/journeys/) → [J1 First run](10-functional/journeys/j1-first-run.md) -- **"What does it actually do?"** → [the feature catalogue](10-functional/features/) — 51 features +- **"What does it actually do?"** → [the feature catalogue](10-functional/features/) — 52 features - **"What is shipped, and what is not?"** → [roadmap](00-overview/roadmap.md) — three buckets, kept strictly apart - **"How does sync work?"** → [E1](10-functional/features/e-sync/e1-change-capture.md) → [E3](10-functional/features/e-sync/e3-transport.md) → [ADR-0014](00-overview/decisions/0014-op-log-crdt-merge-engine.md) → [ADR-0015](00-overview/decisions/0015-multi-master-p2p-sync.md) - **"What does it send anywhere?"** → [G1 Privacy stance](10-functional/features/g-ux/g1-privacy.md) — the complete outbound surface @@ -107,7 +107,7 @@ committed to. | Section | Status | Contents | |---------|--------|----------| | 00-overview | Accepted | Vision, glossary, roadmap, 20 decision records | -| 10-functional | Accepted | 51 features across 7 areas, 7 journeys | +| 10-functional | Accepted | 52 features across 7 areas, 7 journeys | | 20-architecture | Accepted | System context, components, data flow, data model, platform matrix, 4 contracts | | 30-repos | Accepted | spec, beatrax, website, .github | | 40-quality | Accepted | Standards, comments, testing, CI/CD, security, done, tooling |