# Languages: how S9 is built

What S9 built (plan CORE §3.9; handbook §11, new in the family, 24 October 2026) and how later slices use it. The rule
it serves: **every word a person reads is a translation key, and every date, number and money is written by one
formatter, in the reader's language and time zone.** English is the only language shipped; the machinery for the
others is whole.

## The pieces

| Piece | What it does |
|---|---|
| `Languages` | A language is a folder, `lang/<code>`, holding every file `lang/en` has. `folders()`, `on()` (English always), `isOn()`, `direction($code)` (its own `shell.direction`), `completeness($code)` (`{done, total, missing}`: every key, every Input Fields label, every template's subject and message), `complete()`, `englishName()`, `keys()` (strings only) |
| `Region` | `locale(?User, ?Tenant)`: the person's, else the workspace's, else `en`, only a switched-on one; `timeZone()`: the person's, else the workspace's, else UTC; `currency()`: the workspace's; `zones()` |
| `SetLocale` | Web middleware after `ResolveTenant`: `app()->setLocale(Region::locale($user))` |
| `Format` | The one formatter: `date`, `time`, `dateTime` (a zone and a language may be given, for mail), `number`, `money` (the workspace's currency unless given; none: the amount with two decimals), `for($user, $tenant)`, `intl($locale)` (English as `en-GB`, day first; a code Intl has no formats for writes as English) |
| `resources/js/lib/format.ts` | Its twin for the pages: `formatDate` (a date alone is that day anywhere), `formatDateTime`, `formatTime`, `formatNumber`, `formatMoney`, from the shared `region` prop |
| The `region` prop | `{locale, intl, timeZone, currency, direction}`, shared by `HandleInertiaRequests`; `<html lang dir>` from the same |
| The `language` list | Input Fields, kind `reference`: switched on and off, never added on a page. `lookups:sync` adds a value for each folder, off, named in English by Intl and in its own words by its `lookup.php`; English locked on |
| The switch | `InputFieldEditor::setActive`: a language goes on only when complete (`language.error.incomplete`), whether from Languages or Input Fields |
| Mail | `Recipient::locale` (the person's, when switched on); `ResolvedRecipients::byLocale($fallback)`; `Dispatcher` writes one e-mail per language, each its own row, rendered in that language (`inLocale`) |
| `lang:pseudo` | Writes `lang/xx`, every English word accented and bracketed (`⟦Ŝîĝñ îñ⟧`), placeholders kept; `--rtl` right to left, `--remove`. Never committed |
| The i18n bootstrap | `resources/js/app.ts` loads the page's words (and English) before the first render, so a component that reads a word once never keeps a key |

## The tables

| Table | What S9 added |
|---|---|
| `user` | `time_zone` (nullable: the workspace's). `locale` was there (S2) |
| `tenant` | Nothing: `locale` (`en`), `time_zone` (`UTC`), `currency` (null) were there (S1) |

## Pages

| Page | Route | Permission |
|---|---|---|
| Language and region (yours) | `/me/region` | none: your own (`PermissionGateTest` lists it) |
| Regional (the workspace's) | `/setting/regional`; a panel on Settings; the country (S18, `country` in Input Fields) fills the rest; saved by `UpdateRegion`, as the first-run wizard does | `setting.view`; saving `setting.manage` |
| Languages (the Service's) | `/console/language`, `PATCH …/{value}/active` | `lookup.view`, `lookup.manage` |

The time zone is picked with `PickSearch` (a searchable combobox: four hundred zones); short lists with `PickOne`.
Each language is offered in its own words (Français, not French).

## Writing for every language

- A word is a key: `__('file.key')` in PHP, `$t('file.key')` in Vue. A sentence is one key with placeholders
  (`:name` in both), never words joined in code: word order differs.
- A date, number or money goes through `Format` or `format.ts`, never `->format('d M')`, `number_format`,
  `toLocaleString` or `new Intl.`. A machine format (`Y-m-d`, an ISO string) is not for a person and stays.
- Layout is logical: `ms`/`me`, `ps`/`pe`, `start`/`end`, `text-start`, `border-s`, `rounded-s`, `inset-inline-*`;
  a panel slides from the start (`slide-in-from-start`). The page's `dir` turns it around.
- Stored words (Input Fields labels, templates) are translated on their pages, per language.

## Adding a language

1. `lang/<code>`: every file `lang/en` has, translated; `shell.direction` (`ltr`, `rtl`); its own name in `lookup.php`
   (`language.values.<code>`).
2. Deploy: `lookups:sync`, `notifications:sync`, `npm run build` (the pages' words are compiled from `lang/`).
3. The Service translates what the folder doesn't carry, then switches it on (Platform › Languages).

## The guards

| Test | Proves |
|---|---|
| `Guards/LanguageGuardTest` | one formatter (PHP and pages); every key named exists in `lang/en`; no words joined to a translation; logical layout only (classes, CSS, a panel's slide); every switched-on language complete; its self-test |
| `Guards/NoLiteralTextTest` (S0) | every word a person reads is a key |
| `Languages/PseudoLanguageTest` | the done-when: as someone reading `xx`, every page a member or the Service opens renders (a page failing is English left too), and no prop holds English; switched on only when complete, English never off; the person's language, else the workspace's; `dir="rtl"` |
| `Languages/RegionTest` | the formatter's output; the order language and zone are chosen in; the three pages; each language named in its own words; mail in each recipient's language, one row per language |

`pseudoLanguage($test)` (tests/Pest.php) makes `xx` in a copy of `lang/` for the test process alone, so tests run in
parallel never remove each other's folder.
