Skip to content

M4-06: Localization with ARB lint

Status: ready | Depends on: M4-01 | Milestone: M4

Goal

After this ticket packages/l10n holds every interface text of the apps and the console in German (default) and English as ARB files (app_de.arb, app_en.arb), generated with flutter gen-l10n into a typed AppLocalizations with ICU plurals and selects, plus the pure helpers that every screen needs: the translation of server problem codes with a generic fallback that shows the code, number, date, time and currency formatting per locale, and the fixed term table of 01 9 so that German and English mean the same. The build breaks on a missing translation, and a test over the ARB files rejects en and em dashes (U+2013, U+2014) and emoji and checks the term table. Why: 01 9 makes German and English first-class, D-W-94 keeps every other repository text English, and the ban list of the design README forbids dashes and emoji in interface texts; these rules must be gates before the first screen ticket writes a string.

Read first

  • docs/PLAN.md: section 3; section 10 rows M4-06, M4-07, M4-18 (the first texts).
  • docs/spec/01-product.md: 9 "Languages" (all, including the term table between the language-check markers), 5 (screen texts as the source of the first keys), 6 "Interaction rules" (texts such as "Not found or no access", "Sending delayed"), 8 (row "Language": the app language in the semantics tree), 11 (users.locale is in 03; the app starts in the system language).
  • docs/spec/06-api.md 5 "Errors" (the closed list of codes, item 7) and docs/api/openapi.yaml (x-problem-codes, components.schemas.ProblemCode or the equivalent list).
  • docs/design/README.md ("Ban list": emoji, en and em dashes in UI texts), docs/design/components.md (texts of the components, translated reasons of the live box in 2.1, trigger labels in 5 between language-check markers).
  • docs/spec/03-data-model.md (users.locale), docs/spec/15-client-architecture.md 3, 3.1 (l10n is a Flutter package, design_system depends on it).
  • docs/DECISIONS.md: D-W-94 (English repository, German only in de product strings with markers), D-W-100, D-W-114; section 10. tools/docs/language_check.py and tools/docs/language-allow.txt.
  • Briefs: docs/briefs/M4-01-workspace-arch.md.

This ticket touches no API operation, no table, no action, no event and no configuration key.

Fixed requirements that apply

Owner's fixed build rules (apply verbatim):

  1. No MVP. New from scratch and directly in the final state; stages are only the order.
  2. Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
  3. Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
  4. Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
  5. Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.

Rules for every brief:

  1. Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
  2. No en dash and no em dash in any file you write; the text-rules pre-commit hook rejects them, and the ARB test of M4-06 rejects them in interface texts. Use a hyphen or rephrase.
  3. White label: examples and test data use example.com and the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. Interface texts come from packages/l10n, never as literals in widgets.
  4. No configuration key is added; texts that name a limit take the value as an ICU argument filled by the caller from the app configuration (for example "Valid for {minutes} more minutes"), never a number written into the text.
  5. Specification first: 01 9 names the files, the generator, the checks and the problem code rule; this ticket implements it and adds to 01 9 only what it decides (the fallback key names, the allow-list entry).

Product requirements implemented here:

  1. German (default) and English; the app follows users.locale and starts in the system language as long as no language is chosen; another system language falls back to German (01 9).
  2. ARB files app_de.arb and app_en.arb in packages/l10n, generated with flutter gen-l10n; plurals and selects through ICU; a missing translation breaks the build (01 9).
  3. A test over the ARB files rejects U+2013, U+2014 and emoji (01 9, design README ban list).
  4. Errors arrive as a problem code; the app translates known codes, an unknown code shows a generic text plus the code (01 9, 06 5).
  5. Numbers, dates and currencies follow the app locale ("1,84 USD" in de, "USD 1.84" in en, 01 9); agent content is never translated.
  6. The term table of 01 9 is binding for both languages.

Scope

Create or fill in packages/l10n/:

  • l10n.yaml (generator options, Design note 1), lib/l10n/app_de.arb, lib/l10n/app_en.arb (the template is app_en.arb, Design note 1), the generated lib/src/generated/ output.
  • lib/src/problem_text.dart (problemText), lib/src/formatting.dart (L10nFormat), lib/src/locale_resolution.dart (resolveAppLocale), lib/src/term_table.dart (generated from 01 9, Design note 4), exported from lib/l10n.dart.
  • Tests: test/arb_lint_test.dart, test/completeness_test.dart, test/problem_text_test.dart, test/formatting_test.dart, test/locale_resolution_test.dart, test/term_table_test.dart.
  • tools/l10n/terms.py (generates term_table.dart from 01 9) with tools/l10n/tests/test_terms.py, or the same as a --check mode inside the Dart test if no Python is wanted (Design note 4).

Change:

  • packages/l10n/pubspec.yaml (flutter_localizations, intl; flutter: generate: true), README.md.
  • Root Makefile: codegen-check (or the Dart part of lint) runs flutter gen-l10n in a temporary copy and compares (Design note 2).
  • tools/docs/language-allow.txt: path: packages/l10n/lib/l10n/app_de.arb with a comment (D-W-94: product strings of the de locale); the same for the generated German file under lib/src/generated/.
  • docs/spec/01-product.md 9 (the fallback text keys, the allow-list entry, the generated term table), docs/DECISIONS.md section 10: rows D-M4-06-<n>; root CHANGELOG.md under [Unreleased].

Out of scope

  • The texts of each screen: every screen ticket (M4-07 onward) adds its keys to both ARB files in its own change, under the gates built here.
  • Setting users.locale (M4-25 settings) and reading it at start (M4-18).
  • Translation of agent content (never).

Boundaries

  • Work in a dedicated git worktree on the branch feat/M4-06-l10n; one writer per worktree.
  • Conventional Commits with the package or component as scope (for example feat(client): ..., feat(sync-engine): ..., build(gates): ..., docs(spec): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md).
  • Never push to main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request of AGENTS.md. Never publish a Dart package (publish_to: none in every pubspec.yaml).
  • Do not modify files outside the scope above, except the CHANGELOG, README and docs/DECISIONS.md entries named in this brief.
  • Follow the host rules supplied by the launcher (ports, machine rules).
  • Tests bind only 127.0.0.1 with port 0 and make no network call; fakes replace the server unless a section below names a container. Temporary directories only via mktemp -d (Dart Directory.systemTemp.createTemp, pytest tmp_path), removed at the end. Stop only processes you started, by PID.
  • Never read or print secrets. No network calls except package downloads for pinned tools and pub packages and what the gates of make check do themselves.
  • The Flutter SDK at the version pinned in the root pubspec.yaml must be on PATH (and Go and Docker where a section names them); without them the gates fail by design (R-QA-02), and the ticket stops with Verdict: BLOCKED <tool> missing instead of skipping.
  • Never lower a threshold, disable a gate, delete a test, raise repo.max_file_kb or extend quality/licenses/allowed.txt; never exclude hand-written code from a lint, format, coverage or doc gate.
  • The product name appears only at the places of 02 "Code layout" ("Places that carry the product name"); the app shows branding.product_name from the instance, never a literal.
  • No permission logic in the client (15 1 principle 1): controls follow server responses, snapshots and events, never an own derivation from grants.
  • If the core contract (chapters 00, 03, 04, 05) or docs/api/ lacks something this ticket needs, stop with Verdict: BLOCKED <reason> instead of building a deviation (P-11).
  • This brief was written before milestone M1 closed (D-W-100, extended to M4 by D-W-114). Where the merged code of an earlier ticket names a package, type, function, file, provider or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with Verdict: BLOCKED <reason>.

Design notes

  1. Generator setup. l10n.yaml: arb-dir: lib/l10n, template-arb-file: app_en.arb, output-dir: lib/src/generated, output-localization-file: app_localizations.dart, output-class: AppLocalizations, synthetic-package: false, nullable-getter: false, required-resource-attributes: true (every message has an @key with a description), untranslated-messages-file in a temporary path only during the check. Ruling for docs/DECISIONS.md: English is the template because the code and its reviewers read English (D-W-94), while German stays the default locale of the app (01 9); supportedLocales lists de first. Key names are lowerCamelCase with a screen or component prefix (e1ServerAddressLabel, liveBoxStateRunning, problemFallback).

  2. Missing translation breaks the build. test/completeness_test.dart parses both ARB files and fails on a key that is missing in one of them, on a key whose ICU placeholders differ between the two (name and type), on an @key without description, and on an empty message. In addition the gate runs flutter gen-l10n with the untranslated-messages-file option into a temporary copy and fails when that file lists anything, and compares the generated output with the checked-in lib/src/generated/ (a stale generation fails). Put the generation check into make codegen-check for Dart members (one loop over members that have l10n.yaml, SKIP when none); record the ruling.

  3. ARB lint. test/arb_lint_test.dart scans every message value (not the keys or descriptions, which are English prose and fall under the repository rules): U+2013 and U+2014 fail; emoji fail, defined as any code point with the Unicode property Extended_Pictographic plus the regional indicators U+1F1E6 to U+1F1FF and the variation selector U+FE0F (keep the ranges in one constant with the Unicode version and source in a comment); a deliberately inserted U+2014 and a deliberately inserted emoji in a test fixture ARB file must fail (test/fixtures/bad_dash.arb, test/fixtures/bad_emoji.arb). The same scan covers the description texts for dashes only.

  4. Term table. The table of 01 9 between its language-check markers is the source. A generator reads its rows (German, English, Code) and writes lib/src/term_table.dart (const List<TermPair> termTable, generated code); the gate compares. test/term_table_test.dart: for every English message that contains an English term of the table as a whole word (case-insensitive, plural with s included), the German message of the same key contains the German term of the row (word stem match: the German term or its stem without a final e, en, n or s); a list of explicit exceptions with a reason per key is allowed in the test and starts empty. Ruling for docs/DECISIONS.md: the check direction English to German, because the template is English. Implement the generator in Python in tools/l10n/ (standard library, ruff D, pytest) so the Markdown parsing runs in the tool environment; or, if a Dart test can read docs/spec/01-product.md reliably from the package (walk up to the repository root as M2-38 does for its vectors), as a Dart test with --check; record which.

  5. Problem codes. String problemText(AppLocalizations l, String code, {String? reason}): a switch over every code of x-problem-codes in docs/api/openapi.yaml to an ARB message (problem<Code>), with reason selecting a more specific message where 01 or 07 names one (for example forbidden with capability_missing); any other code gives problemFallback(code) ("Something went wrong ({code})"). test/problem_text_test.dart reads the code list from docs/api/openapi.yaml (walk up to the repository root) and fails for a code without an ARB message, so a new code in the spec needs a text in the same change.

  6. Formatting. L10nFormat(Locale locale) with money(num amount, String currency) (de: 1,84 USD; en: USD 1.84, as 01 9 shows), count(int), percent(num), durationShort(Duration) ("4:12", "28 min", "2 h 5 min" per locale), time(DateTime, {required String timeZone}) and relativeDay ("today", "yesterday", weekday) using intl with the IANA time zone given by the caller (users.time_zone); no clock inside, the caller passes now.

  7. Locale resolution. Locale resolveAppLocale({String? userLocale, required List<Locale> systemLocales}): users.locale de or en wins; otherwise the first system locale whose language is de or en; otherwise de.

  8. First keys. Add the keys that M4-07 and M4-18 need first: the trigger labels (components.md 5), the live box state words and translated reasons (components.md 2.1), "Not found or no access", "No access anymore", "Sending", "Sending delayed", "Offline", the problem texts. German texts of components.md 5 are given there verbatim; take them from that table.

  9. Rulings for docs/DECISIONS.md (D-M4-06-<n>): English template and German default (note 1); generation check in codegen-check (note 2); the emoji definition (note 3); term check direction and generator place (note 4); the allow-list entry for the German ARB file (D-W-94).

Tests (write them first)

packages/l10n/test/:

  • arb_lint_test.dart: the real files pass; bad_dash.arb (a value with U+2014) fails naming key and code point; bad_emoji.arb (a value with U+1F680) fails; an en dash U+2013 in a description fails.
  • completeness_test.dart: the real files pass; a fixture pair with a missing German key fails; a placeholder {count} typed int in English and String in German fails; an @key without description fails.
  • problem_text_test.dart: every code of the spec has a text; forbidden with capability_missing gives the specific text; future_code gives the fallback containing future_code, in both locales.
  • formatting_test.dart: money(1.84, 'USD') gives 1,84 USD in de and USD 1.84 in en (the non-breaking space that intl may produce is normalized in the assertion and documented); durations; times in Europe/Berlin and America/New_York across a daylight saving change.
  • locale_resolution_test.dart: user en with system de gives en; no user locale with system fr, en gives en; system fr only gives de.
  • term_table_test.dart: the generated table equals 01 9; an English message with "Space" whose German text lacks the German term fails (fixture); the real files pass.

Commands that must pass, from the repository root:

  • cd packages/l10n && flutter gen-l10n && flutter analyze --fatal-infos && flutter test --coverage
  • make codegen-check, make docs (language check with the allow-list entry), make lint, make fmt-check, make coverage
  • make check

Acceptance criteria

  • app_de.arb and app_en.arb exist with the first keys; AppLocalizations is generated and checked in; a stale generation fails the gate.
  • A missing translation, a placeholder mismatch or a missing description breaks the build.
  • A deliberately inserted U+2014 and an emoji are found by the test.
  • Every problem code of the spec has a text; unknown codes show a generic text with the code.
  • Money, numbers, dates and times follow the locale as 01 9 shows; the term table of 01 9 is checked.
  • make docs stays green with the German ARB file in the allow list; rulings recorded.
  • make check is green.

Definition of done

  • Code and tests as specified; doc comments on every public symbol; comments explain why.
  • packages/l10n/README.md (English): how to add a text (both files, description, placeholders), the gates, the term table, the problem code rule.
  • 01 9 updated; rulings of Design note 9 in docs/DECISIONS.md section 10; tools/docs/language-allow.txt entry with a comment.
  • Root CHANGELOG.md entry under 'Unreleased'.
  • An ADR only if you take an architecture decision beyond this brief (MADR 4 as in docs/adr/0000-template-madr-4.md, headings and text in English, next free number, make adr-index).
  • make check green.
  • An independent code review over the diff, then the red team gate (docs/quality/red-team-gate.md, verdict JA or NEIN); on NEIN fix with a test per finding and have the delta checked again. Record both verdicts.
  • Land the change as AGENTS.md ("How work lands on main") describes: a pull request from this branch, squash-merged only when review, red team and make check, ci-ok and the review threads are green; then git worktree remove.

Report

Write to {{REPORT_PATH}}:

  1. First line exactly Verdict: DONE or Verdict: BLOCKED <reason>.
  2. Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of make check.
  3. Review and red team verdicts and the findings fixed.
  4. The number of keys per file, the Unicode version of the emoji ranges, where the term generator lives.
  5. What is not proven (for example a platform test that ran only in CI, or a check done by hand).
  6. Follow-ups that could not be done in this scope, each with its reason, every difference to the names of this brief (D-W-100, D-W-114), and spec gaps found.

Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.