From bf00412863bae62033900ff9c0e29dbdd5014b9c Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 7 Nov 2026 23:21:33 +0100 Subject: [PATCH] fix(mobile): Arabic reads in its direction: arrows turn round, Latin names keep their ends, figures stay whole In the touch shell the back, forward, disclosure, date-range and flow arrows kept pointing right in Arabic, so Settle up showed the debt the wrong way round and Vacay's year arrows pointed inward. The shell now mirrors lucide's direction arrows in right-to-left languages; an icon that names its own treatment keeps it, and the lightbox arrows stay on their physical sides. Latin user text in Arabic rows lost its beginning when cut and moved its punctuation to the wrong end. Names, titles, addresses and file names take their direction from their own text (dir=auto) and still line up with the row; markdown paragraphs, chat messages, poll options and journal stories are isolated; signed balances, amounts, sizes, rates and leg figures read as one left-to-right run; URL fields read left to right. Name pairs joined by an arrow isolate each name and point from the first to the second. The hero progress and poll result bars fill from the start edge. Arabic and Hebrew uppercase caption labels grow a step and take the muted ink, and a todo's due date in a notification is written in the reader's language instead of as YYYY-MM-DD. --- client/scripts/rtl-baseline.json | 2 - .../components/Journey/JournalBody.test.tsx | 5 +- client/src/components/Journey/JournalBody.tsx | 9 +-- .../Notifications/InAppNotificationItem.tsx | 4 +- .../Notifications/notificationModel.test.tsx | 22 ++++++- .../Notifications/notificationModel.ts | 28 +++++++++ .../components/shared/MarkdownText.test.tsx | 19 ++++++ client/src/components/shared/MarkdownText.tsx | 9 ++- .../components/shared/markdownSanitize.tsx | 30 +++++++++ client/src/main.tsx | 1 + .../screens/collections/MCollAddSheet.tsx | 4 +- .../screens/collections/MCollLinksEditor.tsx | 1 + .../collections/MCollListPickerSheet.tsx | 2 +- .../screens/collections/MCollPlaceRow.tsx | 6 +- .../screens/collections/MCollPlaceSheet.tsx | 2 +- .../collections/MCollTripPickerSheet.tsx | 2 +- .../screens/collections/MCollections.tsx | 2 +- .../mobile/screens/dashboard/MDashWidgets.tsx | 6 +- .../mobile/screens/dashboard/MDashboard.tsx | 8 +-- .../src/mobile/screens/journey/MJourney.tsx | 4 +- .../screens/journey/MJourneyEntrySheet.tsx | 10 ++- .../mobile/screens/journey/MJourneyFeed.tsx | 6 +- .../notifications/MNotificationRow.tsx | 5 +- .../screens/settings/MSettingsGeneral.tsx | 9 +-- .../screens/trip/places/MPlacesBrowser.tsx | 2 +- .../screens/trip/plan/MPlanTimeline.tsx | 2 +- .../screens/trip/plan/MPlanTimelineRows.tsx | 18 +++--- .../trip/sheets/MAccommodationSheet.tsx | 8 +-- .../screens/trip/sheets/MBookingFields.tsx | 1 + .../mobile/screens/trip/sheets/MCostSheet.tsx | 8 +-- .../mobile/screens/trip/sheets/MDaySheet.tsx | 6 +- .../screens/trip/sheets/MLinkedCosts.tsx | 4 +- .../screens/trip/sheets/MPlaceEditSheet.tsx | 1 + .../screens/trip/sheets/MPlaceSheet.tsx | 12 ++-- .../screens/trip/sheets/MReservationSheet.tsx | 4 +- .../screens/trip/sheets/MTransportSheet.tsx | 13 ++-- .../screens/trip/sheets/PlFileAttach.tsx | 6 +- .../screens/trip/sheets/PlPlaceSearch.tsx | 8 +-- .../mobile/screens/trip/tabs/MBookingsTab.tsx | 2 +- .../mobile/screens/trip/tabs/MCollabChat.tsx | 5 +- .../mobile/screens/trip/tabs/MCollabNotes.tsx | 7 ++- .../mobile/screens/trip/tabs/MCollabPolls.tsx | 4 +- .../mobile/screens/trip/tabs/MCostsTab.tsx | 33 +++++----- .../screens/trip/tabs/MFileLightbox.tsx | 5 +- .../screens/trip/tabs/MFileMenuSheet.tsx | 2 +- .../screens/trip/tabs/MFileTrashSheet.tsx | 2 +- .../mobile/screens/trip/tabs/MFilesBoard.tsx | 7 ++- .../mobile/screens/trip/tabs/MFilesTab.tsx | 6 +- .../screens/trip/tabs/MPackingListTab.tsx | 2 +- .../trip/tabs/MReservationCardParts.tsx | 6 +- .../screens/trip/tabs/MTransportsTab.tsx | 5 +- .../mobile/screens/trip/tabs/tabChrome.tsx | 2 +- client/src/styles/rtl.css | 62 +++++++++++++++++++ client/src/styles/rtlCss.test.ts | 50 +++++++++++++++ client/src/utils/flowText.test.ts | 34 ++++++++++ client/src/utils/flowText.ts | 22 +++++++ .../unit/mobile/dashboard/MDashboard.test.tsx | 12 ++++ .../unit/mobile/trip/MCollabChat.test.tsx | 14 ++++- .../unit/mobile/trip/MCollabPolls.test.tsx | 21 +++++++ .../tests/unit/mobile/trip/MCostsTab.test.tsx | 23 +++++++ .../tests/unit/mobile/trip/MFilesTab.test.tsx | 9 ++- 61 files changed, 496 insertions(+), 128 deletions(-) create mode 100644 client/src/styles/rtl.css create mode 100644 client/src/styles/rtlCss.test.ts create mode 100644 client/src/utils/flowText.test.ts create mode 100644 client/src/utils/flowText.ts 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 }) => ( +

+ {children} +

+ ), + h1: ({ children }) => ( +

+ {children} +

+ ), + h2: ({ children }) => ( +

+ {children} +

+ ), + h3: ({ children }) => ( +

+ {children} +

+ ), +} + /** * Links as in markdownLinkComponents (#1629): own tab plus `rel` protection. * The exception is a footnote or heading link, which points inside the text @@ -38,6 +67,7 @@ export const sanitizedMarkdownPlugins: NonNullable = [ * somewhere else. */ export const sanitizedMarkdownComponents: Components = { + ...isolatedTextBlocks, a: ({ children, href, ...rest }) => href?.startsWith('#') ? ( diff --git a/client/src/main.tsx b/client/src/main.tsx index 7b879d5c8..573a80997 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -25,6 +25,7 @@ import '@fontsource/geist-sans/600.css' // several .leaflet-* styles. import 'leaflet/dist/leaflet.css' import './index.css' +import './styles/rtl.css' import './styles/ferratas.css' import './styles/paddle.css' import './styles/walkRoute.css' diff --git a/client/src/mobile/screens/collections/MCollAddSheet.tsx b/client/src/mobile/screens/collections/MCollAddSheet.tsx index bae23e7b7..6cbab86df 100644 --- a/client/src/mobile/screens/collections/MCollAddSheet.tsx +++ b/client/src/mobile/screens/collections/MCollAddSheet.tsx @@ -99,8 +99,8 @@ export default function MCollAddSheet({ open, collectionId, collectionName, list - {str(r.name)} - {str(r.address) && {str(r.address)}} + {str(r.name)} + {str(r.address) && {str(r.address)}} ))} diff --git a/client/src/mobile/screens/collections/MCollLinksEditor.tsx b/client/src/mobile/screens/collections/MCollLinksEditor.tsx index ccf74c4f3..deb1b5bdb 100644 --- a/client/src/mobile/screens/collections/MCollLinksEditor.tsx +++ b/client/src/mobile/screens/collections/MCollLinksEditor.tsx @@ -27,6 +27,7 @@ export default function MCollLinksEditor({ links, onChange, t }: MCollLinksEdito className={`w-24 flex-none ${rowInput}`} /> setLink(i, { url: e.target.value })} placeholder="https://…" diff --git a/client/src/mobile/screens/collections/MCollListPickerSheet.tsx b/client/src/mobile/screens/collections/MCollListPickerSheet.tsx index 3ef0f75ce..1f2311d7c 100644 --- a/client/src/mobile/screens/collections/MCollListPickerSheet.tsx +++ b/client/src/mobile/screens/collections/MCollListPickerSheet.tsx @@ -56,7 +56,7 @@ export default function MCollListPickerSheet({ mode, lists, count, onPick, onClo className="mb-1 flex w-full items-center gap-[10px] rounded-[13px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-3 py-[11px] text-start disabled:opacity-60" > - {list.name} + {list.name} {busyId === list.id ? : {list.place_count ?? 0}} diff --git a/client/src/mobile/screens/collections/MCollPlaceRow.tsx b/client/src/mobile/screens/collections/MCollPlaceRow.tsx index ce8cdc82a..29fada868 100644 --- a/client/src/mobile/screens/collections/MCollPlaceRow.tsx +++ b/client/src/mobile/screens/collections/MCollPlaceRow.tsx @@ -59,11 +59,11 @@ export default function MCollPlaceRow({ {selected ? : } - {place.name} + {place.name} {place.address && ( - {place.address} + {place.address} )} @@ -84,7 +84,7 @@ export default function MCollPlaceRow({ {cat && place.category?.name && ( - {place.category.name} + {place.category.name} )}
diff --git a/client/src/mobile/screens/collections/MCollPlaceSheet.tsx b/client/src/mobile/screens/collections/MCollPlaceSheet.tsx index b71bbf063..a4f6f83f5 100644 --- a/client/src/mobile/screens/collections/MCollPlaceSheet.tsx +++ b/client/src/mobile/screens/collections/MCollPlaceSheet.tsx @@ -89,7 +89,7 @@ export default function MCollPlaceSheet({ {held.category?.name && ( - {held.category.name} + {held.category.name} )} {/* -me-1 keeps the buttons 14px off the edge, where close has always sat. */} diff --git a/client/src/mobile/screens/collections/MCollTripPickerSheet.tsx b/client/src/mobile/screens/collections/MCollTripPickerSheet.tsx index 698cf81c7..8159e376b 100644 --- a/client/src/mobile/screens/collections/MCollTripPickerSheet.tsx +++ b/client/src/mobile/screens/collections/MCollTripPickerSheet.tsx @@ -59,7 +59,7 @@ export default function MCollTripPickerSheet({ open, count, onCopy, onClose, t } : } - {trip.title} + {trip.title} {dateRange(trip) && ( {dateRange(trip)} diff --git a/client/src/mobile/screens/collections/MCollections.tsx b/client/src/mobile/screens/collections/MCollections.tsx index bb271a27e..ae6e2e48c 100644 --- a/client/src/mobile/screens/collections/MCollections.tsx +++ b/client/src/mobile/screens/collections/MCollections.tsx @@ -367,7 +367,7 @@ export default function MCollections() { className={`flex w-full items-center gap-[10px] rounded-[11px] p-[10px] text-start ${on ? 'bg-[color:var(--m-ic)]' : ''}`} > - {l.name} + {l.name} {l.count} {on && } diff --git a/client/src/mobile/screens/dashboard/MDashWidgets.tsx b/client/src/mobile/screens/dashboard/MDashWidgets.tsx index 5cf89cac5..73aaa8132 100644 --- a/client/src/mobile/screens/dashboard/MDashWidgets.tsx +++ b/client/src/mobile/screens/dashboard/MDashWidgets.tsx @@ -140,7 +140,7 @@ function MCurrencyWidget(): React.ReactElement {
- {rate != null ? `1 ${from} = ${rate.toFixed(4)} ${to}` : t('dashboard.fx.unavailable')} + {rate != null ? {`1 ${from} = ${rate.toFixed(4)} ${to}`} : t('dashboard.fx.unavailable')}
) @@ -195,7 +195,7 @@ function MCollectionsWidget(): React.ReactElement { className="mt-[11px] flex w-full items-center rounded-[14px] p-[15px_14px] text-start text-white" style={{ background: list.color || entityGradient(list.id) }} > - {list.name} + {list.name} {list.place_count ?? 0} @@ -307,7 +307,7 @@ function MUpcomingWidget({ items }: { items: UpcomingReservation[] }): React.Rea
- {r.title} + {r.title} {r.status === 'pending' && ( {t('reservations.pending')} diff --git a/client/src/mobile/screens/dashboard/MDashboard.tsx b/client/src/mobile/screens/dashboard/MDashboard.tsx index 92df6413d..f766028ad 100644 --- a/client/src/mobile/screens/dashboard/MDashboard.tsx +++ b/client/src/mobile/screens/dashboard/MDashboard.tsx @@ -418,10 +418,10 @@ function MSpotlightCard({ trip, t, onOpen, actions }: { )} -
{trip.title}
+
{trip.title}
{ongoing && (
- +
)}
@@ -541,7 +541,7 @@ function MTripGridCard({ trip, locale, badge, pluginBadges, actions, onOpen }: {
{/* The name has the card's whole width; the dates take the line under it. */} -
{trip.title}
+
{trip.title}
{fullDate(trip.start_date, locale) ?? '—'} @@ -572,7 +572,7 @@ function MTripListCard({ trip, locale, t, badge, pluginBadges, actions, onOpen }
{actions.map(a => )}
-
+
{trip.title}
diff --git a/client/src/mobile/screens/journey/MJourney.tsx b/client/src/mobile/screens/journey/MJourney.tsx index 83dc69e47..adf5ed1d0 100644 --- a/client/src/mobile/screens/journey/MJourney.tsx +++ b/client/src/mobile/screens/journey/MJourney.tsx @@ -151,7 +151,7 @@ function HeroCard({ journey, onOpen }: { journey: JourneyListItem; onOpen: () => {t('mobileJourney.latestJourney')}
-
{journey.title}
+
{journey.title}
} label={t('mobileJourney.entriesCount', { count: journey.entry_count ?? 0 })} /> } label={t('mobileJourney.photosCount', { count: journey.photo_count ?? 0 })} /> @@ -186,7 +186,7 @@ function GridCard({ journey, onOpen }: { journey: JourneyListItem; onOpen: () => >
-
{journey.title}
+
{journey.title}
{hasStats && (
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 ? ( -
{title || t('journey.editor.titlePlaceholder')}
+
{title || t('journey.editor.titlePlaceholder')}
) : ( setTitle(e.target.value)} placeholder={t('journey.editor.titlePlaceholder')} @@ -404,6 +405,7 @@ export default function MJourneyEntrySheet({ {captureOnly && !readOnly && (