From 74680096a14db2a19da7f833496be1cecc1e49be Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 11 Jun 2026 18:51:32 +0500 Subject: [PATCH] =?UTF-8?q?feat(i18n):=20RTL=20layout=20for=20Arabic=20?= =?UTF-8?q?=E2=80=94=20direction=20switch=20+=20logical-property=20migrati?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the last i18n gap. Arabic now lays out right-to-left, not just translated LTR. Mechanism (`lib/rtl.ts`): - `syncRTL(lang)` — web sets `document.dir`; native sets `I18nManager.forceRTL` (persisted). Applied on launch from `_layout` for the active language. - `setLanguage` persists the choice (so it survives the reload), flips direction via syncRTL, and on web reloads when the direction actually changes (en↔ar). - i18n init now prefers the persisted language over device detection — without this the RTL reload reset back to the device locale. Layout — migrate physical → logical Tailwind utilities so spacing/alignment flip under RTL (and stay correct in LTR): `ml/mr→ms/me`, `pl/pr→ps/pe`, `text-left/right→text-start/end`, and the FAB / corner-badge insets `right→end`. `flex-row` auto-reverses from `dir`/`forceRTL`; symmetric `left-4 right-4` left as-is. NativeWind 4 maps the logical classes to RN `marginStart/End` etc., which honour direction on both web and native. Verified on web: zh/en LTR unchanged; switching to العربية mirrors the whole shell (header, cards, chevrons, tab bar, text alignment) and persists across reload; switching back restores LTR. Snapshots updated for the class rename. tsc + lint clean, full suite 1337 passing. Note: native applies the direction on next launch (no `expo-updates` / react-native-restart bundled to hot-reload); web is instant. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../batch-components.test.tsx.snap | 4 +- .../common-components-extended.test.tsx.snap | 4 +- app/(app)/[appName]/index.tsx | 4 +- app/(tabs)/apps.tsx | 2 +- app/(tabs)/index.tsx | 8 +-- app/(tabs)/more.tsx | 4 +- app/(tabs)/search.tsx | 6 +-- app/_layout.tsx | 9 +++- components/actions/ResultDialog.tsx | 2 +- components/batch/BatchActionBar.tsx | 4 +- components/common/FloatingActionButton.tsx | 2 +- components/common/OfflineIndicator.tsx | 2 +- components/common/ScreenHeader.tsx | 2 +- components/common/SkeletonList.tsx | 2 +- components/common/UndoSnackbar.tsx | 2 +- components/home/QuickCreateSheet.tsx | 2 +- components/query/QueryBuilder.tsx | 6 +-- .../realtime/CollaborationIndicator.tsx | 4 +- components/renderers/CalendarViewRenderer.tsx | 2 +- components/renderers/ChartViewRenderer.tsx | 2 +- .../renderers/DashboardViewRenderer.tsx | 4 +- components/renderers/DetailViewRenderer.tsx | 8 +-- components/renderers/FilterDrawer.tsx | 8 +-- components/renderers/KanbanViewRenderer.tsx | 6 +-- components/renderers/ListViewRenderer.tsx | 8 +-- components/renderers/TimelineViewRenderer.tsx | 2 +- components/renderers/charts/WidgetChart.tsx | 8 +-- components/renderers/fields/FieldRenderer.tsx | 2 +- components/sync/ConflictResolutionDialog.tsx | 4 +- components/ui/Input.tsx | 4 +- components/ui/ListSkeleton.tsx | 2 +- components/ui/MultiSelect.tsx | 2 +- components/views/SaveViewDialog.tsx | 2 +- components/views/ViewTabs.tsx | 6 +-- components/workflow/RecordStateMachines.tsx | 2 +- lib/i18n.ts | 22 ++++++++- lib/rtl.ts | 49 +++++++++++++++++++ stores/ui-store.ts | 12 ++++- 38 files changed, 155 insertions(+), 69 deletions(-) create mode 100644 lib/rtl.ts diff --git a/__tests__/snapshots/__snapshots__/batch-components.test.tsx.snap b/__tests__/snapshots/__snapshots__/batch-components.test.tsx.snap index 81c558d..6fee1ed 100644 --- a/__tests__/snapshots/__snapshots__/batch-components.test.tsx.snap +++ b/__tests__/snapshots/__snapshots__/batch-components.test.tsx.snap @@ -91,7 +91,7 @@ exports[`BatchActionBar matches snapshot with all actions 1`] = ` size={14} /> Edit @@ -133,7 +133,7 @@ exports[`BatchActionBar matches snapshot with all actions 1`] = ` size={14} /> Delete diff --git a/__tests__/snapshots/__snapshots__/common-components-extended.test.tsx.snap b/__tests__/snapshots/__snapshots__/common-components-extended.test.tsx.snap index 9972686..aba4c32 100644 --- a/__tests__/snapshots/__snapshots__/common-components-extended.test.tsx.snap +++ b/__tests__/snapshots/__snapshots__/common-components-extended.test.tsx.snap @@ -97,7 +97,7 @@ exports[`OfflineIndicator snapshots renders syncing in progress 1`] = ` size={12} /> Syncing… @@ -155,7 +155,7 @@ exports[`OfflineIndicator snapshots renders syncing state when online with pendi size={12} /> Sync diff --git a/app/(app)/[appName]/index.tsx b/app/(app)/[appName]/index.tsx index 608a83b..16202be 100644 --- a/app/(app)/[appName]/index.tsx +++ b/app/(app)/[appName]/index.tsx @@ -77,7 +77,7 @@ export default function AppHomeScreen() { - + {item.label} {navigable ? : null} @@ -92,7 +92,7 @@ export default function AppHomeScreen() { - + {group.label} diff --git a/app/(tabs)/apps.tsx b/app/(tabs)/apps.tsx index 2fbfcbd..f4b85b8 100644 --- a/app/(tabs)/apps.tsx +++ b/app/(tabs)/apps.tsx @@ -89,7 +89,7 @@ export default function AppsScreen() { - + {app.label} diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 029d088..7a23f81 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -133,7 +133,7 @@ export default function HomeScreen() { } > - + {heading} {t("home.subtitle")} @@ -161,7 +161,7 @@ export default function HomeScreen() { - + {t("home.assistantTitle")} @@ -206,7 +206,7 @@ export default function HomeScreen() { - + - + {d.label} diff --git a/app/(tabs)/more.tsx b/app/(tabs)/more.tsx index 4ee2182..cbc750b 100644 --- a/app/(tabs)/more.tsx +++ b/app/(tabs)/more.tsx @@ -34,7 +34,7 @@ function MenuItem({ icon, label, onPress, showChevron = true, destructive = fals accessibilityLabel={label} accessibilityRole="button" > - {icon} + {icon} - + {session?.user.name ?? t("more.profileFallbackName")} diff --git a/app/(tabs)/search.tsx b/app/(tabs)/search.tsx index af0d06e..64a67c2 100644 --- a/app/(tabs)/search.tsx +++ b/app/(tabs)/search.tsx @@ -32,7 +32,7 @@ export default function SearchScreen() { - + {q} @@ -163,7 +163,7 @@ export default function SearchScreen() { accessibilityRole="button" accessibilityLabel={t("search.openLabel", { title: rec.title })} > - + diff --git a/app/_layout.tsx b/app/_layout.tsx index 7de3868..050686f 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,5 +1,6 @@ import "../global.css"; -import "~/lib/i18n"; // Initialize i18next before any screen calls useTranslation() +import i18n from "~/lib/i18n"; // Initialize i18next before any screen calls useTranslation() +import { syncRTL } from "~/lib/rtl"; import { useCallback, useEffect, useMemo } from "react"; import { Stack, useRouter, useSegments } from "expo-router"; @@ -88,6 +89,12 @@ export default function RootLayout() { colorScheme.set(themeMode); }, [themeMode]); + // Apply the writing direction (LTR/RTL) for the active language on launch — + // on web this sets the document `dir`; on native it forces RTL (persisted). + useEffect(() => { + syncRTL(i18n.language); + }, []); + useProtectedRoute(serverUrl, isReady); const { data: session } = authClient.useSession(); diff --git a/components/actions/ResultDialog.tsx b/components/actions/ResultDialog.tsx index b5134f4..8484ca9 100644 --- a/components/actions/ResultDialog.tsx +++ b/components/actions/ResultDialog.tsx @@ -88,7 +88,7 @@ function ResultField({ field, value }: { field: ResolvedField; value: unknown }) {isSecret && ( setRevealed((r) => !r)} accessibilityRole="button"> - + {revealed ? "Hide" : "Reveal"} diff --git a/components/batch/BatchActionBar.tsx b/components/batch/BatchActionBar.tsx index 0fa9211..a219010 100644 --- a/components/batch/BatchActionBar.tsx +++ b/components/batch/BatchActionBar.tsx @@ -54,7 +54,7 @@ export function BatchActionBar({ className="flex-row items-center rounded-lg bg-primary/10 px-3 py-2" > - {t("common.edit")} + {t("common.edit")} )} {onBatchDelete && ( @@ -63,7 +63,7 @@ export function BatchActionBar({ className="flex-row items-center rounded-lg bg-destructive/10 px-3 py-2" > - {t("common.delete")} + {t("common.delete")} )} diff --git a/components/common/FloatingActionButton.tsx b/components/common/FloatingActionButton.tsx index 56ad79e..33729c8 100644 --- a/components/common/FloatingActionButton.tsx +++ b/components/common/FloatingActionButton.tsx @@ -32,7 +32,7 @@ export function FloatingActionButton({ }; return ( - + {/* Expanded action items */} {expanded && actions && actions.map((action) => ( - + {isSyncing ? "Syncing…" : "Sync"} diff --git a/components/common/ScreenHeader.tsx b/components/common/ScreenHeader.tsx index e2f3558..b027ccb 100644 --- a/components/common/ScreenHeader.tsx +++ b/components/common/ScreenHeader.tsx @@ -97,7 +97,7 @@ export function ScreenHeader({ {right ? ( - {right} + {right} ) : ( )} diff --git a/components/common/SkeletonList.tsx b/components/common/SkeletonList.tsx index edf5a6d..f3d157c 100644 --- a/components/common/SkeletonList.tsx +++ b/components/common/SkeletonList.tsx @@ -15,7 +15,7 @@ export function SkeletonList({ rows = 5, showAvatar = true, testID = "skeleton-l {Array.from({ length: rows }).map((_, i) => ( {showAvatar && ( - + )} diff --git a/components/common/UndoSnackbar.tsx b/components/common/UndoSnackbar.tsx index 7300dd4..1eab4cb 100644 --- a/components/common/UndoSnackbar.tsx +++ b/components/common/UndoSnackbar.tsx @@ -56,7 +56,7 @@ export function UndoSnackbar({ accessibilityLabel={t("common.undo")} accessibilityRole="button" > - {t("common.undo")} + {t("common.undo")} ); diff --git a/components/home/QuickCreateSheet.tsx b/components/home/QuickCreateSheet.tsx index c077504..68e0790 100644 --- a/components/home/QuickCreateSheet.tsx +++ b/components/home/QuickCreateSheet.tsx @@ -98,7 +98,7 @@ export function QuickCreateSheet({ - + {o.label} {o.appLabel} diff --git a/components/query/QueryBuilder.tsx b/components/query/QueryBuilder.tsx index a010e8f..b68f0c3 100644 --- a/components/query/QueryBuilder.tsx +++ b/components/query/QueryBuilder.tsx @@ -61,7 +61,7 @@ export function QueryBuilder({ className="flex-row items-center rounded-lg border border-primary/30 bg-primary/10 px-3 py-1.5" > - + Match {root.logic === "AND" ? "ALL" : "ANY"} @@ -69,7 +69,7 @@ export function QueryBuilder({ {root.filters.length > 0 && ( - {t("common.clear")} + {t("common.clear")} )} @@ -100,7 +100,7 @@ export function QueryBuilder({ className="mt-3 flex-row items-center self-start rounded-lg border border-dashed border-border px-3 py-2" > - {t("filter.addFilter")} + {t("filter.addFilter")} ); diff --git a/components/realtime/CollaborationIndicator.tsx b/components/realtime/CollaborationIndicator.tsx index 06e570c..343dd79 100644 --- a/components/realtime/CollaborationIndicator.tsx +++ b/components/realtime/CollaborationIndicator.tsx @@ -47,7 +47,7 @@ export function CollaborationIndicator({ return ( - Viewing: + Viewing: {visible.map((member) => ( onEventPress?.(ev)} > diff --git a/components/renderers/ChartViewRenderer.tsx b/components/renderers/ChartViewRenderer.tsx index a6eed7a..cf15e98 100644 --- a/components/renderers/ChartViewRenderer.tsx +++ b/components/renderers/ChartViewRenderer.tsx @@ -157,7 +157,7 @@ function PieChartView({ data }: { data: AnalyticsDataPoint[] }) { {point.label} {point.value} - {pct}% + {pct}% ); })} diff --git a/components/renderers/DashboardViewRenderer.tsx b/components/renderers/DashboardViewRenderer.tsx index 79b2901..30d18b7 100644 --- a/components/renderers/DashboardViewRenderer.tsx +++ b/components/renderers/DashboardViewRenderer.tsx @@ -104,7 +104,7 @@ function MetricWidget({ {widget.title ?? widget.name} @@ -137,7 +137,7 @@ function MetricWidget({ )} diff --git a/components/renderers/DetailViewRenderer.tsx b/components/renderers/DetailViewRenderer.tsx index 4c558ee..d13c5bc 100644 --- a/components/renderers/DetailViewRenderer.tsx +++ b/components/renderers/DetailViewRenderer.tsx @@ -300,7 +300,7 @@ function DetailActionBar({ {hasMore && ( <> setMoreOpen(true)} accessibilityRole="button" accessibilityLabel={t("records.moreActions")} @@ -393,7 +393,7 @@ function RelatedListSection({ {config.columns.map((col) => ( {humanizeField(col)} - + {rec[col] == null ? "—" : String(rec[col])} @@ -444,7 +444,7 @@ function RecordNavigator({ @@ -466,7 +466,7 @@ function RecordNavigator({ > diff --git a/components/renderers/FilterDrawer.tsx b/components/renderers/FilterDrawer.tsx index 172f620..7f8c099 100644 --- a/components/renderers/FilterDrawer.tsx +++ b/components/renderers/FilterDrawer.tsx @@ -77,11 +77,11 @@ export function FilterDrawer({ - + Filters {filterCount > 0 && ( - + {filterCount} @@ -166,7 +166,7 @@ export function FilterButton({ count = 0, onPress, className }: FilterButtonProp @@ -174,7 +174,7 @@ export function FilterButton({ count = 0, onPress, className }: FilterButtonProp {isActive && ( - + {count} diff --git a/components/renderers/KanbanViewRenderer.tsx b/components/renderers/KanbanViewRenderer.tsx index 76bd41d..9fd2de5 100644 --- a/components/renderers/KanbanViewRenderer.tsx +++ b/components/renderers/KanbanViewRenderer.tsx @@ -16,7 +16,7 @@ function KanbanSkeleton() { return ( {[0, 1, 2].map((c) => ( - + @@ -103,7 +103,7 @@ function KanbanCard({ onPress={onPress} > - + @@ -142,7 +142,7 @@ function KanbanColumnView({ }) { const { t } = useTranslation(); return ( - + {/* Column header */} diff --git a/components/renderers/ListViewRenderer.tsx b/components/renderers/ListViewRenderer.tsx index 85f7100..87c4054 100644 --- a/components/renderers/ListViewRenderer.tsx +++ b/components/renderers/ListViewRenderer.tsx @@ -491,7 +491,7 @@ export function ListViewRenderer({ {selectionMode !== "none" && ( {item.label} - ({item.count}) + ({item.count}) ); } @@ -710,7 +710,7 @@ export function ListViewRenderer({ @@ -744,7 +744,7 @@ export function ListViewRenderer({ > {col.label ?? col.field} - + {isActive ? ( sortDir === "asc" ? ( diff --git a/components/renderers/TimelineViewRenderer.tsx b/components/renderers/TimelineViewRenderer.tsx index 18cb9c5..e24b4d0 100644 --- a/components/renderers/TimelineViewRenderer.tsx +++ b/components/renderers/TimelineViewRenderer.tsx @@ -135,7 +135,7 @@ export function TimelineViewRenderer({ onPress={onEntryPress ? () => onEntryPress(entry) : undefined} > {/* Timeline connector */} - + {/* Dot */} - + {slices.slice(0, 6).map((s, i) => ( - + {s.label} - + {Math.round(s.frac * 100)}% @@ -231,7 +231,7 @@ function FunnelChart({ data, colors, format = identity }: WidgetChartProps) { diff --git a/components/renderers/fields/FieldRenderer.tsx b/components/renderers/fields/FieldRenderer.tsx index d6eba32..dacb662 100644 --- a/components/renderers/fields/FieldRenderer.tsx +++ b/components/renderers/fields/FieldRenderer.tsx @@ -190,7 +190,7 @@ function ReadOnlyValue({ field, value }: { field: FieldDefinition; value: unknow value ? "bg-emerald-500" : "bg-muted", )} /> - + {value ? "Yes" : "No"} diff --git a/components/sync/ConflictResolutionDialog.tsx b/components/sync/ConflictResolutionDialog.tsx index 7738969..7f2448b 100644 --- a/components/sync/ConflictResolutionDialog.tsx +++ b/components/sync/ConflictResolutionDialog.tsx @@ -79,7 +79,7 @@ export function ConflictResolutionDialog({ className="flex-1 flex-row items-center justify-center rounded-lg bg-primary/10 py-2" > - + Keep Local @@ -88,7 +88,7 @@ export function ConflictResolutionDialog({ className="flex-1 flex-row items-center justify-center rounded-lg bg-destructive/10 py-2" > - + Keep Server diff --git a/components/ui/Input.tsx b/components/ui/Input.tsx index 83d3f48..efc044a 100644 --- a/components/ui/Input.tsx +++ b/components/ui/Input.tsx @@ -29,7 +29,7 @@ export const Input = React.forwardRef( containerClassName )} > - {leftSlot ? {leftSlot} : null} + {leftSlot ? {leftSlot} : null} ( }} {...props} /> - {rightSlot ? {rightSlot} : null} + {rightSlot ? {rightSlot} : null} ); } diff --git a/components/ui/ListSkeleton.tsx b/components/ui/ListSkeleton.tsx index a8952eb..1628320 100644 --- a/components/ui/ListSkeleton.tsx +++ b/components/ui/ListSkeleton.tsx @@ -21,7 +21,7 @@ export function ListSkeleton({ count = 5 }: ListSkeletonProps) { className="flex-row items-center rounded-2xl border border-border bg-card p-5" > - + diff --git a/components/ui/MultiSelect.tsx b/components/ui/MultiSelect.tsx index 2185afc..694e02f 100644 --- a/components/ui/MultiSelect.tsx +++ b/components/ui/MultiSelect.tsx @@ -74,7 +74,7 @@ export function MultiSelect({ ))} )} - + setOpen(false)}> diff --git a/components/views/SaveViewDialog.tsx b/components/views/SaveViewDialog.tsx index 0fe2fb7..9b8adda 100644 --- a/components/views/SaveViewDialog.tsx +++ b/components/views/SaveViewDialog.tsx @@ -126,7 +126,7 @@ export function SaveViewDialog({ )} > - + {isSaving ? t("common.saving") : t("views.saveView")} diff --git a/components/views/ViewTabs.tsx b/components/views/ViewTabs.tsx index cb29106..94edd6d 100644 --- a/components/views/ViewTabs.tsx +++ b/components/views/ViewTabs.tsx @@ -76,7 +76,7 @@ export function ViewTabs({ {isActive && view.visibility === "shared" && ( - + )} {isActive && onDelete && ( - onDelete(view.id)} className="ml-1 p-1"> + onDelete(view.id)} className="ms-1 p-1"> )} diff --git a/components/workflow/RecordStateMachines.tsx b/components/workflow/RecordStateMachines.tsx index bf7b740..5795eaa 100644 --- a/components/workflow/RecordStateMachines.tsx +++ b/components/workflow/RecordStateMachines.tsx @@ -82,7 +82,7 @@ export function RecordStateMachines({ anyBusy ? "opacity-50" : "" }`} > - + {tr.label ?? tr.event} diff --git a/lib/i18n.ts b/lib/i18n.ts index 0e89c33..61d6482 100644 --- a/lib/i18n.ts +++ b/lib/i18n.ts @@ -10,11 +10,16 @@ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; import { getLocales } from "expo-localization"; +import { createMMKV } from "react-native-mmkv"; import en from "~/locales/en.json"; import zh from "~/locales/zh.json"; import ar from "~/locales/ar.json"; +/** Shared with `ui-store` — the persisted language key lives in this store. */ +const uiStorage = createMMKV({ id: "objectstack-ui" }); +export const LANGUAGE_KEY = "language"; + /** Languages that use right-to-left layout */ export const RTL_LANGUAGES = new Set(["ar", "he", "fa", "ur"]); @@ -42,6 +47,21 @@ export function detectDeviceLanguage(): SupportedLanguage { } } +/** + * Initial language: the user's persisted choice wins over device detection, so + * a manual selection survives the reload that an RTL direction change triggers. + */ +function loadInitialLanguage(): SupportedLanguage { + try { + const stored = uiStorage.getString(LANGUAGE_KEY); + const supported = SUPPORTED_LANGUAGES.map((l) => l.code) as readonly string[]; + if (stored && supported.includes(stored)) return stored as SupportedLanguage; + } catch { + // fall through to device detection + } + return detectDeviceLanguage(); +} + /** Check whether the current language is RTL */ export function isRTL(lang?: string): boolean { const code = lang ?? i18n.language ?? "en"; @@ -52,7 +72,7 @@ const resources = { en: { translation: en }, zh: { translation: zh }, ar: { tran i18n.use(initReactI18next).init({ resources, - lng: detectDeviceLanguage(), + lng: loadInitialLanguage(), fallbackLng: "en", interpolation: { escapeValue: false }, compatibilityJSON: "v4", diff --git a/lib/rtl.ts b/lib/rtl.ts new file mode 100644 index 0000000..ea0b16b --- /dev/null +++ b/lib/rtl.ts @@ -0,0 +1,49 @@ +import { I18nManager, Platform } from "react-native"; +import { isRTL } from "./i18n"; + +/** + * Apply the writing direction for a language. + * + * - **Web**: React-Native-Web doesn't honour `I18nManager.forceRTL`, so the + * direction is driven by the document `dir` attribute (CSS logical + * properties + `flex-direction: row` flip from there). + * - **Native**: `I18nManager.forceRTL` flips `flex-direction: row`, + * `marginStart/End`, `start/end` insets and text alignment. The flag persists + * across launches, so a change takes full effect after the app restarts. + * + * Returns `true` when a **native** restart is needed to fully apply the change + * (the direction flag differs from what's currently live). + */ +export function syncRTL(lang?: string): boolean { + const rtl = isRTL(lang); + + if (Platform.OS === "web") { + if (typeof document !== "undefined") { + document.documentElement.dir = rtl ? "rtl" : "ltr"; + if (lang) document.documentElement.lang = lang; + } + return false; + } + + const needsRestart = I18nManager.isRTL !== rtl; + // Allow RTL at all, then force the concrete direction. `forceRTL` persists, + // so even without an immediate reload the next launch lays out correctly. + I18nManager.allowRTL(true); + I18nManager.forceRTL(rtl); + return needsRestart; +} + +/** + * Best-effort app reload to apply an RTL direction change immediately. + * + * - **Web**: a full page reload re-reads `document.dir`. + * - **Native**: there is no bundled restart module (no `expo-updates` / + * `react-native-restart`), so this is a no-op — `forceRTL` has been + * persisted and applies on the next launch. Callers should tell the user to + * restart. + */ +export function reloadForRTL(): void { + if (Platform.OS === "web" && typeof window !== "undefined") { + window.location.reload(); + } +} diff --git a/stores/ui-store.ts b/stores/ui-store.ts index 2851525..ecf2fb9 100644 --- a/stores/ui-store.ts +++ b/stores/ui-store.ts @@ -1,8 +1,9 @@ import { create } from "zustand"; import { colorScheme } from "nativewind"; import { createMMKV } from "react-native-mmkv"; -import i18n from "~/lib/i18n"; +import i18n, { isRTL, LANGUAGE_KEY } from "~/lib/i18n"; import type { SupportedLanguage } from "~/lib/i18n"; +import { syncRTL, reloadForRTL } from "~/lib/rtl"; export type ThemeMode = "light" | "dark" | "system"; /** Default list row spacing when a view doesn't dictate its own. */ @@ -57,7 +58,16 @@ export const useUIStore = create((set) => ({ }, language: (i18n.language ?? "en") as SupportedLanguage, setLanguage: (lang) => { + const directionFlips = isRTL(i18n.language) !== isRTL(lang); + // Persist so the choice survives the reload an RTL flip triggers (and app + // restarts in general) — i18n's init reads this before device detection. + storage.set(LANGUAGE_KEY, lang); i18n.changeLanguage(lang); set({ language: lang }); + // Apply writing direction. When the direction actually flips (e.g. en↔ar), + // the layout must re-lay-out: on web a reload re-reads `document.dir`; on + // native `forceRTL` is persisted and applies on the next launch. + syncRTL(lang); + if (directionFlips) reloadForRTL(); }, }));