From 2a6c771ad9b0b58464a5c2349d0ca3755f047513 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 7 Nov 2026 23:21:25 +0100 Subject: [PATCH] fix(native): the first screens fit a phone on its side, fill an upright iPad, and read right in Arabic and Russian On a phone or a folded foldable turned landscape the welcome, address, TREK Cloud and plan screens were taller than the glass: the second choice and the main button sat below the edge and Back was cut at the top. There the explanation and the choices now stand side by side, the explanation pinned while a long plan list scrolls. Every screen centres while it fits and starts at the top when it does not. Upright on an iPad, half of one in Split View and an unfolded foldable, the photo spans the full width and fades only downwards, and the choices stand centred in the room below it. Buttons and fields are pills like the app's, choice cards take the app's card radius, and headings and subtitles balance their lines. In Arabic (and Hebrew, which never ran right to left here) chevrons point along the reading direction and sit at the far edge of their card, the Cloud lockup stays unmirrored on the leading edge, prices and addresses read left to right, and a plan's period no longer touches its price. The slides follow the reading direction. Russian gets Cyrillic stand-ins under the same family names, so the headline keeps a rounded face. --- .../fonts/comfortaa-cyrillic-700.woff2 | Bin 0 -> 7900 bytes .../fonts/montserrat-cyrillic-500.woff2 | Bin 0 -> 11128 bytes .../fonts/montserrat-cyrillic-600.woff2 | Bin 0 -> 11120 bytes .../fonts/montserrat-cyrillic-700.woff2 | Bin 0 -> 11160 bytes client/native-shell/i18n.ts | 7 +- client/native-shell/main.ts | 151 ++++++---- client/native-shell/shell.css | 258 +++++++++++++++--- client/tests/unit/native/nativeShell.test.ts | 94 +++++++ 8 files changed, 420 insertions(+), 90 deletions(-) create mode 100644 client/native-shell/fonts/comfortaa-cyrillic-700.woff2 create mode 100644 client/native-shell/fonts/montserrat-cyrillic-500.woff2 create mode 100644 client/native-shell/fonts/montserrat-cyrillic-600.woff2 create mode 100644 client/native-shell/fonts/montserrat-cyrillic-700.woff2 create mode 100644 client/tests/unit/native/nativeShell.test.ts diff --git a/client/native-shell/fonts/comfortaa-cyrillic-700.woff2 b/client/native-shell/fonts/comfortaa-cyrillic-700.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..41d58aa3454a9f1dfaede9ca241383b0e82b2840 GIT binary patch literal 7900 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 CxmeTy literal 0 HcmV?d00001 diff --git a/client/native-shell/fonts/montserrat-cyrillic-700.woff2 b/client/native-shell/fonts/montserrat-cyrillic-700.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..3138c7769be870c267e4ec66179340cb462d3211 GIT binary patch literal 11160 zcmV;JD`(VqPew8T0RR9104taP5&!@I09zaY04py50RR9100000000000000000000 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 literal 0 HcmV?d00001 diff --git a/client/native-shell/i18n.ts b/client/native-shell/i18n.ts index 43e9df949..c22cc594d 100644 --- a/client/native-shell/i18n.ts +++ b/client/native-shell/i18n.ts @@ -1,4 +1,4 @@ -import { SUPPORTED_LANGUAGE_CODES } from '@trek/shared/i18n' +import { SUPPORTED_LANGUAGE_CODES, isRtlLanguage } from '@trek/shared/i18n' import type { TranslationStrings } from '@trek/shared/i18n' // Only the native strings of every locale, not the whole UI: this page ships @@ -32,3 +32,8 @@ export function translator(code: string) { : key } } + +/** Arabic and Hebrew run right to left. */ +export function directionOf(code: string): 'rtl' | 'ltr' { + return isRtlLanguage(code) ? 'rtl' : 'ltr' +} diff --git a/client/native-shell/main.ts b/client/native-shell/main.ts index b26c6bd74..6c7f978c2 100644 --- a/client/native-shell/main.ts +++ b/client/native-shell/main.ts @@ -9,7 +9,7 @@ import { fakeIapClient, getIapClient, productIdFor, type Purchase } from '../src import { CLOUD_TERMS_URL, CLOUD_URL, PRIVACY_URL, SERVER_DOCS_URL, STORE_TERMS_URL } from '../src/native/links'; import { probeDemo, probeServer, type ProbeFailure, type ProbeGet } from '../src/native/serverAddress'; import { TrekShell } from '../src/native/trekShell'; -import { pickLocale, translator } from './i18n'; +import { directionOf, pickLocale, translator } from './i18n'; import './shell.css'; /** @@ -24,7 +24,7 @@ const locale = pickLocale(navigator.languages?.length ? navigator.languages : [n const onPlay = Capacitor.getPlatform() === 'android'; const t = translator(locale); document.documentElement.lang = locale; -document.documentElement.dir = locale === 'ar' ? 'rtl' : 'ltr'; +document.documentElement.dir = directionOf(locale); const root = document.getElementById('shell')!; const PROBE_TIMEOUT_MS = 8000; @@ -128,6 +128,11 @@ function show(screen: HTMLElement, opts: { back?: boolean } = {}): void { if (opts.back) screen.classList.add('screen-back'); const previous = current; if (previous) { + // The leaving screen is lifted out of the flow where it stands, so it fades + // from its own place rather than jumping to the top of a centred layout. + previous.style.top = `${previous.offsetTop}px`; + previous.style.left = `${previous.offsetLeft}px`; + previous.style.width = `${previous.offsetWidth}px`; previous.classList.remove('screen-enter'); previous.classList.add('screen-leave'); if (opts.back) previous.classList.add('screen-back'); @@ -144,6 +149,15 @@ function externalLink(text: string, href: string): HTMLAnchorElement { function footLinks(...extra: Node[]): HTMLElement { return el('div', { className: 'foot-links' }, ...extra, externalLink(t('native.privacy'), PRIVACY_URL)); } +/** + * A screen's two halves: what it is (brand, title, explanation) and what to do + * on it (the choices, the form). Stacked in one column they read as before; on + * a phone or a foldable on its side the screen is too short for one column, so + * the halves stand side by side there (shell.css, `.col`). + */ +function columns(about: Node[], act: Node[]): HTMLElement[] { + return [el('div', { className: 'col col-about' }, ...about), el('div', { className: 'col col-act' }, ...act)]; +} function topbar(onBack: () => void): HTMLElement { const back = el('button', { type: 'button', className: 'back' }, icon('M15 18l-6-6 6-6', 18), t('native.back')); back.addEventListener('click', () => { @@ -202,13 +216,14 @@ function showWelcome(opts: { back?: boolean } = {}): void { el( 'div', {}, - hero, - el('h1', { className: 'title brand-title' }, t('native.welcome.headline')), - el('p', { className: 'sub' }, t('native.welcome.sub')), - cloud, - connect, - demo, - footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL)) + ...columns( + [ + hero, + el('h1', { className: 'title brand-title' }, t('native.welcome.headline')), + el('p', { className: 'sub' }, t('native.welcome.sub')), + ], + [cloud, connect, demo, footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))] + ) ), opts ); @@ -225,6 +240,8 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void { }); input.setAttribute('inputmode', 'url'); input.setAttribute('autocomplete', 'url'); + // An address reads left to right in every language, Arabic too. + input.dir = 'ltr'; // The on-screen keyboard's return key submits the address; it reads "Go", not a newline. input.setAttribute('enterkeyhint', 'go'); input.setAttribute('aria-label', t('native.connect.title')); @@ -266,11 +283,14 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void { el( 'div', {}, - topbar(() => showWelcome({ back: true })), - el('h1', { className: 'title-sm' }, t('native.connect.title')), - el('p', { className: 'sub' }, t('native.connect.hint')), - form, - footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL)) + ...columns( + [ + topbar(() => showWelcome({ back: true })), + el('h1', { className: 'title-sm' }, t('native.connect.title')), + el('p', { className: 'sub' }, t('native.connect.hint')), + ], + [form, footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))] + ) ), opts ); @@ -288,7 +308,13 @@ function feature(path: string, text: string): HTMLElement { } function showCloud(opts: { back?: boolean } = {}): void { - const cta = el('button', { type: 'button', className: 'btn' }, t('native.cloud.cta'), icon(I.chevron, 20, 'chev')); + // The label centred, the chevron at the far edge: it points on, not at the word. + const cta = el( + 'button', + { type: 'button', className: 'btn btn-go' }, + el('span', { className: 'btn-label' }, t('native.cloud.cta')), + icon(I.chevron, 20, 'chev') + ); cta.addEventListener('click', () => { void tap(); showPlans(); @@ -304,25 +330,32 @@ function showCloud(opts: { back?: boolean } = {}): void { el( 'div', {}, - topbar(() => showWelcome({ back: true })), - el( - 'div', - { className: 'cloud-wordmark', role: 'img', ariaLabel: t('native.cloud.title') }, - el('img', { src: logoWhite, alt: '', className: 'on-dark' }), - el('img', { src: logoBlack, alt: '', className: 'on-light' }), - el('span', { className: 'cloud-tag' }, 'cloud') - ), - el('p', { className: 'sub' }, t('native.cloud.sub')), - el( - 'div', - { className: 'features' }, - feature(I.globe, t('native.cloud.f1')), - feature(I.bolt, t('native.cloud.f2')), - feature(I.shield, t('native.cloud.f3')) - ), - cta, - have, - footLinks() + ...columns( + [ + topbar(() => showWelcome({ back: true })), + // The lockup is a logo: "trek" then "cloud" in every language, never mirrored. + el( + 'div', + { className: 'cloud-wordmark', role: 'img', ariaLabel: t('native.cloud.title'), dir: 'ltr' }, + el('img', { src: logoWhite, alt: '', className: 'on-dark' }), + el('img', { src: logoBlack, alt: '', className: 'on-light' }), + el('span', { className: 'cloud-tag' }, 'cloud') + ), + el('p', { className: 'sub' }, t('native.cloud.sub')), + ], + [ + el( + 'div', + { className: 'features' }, + feature(I.globe, t('native.cloud.f1')), + feature(I.bolt, t('native.cloud.f2')), + feature(I.shield, t('native.cloud.f3')) + ), + cta, + have, + footLinks(), + ] + ) ), opts ); @@ -427,7 +460,9 @@ function planCard(plan: CloudPlan, interval: 'month' | 'year', offer: Offer): HT { className: 'plan-top' }, el('span', { className: 'plan-name' }, plan.name), // No price yet (orders not open): the plan shows without one rather than with an empty one. - ...(label ? [el('span', { className: 'plan-price' }, label, el('small', {}, per))] : []) + // The price is figures and a currency sign: one left-to-right run, so Arabic + // keeps "5,99 €" whole, with the period standing apart beside it. + ...(label ? [el('span', { className: 'plan-price' }, el('bdi', { dir: 'ltr' }, label), el('small', {}, per))] : []) ), el( 'div', @@ -578,12 +613,15 @@ function showPlans(opts: { back?: boolean } = {}): void { el( 'div', {}, - topbar(() => showCloud({ back: true })), - el('h1', { className: 'title-sm' }, t('native.plans.title')), - el('p', { className: 'sub' }, t('native.plans.sub')), - seg, - content, - footer + ...columns( + [ + topbar(() => showCloud({ back: true })), + el('h1', { className: 'title-sm' }, t('native.plans.title')), + el('p', { className: 'sub' }, t('native.plans.sub')), + seg, + ], + [content, footer] + ) ), opts ); @@ -661,9 +699,10 @@ function showClaim( slugInput.setAttribute('autocomplete', 'off'); slugInput.setAttribute('maxlength', '40'); slugInput.setAttribute('aria-label', t('native.claim.addressLabel')); + // "name.liketrek.com" is one address, left to right in every language. const slugField = el( 'div', - { className: 'field slug-field' }, + { className: 'field slug-field', dir: 'ltr' }, slugInput, el('span', { className: 'slug-suffix' }, cloudSuffix()) ); @@ -680,6 +719,8 @@ function showClaim( emailInput.setAttribute('autocomplete', 'email'); emailInput.setAttribute('enterkeyhint', 'go'); emailInput.setAttribute('aria-label', t('native.claim.emailLabel')); + emailInput.dir = 'ltr'; + emailInput.classList.add('ltr-value'); const emailField = el('div', { className: 'field' }, emailInput); const submit = el('button', { type: 'submit', className: 'btn' }, t('native.claim.subscribe', { price })); @@ -805,10 +846,14 @@ function showClaim( el( 'div', {}, - topbar(() => showPlans({ back: true })), - el('h1', { className: 'title-sm' }, t('native.claim.title')), - el('p', { className: 'sub' }, t('native.claim.sub')), - form + ...columns( + [ + topbar(() => showPlans({ back: true })), + el('h1', { className: 'title-sm' }, t('native.claim.title')), + el('p', { className: 'sub' }, t('native.claim.sub')), + ], + [form] + ) ), opts ); @@ -933,12 +978,14 @@ function showUnreachable(server: string): void { el( 'div', {}, - el('img', { src: logoUrl, alt: '', className: 'shell-logo' }), - el('h1', { className: 'title-sm' }, t('native.offline.title')), - el('p', { className: 'sub' }, t('native.offline.hint', { server: new URL(server).host })), - retry, - change, - footLinks() + ...columns( + [ + el('img', { src: logoUrl, alt: '', className: 'shell-logo' }), + el('h1', { className: 'title-sm' }, t('native.offline.title')), + el('p', { className: 'sub' }, t('native.offline.hint', { server: new URL(server).host })), + ], + [retry, change, footLinks()] + ) ) ); } diff --git a/client/native-shell/shell.css b/client/native-shell/shell.css index 016c73e61..9778de8cd 100644 --- a/client/native-shell/shell.css +++ b/client/native-shell/shell.css @@ -38,6 +38,45 @@ font-display: swap; src: url('./fonts/museomoderno-latin-600.woff2') format('woff2'); } +/* + * Poppins and MuseoModerno have no Cyrillic, so Russian and Ukrainian fell back + * to the system's plain sans. Each family gets a Cyrillic-only stand-in under its + * own name, picked for the same build: Montserrat for the geometric text face, + * Comfortaa for the rounded headline. The unicode-range keeps Latin on TREK's + * own faces and loads these only when a Cyrillic letter is on screen. + */ +@font-face { + font-family: 'Poppins'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('./fonts/montserrat-cyrillic-500.woff2') format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +@font-face { + font-family: 'Poppins'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url('./fonts/montserrat-cyrillic-600.woff2') format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +@font-face { + font-family: 'Poppins'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('./fonts/montserrat-cyrillic-700.woff2') format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +@font-face { + font-family: 'MuseoModerno'; + font-style: normal; + font-weight: 600 700; + font-display: swap; + src: url('./fonts/comfortaa-cyrillic-700.woff2') format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} /* Italic, subset to the letters of "cloud" — the wordmark's badge, nothing else. */ @font-face { font-family: 'MuseoModerno'; @@ -71,8 +110,13 @@ --radius: 16px; --ease: cubic-bezier(0.22, 1, 0.36, 1); --ease-in: cubic-bezier(0.4, 0, 1, 1); + /* 1 left to right, -1 in Arabic: a screen that comes next slides in from the trailing side. */ + --flow: 1; color-scheme: dark; } +:root[dir='rtl'] { + --flow: -1; +} @media (prefers-color-scheme: light) { :root { @@ -167,6 +211,18 @@ body::after { pointer-events: none; z-index: 0; } +/* In Arabic the photo holds the leading (right) side and the grid the other. */ +:root[dir='rtl'] body::after { + background-position: + 0 0, + right 28%; + -webkit-mask-image: linear-gradient(270deg, #000 0%, #000 40%, transparent 80%); + mask-image: linear-gradient(270deg, #000 0%, #000 40%, transparent 80%); +} +:root[dir='rtl'] body::before { + -webkit-mask-image: radial-gradient(135% 95% at 0% 0%, #000 34%, transparent 80%); + mask-image: radial-gradient(135% 95% at 0% 0%, #000 34%, transparent 80%); +} .shell { position: relative; @@ -175,11 +231,12 @@ body::after { min-height: 100dvh; display: flex; flex-direction: column; - justify-content: center; max-width: 440px; margin: 0 auto; padding: calc(env(safe-area-inset-top) + 28px) 24px calc(env(safe-area-inset-bottom) + 24px); - overflow: hidden; + /* Clip only the sideways slide: a screen taller than the window scrolls the + page instead (and a sticky column needs an ancestor that does not scroll). */ + overflow-x: clip; } /* ── Screen transitions ──────────────────────────────────────────────────── */ @@ -187,14 +244,22 @@ body::after { display: flex; flex-direction: column; gap: 14px; + /* Centred while it fits; taller than the window it starts at the top and the + page scrolls, rather than overflowing both edges as justify-content would. */ + margin-block: auto; +} +/* A screen's two halves (main.ts, columns()): one column unless the layout asks. */ +.col { + display: contents; } .screen-enter { animation: screenIn 0.5s var(--ease) both; } +/* Lifted out where it stood: main.ts sets its top, left and width. */ .screen-leave { animation: screenOut 0.28s var(--ease-in) both; position: absolute; - inset: calc(env(safe-area-inset-top) + 28px) 24px calc(env(safe-area-inset-bottom) + 24px); + margin: 0; } .screen-back.screen-enter { animation-name: screenInBack; @@ -205,7 +270,7 @@ body::after { @keyframes screenIn { from { opacity: 0; - transform: translateX(26px); + transform: translateX(calc(26px * var(--flow))); } to { opacity: 1; @@ -215,13 +280,13 @@ body::after { @keyframes screenOut { to { opacity: 0; - transform: translateX(-22px); + transform: translateX(calc(-22px * var(--flow))); } } @keyframes screenInBack { from { opacity: 0; - transform: translateX(-26px); + transform: translateX(calc(-26px * var(--flow))); } to { opacity: 1; @@ -231,14 +296,40 @@ body::after { @keyframes screenOutBack { to { opacity: 0; - transform: translateX(22px); + transform: translateX(calc(22px * var(--flow))); } } -/* Children stagger in, so a screen assembles rather than snaps. */ -.screen-enter > * { +/* Children stagger in, so a screen assembles rather than snaps. Inside the two + halves the explanation comes first and the choices follow. */ +.screen-enter > :not(.col), +.screen-enter > .col > * { animation: riseIn 0.55s var(--ease) both; } +.screen-enter > .col > :nth-child(1) { + animation-delay: 0.02s; +} +.screen-enter > .col > :nth-child(2) { + animation-delay: 0.07s; +} +.screen-enter > .col > :nth-child(3) { + animation-delay: 0.12s; +} +.screen-enter > .col > :nth-child(4) { + animation-delay: 0.17s; +} +.screen-enter > .col-act > :nth-child(1) { + animation-delay: 0.15s; +} +.screen-enter > .col-act > :nth-child(2) { + animation-delay: 0.2s; +} +.screen-enter > .col-act > :nth-child(3) { + animation-delay: 0.25s; +} +.screen-enter > .col-act > :nth-child(4) { + animation-delay: 0.3s; +} .screen-enter > *:nth-child(1) { animation-delay: 0.02s; } @@ -364,6 +455,7 @@ body::after { margin: 0; /* Honour the newline in the headline string (e.g. "Deine Reisen.\nDein Plan."). */ white-space: pre-line; + text-wrap: balance; } /* The welcome headline as the brand says it: MuseoModerno, lower case. */ .brand-title { @@ -380,6 +472,8 @@ body::after { display: inline-flex; align-items: center; gap: 9px; + /* Left to right inside (dir="ltr"), yet on the screen's start edge in Arabic. */ + align-self: flex-start; } .cloud-wordmark img { display: block; @@ -414,6 +508,7 @@ body::after { font-weight: 700; letter-spacing: -0.025em; margin: 0; + text-wrap: balance; } .sub { color: var(--muted); @@ -421,6 +516,8 @@ body::after { line-height: 1.55; font-size: 0.9rem; overflow-wrap: anywhere; + /* No lone last word ("AI.") on a line of its own. */ + text-wrap: balance; } /* ── Buttons ─────────────────────────────────────────────────────────────── */ @@ -428,9 +525,10 @@ body::after { font: inherit; font-weight: 600; font-size: 1.02rem; - padding: 16px 18px; + padding: 16px 22px; border: 1px solid transparent; - border-radius: var(--radius); + /* A pill, as the app's buttons are. */ + border-radius: 999px; background: var(--accent); color: var(--accent-ink); cursor: pointer; @@ -455,6 +553,11 @@ body::after { .btn:disabled { opacity: 0.55; } +/* A two-line choice (welcome) is a card rather than a button: the app's card radius. */ +.btn:has(.btn-row) { + border-radius: 22px; + padding-inline: 18px; +} .btn-ghost { background: var(--surface); color: var(--ink); @@ -471,6 +574,8 @@ body::after { flex-direction: column; align-items: flex-start; gap: 2px; + /* A subtitle that wraps keeps to the start edge under its title. */ + text-align: start; } .btn-row b { font-weight: 650; @@ -481,9 +586,20 @@ body::after { opacity: 0.7; } .btn .chev { - margin-left: auto; + flex: none; + margin-inline-start: auto; opacity: 0.5; } +/* A centred label with the chevron at the far edge, the label optically centred. */ +.btn-go .btn-label { + flex: 1; + padding-inline-start: 20px; +} +/* Chevrons point along the reading direction. */ +:root[dir='rtl'] .chev, +:root[dir='rtl'] .back svg { + transform: scaleX(-1); +} .linkbtn { font: inherit; @@ -507,7 +623,7 @@ body::after { margin-top: 2px; padding: 9px 18px; min-height: 40px; - border-radius: var(--radius); + border-radius: 999px; border: 1px solid var(--edge); background: transparent; color: var(--muted); @@ -565,8 +681,8 @@ a.linkbtn { width: 100%; font: inherit; font-size: 1rem; - padding: 13px 16px; - border-radius: var(--radius); + padding: 13px 18px; + border-radius: 999px; border: 1px solid var(--edge); background: var(--surface); color: var(--ink); @@ -707,7 +823,8 @@ a.linkbtn { .topbar { display: flex; align-items: center; - margin: 0 0 2px -8px; + margin-block: 0 2px; + margin-inline-start: -8px; } .back { font: inherit; @@ -741,7 +858,7 @@ a.linkbtn { padding: 4px; background: var(--surface); border: 1px solid var(--edge); - border-radius: 13px; + border-radius: 999px; align-self: stretch; } .segmented button { @@ -751,7 +868,7 @@ a.linkbtn { font-size: 0.95rem; padding: 9px; border: 0; - border-radius: 9px; + border-radius: 999px; background: none; color: var(--muted); cursor: pointer; @@ -829,7 +946,7 @@ a.linkbtn { font-weight: 500; font-size: 0.8rem; color: var(--muted); - margin-left: 7px; + margin-inline-start: 7px; } .plan-feats { margin: 10px 0 14px; @@ -902,8 +1019,8 @@ a.linkbtn { .slug-field { display: flex; align-items: center; - padding-right: 16px; - border-radius: var(--radius); + padding-inline-end: 18px; + border-radius: 999px; border: 1px solid var(--edge); background: var(--surface); box-shadow: @@ -913,14 +1030,20 @@ a.linkbtn { border-color 0.2s, box-shadow 0.25s; } -.slug-field .slug-input { +.slug-field .slug-input, +.slug-field .slug-input:focus { flex: 1; min-width: 0; - text-align: left; + text-align: start; border: 0; background: transparent; + /* The ring is the whole field's (focus-within below), not a second one inside it. */ box-shadow: none; } +/* An email is typed left to right, but its Arabic placeholder reads from the right. */ +:root[dir='rtl'] .field input.ltr-value:placeholder-shown { + direction: rtl; +} .slug-field:focus-within { border-color: var(--edge-strong); box-shadow: 0 0 0 4px color-mix(in srgb, var(--ink) 9%, transparent); @@ -934,7 +1057,7 @@ a.linkbtn { flex: none; color: var(--muted); font-size: 0.95rem; - padding-left: 4px; + padding-inline-start: 4px; user-select: none; } .hint { @@ -984,7 +1107,8 @@ a.linkbtn { Upright, the photo band grows with the screen instead of stopping at a phone's 440px. Logical sides, so Arabic mirrors it. */ @media (min-width: 840px) and (min-height: 600px) and (orientation: landscape) { - body::after { + body::after, + :root[dir='rtl'] body::after { inset: 16px auto 16px 16px; inset-block: 16px; inset-inline: 16px auto; @@ -1009,9 +1133,6 @@ a.linkbtn { margin-inline: 50% 0; padding-inline: max(32px, calc(25vw - 220px)); } - .screen-leave { - inset-inline: max(32px, calc(25vw - 220px)); - } } /* With the whole half of a 13" iPad on its side, the plans stand two by two rather than in a column to scroll through. */ @@ -1024,23 +1145,28 @@ a.linkbtn { grid-template-columns: repeat(2, minmax(0, 1fr)); } } -/* An iPad upright or an unfolded foldable: the photo spans the width as a band - across the top, and the choices stand under it in a wider column than a - phone's, so the page reads as one composition rather than a phone screen - floating in the middle of the glass. The plans take two columns. */ -@media (min-width: 700px) and (min-height: 900px) and (orientation: portrait) { +/* An iPad upright, a foldable unfolded, half of an iPad in Split View: the photo + spans the whole width as a band across the top and fades out only downwards, + and the choices stand under it in a wider column than a phone's, centred in + the room the band leaves, so the page reads as one composition rather than a + phone screen at the top of the glass. The plans take two columns. */ +@media (min-width: 600px) and (min-height: 800px) and (orientation: portrait) { body::before, body::after { - height: 52vh; + height: 56vh; } - body::after { + body::after, + :root[dir='rtl'] body::after { + background-position: + 0 0, + center 30%; -webkit-mask-image: none; mask-image: none; } .shell { max-width: 560px; - justify-content: flex-start; - padding-top: calc(env(safe-area-inset-top) + 38vh); + padding-top: calc(env(safe-area-inset-top) + 28vh); + padding-bottom: calc(env(safe-area-inset-bottom) + 6vh); } .shell:has(.plans) { max-width: 760px; @@ -1051,3 +1177,61 @@ a.linkbtn { grid-template-columns: repeat(2, minmax(0, 1fr)); } } + +/* ── A short screen on its side ────────────────────────────────────────────── + A phone, or a foldable folded, turned landscape: one column of choices is + taller than the glass, and the second choice or the main button ended up + below the edge. The two halves of a screen stand side by side instead, what + it is on the leading side and what to do on the other, top-aligned under the + status bar. The leading half stays put while a long list beside it (the + plans) scrolls, so Back is always in reach. */ +@media (orientation: landscape) and (max-height: 500px) { + .shell { + max-width: 880px; + padding-top: calc(env(safe-area-inset-top) + 16px); + padding-bottom: calc(env(safe-area-inset-bottom) + 16px); + padding-inline: max(28px, env(safe-area-inset-left)) max(28px, env(safe-area-inset-right)); + } + .screen:has(> .col) { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + column-gap: 40px; + align-items: center; + } + /* The plans run longer than the window: both halves start at the top. */ + .screen:has(.plans) { + align-items: start; + } + .col { + display: flex; + flex-direction: column; + gap: 12px; + } + .col-about { + position: sticky; + top: calc(env(safe-area-inset-top) + 16px); + } + .hero-logo { + margin-bottom: 4px; + } + .hero-logo img { + height: 46px; + } + .title { + font-size: 1.75rem; + } + .btn { + min-height: 50px; + padding-block: 12px; + } + .features { + margin: 0; + } + .feature { + padding: 7px 0; + } + .foot-links { + margin-top: 0; + padding-top: 10px; + } +} diff --git a/client/tests/unit/native/nativeShell.test.ts b/client/tests/unit/native/nativeShell.test.ts new file mode 100644 index 000000000..c92548ca1 --- /dev/null +++ b/client/tests/unit/native/nativeShell.test.ts @@ -0,0 +1,94 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +import { directionOf, pickLocale, translator } from '../../../native-shell/i18n' + +// FE-NATIVE-SHELL-001 to FE-NATIVE-SHELL-006 +// +// The app's own first screens (client/native-shell): which language and reading +// direction they start in, and the layout rules in shell.css that no component +// test reaches (jsdom applies no stylesheet, so these read the real file). + +/** 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 = '' + for (const ch of css.replace(/\/\*[\s\S]*?\*\//g, '')) { + if (ch === '{') { + stack.push(buf.trim()) + buf = '' + } else if (ch === '}') { + 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 }) + } + buf = '' + } else { + buf += ch + } + } + return out +} + +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))) + +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') + }) + + it('FE-NATIVE-SHELL-002: the first screens speak without dashes as punctuation', () => { + for (const code of ['en', 'de']) { + const t = translator(code) + for (const key of ['native.welcome.sub', 'native.cloud.sub', 'native.cloud.f3']) { + 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) + 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() + } + }) + + 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/) + // Everywhere else the halves are no boxes of their own: one column, as before. + 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/) + }) + + 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/) + }) +})