Skip to main content

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, alt text)
  • 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:

  1. All keys are flat strings (no nested JSON objects)
  2. Domain prefix matches the file: common.* lives in common.json, communications.* in communications.json
  3. Keys are camelCase within each segment
  4. Keys describe the content, not the UI element (common.save not common.saveButton)
  5. 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):

LayerLocationTTLScope
L1JavaScript Map in browser memorySession (until refresh)Per tab
L2localStorage key i18n_cache_v1_{locale}7 daysPer browser
L3Next.js server Map24 hoursPer pod instance
L4LibreTranslate internal cacheNetwork-wide

TODO (production): L3 cache must be migrated from in-memory Map to 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:

  1. Add the key to the appropriate English JSON file in packages/shared/lib/i18n/locales/en/
  2. Use a semantic, namespaced key following existing conventions
  3. Add the key to ALL other locale files (same key, translated value or placeholder)
  4. 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:

  1. Fetching the user's Frappe settings on mount
  2. 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 FieldDefaultPurpose
languageenUI language
time_zoneAsia/TokyoTimestamp display
date_formatyyyy-MM-ddDate rendering
time_formatHH:mmTime rendering (24h or 12h)
number_format#,###.##Number display
currencyUSDDefault currency
first_day_of_weekMondayCalendar display

Patient, Provider, and Document Language

Language preference fields (data model)

Every entity that communicates should carry language preference data:

EntityFrappe DocTypeLanguage FieldPurpose
App userUserlanguageUI language + email/notification language
Customer / PatientCustomerlanguage (custom field)Language to use in outbound to this customer
ProviderSupplier / Providerlanguage (custom field)Language for provider communications
Inbound documentEmail Message, Communicationdetected_languageLanguage Anna detected in the inbound message
BookingBookingcustomer_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's User.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:

  1. Flag the communication as requires_human_translation = true
  2. Show a banner: "This message is in an unsupported language. Human translation required."
  3. Do NOT attempt to auto-classify or auto-respond
  4. 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):

  1. Anna runs language detection via LibreTranslate /detect endpoint
  2. Result stored on the Communication DocType: detected_language
  3. Confidence score stored: language_confidence (0.0–1.0)
  4. If language_confidence < 0.5: flag as uncertain, default to English processing
  5. Translated body stored: translated_body (English translation for operator review)
  6. Original body preserved: body (never overwrite — legal/compliance requirement)

Fax-specific handling

Faxes arrive as scanned images. The pipeline:

  1. OCR converts image to text (on-prem Tesseract or similar)
  2. Language detection on OCR output
  3. If non-English: translate to English for processing
  4. Both OCR original and English translation stored
  5. Response generated in source language via Anna
  6. 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

OperationTarget LatencyCache 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)< 1msCovers 7 days
On-the-fly API call (L3 hit)< 5ms~95% for hot content
On-the-fly API call (LibreTranslate)< 200msBatched 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_VERSION bump in translation-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:

  1. The key exists in the component but NOT in the JSON file for that locale
  2. Check English first: if missing in English, it's not in any locale
  3. Add the key to all locale files
  4. 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

  1. Create locale directory: packages/shared/lib/i18n/locales/{code}/
  2. Copy all 32 JSON files from en/ with translated values (or use placeholder = English)
  3. Create index.ts with the same barrel structure as en/index.ts
  4. Add to SUPPORTED_LANGUAGES in constants.ts:
    { code: 'ar', label: 'Arabic', native: 'العربية', flag: '🇸🇦' }
  5. Add locale mapping in src/app/api/translate/route.ts localeMap
  6. Consider RTL: If the language is right-to-left (Arabic, Hebrew), add dir="rtl" handling to the layout provider and CSS

Supported Languages Reference

CodeLabelNativeFlagRTL?
enEnglishEnglish🇺🇸No
jaJapanese日本語🇯🇵No
zhChinese (Simplified)简体中文🇨🇳No
zh-TWChinese (Traditional)繁體中文🇹🇼No
koKorean한국어🇰🇷No
thThaiไทย🇹🇭No
viVietnameseTiếng Việt🇻🇳No
deGermanDeutsch🇩🇪No
frFrenchFrançais🇫🇷No
esSpanishEspañol🇪🇸No

Note: zh-TW maps to LibreTranslate code zh (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-9 showing the current language flag emoji
  • Dropdown: w-48, aligned end, with DropdownMenuLabel "Select Language"
  • Items: flag + native name, Check h-4 w-4 on 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
  • isLoading checked — interactive elements disabled during locale switch
  • No hardcoded locale strings (no toLocaleDateString('en-US'))
  • Inbound communications store detected_language and translate before display
  • Outbound communications use customer/patient preferred language, not operator UI language