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 && (