diff --git a/client/scripts/rtl-baseline.json b/client/scripts/rtl-baseline.json
index 900aa6a77..336a0ca7f 100644
--- a/client/scripts/rtl-baseline.json
+++ b/client/scripts/rtl-baseline.json
@@ -80,11 +80,9 @@
"hooks/useElementRect.ts": 3,
"mobile/components/MSegmented.tsx": 1,
"mobile/components/MSheet.tsx": 1,
- "mobile/screens/dashboard/MDashboard.tsx": 1,
"mobile/screens/journey/MJourneyDetail.tsx": 3,
"mobile/screens/journey/MJourneyEntrySheet.tsx": 1,
"mobile/screens/trip/map/MMapArea.tsx": 3,
- "mobile/screens/trip/tabs/MCollabPolls.tsx": 1,
"mobile/screens/trip/tabs/MFileLightbox.tsx": 2,
"mobile/screens/vacay/MVacayMonth.tsx": 1,
"pages/AtlasPage.tsx": 2,
diff --git a/client/src/components/Journey/JournalBody.test.tsx b/client/src/components/Journey/JournalBody.test.tsx
index f22553bd4..30e1abb3a 100644
--- a/client/src/components/Journey/JournalBody.test.tsx
+++ b/client/src/components/Journey/JournalBody.test.tsx
@@ -77,8 +77,9 @@ describe('JournalBody', () => {
const { container } = render();
expect(container.querySelector('h1')).not.toBeInTheDocument();
expect(container.querySelector('h3')).not.toBeInTheDocument();
- expect(screen.getByText('Big').tagName).toBe('P');
- expect(screen.getByText('Small').tagName).toBe('P');
+ // Each paragraph's text sits in an isolate () so it keeps its own direction.
+ expect(screen.getByText('Big').parentElement?.tagName).toBe('P');
+ expect(screen.getByText('Small').parentElement?.tagName).toBe('P');
});
it('FE-COMP-JOURNALBODY-012: keeps a setext underline as text instead of a heading', () => {
diff --git a/client/src/components/Journey/JournalBody.tsx b/client/src/components/Journey/JournalBody.tsx
index 962a9ef21..08987a7f3 100644
--- a/client/src/components/Journey/JournalBody.tsx
+++ b/client/src/components/Journey/JournalBody.tsx
@@ -15,13 +15,14 @@ export default function JournalBody({ text, dark }: Props) {
lineHeight: 1.6,
color: 'inherit',
}}>
+ {/* Each paragraph in its own direction (bdi): a Latin story in the Arabic UI keeps its full stop at its end. */}
{children}
,
- h2: ({ children }) =>
{children}
,
- h3: ({ children }) =>
{children}
,
- p: ({ children }) =>
{children}
,
+ h1: ({ children }) =>
{children}
,
+ h2: ({ children }) =>
{children}
,
+ h3: ({ children }) =>
{children}
,
+ p: ({ children }) =>
{children}
,
blockquote: ({ children }) => (
({
id,
@@ -89,3 +89,21 @@ describe('NotificationDayList', () => {
expect(screen.getAllByRole('region')).toHaveLength(2);
});
});
+
+describe('localizedParams', () => {
+ const NOW = new Date(2026, 10, 7, 12);
+
+ it('FE-NOTIF-MODEL-008: a due day sent as YYYY-MM-DD reads as the short date of the language', () => {
+ const params = { todo: 'Exchange the JR Pass', trip: 'Kyoto', due: '2026-11-05' };
+ expect(localizedParams(params, 'de', NOW)).toEqual({ todo: 'Exchange the JR Pass', trip: 'Kyoto', due: '5. Nov.' });
+ expect(localizedParams(params, 'en-US', NOW).due).toBe('Nov 5');
+ expect(localizedParams(params, 'ru', NOW).due).toBe('5 нояб.');
+ // Another year names it.
+ expect(localizedParams({ due: '2027-01-02' }, 'en-US', NOW).due).toBe('Jan 2, 2027');
+ });
+
+ it('FE-NOTIF-MODEL-009: anything that is not a bare date passes through, and no params stay none', () => {
+ expect(localizedParams({ trip: '2026-11-05 at noon', n: '3' }, 'de', NOW)).toEqual({ trip: '2026-11-05 at noon', n: '3' });
+ expect(localizedParams(undefined, 'de', NOW)).toEqual({});
+ });
+});
diff --git a/client/src/components/Notifications/notificationModel.ts b/client/src/components/Notifications/notificationModel.ts
index c4b20f00b..af47586ea 100644
--- a/client/src/components/Notifications/notificationModel.ts
+++ b/client/src/components/Notifications/notificationModel.ts
@@ -80,6 +80,34 @@ export function createdAt(value: string): Date {
const dayStart = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
+const BARE_DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
+
+/**
+ * The parameters of a notification's text as the reader's language writes
+ * them. A day the server sends (a todo's due date) arrives as YYYY-MM-DD, which
+ * would stand as "2026-11-05" in a German or Arabic sentence; it becomes the
+ * short date of the language ("5. Nov.", "٥ نوفمبر"), with the year only when
+ * it is not this one. Everything else passes through as it came.
+ */
+export function localizedParams(
+ params: Record | null | undefined,
+ locale: string,
+ now: Date = new Date()
+): Record {
+ const out: Record = { ...params };
+ for (const [key, value] of Object.entries(out)) {
+ const day = BARE_DAY.exec(value);
+ if (!day) continue;
+ const date = new Date(Number(day[1]), Number(day[2]) - 1, Number(day[3]));
+ out[key] = new Intl.DateTimeFormat(locale, {
+ day: 'numeric',
+ month: 'short',
+ ...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }),
+ }).format(date);
+ }
+ return out;
+}
+
/**
* The heading of the day a notification belongs to, in the reader's language:
* today and yesterday by name, the rest of the week by weekday, anything older
diff --git a/client/src/components/shared/MarkdownText.test.tsx b/client/src/components/shared/MarkdownText.test.tsx
index afd9ad015..6628a9e10 100644
--- a/client/src/components/shared/MarkdownText.test.tsx
+++ b/client/src/components/shared/MarkdownText.test.tsx
@@ -41,4 +41,23 @@ describe('MarkdownText', () => {
expect(wrapper.className).toContain('truncate')
expect(wrapper.className).toContain('text-m-muted')
})
+
+ it('FE-MDTEXT-007: a clamped row takes its direction from its own text, so a Latin address in Arabic is cut at its end', () => {
+ const { container } = render({'68 Fukakusa Yabunouchicho, Fushimi Ward, Kyoto'})
+ const wrapper = container.firstElementChild as HTMLElement
+ expect(wrapper).toHaveAttribute('dir', 'auto')
+ expect(wrapper).toHaveClass('truncate')
+ // No isolate inside: dir="auto" would not see the text through a .
+ expect(wrapper.querySelector('bdi')).toBeNull()
+ })
+
+ it('FE-MDTEXT-008: in a full view each paragraph runs in its own direction, inside its block', () => {
+ const text = ["Kyoto's kitchen: pickles and matcha everything.", 'Second paragraph?'].join('\n\n')
+ const { container } = render({text})
+ expect(container.firstElementChild).not.toHaveAttribute('dir')
+ const paragraphs = container.querySelectorAll('p')
+ expect(paragraphs).toHaveLength(2)
+ paragraphs.forEach(p => expect(p.firstElementChild?.tagName).toBe('BDI'))
+ expect(paragraphs[1].textContent).toBe('Second paragraph?')
+ })
})
diff --git a/client/src/components/shared/MarkdownText.tsx b/client/src/components/shared/MarkdownText.tsx
index f65be4069..bc9714b36 100644
--- a/client/src/components/shared/MarkdownText.tsx
+++ b/client/src/components/shared/MarkdownText.tsx
@@ -3,6 +3,7 @@ import type { Components } from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { markdownLinkComponents } from './markdownLink'
+import { isolatedTextBlocks } from './markdownSanitize'
/**
* A clamped caption lives inside a tappable row, so an anchor there would eat
@@ -12,6 +13,8 @@ const clampedComponents: Components = {
a: ({ children }) => <>{children}>,
}
+const fullComponents: Components = { ...isolatedTextBlocks, ...markdownLinkComponents }
+
/**
* A description or note, rendered (#2337). The field is Markdown wherever it is
* written — the editors ship a formatting bar — so printing it raw anywhere
@@ -20,6 +23,9 @@ const clampedComponents: Components = {
* `clamp` is the list-row variant: one line high, matching what the desktop
* sidebar rows do with the same field. The height cap is inline because the row
* itself sets a font size, and a Tailwind `leading-*` would race with it.
+ * The clamped line takes its direction from its own text (dir="auto" on the
+ * row, which a inside would hide from it), so a Latin address in an Arabic
+ * row loses its end at the cut, not its beginning.
*/
export default function MarkdownText({ children, className = '', clamp = false }: {
children: string
@@ -28,12 +34,13 @@ export default function MarkdownText({ children, className = '', clamp = false }
}) {
return (
{children}
diff --git a/client/src/components/shared/markdownSanitize.tsx b/client/src/components/shared/markdownSanitize.tsx
index 8d78abaa9..5808e5893 100644
--- a/client/src/components/shared/markdownSanitize.tsx
+++ b/client/src/components/shared/markdownSanitize.tsx
@@ -31,6 +31,35 @@ export const sanitizedMarkdownPlugins: NonNullable = [
[rehypeSanitize, schema],
]
+/**
+ * A paragraph or heading of user text runs in its own direction: a Latin note
+ * in the Arabic UI keeps its question mark and full stop at its end (and an
+ * Arabic one in an English UI), while the block keeps the alignment of the page
+ * around it. The isolate is inline, so the block itself stays as it was.
+ */
+export const isolatedTextBlocks: Components = {
+ p: ({ children }) => (
+
diff --git a/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx b/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx
index c5bb711cb..85114a86a 100644
--- a/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx
+++ b/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx
@@ -162,9 +162,10 @@ export default function MJourneyEntrySheet({
name is the one thing that makes an entry findable later, and it is one
line to type, so it comes first here too. */}
{readOnly ? (
-
diff --git a/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx b/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx
index c24f6b4ca..6af04ac27 100644
--- a/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx
+++ b/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx
@@ -23,6 +23,7 @@ import { WalkLateLine } from '../../../../components/Tours/walk/WalkTransitNote'
import { TourDayTimeText, TourOverrunLine } from '../../../../components/Tours/TourDayTimeParts'
import { tourDayEntryOf, type TourOverrun } from '../../../../components/Tours/tourDayTime'
import type { WalkLate } from '../../../../components/Tours/walk/walkTransit'
+import { flowText } from '../../../../utils/flowText'
/**
* The five row types of the mobile day timeline (place / manual transport /
@@ -198,7 +199,7 @@ export function PlaceRow({ assignment, fullPlace, tour, linkedReservations, chro
}
@@ -376,7 +377,7 @@ function TransitLegRows({ legs, t }: { legs: TransitLegDisplay[]; t: Translation
- {[leg.from?.name, leg.to?.name].filter(Boolean).join(' → ')}
+ {flowText([leg.from?.name, leg.to?.name].filter((n): n is string => !!n))}
@@ -434,7 +435,7 @@ export function TransitRow({ res, transit, dayId, open, chrome, reorder, drag, o
>
- {to ? `${from} → ${to}` : res.title}
+ {to ? flowText([from, to]) : res.title}
{start && (
@@ -475,8 +476,9 @@ function TravelLine({ seg }: { seg: RouteSegment }) {
- {durationText}
- {seg.distanceText && {seg.distanceText}}
+ {/* Each figure in its own direction, so "5 km" does not turn into "km 5" in Arabic. */}
+ {durationText}
+ {seg.distanceText && {seg.distanceText}}
{/* Extra text a plugin route attached to this leg (e.g. "25 min charge"). */}
{seg.noteText && (
@@ -540,7 +542,7 @@ export function HotelConnRow({ seg, name, placement }: {
const hotel = (
- {name}
+ {name}
)
return (
diff --git a/client/src/mobile/screens/trip/sheets/MAccommodationSheet.tsx b/client/src/mobile/screens/trip/sheets/MAccommodationSheet.tsx
index ca0f48937..0a7d84308 100644
--- a/client/src/mobile/screens/trip/sheets/MAccommodationSheet.tsx
+++ b/client/src/mobile/screens/trip/sheets/MAccommodationSheet.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
-import { Check, Hotel, MapPin, X } from 'lucide-react'
+import { ArrowRight, Check, Hotel, MapPin, X } from 'lucide-react'
import MSheet from '../../../components/MSheet'
import MIconBtn from '../../../components/MIconBtn'
import CustomSelect from '../../../../components/shared/CustomSelect'
@@ -140,7 +140,7 @@ export default function MAccommodationSheet({ planner, shell }: MTripSheetsProps
size="sm"
/>
- {place.name}
+ {place.name}
{/* Accepted out of the traveller's own recordings — the same
mark the desktop inspector carries. */}
{place.source === 'dawarich' && (
@@ -224,14 +224,14 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
)}
{place.address && (
-
{place.address}
+
{place.address}
)}
{/* The category under the name and address, as wide as its name:
squeezed under the photo it was cut to "Shoppi…". */}
{category && (
- {category.name}
+ {category.name}
)}
+ {/* An address reads left to right in every language, icon and all. */}
+
!!n)))
+ else if (from?.name && to?.name) subParts.push(flowText([from.name, to.name]))
else if (date) subParts.push(bookingDayLabel(date, locale))
// A flight's legs carry no seat or platform of their own, so those stay the booking's.
@@ -210,7 +211,7 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
)}
@@ -638,7 +639,7 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
}`}
>
-
+
{formatMoney(memberShareOf(item, m.user_id, ctx), base, locale)}
@@ -646,7 +647,7 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
)}
-
+
{formatMoney(total, base, locale)}
@@ -716,7 +717,7 @@ function PaymentRow({ settlement, ctx, base, locale, t, personName, canEdit, onE
// A transfer without a currency was entered in the display currency, not the trip's.
const amount = paymentAmount(settlement, ctx)
const line = paymentLineOf(settlement, ctx, amount)
- const who = `${personName(settlement.from_user_id)} → ${personName(settlement.to_user_id)}`
+ const who = flowText([personName(settlement.from_user_id), personName(settlement.to_user_id)])
return (
@@ -728,12 +729,12 @@ function PaymentRow({ settlement, ctx, base, locale, t, personName, canEdit, onE
diff --git a/client/src/mobile/screens/trip/tabs/MFilesBoard.tsx b/client/src/mobile/screens/trip/tabs/MFilesBoard.tsx
index 4857d4d70..6ec979ebe 100644
--- a/client/src/mobile/screens/trip/tabs/MFilesBoard.tsx
+++ b/client/src/mobile/screens/trip/tabs/MFilesBoard.tsx
@@ -118,7 +118,7 @@ export function FileTile({ file, selected, canStar, onPick, onStar, onMenu, t }:
)}
-
+
{file.original_name}
@@ -160,7 +160,8 @@ export function FileTile({ file, selected, canStar, onPick, onStar, onMenu, t }:
function Badge({ children }: { children: ReactNode }) {
return (
-
+ // A size, a date or a name, each in its own direction ("46.1 KB" stays so in Arabic).
+
{children}
);
@@ -220,7 +221,7 @@ export function FilePreviewPane({ file, planner, onOpen, onStar, onLinks, onMenu
{eps.map((ep, i) => (
- {i > 0 && }
- {ep.name}
+ {/* The vehicle heads for the next stop, so it turns round with the reading direction. */}
+ {i > 0 && }
+ {ep.name}
))}
diff --git a/client/src/mobile/screens/trip/tabs/tabChrome.tsx b/client/src/mobile/screens/trip/tabs/tabChrome.tsx
index 90aad4ca5..0ea0c3e6c 100644
--- a/client/src/mobile/screens/trip/tabs/tabChrome.tsx
+++ b/client/src/mobile/screens/trip/tabs/tabChrome.tsx
@@ -281,7 +281,7 @@ export function TravelerAvatars({ travelers, label }: {
{src ? : tv.username?.[0]?.toUpperCase()}
- {tv.username}
+ {tv.username}
{!!tv.is_guest && }
)
diff --git a/client/src/styles/rtl.css b/client/src/styles/rtl.css
new file mode 100644
index 000000000..67a98a3e4
--- /dev/null
+++ b/client/src/styles/rtl.css
@@ -0,0 +1,62 @@
+/*
+ * Right to left (Arabic). Layout already follows the reading direction through
+ * the logical utilities (ms-, ps-, start-, text-start); what is here are the
+ * three things a logical utility cannot say.
+ */
+
+/*
+ * User text in its own direction. A name, title or address carries dir="auto",
+ * so a Latin one in an Arabic row keeps its punctuation at its own end and, when
+ * it is cut, loses its end rather than its beginning. Cut, clamped or typed into
+ * a field, it still lines up with the row and the form: on the UI's start edge,
+ * wherever its own start is (an empty field reads as left to right, so without
+ * this its placeholder would jump to the far side). A free paragraph shown on
+ * its own (a note, a chat message) keeps the alignment of its own language.
+ * Zero specificity, so an explicit text-center or text-end on the element wins.
+ */
+:where(
+ :root[dir='rtl'] [dir='auto']:dir(ltr):is(.truncate, [class*='line-clamp-'], input, textarea),
+ :root[dir='ltr'] [dir='auto']:dir(rtl):is(.truncate, [class*='line-clamp-'], input, textarea)
+ ) {
+ text-align: end;
+}
+
+/*
+ * Arrows and chevrons that point along the reading direction (back, forward, a
+ * disclosure row, a date range, a payment from one person to another) turn
+ * round in a right-to-left language. The touch shell draws them all with
+ * lucide, so the shell (and the sheets it portals, which carry m-root too)
+ * mirrors them here instead of every icon carrying its own class. An icon that
+ * names its own RTL treatment keeps it: rtl:-scale-x-100 already turns it, and
+ * rtl:scale-x-100 marks an arrow that is physical on purpose. `scale` is its
+ * own property, so it composes with a rotation the icon carries (an open
+ * disclosure pointing down) instead of replacing it.
+ */
+:root[dir='rtl'] .m-root
+ :is(
+ .lucide-chevron-left,
+ .lucide-chevron-right,
+ .lucide-chevrons-left,
+ .lucide-chevrons-right,
+ .lucide-arrow-left,
+ .lucide-arrow-right,
+ .lucide-move-left,
+ .lucide-move-right
+ ):not([class*='rtl:']) {
+ scale: -1 1;
+}
+
+/*
+ * Arabic (and Hebrew) has no capitals, so the touch shell's small uppercase
+ * eyebrows (field labels, stat labels, status pills) lose the weight capitals
+ * give a Latin label and read as grey specks. There they grow by a fifth (about
+ * 2px, padding and pill with them), drop the tracking that pulls joined letters
+ * apart, and take the muted ink instead of the faint one.
+ */
+:root:is(:lang(ar), :lang(he)) .m-root .uppercase {
+ zoom: 1.2;
+ letter-spacing: 0;
+}
+:root:is(:lang(ar), :lang(he)) .m-root .uppercase.text-m-faint {
+ color: var(--m-muted);
+}
diff --git a/client/src/styles/rtlCss.test.ts b/client/src/styles/rtlCss.test.ts
new file mode 100644
index 000000000..8bb1af4db
--- /dev/null
+++ b/client/src/styles/rtlCss.test.ts
@@ -0,0 +1,50 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+// FE-RTL-CSS-001 to FE-RTL-CSS-004
+//
+// styles/rtl.css carries what the logical utilities cannot say for Arabic: user
+// text that lines up with the UI in its own direction, arrows that turn round
+// in the touch shell, and Arabic's caption labels. jsdom applies no
+// stylesheet, so these read the real file.
+const css = readFileSync(resolve(process.cwd(), 'src/styles/rtl.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
+
+/** The declarations of the rule whose selector text contains every given part. */
+function ruleBody(...parts: string[]): string {
+ for (const chunk of css.split('}')) {
+ const open = chunk.indexOf('{')
+ if (open < 0) continue
+ const head = chunk.slice(0, open)
+ if (parts.every(part => head.includes(part))) return chunk.slice(open + 1)
+ }
+ return ''
+}
+
+describe('rtl.css', () => {
+ it('FE-RTL-CSS-001: the stylesheet is loaded with the app', () => {
+ const main = readFileSync(resolve(process.cwd(), 'src/main.tsx'), 'utf8')
+ expect(main).toMatch(/import '\.\/styles\/rtl\.css'/)
+ })
+
+ it('FE-RTL-CSS-002: cut, clamped or typed user text in its own direction sits on the UI start edge', () => {
+ const body = ruleBody(":root[dir='rtl'] [dir='auto']:dir(ltr)", '.truncate', "[class*='line-clamp-']", 'input', 'textarea')
+ expect(body).toMatch(/text-align:\s*end/)
+ // The left-to-right UI gets the mirror case: Arabic text in an English row.
+ expect(css).toContain(":root[dir='ltr'] [dir='auto']:dir(rtl)")
+ // Zero specificity: an explicit alignment class on the element still wins.
+ expect(css).toMatch(/:where\(\s*:root\[dir='rtl'\]/)
+ })
+
+ it('FE-RTL-CSS-003: in the touch shell every back, forward and flow arrow turns round in a right-to-left language', () => {
+ const body = ruleBody(":root[dir='rtl'] .m-root", '.lucide-chevron-left', '.lucide-chevron-right', '.lucide-arrow-left', '.lucide-arrow-right')
+ expect(body).toMatch(/scale:\s*-1 1/)
+ // An icon that names its own treatment keeps it.
+ expect(css).toContain(":not([class*='rtl:'])")
+ })
+
+ it('FE-RTL-CSS-004: Arabic and Hebrew caption labels grow, drop the tracking and take the muted ink', () => {
+ expect(ruleBody(':lang(ar)', ':lang(he)', '.m-root .uppercase')).toMatch(/zoom:\s*1\.2[\s\S]*letter-spacing:\s*0/)
+ expect(ruleBody('.uppercase.text-m-faint')).toMatch(/color:\s*var\(--m-muted\)/)
+ })
+})
diff --git a/client/src/utils/flowText.test.ts b/client/src/utils/flowText.test.ts
new file mode 100644
index 000000000..5abc9c8b6
--- /dev/null
+++ b/client/src/utils/flowText.test.ts
@@ -0,0 +1,34 @@
+/**
+ * FE-UTIL-FLOW-001 to FE-UTIL-FLOW-003
+ *
+ * "A → B" as one string that reads in the direction of the UI.
+ */
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { flowText, isRtlDocument } from './flowText';
+
+const FSI = '';
+const PDI = '';
+
+describe('flowText', () => {
+ afterEach(() => {
+ document.documentElement.dir = '';
+ });
+
+ it('FE-UTIL-FLOW-001: a left-to-right UI gets the plain arrow between the names', () => {
+ expect(flowText(['Sam Carter', 'Ada'], false)).toBe('Sam Carter → Ada');
+ expect(flowText(['Kyoto', 'Osaka', 'Nara'], false)).toBe('Kyoto → Osaka → Nara');
+ });
+
+ it('FE-UTIL-FLOW-002: in Arabic each name is isolated and the arrow points left, from the first to the second', () => {
+ expect(flowText(['Sam Carter', 'أنت'], true)).toBe(`${FSI}Sam Carter${PDI} ← ${FSI}أنت${PDI}`);
+ });
+
+ it('FE-UTIL-FLOW-003: without an explicit direction it follows the document', () => {
+ expect(isRtlDocument()).toBe(false);
+ expect(flowText(['A', 'B'])).toBe('A → B');
+ document.documentElement.dir = 'rtl';
+ expect(isRtlDocument()).toBe(true);
+ expect(flowText(['A', 'B'])).toBe(`${FSI}A${PDI} ← ${FSI}B${PDI}`);
+ });
+});
diff --git a/client/src/utils/flowText.ts b/client/src/utils/flowText.ts
new file mode 100644
index 000000000..5da24867c
--- /dev/null
+++ b/client/src/utils/flowText.ts
@@ -0,0 +1,22 @@
+/**
+ * "Sam → Ada", "Kyoto → Osaka": two names with an arrow from the first to the
+ * second, as one string (a row's label, an aria-label).
+ *
+ * In an Arabic UI the plain form reads backwards. A Latin name and an Arabic one
+ * reorder around the arrow, and the arrow itself never turns, so "Sam Carter →
+ * أنت" shows the arrow pointing back at Sam. There each name goes into its own
+ * isolate (U+2068 … U+2069), so the pair lines up in reading order whatever
+ * script the names are in, and the arrow points left, from the first name to
+ * the second. A left-to-right UI gets the plain string.
+ */
+const FSI = '';
+const PDI = '';
+
+export function isRtlDocument(): boolean {
+ return typeof document !== 'undefined' && document.documentElement.dir === 'rtl';
+}
+
+export function flowText(parts: readonly string[], rtl: boolean = isRtlDocument()): string {
+ if (!rtl) return parts.join(' → ');
+ return parts.map((part) => `${FSI}${part}${PDI}`).join(' ← ');
+}
diff --git a/client/tests/unit/mobile/dashboard/MDashboard.test.tsx b/client/tests/unit/mobile/dashboard/MDashboard.test.tsx
index b8a293235..fd36e6cb2 100644
--- a/client/tests/unit/mobile/dashboard/MDashboard.test.tsx
+++ b/client/tests/unit/mobile/dashboard/MDashboard.test.tsx
@@ -109,6 +109,18 @@ describe('MDashboard', () => {
expect(screen.getByText('Lisbon')).toBeInTheDocument();
});
+ it('FE-MOB-DASH-040: the spotlight title keeps its own direction and its progress fills from the start edge', () => {
+ mocks.dash = buildDash({ spotlight: buildTrip() });
+ render();
+ // dir="auto": a Latin title in the Arabic UI is cut at its end, not its beginning.
+ expect(screen.getAllByText('Japan 2026')[0]).toHaveAttribute('dir', 'auto');
+ const fill = document.querySelector('span[style*="width"].rounded-full.bg-white') as HTMLElement;
+ expect(fill).not.toBeNull();
+ // start-0, not left-0: in Arabic the bar fills from the right like every other bar.
+ expect(fill).toHaveClass('start-0');
+ expect(fill.className).not.toMatch(/\bleft-0\b/);
+ });
+
it('FE-MOB-DASH-002: archived filter swaps card actions to restore + permanent delete', () => {
const handleUnarchive = vi.fn();
mocks.dash = buildDash({
diff --git a/client/tests/unit/mobile/trip/MCollabChat.test.tsx b/client/tests/unit/mobile/trip/MCollabChat.test.tsx
index 6c6bdb9b4..e2df1bc41 100644
--- a/client/tests/unit/mobile/trip/MCollabChat.test.tsx
+++ b/client/tests/unit/mobile/trip/MCollabChat.test.tsx
@@ -11,7 +11,7 @@ import { resetAllStores, seedStore } from '../../../helpers/store'
import { server } from '../../../helpers/msw/server'
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
-// FE-MOB-CCHAT-001 to FE-MOB-CCHAT-033
+// FE-MOB-CCHAT-001 to FE-MOB-CCHAT-035
const ME = 7
@@ -176,6 +176,18 @@ describe('MCollabChat', () => {
expect(screen.getByText('typed').closest('.flex-col')).not.toHaveTextContent('from the assistant')
})
+ it('FE-MOB-CCHAT-035: a message, its quote and the composer each take their direction from their own text', async () => {
+ serveMessages([
+ chatMsg(1, { text: 'Who has the JR Pass vouchers?' }),
+ chatMsg(2, { text: 'Me!', reply_to: 1, reply_text: 'Who has the JR Pass vouchers?', reply_username: 'Alice' }),
+ ])
+ await renderChat()
+ // In Arabic a Latin message keeps its question mark at its end.
+ for (const node of screen.getAllByText('Who has the JR Pass vouchers?')) expect(node).toHaveAttribute('dir', 'auto')
+ expect(screen.getByText('Me!')).toHaveAttribute('dir', 'auto')
+ expect(screen.getByPlaceholderText('collab.chat.placeholder')).toHaveAttribute('dir', 'auto')
+ })
+
it('FE-MOB-CCHAT-008: shows the quoted message above a reply bubble', async () => {
serveMessages([
chatMsg(1, { text: 'Where do we meet?' }),
diff --git a/client/tests/unit/mobile/trip/MCollabPolls.test.tsx b/client/tests/unit/mobile/trip/MCollabPolls.test.tsx
index e2f0c58e6..eb749788c 100644
--- a/client/tests/unit/mobile/trip/MCollabPolls.test.tsx
+++ b/client/tests/unit/mobile/trip/MCollabPolls.test.tsx
@@ -483,4 +483,25 @@ describe('MCollabPolls', () => {
options: ['A', 'B'],
})))
})
+
+ it('FE-MOB-POLLS-030: in Arabic the result fill grows from the start edge, and question and options keep their own direction', async () => {
+ setup([poll({
+ is_closed: true,
+ question: 'Day trip on Saturday?',
+ options: [
+ { text: 'Nara and the deer', label: 'Nara and the deer', voters: [voter(9, 'lea'), voter(10, 'tom')] },
+ { text: 'Kurama hike', label: 'Kurama hike', voters: [voter(11, 'ada')] },
+ ],
+ })])
+ const question = await screen.findByText('Day trip on Saturday?')
+ // The paragraph is isolated, so its question mark stays at its end in a right-to-left page.
+ expect(question.tagName).toBe('BDI')
+ expect(screen.getByText('Nara and the deer').tagName).toBe('BDI')
+ const option = screen.getByRole('button', { name: /Nara and the deer/ })
+ const fill = option.querySelector('span[style*="width"]') as HTMLElement
+ expect(fill.style.width).toBe('67%')
+ // Logical, not left-0: in Arabic the bar fills from the right, under the option's radio.
+ expect(fill).toHaveClass('start-0')
+ expect(fill.className).not.toMatch(/\bleft-0\b/)
+ })
})
diff --git a/client/tests/unit/mobile/trip/MCostsTab.test.tsx b/client/tests/unit/mobile/trip/MCostsTab.test.tsx
index 34867b9ca..27cd289ff 100644
--- a/client/tests/unit/mobile/trip/MCostsTab.test.tsx
+++ b/client/tests/unit/mobile/trip/MCostsTab.test.tsx
@@ -945,4 +945,27 @@ describe('MCostsTab', () => {
expect(within(expenses).getByPlaceholderText('costs.searchPlaceholder')).toBeInTheDocument()
expect(within(expenses).getByRole('heading', { name: 'costs.expenses' })).toBeInTheDocument()
})
+
+ it('FE-MOB-COSTT-055: in Arabic a signed balance and an amount read as one left-to-right run, and a payment names who paid whom in reading order', async () => {
+ const payment = { id: 503, from_user_id: 3, to_user_id: 1, amount: 15, currency: 'USD', created_at: '2026-06-15T09:00:00Z' }
+ serveSettlement({ ...SETTLEMENT, settlements: [payment] })
+ // The page reads right to left for the whole render (the translation provider
+ // would otherwise set it back to the test's English).
+ Object.defineProperty(document.documentElement, 'dir', { configurable: true, get: () => 'rtl', set: () => {} })
+ try {
+ await renderTab()
+ const settle = up(screen.getByText('costs.settleUp'), 3)
+ // The sign stays in front of the figure instead of trailing it.
+ expect(within(settle).getByText('+$12.50')).toHaveAttribute('dir', 'ltr')
+ expect(within(settle).getByText('−$7.25')).toHaveAttribute('dir', 'ltr')
+ expect(within(settle).getByText('$40.00')).toHaveAttribute('dir', 'ltr')
+ // Names keep their own direction, so a Latin one loses its end when cut.
+ expect(within(settle).getByText('Ghost')).toHaveAttribute('dir', 'auto')
+ // Each name isolated, the arrow pointing left from the payer to the payee.
+ expect(screen.getByText('\u2068Bob\u2069 ← \u2068costs.you\u2069')).toBeInTheDocument()
+ } finally {
+ // Back to the prototype's reflected attribute.
+ delete (document.documentElement as { dir?: string }).dir
+ }
+ })
})
diff --git a/client/tests/unit/mobile/trip/MFilesTab.test.tsx b/client/tests/unit/mobile/trip/MFilesTab.test.tsx
index 1b527b5d7..0d6d3bb68 100644
--- a/client/tests/unit/mobile/trip/MFilesTab.test.tsx
+++ b/client/tests/unit/mobile/trip/MFilesTab.test.tsx
@@ -10,7 +10,7 @@ import { setSizeClass } from '../../../helpers/sizeClass'
import { fileRepo } from '../../../../src/repo/fileRepo'
import { act, fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
-// FE-MOB-FTAB-001 to FE-MOB-FTAB-027
+// FE-MOB-FTAB-001 to FE-MOB-FTAB-028
vi.mock('../../../../src/utils/fileDownload', () => ({
downloadFile: vi.fn(async () => undefined),
@@ -189,6 +189,13 @@ describe('MFilesTab', () => {
expect(screen.getByText('files.sourceCollab')).toBeInTheDocument()
})
+ it('FE-MOB-FTAB-028: in Arabic a size reads "2.0 KB", and names keep their own direction', () => {
+ renderTab()
+ expect(screen.getByText('2.0 KB')).toHaveAttribute('dir', 'ltr')
+ expect(screen.getByText('ada')).toHaveAttribute('dir', 'auto')
+ for (const node of screen.queryAllByText(NAME_RE)) expect(node).toHaveAttribute('dir', 'auto')
+ })
+
it('FE-MOB-FTAB-008: starring calls the api and refreshes the store', async () => {
const { planner } = renderTab()
expect(screen.getByRole('button', { name: 'files.unstar' })).toBeInTheDocument()