diff --git a/client/scripts/rtl-lint.mjs b/client/scripts/rtl-lint.mjs index 0b68a1696..bf74034c7 100644 --- a/client/scripts/rtl-lint.mjs +++ b/client/scripts/rtl-lint.mjs @@ -24,6 +24,7 @@ import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { join, relative } from 'node:path' import { fileURLToPath } from 'node:url' +import ts from 'typescript' const SRC = fileURLToPath(new URL('../src', import.meta.url)) const BASELINE = fileURLToPath(new URL('./rtl-baseline.json', import.meta.url)) @@ -61,40 +62,47 @@ function walk(dir, files = []) { } /** - * The code of each line, without its comments: prose like "top right" or - * "left out" is no side. Good enough for this source, which keeps `//` and - * `/*` out of string literals except in URLs (`https://`). + * The source with its comments blanked out, so prose like "top right" or + * "left out" is no side. TypeScript finds the comments in a .ts/.tsx file, + * which keeps `/*` and `//` inside strings and JSX text where they belong + * (accept="image/*", a URL); a stylesheet only has block comments. */ -function codeLines(source) { - let inBlock = false - return source.split('\n').map((line) => { - let code = '' - let rest = line - while (rest) { - if (inBlock) { - const end = rest.indexOf('*/') - if (end < 0) return code - rest = rest.slice(end + 2) - inBlock = false - continue - } - const block = rest.indexOf('/*') - const lineComment = rest.search(/(?= 0 && (block < 0 || lineComment < block)) return code + rest.slice(0, lineComment) - if (block < 0) return code + rest - code += rest.slice(0, block) - rest = rest.slice(block + 2) - inBlock = true +function withoutComments(source, file) { + const chars = source.split('') + const blank = (pos, end) => { + for (let i = pos; i < end; i++) if (chars[i] !== '\n') chars[i] = ' ' + } + if (file.endsWith('.css')) { + for (const m of source.matchAll(/\/\*[\s\S]*?\*\//g)) blank(m.index, m.index + m[0].length) + return chars.join('') + } + const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS + const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind) + const seen = new Set() + const take = (ranges) => { + for (const r of ranges ?? []) { + if (seen.has(r.pos)) continue + seen.add(r.pos) + blank(r.pos, r.end) } - return code - }) + } + const visit = (node) => { + take(ts.getLeadingCommentRanges(source, node.pos)) + take(ts.getTrailingCommentRanges(source, node.end)) + // JSX text holds no comments, and asking for them there would read `//` in a URL as one. + if (!ts.isJsxText(node)) for (const child of node.getChildren(sf)) visit(child) + } + visit(sf) + take(ts.getLeadingCommentRanges(source, sf.endOfFileToken.pos)) + return chars.join('') } /** Every physical use in one file, as `line: match`. */ -export function physicalUses(source, isCss) { +export function physicalUses(source, file) { + const isCss = file.endsWith('.css') const uses = [] const lines = source.split('\n') - codeLines(source).forEach((code, i) => { + withoutComments(source, file).split('\n').forEach((code, i) => { if (lines[i].includes(DISABLE)) return const patterns = isCss ? [CSS] : [CLASS, STYLE] for (const re of patterns) for (const m of code.match(re) ?? []) uses.push(`${i + 1}: ${m.trim()}`) @@ -106,7 +114,7 @@ function scan() { const counts = {} const listed = {} for (const file of walk(SRC)) { - const uses = physicalUses(readFileSync(file, 'utf8'), file.endsWith('.css')) + const uses = physicalUses(readFileSync(file, 'utf8'), file) if (!uses.length) continue const key = relative(SRC, file).replace(/\\/g, '/') counts[key] = uses.length diff --git a/client/src/components/Budget/BudgetPanelSummary.tsx b/client/src/components/Budget/BudgetPanelSummary.tsx index bf829ab49..fbf335123 100644 --- a/client/src/components/Budget/BudgetPanelSummary.tsx +++ b/client/src/components/Budget/BudgetPanelSummary.tsx @@ -57,11 +57,14 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has const sep = (0.1).toLocaleString(locale).replace(/\d/g, '') const [integerPart, decimalPart] = decimals > 0 ? full.split(sep) : [full, ''] return ( - // dir=ltr: the amount is split into spans, which a right-to-left flex row would reorder. -
- {integerPart} - {decimalPart && {sep}{decimalPart}} - {SYMBOLS[currency] || currency} +
+ {/* One left-to-right box at the row's start: the amount is split into + spans, which a right-to-left flex row would reorder. */} + + {integerPart} + {decimalPart && {sep}{decimalPart}} + {SYMBOLS[currency] || currency} +
) })()} diff --git a/client/src/components/SystemNotices/SystemNoticeModal.test.tsx b/client/src/components/SystemNotices/SystemNoticeModal.test.tsx index 05a68b6fa..ab2810494 100644 --- a/client/src/components/SystemNotices/SystemNoticeModal.test.tsx +++ b/client/src/components/SystemNotices/SystemNoticeModal.test.tsx @@ -1199,10 +1199,13 @@ describe('ModalRenderer', () => { await flushGraceDelay(); const { sheet, strip } = sheetParts(); + // The slots run right to left, so the strip sits shifted the other way to + // show the current one; the left-to-right shift left the sheet empty. + expect(strip.style.transform).toBe('translateX(33.333%)'); await act(async () => { swipeHorizontally(sheet, 120, 250); }); - expect(strip.style.transform).toBe('translateX(-66.666%)'); + expect(strip.style.transform).toBe('translateX(66.666%)'); await act(async () => { fireEvent.transitionEnd(strip); diff --git a/client/src/components/SystemNotices/SystemNoticeModal.tsx b/client/src/components/SystemNotices/SystemNoticeModal.tsx index edd71e016..a9e0d168a 100644 --- a/client/src/components/SystemNotices/SystemNoticeModal.tsx +++ b/client/src/components/SystemNotices/SystemNoticeModal.tsx @@ -668,6 +668,11 @@ function MobileNoticeSheet(S: NoticeState) { const prevNotice = notices[idx - 1] ?? null; const nextNotice = notices[idx + 1] ?? null; + // Where the 300% strip has to sit to show slot 0 (prev), 1 (current) or 2 + // (next). In a right-to-left page the slots run the other way and the strip + // overflows to the left, so every shift points the other way; with the + // left-to-right shifts the sheet showed nothing at all in Arabic. + const stripShift = (slot: 0 | 1 | 2): number => (isRtlLanguage(language) ? 1 : -1) * slot * 33.333; return (
@@ -718,7 +723,7 @@ function MobileNoticeSheet(S: NoticeState) { if (!strip) return; strip.style.transition = 'none'; // Strip base = -33.333% (center slot visible); dx offsets from there - strip.style.transform = `translateX(calc(-33.333% + ${dx}px))`; + strip.style.transform = `translateX(calc(${stripShift(1)}% + ${dx}px))`; } else if (dragLockRef.current === 'v' && notice.dismissible) { // Only intercept downward drag for dismiss when the sheet is scrolled to the top. // If scrolled into content, let native pan-y scroll it back up. @@ -751,7 +756,7 @@ function MobileNoticeSheet(S: NoticeState) { if ((goNext && canGoNext) || (goPrev && canGoPrev)) { // Animate strip to the adjacent slot (-66.666% = next, 0% = prev) strip.style.transition = 'transform 200ms ease-out'; - strip.style.transform = goNext ? 'translateX(-66.666%)' : 'translateX(0%)'; + strip.style.transform = `translateX(${stripShift(goNext ? 2 : 0)}%)`; strip.addEventListener('transitionend', function onDone() { strip.removeEventListener('transitionend', onDone); strip.style.transition = 'none'; @@ -767,16 +772,16 @@ function MobileNoticeSheet(S: NoticeState) { prevSlotRef.current?.scrollTo({ top: 0 }); contentWrapperRef.current?.scrollTo({ top: 0 }); nextSlotRef.current?.scrollTo({ top: 0 }); - strip.style.transform = 'translateX(-33.333%)'; + strip.style.transform = `translateX(${stripShift(1)}%)`; }, { once: true }); } else { // Spring back to center strip.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)'; - strip.style.transform = 'translateX(-33.333%)'; + strip.style.transform = `translateX(${stripShift(1)}%)`; strip.addEventListener('transitionend', function onSnap() { strip.removeEventListener('transitionend', onSnap); strip.style.transition = ''; - strip.style.transform = 'translateX(-33.333%)'; + strip.style.transform = `translateX(${stripShift(1)}%)`; }, { once: true }); } return; @@ -809,7 +814,7 @@ function MobileNoticeSheet(S: NoticeState) { {/* 3-slot strip: [prev][current][next] โ€” starts at -33.333% to show current */}
{/* The side slots are drag previews only: their pager and CTA are a second copy of the centre ones, so they stay out of the a11y tree diff --git a/client/src/mobile/screens/trip/tabs/MCollabChat.tsx b/client/src/mobile/screens/trip/tabs/MCollabChat.tsx index c45cf4ba7..bb43887ba 100644 --- a/client/src/mobile/screens/trip/tabs/MCollabChat.tsx +++ b/client/src/mobile/screens/trip/tabs/MCollabChat.tsx @@ -471,7 +471,7 @@ function ChatBubbleRow({ msg, own, showHeader, isLastInGroup, marginTop, is12h, className={`px-[13px] py-[9px] text-[0.8125rem] leading-[1.45] ${ own ? 'bg-m-act text-m-actfg' : 'border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-ink' }`} - style={{ borderRadius: own ? OWN_BUBBLE_RADIUS : OTHER_BUBBLE_RADIUS }} + style={own ? OWN_BUBBLE_RADIUS : OTHER_BUBBLE_RADIUS} > {hasReply && (
diff --git a/client/src/mobile/screens/trip/tabs/collabModel.ts b/client/src/mobile/screens/trip/tabs/collabModel.ts index a34c527a2..d818e8b67 100644 --- a/client/src/mobile/screens/trip/tabs/collabModel.ts +++ b/client/src/mobile/screens/trip/tabs/collabModel.ts @@ -54,9 +54,10 @@ export const QUICK_REACTIONS = ['โค๏ธ', '๐Ÿ˜‚', '๐Ÿ‘', '๐Ÿ˜ฎ', '๐Ÿ˜ข', '๐Ÿ”ฅ' /** Bubble corner radii, spec 03 ยง6.1 โ€” constant per sender, not per group * position (grouping is expressed through spacing + a single trailing - * timestamp instead, see MCollabChat). */ -export const OWN_BUBBLE_RADIUS = '16px 16px 4px 16px' -export const OTHER_BUBBLE_RADIUS = '4px 16px 16px 16px' + * timestamp instead, see MCollabChat). Logical corners, so the tail follows + * the row to the other side in a right-to-left language. */ +export const OWN_BUBBLE_RADIUS = { borderStartStartRadius: 16, borderStartEndRadius: 16, borderEndEndRadius: 4, borderEndStartRadius: 16 } as const +export const OTHER_BUBBLE_RADIUS = { borderStartStartRadius: 4, borderStartEndRadius: 16, borderEndEndRadius: 16, borderEndStartRadius: 16 } as const // SQLite stores UTC without a 'Z' suffix โ€” append one so JS parses it as UTC // rather than local time (same fix the desktop helpers apply). diff --git a/client/tests/unit/mobile/trip/collabModel.test.ts b/client/tests/unit/mobile/trip/collabModel.test.ts index 205120cdd..c51b484f1 100644 --- a/client/tests/unit/mobile/trip/collabModel.test.ts +++ b/client/tests/unit/mobile/trip/collabModel.test.ts @@ -105,8 +105,10 @@ describe('collabModel โ€” constants', () => { expect(QUICK_REACTIONS).toHaveLength(8); expect(QUICK_REACTIONS).toContain('โค๏ธ'); expect(new Set(QUICK_REACTIONS).size).toBe(QUICK_REACTIONS.length); - expect(OWN_BUBBLE_RADIUS).toBe('16px 16px 4px 16px'); - expect(OTHER_BUBBLE_RADIUS).toBe('4px 16px 16px 16px'); + // Logical corners: in a left-to-right language the same as '16px 16px 4px 16px' + // and '4px 16px 16px 16px', and the tail follows the row in right to left. + expect(OWN_BUBBLE_RADIUS).toEqual({ borderStartStartRadius: 16, borderStartEndRadius: 16, borderEndEndRadius: 4, borderEndStartRadius: 16 }); + expect(OTHER_BUBBLE_RADIUS).toEqual({ borderStartStartRadius: 4, borderStartEndRadius: 16, borderEndEndRadius: 16, borderEndStartRadius: 16 }); expect(NOTE_COLORS).toHaveLength(6); }); });