# System: i18n & Localization

**Date:** 2026-05-30  
**Status:** Draft  
**Depends on:** `foundation-monorepo`, `foundation-auth-rbac`  
**Referenced by:** `app-shell`, `settings-module`, all UI specs

---

## Overview

Internationalization framework covering language switching (Hebrew/English first), RTL layout direction, country-specific adapters (starting with Israel), and the IL VAT rate history table.

---

## Languages

v1 languages: **English (en)** and **Hebrew (he)**. Architecture must allow adding new languages without code changes — only new translation files.

Language is a per-user preference (stored in `user_preferences.locale`). Falls back to tenant default → browser Accept-Language → `en`.

---

## Architecture

### Translation files

Stored as JSON per language under `packages/ui/src/i18n/`:

```
packages/ui/src/i18n/
├── en.json
├── he.json
└── index.ts   # exports { translations, supportedLocales }
```

No code-splitting per namespace — single file per locale. Revisit when file > 100KB (unlikely in v1).

### i18n library: `react-i18next`

- `i18next` + `react-i18next` — industry standard, small, tree-shakeable
- Initialized once in `zync-app/src/i18n.ts`, provider wraps app root
- Key format: `module.component.key`, e.g. `tasks.board.emptyState`
- Pluralization: use `_one` / `_other` suffixes
- Interpolation: `{{variable}}` syntax

### Hebrew Pluralization (5 CLDR forms required)

Hebrew has 5 CLDR plural categories: `zero`, `one`, `two`, `many`, `other`. English has 2 (`one`, `other`). All count-bearing translation keys must define all 5 Hebrew forms.

```json
// he.json — 5 forms required for any key using {{ count }}
{
  "tasks_count": {
    "zero":  "אין משימות",
    "one":   "משימה אחת",
    "two":   "שתי משימות",
    "many":  "{{count}} משימות",
    "other": "{{count}} משימות"
  }
}
```

```json
// en.json — standard 2 forms (flat key suffix format)
{
  "tasks_count_one":   "1 task",
  "tasks_count_other": "{{count}} tasks"
}
```

```ts
// Usage — identical call for both locales:
t('tasks_count', { count: n })
```

CLDR semantics for Hebrew:
- `many`: 10–19, 100, 1000, 10000, 100000, 1000000, … (round numbers in Hebrew grammar)
- `other`: 3–9, 20–99, and all other values not matched by the above

Translation governance extension: a PR that adds an English count key without all 5 Hebrew plural forms is rejected in review. The same-PR rule extends to plural key completeness.

### Provider setup (`zync-app/src/i18n.ts`)

```ts
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import en from '@zync/ui/i18n/en.json'
import he from '@zync/ui/i18n/he.json'

i18n.use(initReactI18next).init({
  resources: { en: { translation: en }, he: { translation: he } },
  lng: getUserLocale(),     // from user preferences or localStorage
  fallbackLng: 'en',
  interpolation: { escapeValue: false },
})
```

### RTL direction

On locale change: `document.documentElement.lang = locale; document.documentElement.dir = locale === 'he' ? 'rtl' : 'ltr'`

When `lang="he"`, the design-system typography tokens switch `--font-sans` to Heebo (`'Heebo Variable', 'Heebo', Arial, sans-serif`). English and fallback locales use the design-system Latin stack: Fraunces for display and IBM Plex Sans for product text.

All components use logical CSS properties (see design-system spec RTL section). Tailwind config includes `@tailwindcss/typography` with RTL support.

---

## Country Adapters

A country adapter encapsulates locale-specific business rules. v1 adapter: **Israel**.

```ts
// packages/types/src/country-adapter.ts
interface CountryAdapter {
  code: string                          // ISO 3166-1 alpha-2
  name: string
  defaultCurrency: string               // ISO 4217
  defaultTimezone: string
  getVatRate(date: Date): number        // returns rate as decimal (e.g. 0.18)
  vatLabel: string                      // "מע"מ" for IL, "VAT" for others
  invoiceRequirements: InvoiceRequirements
}
```

`packages/db/src/queries/country.ts` loads the adapter for a tenant based on `tenant.country_code`. Country code set during tenant onboarding, editable in business settings.

---

## Israel VAT Rate History

Stored in a seeded DB table, not hardcoded. Allows adding future rate changes without code deploys.

### Table

```sql
CREATE TABLE vat_rates (
  country_code TEXT NOT NULL,
  effective_from DATE NOT NULL,
  rate NUMERIC(5, 4) NOT NULL,  -- e.g. 0.1800 for 18%
  PRIMARY KEY (country_code, effective_from)
);
```

### Israel VAT seed data

| Date | Rate |
|------|------|
| 1976-07-01 | 8.00% |
| 1977-11-01 | 12.00% |
| 1982-08-01 | 15.00% |
| 1985-06-01 | 17.00% |
| 1985-10-01 | 15.00% |
| 1990-03-01 | 16.00% |
| 1991-01-01 | 18.00% |
| 1993-01-01 | 17.00% |
| 2002-06-15 | 18.00% |
| 2004-03-01 | 17.00% |
| 2005-09-01 | 16.50% |
| 2006-07-01 | 15.50% |
| 2009-07-01 | 16.50% |
| 2010-01-01 | 16.00% |
| 2012-09-01 | 17.00% |
| 2013-06-02 | 18.00% |
| 2015-10-01 | 17.00% |
| 2025-01-01 | 18.00% |

### VAT rate lookup

```ts
// packages/db/src/queries/vat.ts
export async function getVatRate(db: DB, countryCode: string, date: Date): Promise<number> {
  const row = await db
    .select({ rate: vatRates.rate })
    .from(vatRates)
    .where(
      and(
        eq(vatRates.countryCode, countryCode),
        lte(vatRates.effectiveFrom, date)
      )
    )
    .orderBy(desc(vatRates.effectiveFrom))
    .limit(1)
  return Number(row[0]?.rate ?? 0)
}
```

Used by invoice generation, expense categorization, and tax reports.

---

## Settings UI

`/settings/locale`:
- **Site Language** — dropdown: English / Hebrew (+ any future locales)
- **Country** — dropdown: Israel (v1 only). Drives VAT rules, currency, invoice requirements.

Language change: instant (no reload). Direction change: CSS transition on `html[dir]` change.

Stored in `user_preferences` table (per-user, not per-tenant — each user picks their own language).

---

## Translation Governance

- All user-visible strings use `t('...')` — no hardcoded English strings in JSX
- Translation keys are typed via codegen (`i18next-resources-for-ts`) — missing keys are TypeScript errors
- New components must add keys to both `en.json` and `he.json` in the same PR
- PRs without Hebrew translations for new strings are blocked (same PR requirement, enforced by review)

---

## Non-goals

- No server-side translation (API error messages are error codes, translated client-side)
- No namespace splitting in v1 (single file per locale)
- No automatic machine translation — all translations are manual
- No right-to-left auto-detection (explicit `he` selection sets RTL)

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| i18n library | react-i18next | Standard, maintained, typed keys with codegen |
| Translation storage | JSON files in packages/ui | Co-located with components; no DB round-trip for strings |
| VAT rates | DB table `vat_rates` (seeded) | Future rate changes via admin UI (`/admin/tax-rates`), no code deploy needed |
| All other IL tax rates | DB table `tax_rates` (spec 8) | Corporate, personal income brackets, withholding — all rate changes via admin UI; reports/payouts query DB, no hardcoded percentages |
| RTL strategy | CSS logical properties | Works with Tailwind `ms-/me-` utilities; no JS layout recalc |
| Locale scope | Per-user preference | Teams may have mixed language preferences |
