feat(i18n): editor translation runtime, English catalog and Accept-Language resolution #229

Merged
dominik.polakovics merged 3 commits from afk/225 into main 2026-10-12 18:23:04 +02:00

Closes #225

What

  • Runtime (web/src/i18n/): a hand-written t(key, params) reads flat JSON catalogs through a Solid locale signal.
    • Plurals are key.one/key.other, chosen with Intl.PluralRules.
    • Placeholders use the same syntax as the server: {name}, {name|q}, {name|list}.
    • Formatters cover dates, relative times, lists and sorting, all in the UI locale.
    • I18nProvider/useI18n provide the language to components; tRich (in rich.tsx) renders one sentence around an inline JSX element.
    • Adds no dependencies.
  • Catalog: en.json (1171 keys) includes one server.<code> per code in the exported registry from #224. glossary.md records the register rule (impersonal; formal "Sie") for the German issue.
  • Language resolution: GET /api/site now returns acceptLanguage, the header parsed into tags ordered by q. It is [] when the header is absent, and Go does no matching.
    • boot() picks the first acceptLanguage tag that has a catalog (exact tag, then primary subtag), then tries navigator.languages, then English.
    • It then sets <html lang> and document.title, and only then renders. There is no flash of English.
  • Server errors: serverError, looseFormError and decodeFieldErrors translate by code/args. When the catalog has no entry for a code, they show the server's English text.
    • Translated by code, coming from #224: publish messages, git reason, the tree root title, form and filter labelCodes, and badge unavailable.
    • No SPA code branches on server message text.
  • Extraction: every component, store and helper uses t(), and no hand-made plurals remain.
    • Sentences are single parameterized keys.
    • Sidecar, site and host-nav labels and the [Translate to <label>:] content marker stay as written.
  • Docs and skills:
    • README: a new "The editor's language" subsection under Host navigation, and "Editor language catalogs" under Development.
    • scaffold/site/AGENTS.md: the Languages section now covers the editor's own language.
    • setup-site skill: an interview line asking which language the customer's editors work in.
    • verify-site skill: the smoke test now says to check the editor in the customer's language.

Tests

  • i18nParity: every literal t()/tRich() key exists in every catalog, and no catalog key is unused.
    • Plurals have .one/.other.
    • Every registry code has a server.* entry, and its English equals the registry template.
    • Keys must be literals, and no hand-made plurals are allowed.
  • i18nLeak: an AST scan of the .tsx sources for JSX text and title/placeholder/aria-label/alt literals. The allowlist has one entry, "Deckle".
  • i18nShell: a smoke test that boots the full shell under a fixture zz catalog.
    • It checks html[lang], the title, a translated rail label, the English fallback for a missing key, and a translated server error.
    • It also checks that Accept-Language: de without a German catalog renders English, and that the browser-language fallback works.
  • i18n / i18nRich: unit tests for the runtime. Go: tests for the acceptLanguageTags parser and the handler.
  • The test setup forces English. Apart from the new acceptLanguage field, no existing assertion changed.

Verified locally:

  • tsc --noEmit, npm run build, vitest run (116 files, 2846 tests), and go vet ./... plus go test ./... all pass.
  • Adding a raw <button>Save</button> makes the leak check fail and name the file and line.
  • Deleting common.save from en.json makes the parity check fail and name the key.
  • With harness serve, curl -H 'Accept-Language: de-AT,de;q=0.9,en;q=0.8' /api/site returns "acceptLanguage":["de-AT","de","en"], and the same request without the header returns [].
  • Not checked in a real browser: the German-browser case was only covered by the jsdom smoke test.

Follow-ups / notes

  • The undo tooltip's step word ("save", "transfer") comes from the server's git snapshot subject as data, without a code, so it stays English until the server codes it.
  • The form's at-rest errors map (formDescription.errors) carries no codes on the wire, so it stays English for now.
  • joinNatural keeps "A, B and C" (no serial comma) through {items|list} {conj} {last} keys. Intl.ListFormat would change the English.

🤖 Generated with Claude Code

Closes #225 ## What - **Runtime** (`web/src/i18n/`): a hand-written `t(key, params)` reads flat JSON catalogs through a Solid locale signal. - Plurals are `key.one`/`key.other`, chosen with `Intl.PluralRules`. - Placeholders use the same syntax as the server: `{name}`, `{name|q}`, `{name|list}`. - Formatters cover dates, relative times, lists and sorting, all in the UI locale. - `I18nProvider`/`useI18n` provide the language to components; `tRich` (in `rich.tsx`) renders one sentence around an inline JSX element. - Adds no dependencies. - **Catalog**: `en.json` (1171 keys) includes one `server.<code>` per code in the exported registry from #224. `glossary.md` records the register rule (impersonal; formal "Sie") for the German issue. - **Language resolution**: `GET /api/site` now returns `acceptLanguage`, the header parsed into tags ordered by q. It is `[]` when the header is absent, and Go does no matching. - `boot()` picks the first `acceptLanguage` tag that has a catalog (exact tag, then primary subtag), then tries `navigator.languages`, then English. - It then sets `<html lang>` and `document.title`, and only then renders. There is no flash of English. - **Server errors**: `serverError`, `looseFormError` and `decodeFieldErrors` translate by `code`/`args`. When the catalog has no entry for a code, they show the server's English text. - Translated by code, coming from #224: publish messages, git `reason`, the tree root title, form and filter `labelCode`s, and badge `unavailable`. - No SPA code branches on server message text. - **Extraction**: every component, store and helper uses `t()`, and no hand-made plurals remain. - Sentences are single parameterized keys. - Sidecar, site and host-nav labels and the `[Translate to <label>:] ` content marker stay as written. - **Docs and skills**: - README: a new "The editor's language" subsection under Host navigation, and "Editor language catalogs" under Development. - `scaffold/site/AGENTS.md`: the Languages section now covers the editor's own language. - `setup-site` skill: an interview line asking which language the customer's editors work in. - `verify-site` skill: the smoke test now says to check the editor in the customer's language. ## Tests - `i18nParity`: every literal `t()`/`tRich()` key exists in every catalog, and no catalog key is unused. - Plurals have `.one`/`.other`. - Every registry code has a `server.*` entry, and its English equals the registry template. - Keys must be literals, and no hand-made plurals are allowed. - `i18nLeak`: an AST scan of the `.tsx` sources for JSX text and `title`/`placeholder`/`aria-label`/`alt` literals. The allowlist has one entry, "Deckle". - `i18nShell`: a smoke test that boots the full shell under a fixture `zz` catalog. - It checks `html[lang]`, the title, a translated rail label, the English fallback for a missing key, and a translated server error. - It also checks that `Accept-Language: de` without a German catalog renders English, and that the browser-language fallback works. - `i18n` / `i18nRich`: unit tests for the runtime. Go: tests for the `acceptLanguageTags` parser and the handler. - The test setup forces English. Apart from the new `acceptLanguage` field, no existing assertion changed. Verified locally: - `tsc --noEmit`, `npm run build`, `vitest run` (116 files, 2846 tests), and `go vet ./...` plus `go test ./...` all pass. - Adding a raw `<button>Save</button>` makes the leak check fail and name the file and line. - Deleting `common.save` from `en.json` makes the parity check fail and name the key. - With `harness serve`, `curl -H 'Accept-Language: de-AT,de;q=0.9,en;q=0.8' /api/site` returns `"acceptLanguage":["de-AT","de","en"]`, and the same request without the header returns `[]`. - Not checked in a real browser: the German-browser case was only covered by the jsdom smoke test. ## Follow-ups / notes - The undo tooltip's step word ("save", "transfer") comes from the server's git snapshot subject as data, without a code, so it stays English until the server codes it. - The form's at-rest `errors` map (`formDescription.errors`) carries no codes on the wire, so it stays English for now. - `joinNatural` keeps "A, B and C" (no serial comma) through `{items|list} {conj} {last}` keys. `Intl.ListFormat` would change the English. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The editor SPA gains web/src/i18n: t() over flat JSON catalogs with
Intl plural rules, date, relative-time and list formatters, a Solid
provider, and a boot step that resolves the UI language from GET
/api/site's new acceptLanguage (then navigator.languages, then English)
before the shell renders. Server errors translate by code with args,
falling back to the server's English text. Parity and leak tests guard
the catalog; README, AGENTS.md and the setup-site/verify-site skills
document the language axis.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
feat(i18n): extract every editor string into the English catalog
All checks were successful
ci / build-and-test (pull_request) Successful in 15m52s
ci / release (pull_request) Has been skipped
a65a4a811c
Every component, store and helper in web/src renders its user-visible
text through t(): 1171 catalog keys grouped by screen, plurals as
key.one/key.other, whole sentences as single parameterized keys (tRich
for sentences around an inline element), dates through Intl and sorting
with the UI locale. Server-coded fields (git reason, tree root title,
form and filter labels, badge availability) translate by code. Sidecar,
site and host labels stay as written.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Author
Owner

[autoland] verdict: pass

Verdict: CONCERNS (non-blocking). Approved, but not auto-merged, so a human can look at the two concerns below.

CI: labctl pr checks 229 is success. ci / build-and-test passed in 15m52s: npm ci && vite build && vitest run (including i18nParity, i18nLeak and i18nShell), then go vet ./... and go test ./... (including the server tests and the scaffold drift tests scaffold/skills_test.go and internal/element/agentsdoc_test.go). CI does not run tsc --noEmit; the PR says the author ran it locally.

Conventions:

  • The title follows Conventional Commits.
  • The body has Closes #225.
  • The README, scaffold/site/AGENTS.md "Languages", and the setup-site and verify-site skills are updated as the issue asked.
  • The diff stays within #225. The base is the current main, so there is no conflict.

What was checked: all 990 t()/tRich() call sites. Each key exists in en.json, and the params match the placeholders. Every removed literal was compared with its catalog value, and every plural with its old n === 1 condition. Apart from the two items below, the English output is unchanged.

Concerns

  1. Dates now always use US English format. This affects web/src/widgets/controls.tsx:723 (DateTimeReadonly) and web/src/components/SourceValue.tsx:225.

    • d.toLocaleString() became formatDateTime(d), which formats in the UI locale.
    • resolveLocale returns the catalog code, which is always "en" today. The old code used the browser's locale.
    • So en-GB, en-AU, en-IN and every non-English browser change from day-first to US month-first. For 2026-01-05 13:04:05:
      • en-GB: 05/01/2026, 13:04:05 becomes 1/5/2026, 1:04:05 PM
      • de-DE: 5.1.2026, 13:04:05 becomes 1/5/2026, 1:04:05 PM
    • Day/month dates become ambiguous. This contradicts the issue's "No visible change for English users".
    • The issue does ask for dates "in the UI locale", so this may be intended; it needs a human call.
    • A possible fix: keep the matched Accept-Language or browser tag (e.g. en-GB) as the formatting locale while using the en catalog.
    • The same applies, less visibly, to sorting at web/src/elementcrud.ts:532: compareText passes "en" where localeCompare(b, undefined, …) used the browser's collation.
  2. The upload hint lost its bold folder name. This is at web/src/components/FileBrowser.tsx:592.

    • Old: Uploads go to <b>media/{DEFAULT_UPLOAD_FOLDER}</b>
    • New: t("files.uploadHint", { folder: … }), which renders the path as plain text.
    • It should use tRich("files.uploadHint", undefined, { folder: () => <b>media/…</b> }), as the other sentences with elements do.
    • The tests only check the text, so they don't catch this.

Minor notes (not blocking)

  • formatList uses Intl.ListFormat, which throws on a non-string item where .join(", ") did not (controls.tsx ~714/764). The server converts multiselect values to strings, so this is likely unreachable.
  • MediaModule's buildFolderTree reads the root label "Media" through t() once, outside any reactive scope, so it won't update if the locale changes mid-session. The locale is fixed at boot, so this has no effect today.
[autoland] verdict: pass **Verdict: CONCERNS (non-blocking).** Approved, but not auto-merged, so a human can look at the two concerns below. **CI:** `labctl pr checks 229` is `success`. `ci / build-and-test` passed in 15m52s: `npm ci && vite build && vitest run` (including i18nParity, i18nLeak and i18nShell), then `go vet ./...` and `go test ./...` (including the server tests and the scaffold drift tests `scaffold/skills_test.go` and `internal/element/agentsdoc_test.go`). CI does not run `tsc --noEmit`; the PR says the author ran it locally. **Conventions:** - The title follows Conventional Commits. - The body has `Closes #225`. - The README, `scaffold/site/AGENTS.md` "Languages", and the `setup-site` and `verify-site` skills are updated as the issue asked. - The diff stays within #225. The base is the current `main`, so there is no conflict. **What was checked:** all 990 `t()`/`tRich()` call sites. Each key exists in `en.json`, and the params match the placeholders. Every removed literal was compared with its catalog value, and every plural with its old `n === 1` condition. Apart from the two items below, the English output is unchanged. ### Concerns 1. **Dates now always use US English format.** This affects `web/src/widgets/controls.tsx:723` (`DateTimeReadonly`) and `web/src/components/SourceValue.tsx:225`. - `d.toLocaleString()` became `formatDateTime(d)`, which formats in the UI locale. - `resolveLocale` returns the catalog code, which is always `"en"` today. The old code used the browser's locale. - So en-GB, en-AU, en-IN and every non-English browser change from day-first to US month-first. For 2026-01-05 13:04:05: - en-GB: `05/01/2026, 13:04:05` becomes `1/5/2026, 1:04:05 PM` - de-DE: `5.1.2026, 13:04:05` becomes `1/5/2026, 1:04:05 PM` - Day/month dates become ambiguous. This contradicts the issue's "No visible change for English users". - The issue does ask for dates "in the UI locale", so this may be intended; it needs a human call. - A possible fix: keep the matched Accept-Language or browser tag (e.g. `en-GB`) as the formatting locale while using the `en` catalog. - The same applies, less visibly, to sorting at `web/src/elementcrud.ts:532`: `compareText` passes `"en"` where `localeCompare(b, undefined, …)` used the browser's collation. 2. **The upload hint lost its bold folder name.** This is at `web/src/components/FileBrowser.tsx:592`. - Old: `Uploads go to <b>media/{DEFAULT_UPLOAD_FOLDER}</b>` - New: `t("files.uploadHint", { folder: … })`, which renders the path as plain text. - It should use `tRich("files.uploadHint", undefined, { folder: () => <b>media/…</b> })`, as the other sentences with elements do. - The tests only check the text, so they don't catch this. ### Minor notes (not blocking) - `formatList` uses `Intl.ListFormat`, which throws on a non-string item where `.join(", ")` did not (`controls.tsx` ~714/764). The server converts multiselect values to strings, so this is likely unreachable. - MediaModule's `buildFolderTree` reads the root label "Media" through `t()` once, outside any reactive scope, so it won't update if the locale changes mid-session. The locale is fixed at boot, so this has no effect today.
fix(i18n): format dates under the person's regional tag; bold the upload folder again
All checks were successful
ci / build-and-test (pull_request) Successful in 16m16s
ci / release (pull_request) Has been skipped
1297f00d1e
Review of #229 found two regressions in the English output:

- Dates, lists and sorting ran under the catalog code ("en"), so every
  non-US English browser (en-GB, en-AU, en-IN) lost its day-first dates and
  24-hour times. The runtime now keeps a second signal, the formatting
  locale: the first Accept-Language tag, then browser tag, in the chosen
  language that names a region, canonicalized by Intl ("en-GB"); the chosen
  language itself when there is none. The bare code deckle-hosted sends for
  the person's choice is matched for the language while the browser's
  en-GB still formats. A tag Intl rejects falls back rather than throwing
  on every date. resolveLocale returns {code, formatLocale}; I18nProvider,
  applyLocale and setLocale carry both.

- The upload hint rendered its folder as plain text; it is tRich with the
  <b> slot again, and the MediaPicker test checks the bold part.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Author
Owner

Addressed both review concerns in 1297f00.

1. Dates in US format for every non-US English browser — fixed. The runtime now carries a second signal, the formatting locale, which the Intl formatters (dates, relative times, lists, {x|list}, collation) run under while lookups keep using the catalog code. resolveLocale returns {code, formatLocale}: the formatting locale is the first Accept-Language tag, then browser tag, in the chosen language that names a region, canonicalized by Intl (en-GB); the chosen language itself when there is none. Two details beyond the suggested "keep the matched tag":

  • deckle-hosted replaces the header with the bare code from the Account page (Accept-Language: en), so the matched tag alone would still have given a British user US dates. The resolver therefore looks past the matched tag to the first regional tag of that language, which is the browser's en-GB on hosted.
  • A tag Intl rejects (en-!!) falls back to the code instead of making every date throw.

The deliberate call: a German browser without a German catalog now gets English dates with its English interface (1/5/2026, 1:04:05 PM), where toLocaleString() printed 5.1.2026, 13:04:05 before. That matches the issue's "dates in the UI locale" and keeps the English UI internally consistent; it is one line in the README ("Dates, lists and sorting") if it should go the other way. compareText follows the same rule.

2. Upload hint lost its bold folder — fixed with tRich and a <b> slot; the MediaPicker test now checks the bold part.

The minor notes stand as they are: formatList only ever sees string[] (FormValue), and the locale is fixed at boot so buildFolderTree's one-time t() is correct today.

Verified: tsc --noEmit, vitest run (116 files, 2851 tests), go test ./scaffold/... ./internal/element/....

Addressed both review concerns in 1297f00. **1. Dates in US format for every non-US English browser** — fixed. The runtime now carries a second signal, the *formatting locale*, which the Intl formatters (dates, relative times, lists, `{x|list}`, collation) run under while lookups keep using the catalog code. `resolveLocale` returns `{code, formatLocale}`: the formatting locale is the first Accept-Language tag, then browser tag, in the chosen language that names a region, canonicalized by Intl (`en-GB`); the chosen language itself when there is none. Two details beyond the suggested "keep the matched tag": - deckle-hosted replaces the header with the bare code from the Account page (`Accept-Language: en`), so the matched tag alone would still have given a British user US dates. The resolver therefore looks past the matched tag to the first regional tag of that language, which is the browser's `en-GB` on hosted. - A tag Intl rejects (`en-!!`) falls back to the code instead of making every date throw. The deliberate call: a German browser without a German catalog now gets English dates with its English interface (`1/5/2026, 1:04:05 PM`), where `toLocaleString()` printed `5.1.2026, 13:04:05` before. That matches the issue's "dates in the UI locale" and keeps the English UI internally consistent; it is one line in the README ("Dates, lists and sorting") if it should go the other way. `compareText` follows the same rule. **2. Upload hint lost its bold folder** — fixed with `tRich` and a `<b>` slot; the MediaPicker test now checks the bold part. The minor notes stand as they are: `formatList` only ever sees `string[]` (`FormValue`), and the locale is fixed at boot so `buildFolderTree`'s one-time `t()` is correct today. Verified: `tsc --noEmit`, `vitest run` (116 files, 2851 tests), `go test ./scaffold/... ./internal/element/...`.
Author
Owner

[autoland] verdict: pass

This was generated by AI while landing a PR.

Verdict: PASS.

Signal relied on: labctl pr checks 229 is success on the head commit 1297f00 (ci / build-and-test, 16m16s: web build, vitest incl. i18nParity/i18nLeak/i18nShell, go vet and go test ./... incl. the scaffold drift tests). Nothing re-run.

Conventions: Conventional Commits title; body carries Closes #225; README, scaffold/site/AGENTS.md "Languages", and the setup-site / verify-site skills are updated as the issue requires. Head is at the current main, no conflict.

Diff: autoland's review of the first two commits (all t()/tRich() call sites) stands. The fix commit 1297f00 resolves both of its concerns: a separate formatting locale (web/src/i18n/translate.ts resolveLocale → {code, formatLocale}) keeps en-GB/en-AU/en-IN day-first dates and 24-hour times while lookups stay on the catalog code, an Intl-rejected tag falls back to the code instead of throwing, and the upload hint is tRich with its <b> slot again (web/src/components/FileBrowser.tsx:593). Unit, shell and MediaPicker tests cover each case, including the hosted bare-en header with an en-GB browser.

Note (non-blocking): a German browser without a German catalog now gets US-English dates with the English UI, where toLocaleString() printed German dates before. This is the author's deliberate call, documented in the README under "Dates, lists and sorting", and matches the issue's "dates in the UI locale".

[autoland] verdict: pass > *This was generated by AI while landing a PR.* **Verdict: PASS.** **Signal relied on:** `labctl pr checks 229` is `success` on the head commit 1297f00 (`ci / build-and-test`, 16m16s: web build, vitest incl. i18nParity/i18nLeak/i18nShell, `go vet` and `go test ./...` incl. the scaffold drift tests). Nothing re-run. **Conventions:** Conventional Commits title; body carries `Closes #225`; README, `scaffold/site/AGENTS.md` "Languages", and the `setup-site` / `verify-site` skills are updated as the issue requires. Head is at the current `main`, no conflict. **Diff:** autoland's review of the first two commits (all `t()`/`tRich()` call sites) stands. The fix commit 1297f00 resolves both of its concerns: a separate formatting locale (`web/src/i18n/translate.ts` `resolveLocale` → `{code, formatLocale}`) keeps en-GB/en-AU/en-IN day-first dates and 24-hour times while lookups stay on the catalog code, an Intl-rejected tag falls back to the code instead of throwing, and the upload hint is `tRich` with its `<b>` slot again (`web/src/components/FileBrowser.tsx:593`). Unit, shell and MediaPicker tests cover each case, including the hosted bare-`en` header with an `en-GB` browser. **Note (non-blocking):** a German browser without a German catalog now gets US-English dates with the English UI, where `toLocaleString()` printed German dates before. This is the author's deliberate call, documented in the README under "Dates, lists and sorting", and matches the issue's "dates in the UI locale".
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/deckle!229
No description provided.