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