UX Standards — 19: Internationalization (i18n) Standards
Governs: ALL aspects of internationalization — UI strings, formatting, dynamic content, inbound/outbound communications, language preferences, and translation infrastructure.
Parent rules: See 00-OVERVIEW-AND-CSS-RULES.md first.
Prime Directive
Every visible string in the application MUST pass through the
t()function or a locale-aware formatter. No exceptions.
This includes: page titles, subtitles, navigation labels, button text, column headers, table cell values, placeholder text, tooltip text, error messages, toast notifications, badge labels, empty states, modal content, form field labels, validation messages, loading states, accessibility text (aria-label, sr-only), and status indicators.
Architecture Overview
The system uses two translation modes that serve different content categories. Choosing the wrong mode is a performance bug.
┌─────────────────────────────────────────────────────────────────────────┐
│ TRANSLATION DECISION TREE │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Is the text authored by developers (UI chrome)? │
│ ──────────────────────────────────────────────── │
│ YES → Mode 1: Static JSON Files │
│ nav labels, button text, column headers, error messages, │
│ form labels, page titles, status badges, empty states │
│ │
│ Is the text authored at runtime (user/external data)? │
│ ──────────────────────────────────────────────── │
│ YES → Mode 2: On-the-Fly API (LibreTranslate) │
│ inbound emails, patient messages, fax content, │
│ free-text search results, document field values, │
│ provider descriptions, user-generated notes │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Mode 1 — Static JSON Files (UI Chrome)
What it covers
Every string a developer writes in code:
- Navigation items, breadcrumbs, section headings
- Button labels, link text, tab labels
- Table column headers, filter labels, sort labels
- Form field labels, placeholder text, help text
- Error messages, validation messages
- Toast messages, notification titles
- Empty state titles and descriptions
- Dialog/modal titles, body text, CTA buttons
- Loading/skeleton labels
- Accessibility strings (
aria-label,sr-only,alttext) - Status badge labels (Active, Inactive, Pending, etc.)
- Date/time format labels
- Footer text, policy links
File Structure
packages/shared/lib/i18n/
├── constants.ts ← SUPPORTED_LANGUAGES array + DEFAULT_SETTINGS
├── types.ts ← TypeScript types for the localization system
├── formatters.ts ← Intl.DateTimeFormat / NumberFormat / RelativeTimeFormat
├── translation-service.ts ← On-the-fly translation client (Mode 2)
├── index.ts ← Barrel exports
└── locales/
├── en/ ← English (source of truth — loaded synchronously)
│ ├── index.ts ← Barrel that merges all 32 JSON files into flat map
│ ├── common.json ← Shared UI (buttons, status words, common labels)
│ ├── nav.json ← Navigation items
│ ├── communications.json ← Email, WhatsApp, calendar UI strings
│ ├── operations.json ← Booking, tour operations UI
│ ├── crm.json ← Customer relationship management UI
│ ├── channels.json ← Channel distribution UI
│ ├── dashboard.json ← Dashboard widgets
│ ├── forms.json ← Shared form patterns
│ ├── settings.json ← Settings page strings
│ ├── users.json ← User management UI
│ ├── workflows.json ← Workflow builder UI
│ ├── orchestration.json ← N8N orchestration UI
│ ├── onboarding.json ← Onboarding wizard
│ ├── scheduling.json ← Scheduling UI
│ ├── masterData.json ← Master data management
│ ├── accounting.json ← Accounting module
│ ├── commerce.json ← Commerce / storefront UI
│ ├── buying.json ← Purchase/procurement UI
│ ├── selling.json ← Sales UI
│ ├── stock.json ← Inventory UI
│ ├── insights.json ← Analytics/reporting UI
│ ├── platformAdmin.json ← Platform admin UI
│ ├── popup.json ← All popup window strings (Anna, workflow builder)
│ ├── selectionBuilder.json ← Advanced filter/selection UI
│ ├── knowledge.json ← Knowledge base UI
│ ├── wiki.json ← Wiki module UI
│ ├── blog.json ← Blog module UI
│ ├── reviews.json ← Reviews UI
│ ├── media.json ← Media/asset management UI
│ ├── pageBuilder.json ← Page builder UI
│ ├── waivers.json ← Waiver management UI
│ └── orch.json ← Core orchestrate-specific strings
├── ja/ ← Japanese (same structure as en/)
├── zh/ ← Simplified Chinese
├── zh-TW/ ← Traditional Chinese
├── ko/ ← Korean
├── th/ ← Thai
├── vi/ ← Vietnamese
├── de/ ← German
├── fr/ ← French
└── es/ ← Spanish
Key Naming Convention
All keys use dotted namespace notation: {domain}.{scope}.{description}
{
"common.save": "Save",
"common.cancel": "Cancel",
"common.loading": "Loading...",
"common.noResults": "No results found",
"table.showing": "Showing {{from}}-{{to}} of {{total}}",
"error.serverError": "Server error. Please try again later.",
"auth.login": "Log in",
"header.askAnna": "Ask Anna AI",
"communications.email.reply": "Reply",
"communications.anna.translatedFrom": "Translated from {{language}}",
"helpdesk.form.subject": "Subject"
}
Rules:
- All keys are flat strings (no nested JSON objects)
- Domain prefix matches the file:
common.*lives incommon.json,communications.*incommunications.json - Keys are camelCase within each segment
- Keys describe the content, not the UI element (
common.savenotcommon.saveButton) - Never use the English text as a key — always use a semantic key
Parameter Interpolation
Use {{paramName}} syntax for dynamic values:
{
"table.showing": "Showing {{from}}-{{to}} of {{total}}",
"common.andMore": "and {{count}} more",
"toast.importedRecords": "Imported {{count}} records",
"auth.sessions.created": "Created {{date}}",
"notifications.unreadCount": "{{count}} unread"
}
// In component:
t('table.showing', { from: 1, to: 25, total: 342 })
// Returns: "Showing 1-25 of 342"
t('common.andMore', { count: 5 })
// Returns: "and 5 more"
Rules:
- Parameter names are camelCase inside
{{...}} - Numbers, strings, and dates can all be passed as params
- For pluralization, use separate keys:
email.syncedEmail/email.syncedEmailPlural
Mode 2 — On-the-Fly Translation (Dynamic Content)
What it covers
Content that arrives at runtime from external sources:
- Inbound emails from patients/providers in foreign languages
- WhatsApp/SMS messages in foreign languages
- Fax content scanned/OCR'd in foreign languages
- Free-text fields entered by users in their native language
- Document names, descriptions, notes from external systems
- Search result snippets containing user-generated content
How it works
Browser (client) Next.js (server) On-prem Services
───────────────── ───────────────── ─────────────────
t_dynamic(text, locale) → POST /api/translate → Frappe BFF
↓ { texts[], targetLocale } anshin_orchestrate.
Check L1 memory cache ↓ services.translation
→ Check L2 localStorage Check L3 server memory .translate
→ Batch (100ms window) → Call Frappe ↓
→ Resolve promise → Parse response LibreTranslate
→ Cache in memory (on-prem, port 5000)
→ Return translations
Cache layers (L1 → L4):
| Layer | Location | TTL | Scope |
|---|---|---|---|
| L1 | JavaScript Map in browser memory | Session (until refresh) | Per tab |
| L2 | localStorage key i18n_cache_v1_{locale} | 7 days | Per browser |
| L3 | Next.js server Map | 24 hours | Per pod instance |
| L4 | LibreTranslate internal cache | — | Network-wide |
TODO (production): L3 cache must be migrated from in-memory
Mapto DragonflyDB (Redis-compatible) for multi-pod environments. Current in-memory cache is per-pod and not shared.
Batching
Client-side batching prevents N API calls for N strings on a page:
String 1 arrives → add to pending batch → set 100ms timeout
String 2 arrives → add to pending batch (same timeout running)
String 3 arrives → add to pending batch
...100ms elapses...
ALL pending strings → single POST /api/translate with texts[] array (max 50 per call)
import { translateText, translateTexts, preloadTranslations } from '@/lib/i18n/translation-service';
// Single text (auto-batched with others in same 100ms window)
const translated = await translateText('Booking confirmed', 'ja');
// Multiple texts (single API call)
const results = await translateTexts(['Hello', 'Goodbye', 'Thank you'], 'ja');
// Returns: { 'Hello': 'こんにちは', 'Goodbye': '...', 'Thank you': '...' }
// Preload for a known set (call on page load to warm cache)
await preloadTranslations(['Status', 'Priority', 'Subject', 'From'], 'ja');
When to use preloading
Call preloadTranslations() at the top of pages that display large lists of dynamic content:
// In a page component that lists customer emails:
useEffect(() => {
if (language !== 'en') {
preloadTranslations(
emails.map(e => e.subject), // All email subjects
language
);
}
}, [emails, language]);
Error behavior
On API failure, the original English text is returned unchanged. The UI never shows translation errors — it falls back gracefully. This means a partially-translated page is always preferable to a broken page.
API error → return original text (English passthrough)
localStorage unavailable → use memory cache only
LibreTranslate down → return original text
Implementing t() in Every File
Step 1 — Import the hook
Every component that displays text MUST import and destructure:
'use client';
import { useTranslation } from '@/lib/providers/localization-provider';
export function MyComponent() {
const { t } = useTranslation();
// ...
}
For components that also need formatting or the current language:
import { useLocalization } from '@/lib/providers/localization-provider';
export function MyComponent() {
const { t, language, formatDate, formatNumber, formatCurrency } = useLocalization();
// ...
}
Step 2 — Wrap every visible string
// ❌ WRONG — hardcoded string
<Button>Save</Button>
<h1>Booking Dashboard</h1>
<p>No results found</p>
<span aria-label="Close">✕</span>
// ✅ CORRECT
<Button>{t('common.save')}</Button>
<h1>{t('operations.bookingDashboard.title')}</h1>
<p>{t('common.noResults')}</p>
<span aria-label={t('common.close')}>✕</span>
Step 3 — Strings in ALL locations
export function BookingList() {
const { t } = useTranslation();
// Column definitions — MUST be translated
const columns = [
{ header: t('operations.booking.columns.reference'), ... },
{ header: t('operations.booking.columns.customer'), ... },
{ header: t('operations.booking.columns.status'), ... },
];
// Placeholder text
<Input placeholder={t('operations.booking.searchPlaceholder')} />
// Toast messages — MUST be translated
toast.success(t('toast.saved'));
toast.error(t('error.serverError'));
// Empty state
<p>{t('operations.booking.emptyTitle')}</p>
<p>{t('operations.booking.emptyDescription')}</p>
// Accessibility
<Button aria-label={t('common.delete')} />
<span className="sr-only">{t('common.loading')}</span>
// Dialog confirm text
<AlertDialogTitle>{t('common.confirm')}</AlertDialogTitle>
<AlertDialogDescription>
{t('operations.booking.deleteConfirm', { name: booking.name })}
</AlertDialogDescription>
Step 4 — Dates, numbers, currency
NEVER format dates or numbers manually. Always use the locale-aware formatters.
const { formatDate, formatTime, formatDateTime, formatRelativeTime, formatNumber, formatCurrency } = useLocalization();
// ❌ WRONG
<span>{new Date(booking.date).toLocaleDateString()}</span>
<span>{booking.amount.toFixed(2)}</span>
<span>¥{booking.price}</span>
// ✅ CORRECT
<span>{formatDate(booking.date)}</span>
<span>{formatNumber(booking.amount, 2)}</span>
<span>{formatCurrency(booking.price, 'JPY')}</span>
<span>{formatRelativeTime(booking.created_at)}</span>
<span>{formatDateTime(booking.updated_at)}</span>
The formatters use Intl.DateTimeFormat and Intl.NumberFormat — they respect the user's locale, timezone, date format, and number format preferences set in Frappe.
Step 5 — Adding a new key
When you need a new translation string:
- Add the key to the appropriate English JSON file in
packages/shared/lib/i18n/locales/en/ - Use a semantic, namespaced key following existing conventions
- Add the key to ALL other locale files (same key, translated value or placeholder)
- Use the key in your component via
t('your.new.key')
// packages/shared/lib/i18n/locales/en/operations.json
{
"operations.booking.cancelTitle": "Cancel Booking",
"operations.booking.cancelConfirm": "Are you sure you want to cancel booking {{reference}}? This cannot be undone.",
"operations.booking.cancelSuccess": "Booking {{reference}} has been cancelled"
}
// packages/shared/lib/i18n/locales/ja/operations.json
{
"operations.booking.cancelTitle": "予約をキャンセルする",
"operations.booking.cancelConfirm": "予約 {{reference}} をキャンセルしてもよろしいですか?この操作は元に戻せません。",
"operations.booking.cancelSuccess": "予約 {{reference}} がキャンセルされました"
}
Fallback behavior: If a key is missing from the non-English locale file, the English value is returned automatically. This means a partially-translated page always works.
Language Loading Strategy
English (pre-loaded, synchronous)
English translations are bundled into the main JavaScript chunk and available immediately — zero loading time, no async:
// localization-provider.tsx
import enTranslations from '@/lib/i18n/locales/en';
const [translations, setTranslations] = React.useState(enTranslations); // pre-loaded
Non-English (lazy, code-split)
All other locales are loaded on-demand via dynamic import() when the user changes language. This keeps the initial bundle small:
// Triggered when settings.language changes from 'en'
const localeModule = await import(`@/lib/i18n/locales/${settings.language}`);
setTranslations(localeModule.default);
This means:
- Each locale is a separate webpack chunk (~50-200KB per locale, gzipped)
- First language switch has a ~200-500ms delay while the chunk loads
- Subsequent switches are instant (chunk already cached by browser)
isLoading state
The isLoading flag from useLocalization() is true during:
- Fetching the user's Frappe settings on mount
- Loading a new locale chunk after language switch
Disable interactive elements during loading:
const { isLoading } = useLocalization();
<Button disabled={isLoading}>
{isLoading ? <Loader2 className="animate-spin" /> : t('common.save')}
</Button>
Language Preference Storage
Priority chain (highest to lowest)
1. Frappe User DocType: userData.language ← persisted server-side (authenticated users)
2. localStorage: preferred_language ← pre-login persistence (all users)
3. DEFAULT_SETTINGS.language = 'en' ← hard default
Database storage — Frappe User DocType
The user's language preference is stored in the Frappe User DocType on the language field. This means:
- Language persists across devices (tied to account, not browser)
- Available server-side for backend communications
- Synced automatically on login
// Setting language (saves to both localStorage AND Frappe)
const { setLanguage } = useLocalization();
await setLanguage('ja');
// 1. localStorage.setItem('preferred_language', 'ja')
// 2. PUT /api/frappe/User/{email} { language: 'ja' }
// 3. Triggers locale chunk lazy-load
// 4. UI re-renders in Japanese
Pre-login language
The localStorage: preferred_language key allows the login page itself to be displayed in the user's preferred language before authentication. This is critical for users who don't speak English — they should never see an English login page.
// The LocalizationProvider reads localStorage on mount before authentication
const [localLanguage, setLocalLanguage] = React.useState<LanguageCode>(() => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem('preferred_language');
if (stored && SUPPORTED_LANGUAGES.some((l) => l.code === stored)) {
return stored as LanguageCode;
}
}
return DEFAULT_SETTINGS.language;
});
Additional localization settings stored in Frappe User
Beyond just language, the full locale profile includes:
| Frappe Field | Default | Purpose |
|---|---|---|
language | en | UI language |
time_zone | Asia/Tokyo | Timestamp display |
date_format | yyyy-MM-dd | Date rendering |
time_format | HH:mm | Time rendering (24h or 12h) |
number_format | #,###.## | Number display |
currency | USD | Default currency |
first_day_of_week | Monday | Calendar display |
Patient, Provider, and Document Language
Language preference fields (data model)
Every entity that communicates should carry language preference data:
| Entity | Frappe DocType | Language Field | Purpose |
|---|---|---|---|
| App user | User | language | UI language + email/notification language |
| Customer / Patient | Customer | language (custom field) | Language to use in outbound to this customer |
| Provider | Supplier / Provider | language (custom field) | Language for provider communications |
| Inbound document | Email Message, Communication | detected_language | Language Anna detected in the inbound message |
| Booking | Booking | customer_language (from Customer) | Language for booking confirmations |
Language detection on inbound messages
When an email, WhatsApp message, SMS, or fax arrives, Anna's classification pipeline automatically detects the source language as part of processing:
Inbound message received
↓
Anna: Detect language (LibreTranslate /detect endpoint)
↓
Store: Communication.detected_language = 'ja'
↓
Translate body to English for classification
↓
Anna: Classify, extract booking data, suggest response
↓
UI: Show "Translated from Japanese" badge
↓
Operator: Reviews translated version
↓
Anna: Generate response in ORIGINAL language (ja)
The i18n key communications.anna.translatedFrom = "Translated from {{language}}" is shown in the Communications UI whenever Anna translates an inbound message.
Responding in the correct language
Rule: All outbound replies MUST be generated in the sender's detected language, not in the operator's UI language.
Inbound email: Japanese
Operator UI language: English
→ Anna's suggested response: Japanese
→ "Generate Response" output: Japanese
→ Sent email: Japanese
This is a hard constraint enforced by Anna's response generation:
- Anna receives
source_language = communication.detected_language - Anna generates reply in
source_language, regardless of the operator'sUser.language - Operator can see translated version for review, but sends in original language
Handling non-standard or unsupported languages
If LibreTranslate cannot detect or translate a language:
- Flag the communication as
requires_human_translation = true - Show a banner: "This message is in an unsupported language. Human translation required."
- Do NOT attempt to auto-classify or auto-respond
- Assign to a human operator for manual handling
Email / SMS / Fax — Language Standards
Outbound template language selection
Email and SMS templates support multiple language variants. The language is selected at send time based on:
Priority:
1. booking.customer_language (explicit preference on the booking)
2. customer.language (customer record language preference)
3. user.language (the user triggering the send)
4. 'en' (final fallback)
Template structure
Each communication template has a language-keyed set of content:
Email Template: "Booking Confirmation"
├── en: Subject + Body (English)
├── ja: Subject + Body (Japanese)
├── zh: Subject + Body (Simplified Chinese)
├── ko: Subject + Body (Korean)
└── [fallback: en]
If a template doesn't have the requested language variant, it falls back to English with an audit log entry noting the fallback occurred.
Capturing inbound language
When a message arrives (email, fax, WhatsApp, SMS):
- Anna runs language detection via LibreTranslate
/detectendpoint - Result stored on the Communication DocType:
detected_language - Confidence score stored:
language_confidence(0.0–1.0) - If
language_confidence < 0.5: flag as uncertain, default to English processing - Translated body stored:
translated_body(English translation for operator review) - Original body preserved:
body(never overwrite — legal/compliance requirement)
Fax-specific handling
Faxes arrive as scanned images. The pipeline:
- OCR converts image to text (on-prem Tesseract or similar)
- Language detection on OCR output
- If non-English: translate to English for processing
- Both OCR original and English translation stored
- Response generated in source language via Anna
- Response faxed back in source language
Performance Rules
What to include in static JSON (Mode 1)
Include any string that:
- Is authored by developers (not runtime data)
- Is the same for all users of the same locale
- Appears on every page load (navigation, buttons, headers)
Benefits: Zero latency, no API calls, bundled and tree-shaken.
What to use the API for (Mode 2)
Use the translation API only when:
- Content arrives from external systems (email subjects, message bodies)
- Content is user-generated (names, descriptions, notes entered in other languages)
- Content is fetched from third-party APIs (OTA booking titles, provider names)
Never call the translation API for static UI strings — this wastes latency, L3 cache space, and LibreTranslate capacity.
Performance budget
| Operation | Target Latency | Cache Hit Rate |
|---|---|---|
Static t() call | < 0.1ms (synchronous) | 100% (bundled) |
| First non-English locale load | < 500ms (lazy chunk) | After first switch |
| On-the-fly single text (L1 hit) | < 0.1ms | ~90% in steady state |
| On-the-fly single text (L2 hit) | < 1ms | Covers 7 days |
| On-the-fly API call (L3 hit) | < 5ms | ~95% for hot content |
| On-the-fly API call (LibreTranslate) | < 200ms | Batched at 100ms |
Cache invalidation
Clear the client translation cache when:
- User switches language (clear old language from L2)
- Application deploys a new version with updated translations (
CACHE_VERSIONbump intranslation-service.ts) - Admin explicitly purges via cache management UI
import { clearCache } from '@/lib/i18n/translation-service';
clearCache('ja'); // Clear Japanese cache only
clearCache(); // Clear all language caches
Error Remediation
Fallback chain
t('some.key') is called
Step 1: translations[key] ← Current locale JSON
Step 2: englishTranslations[key] ← English JSON (always available)
Step 3: key itself ← Raw key string (e.g., "operations.booking.cancelTitle")
The key itself is always returned as final fallback — the UI never crashes due to a missing translation. This makes missing keys visible in production (the dotted key appears in the UI) which is the fastest way to catch them.
Finding missing keys in production
If users see dotted keys like operations.booking.cancelTitle in the UI:
- The key exists in the component but NOT in the JSON file for that locale
- Check English first: if missing in English, it's not in any locale
- Add the key to all locale files
- Deploy — no cache invalidation needed (static bundle update)
Missing locale entirely
If a locale fails to load (network error, code-split failure):
try {
const localeModule = await import(`@/lib/i18n/locales/${settings.language}`);
setTranslations(localeModule.default);
} catch {
console.warn(`Failed to load translations for ${settings.language}, falling back to English`);
setTranslations(enTranslations); // Fallback to English — UI remains functional
}
LibreTranslate unavailable
If the on-prem LibreTranslate service is down:
- On-the-fly API returns original English text unchanged
- No errors shown to users — content is readable (just not translated)
- Translations log to console:
'Translation API error:' - Alert on-call ops team via monitoring (LibreTranslate health endpoint)
Provider-less context
If a component renders outside a <LocalizationProvider> (e.g., in tests or edge cases):
// useLocalization() returns a safe passthrough:
t: (key: string) => key // Returns the key itself
formatDate: (d) => new Date(d).toLocaleDateString('en-US')
// etc.
This guarantees components never crash, even in test environments.
Adding a New Locale
- Create locale directory:
packages/shared/lib/i18n/locales/{code}/ - Copy all 32 JSON files from
en/with translated values (or use placeholder = English) - Create
index.tswith the same barrel structure asen/index.ts - Add to
SUPPORTED_LANGUAGESinconstants.ts:{ code: 'ar', label: 'Arabic', native: 'العربية', flag: '🇸🇦' } - Add locale mapping in
src/app/api/translate/route.tslocaleMap - Consider RTL: If the language is right-to-left (Arabic, Hebrew), add
dir="rtl"handling to the layout provider and CSS
Supported Languages Reference
| Code | Label | Native | Flag | RTL? |
|---|---|---|---|---|
en | English | English | 🇺🇸 | No |
ja | Japanese | 日本語 | 🇯🇵 | No |
zh | Chinese (Simplified) | 简体中文 | 🇨🇳 | No |
zh-TW | Chinese (Traditional) | 繁體中文 | 🇹🇼 | No |
ko | Korean | 한국어 | 🇰🇷 | No |
th | Thai | ไทย | 🇹🇭 | No |
vi | Vietnamese | Tiếng Việt | 🇻🇳 | No |
de | German | Deutsch | 🇩🇪 | No |
fr | French | Français | 🇫🇷 | No |
es | Spanish | Español | 🇪🇸 | No |
Note:
zh-TWmaps to LibreTranslate codezh(the service does not distinguish Traditional from Simplified). This is a known limitation to document to translators.
The Language Selector UI
See packages/shared/components/layouts/language-selector.tsx.
The selector is a DropdownMenu in the header right zone:
- Trigger:
Button variant="ghost" h-9 w-9showing the current language flag emoji - Dropdown:
w-48, alignedend, withDropdownMenuLabel"Select Language" - Items: flag + native name,
Check h-4 w-4on active language - On select: calls
setLanguage(code)which saves to localStorage + Frappe and triggers locale load
The login page also shows a language selector (before authentication), using only the localStorage storage path (no Frappe write yet).
Complete Component Checklist
Before any PR is merged, every new component must pass:
- EVERY hardcoded string uses
t('key')— no English string literals in JSX - EVERY aria-label uses
t('key') - EVERY placeholder uses
t('key') - EVERY toast message uses
t('key') - EVERY date/time display uses
formatDate()/formatTime()/formatDateTime() - EVERY number display uses
formatNumber() - EVERY currency display uses
formatCurrency() - EVERY new key added to ALL locale JSON files (even if only placeholder = English value)
- Key follows namespace convention:
{domain}.{scope}.{description} - Parameters use
{{camelCase}}syntax - Dynamic user/external content uses Mode 2 (on-the-fly API) not static keys
-
preloadTranslations()called for list pages with known dynamic content -
isLoadingchecked — interactive elements disabled during locale switch - No hardcoded locale strings (no
toLocaleDateString('en-US')) - Inbound communications store
detected_languageand translate before display - Outbound communications use customer/patient preferred language, not operator UI language