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 000000000..41d58aa34 Binary files /dev/null and b/client/native-shell/fonts/comfortaa-cyrillic-700.woff2 differ diff --git a/client/native-shell/fonts/montserrat-cyrillic-500.woff2 b/client/native-shell/fonts/montserrat-cyrillic-500.woff2 new file mode 100644 index 000000000..e92b9344d Binary files /dev/null and b/client/native-shell/fonts/montserrat-cyrillic-500.woff2 differ diff --git a/client/native-shell/fonts/montserrat-cyrillic-600.woff2 b/client/native-shell/fonts/montserrat-cyrillic-600.woff2 new file mode 100644 index 000000000..a2ec8920e Binary files /dev/null and b/client/native-shell/fonts/montserrat-cyrillic-600.woff2 differ 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 000000000..3138c7769 Binary files /dev/null and b/client/native-shell/fonts/montserrat-cyrillic-700.woff2 differ 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/) + }) +})