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