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();
},
}));