diff --git a/client/src/components/Notifications/notificationModel.test.tsx b/client/src/components/Notifications/notificationModel.test.tsx
index e35626e5c..87e1ea7f5 100644
--- a/client/src/components/Notifications/notificationModel.test.tsx
+++ b/client/src/components/Notifications/notificationModel.test.tsx
@@ -103,7 +103,10 @@ describe('localizedParams', () => {
});
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({ 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/mobile/screens/journey/MJourneyFeed.tsx b/client/src/mobile/screens/journey/MJourneyFeed.tsx
index 2efefcdaa..27573382d 100644
--- a/client/src/mobile/screens/journey/MJourneyFeed.tsx
+++ b/client/src/mobile/screens/journey/MJourneyFeed.tsx
@@ -137,12 +137,19 @@ function FeedCard({ entry, color, active, onTap, showMood, showWeather, t }: Fee
) : (
)}
-
+
{place}
)}
- {story && {story}}
+ {story && (
+
+ {story}
+
+ )}
);
diff --git a/client/src/mobile/screens/trip/tabs/MReservationCardParts.tsx b/client/src/mobile/screens/trip/tabs/MReservationCardParts.tsx
index 122781c1c..0696c9d3f 100644
--- a/client/src/mobile/screens/trip/tabs/MReservationCardParts.tsx
+++ b/client/src/mobile/screens/trip/tabs/MReservationCardParts.tsx
@@ -44,7 +44,10 @@ export function CardTitleButton({
onClick={onOpen}
className="flex min-w-0 flex-1 flex-wrap items-center gap-x-[7px] gap-y-1 text-start"
>
-
+
{res.title}
@@ -116,7 +119,9 @@ export function CardWhenAndCode({
{dayDate ? (
- {dayValue}
+
+ {dayValue}
+
{dayDate}
) : (
@@ -178,7 +183,9 @@ export function CardFiles({ files, card, t }: { files: ReturnType
- {f.original_name}
+
+ {f.original_name}
+
))}
diff --git a/client/src/styles/rtl.css b/client/src/styles/rtl.css
index 67a98a3e4..dc3527959 100644
--- a/client/src/styles/rtl.css
+++ b/client/src/styles/rtl.css
@@ -15,9 +15,9 @@
* 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)
- ) {
+ :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;
}
@@ -32,7 +32,8 @@
* 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
+:root[dir='rtl']
+ .m-root
:is(
.lucide-chevron-left,
.lucide-chevron-right,
diff --git a/client/src/styles/rtlCss.test.ts b/client/src/styles/rtlCss.test.ts
index 8bb1af4db..56e0c9799 100644
--- a/client/src/styles/rtlCss.test.ts
+++ b/client/src/styles/rtlCss.test.ts
@@ -1,6 +1,6 @@
-import { readFileSync } from 'node:fs'
-import { resolve } from 'node:path'
-import { describe, expect, it } from 'vitest'
+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
//
@@ -8,43 +8,55 @@ import { describe, expect, it } from 'vitest'
// 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, '')
+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)
+ 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 ''
+ 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'/)
- })
+ 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/)
+ 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)")
+ 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'\]/)
- })
+ 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/)
+ 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:'])")
- })
+ 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\)/)
- })
-})
+ 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/tests/unit/native/nativeShell.test.ts b/client/tests/unit/native/nativeShell.test.ts
index c92548ca1..80cddc2cf 100644
--- a/client/tests/unit/native/nativeShell.test.ts
+++ b/client/tests/unit/native/nativeShell.test.ts
@@ -1,8 +1,8 @@
-import { readFileSync } from 'node:fs'
-import { resolve } from 'node:path'
-import { describe, expect, it } from 'vitest'
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { describe, expect, it } from 'vitest';
-import { directionOf, pickLocale, translator } from '../../../native-shell/i18n'
+import { directionOf, pickLocale, translator } from '../../../native-shell/i18n';
// FE-NATIVE-SHELL-001 to FE-NATIVE-SHELL-006
//
@@ -12,83 +12,85 @@ import { directionOf, pickLocale, translator } from '../../../native-shell/i18n'
/** Every rule of a stylesheet as its selector list, its declarations and the media query around it, comments dropped. */
function rules(css: string): { selectors: string[]; body: string; media: string }[] {
- const out: { selectors: string[]; body: string; media: string }[] = []
- const stack: string[] = []
- let buf = ''
+ const out: { selectors: string[]; body: string; media: string }[] = [];
+ const stack: string[] = [];
+ let buf = '';
for (const ch of css.replace(/\/\*[\s\S]*?\*\//g, '')) {
if (ch === '{') {
- stack.push(buf.trim())
- buf = ''
+ stack.push(buf.trim());
+ buf = '';
} else if (ch === '}') {
- const head = stack.pop() ?? ''
+ const head = stack.pop() ?? '';
if (!head.startsWith('@')) {
- const media = stack.filter(h => h.startsWith('@media')).join(' ')
- out.push({ selectors: head.split(',').map(sel => sel.trim()), body: buf, media })
+ const media = stack.filter((h) => h.startsWith('@media')).join(' ');
+ out.push({ selectors: head.split(',').map((sel) => sel.trim()), body: buf, media });
}
- buf = ''
+ buf = '';
} else {
- buf += ch
+ buf += ch;
}
}
- return out
+ return out;
}
-const css = readFileSync(resolve(process.cwd(), 'native-shell/shell.css'), 'utf8')
-const all = rules(css)
+const css = readFileSync(resolve(process.cwd(), 'native-shell/shell.css'), 'utf8');
+const all = rules(css);
const find = (selector: string, media?: RegExp) =>
- all.filter(r => r.selectors.includes(selector) && (!media || media.test(r.media)))
+ all.filter((r) => r.selectors.includes(selector) && (!media || media.test(r.media)));
describe('native shell', () => {
it('FE-NATIVE-SHELL-001: Arabic and Hebrew start right to left, every other language left to right', () => {
- expect(pickLocale(['ar-SA'])).toBe('ar')
- expect(pickLocale(['he-IL'])).toBe('he')
- expect(directionOf('ar')).toBe('rtl')
- expect(directionOf('he')).toBe('rtl')
- expect(directionOf('ru')).toBe('ltr')
- expect(directionOf('de')).toBe('ltr')
- })
+ expect(pickLocale(['ar-SA'])).toBe('ar');
+ expect(pickLocale(['he-IL'])).toBe('he');
+ expect(directionOf('ar')).toBe('rtl');
+ expect(directionOf('he')).toBe('rtl');
+ expect(directionOf('ru')).toBe('ltr');
+ expect(directionOf('de')).toBe('ltr');
+ });
it('FE-NATIVE-SHELL-002: the first screens speak without dashes as punctuation', () => {
for (const code of ['en', 'de']) {
- const t = translator(code)
+ const t = translator(code);
for (const key of ['native.welcome.sub', 'native.cloud.sub', 'native.cloud.f3']) {
- expect(t(key)).not.toMatch(/[\u2013\u2014]| - /)
+ expect(t(key)).not.toMatch(/[\u2013\u2014]| - /);
}
}
- })
+ });
- it('FE-NATIVE-SHELL-003: Russian keeps TREK\'s faces through Cyrillic stand-ins under the same family names', () => {
- const faces = css.match(/@font-face\s*\{[^}]*\}/g) ?? []
- const cyrillic = faces.filter(f => /unicode-range:\s*U\+0301, U\+0400-045F/.test(f))
- expect(cyrillic.filter(f => /font-family:\s*'Poppins'/.test(f))).toHaveLength(3)
- expect(cyrillic.filter(f => /font-family:\s*'MuseoModerno'/.test(f))).toHaveLength(1)
+ it("FE-NATIVE-SHELL-003: Russian keeps TREK's faces through Cyrillic stand-ins under the same family names", () => {
+ const faces = css.match(/@font-face\s*\{[^}]*\}/g) ?? [];
+ const cyrillic = faces.filter((f) => /unicode-range:\s*U\+0301, U\+0400-045F/.test(f));
+ expect(cyrillic.filter((f) => /font-family:\s*'Poppins'/.test(f))).toHaveLength(3);
+ expect(cyrillic.filter((f) => /font-family:\s*'MuseoModerno'/.test(f))).toHaveLength(1);
for (const face of cyrillic) {
- const file = /url\('\.\/fonts\/([^']+)'\)/.exec(face)?.[1]
- expect(file).toBeTruthy()
- expect(() => readFileSync(resolve(process.cwd(), 'native-shell/fonts', file as string))).not.toThrow()
+ const file = /url\('\.\/fonts\/([^']+)'\)/.exec(face)?.[1];
+ expect(file).toBeTruthy();
+ expect(() => readFileSync(resolve(process.cwd(), 'native-shell/fonts', file as string))).not.toThrow();
}
- })
+ });
it('FE-NATIVE-SHELL-004: a short screen on its side puts the two halves of a screen side by side', () => {
- const compact = /orientation: landscape\) and \(max-height: 500px\)/
- expect(find('.screen:has(> .col)', compact)[0]?.body).toMatch(/grid-template-columns:\s*minmax\(0, 1fr\) minmax\(0, 1fr\)/)
- expect(find('.col-about', compact)[0]?.body).toMatch(/position:\s*sticky/)
+ const compact = /orientation: landscape\) and \(max-height: 500px\)/;
+ expect(find('.screen:has(> .col)', compact)[0]?.body).toMatch(
+ /grid-template-columns:\s*minmax\(0, 1fr\) minmax\(0, 1fr\)/
+ );
+ expect(find('.col-about', compact)[0]?.body).toMatch(/position:\s*sticky/);
// Everywhere else the halves are no boxes of their own: one column, as before.
- expect(find('.col')[0]?.body).toMatch(/display:\s*contents/)
- })
+ expect(find('.col')[0]?.body).toMatch(/display:\s*contents/);
+ });
it('FE-NATIVE-SHELL-005: a screen taller than the window scrolls instead of overflowing both edges', () => {
- expect(find('.screen')[0]?.body).toMatch(/margin-block:\s*auto/)
- const shell = find('.shell')[0]?.body ?? ''
- expect(shell).toMatch(/overflow-x:\s*clip/)
- expect(shell).not.toMatch(/justify-content:\s*center/)
- })
+ expect(find('.screen')[0]?.body).toMatch(/margin-block:\s*auto/);
+ const shell = find('.shell')[0]?.body ?? '';
+ expect(shell).toMatch(/overflow-x:\s*clip/);
+ expect(shell).not.toMatch(/justify-content:\s*center/);
+ });
it('FE-NATIVE-SHELL-006: in Arabic the chevrons sit at the far edge and point along the reading direction, the price stands apart', () => {
- expect(find('.btn .chev')[0]?.body).toMatch(/margin-inline-start:\s*auto/)
- expect(find(":root[dir='rtl'] .chev")[0]?.body).toMatch(/transform:\s*scaleX\(-1\)/)
- expect(find('.plan-price small')[0]?.body).toMatch(/margin-inline-start:\s*7px/)
- expect(find('.btn-row')[0]?.body).toMatch(/text-align:\s*start/)
- expect(css).not.toMatch(/margin-left|margin-right|padding-left|padding-right|text-align:\s*left/)
- })
-})
+ expect(find('.btn .chev')[0]?.body).toMatch(/margin-inline-start:\s*auto/);
+ expect(find(":root[dir='rtl'] .chev")[0]?.body).toMatch(/transform:\s*scaleX\(-1\)/);
+ expect(find('.plan-price small')[0]?.body).toMatch(/margin-inline-start:\s*7px/);
+ expect(find('.btn-row')[0]?.body).toMatch(/text-align:\s*start/);
+ expect(css).not.toMatch(/margin-left|margin-right|padding-left|padding-right|text-align:\s*left/);
+ });
+});