Compare commits

...

18 Commits

Author SHA1 Message Date
Maurice aaa2095899 ci(ios): import one stored distribution certificate instead of minting one per run 2026-11-05 23:21:35 +01:00
Maurice 9679a84f32 feat(native): fix the claim fields into one address box, add placeholders, shorter fields, better title 2026-11-05 23:10:09 +01:00
Maurice ab76c3a441 feat(native): equal plan cards, mark absent features with a dash, one-line unlimited label 2026-11-05 23:05:56 +01:00
Maurice 1eb3b0834d fix(native): more space between price and its per-period suffix 2026-11-05 23:03:09 +01:00
Maurice 7db7366299 feat(native): sample plans in dev preview, drop the yearly badge, plain white primary, readable subtext 2026-11-05 23:02:41 +01:00
Maurice e99e0e148d feat(native): drop the demo button from the connect screen 2026-11-05 22:59:39 +01:00
Maurice 8b3e2e31dc feat(native): TREK Cloud wordmark lockup on the cloud screen, fix a German string 2026-11-05 22:56:55 +01:00
Maurice bbe94c6ae5 feat(native): make TREK Cloud the primary action, shorter calmer subtext 2026-11-05 22:52:51 +01:00
Maurice 4c6eeaef26 feat(native): a welcome subtext that shows the whole TREK ecosystem, hosting-neutral 2026-11-05 22:48:32 +01:00
Maurice 3c95678eb1 feat(native): blend the photo into the grid left-to-right, slimmer demo button 2026-11-05 22:46:26 +01:00
Maurice 3d911b13d8 feat(native): set the welcome headline in lowercase MuseoModerno, full-width demo button 2026-11-05 22:43:52 +01:00
Maurice d98fabd095 feat(native): Kyoto hero band, headline tagline, tidy demo and footer 2026-11-05 22:41:18 +01:00
Maurice 664a5399e8 feat(native): use the full TREK wordmark on the welcome screen, per scheme 2026-11-05 22:29:15 +01:00
Maurice ddf5fd2e64 feat(native): richer onboarding — Poppins display type, a masked grid backdrop and depth 2026-11-05 22:24:53 +01:00
Maurice 6654745199 ci(ios): create a passworded keychain so the distribution cert is usable for signing 2026-11-05 21:34:06 +01:00
Maurice 7882c9c837 ci(ios): sign the release with fastlane so TestFlight uploads from a device-less runner 2026-11-05 21:27:01 +01:00
Maurice 743ac0877d fix(ios): sign the Release build for distribution so the archive uploads to TestFlight 2026-11-05 21:17:46 +01:00
Maurice cd0ddce90e ci(native): gate the Android release job behind NATIVE_RELEASE_ANDROID 2026-11-05 21:09:48 +01:00
12 changed files with 481 additions and 102 deletions
+28 -38
View File
@@ -34,7 +34,10 @@ concurrency:
jobs:
android:
if: github.repository == 'liketrek/TREK'
# Gated on a repository variable so the release can ship iOS alone while the
# Google Play account and its signing secrets are not yet in place. Set the
# repository variable NATIVE_RELEASE_ANDROID to 1 to turn the Android half on.
if: github.repository == 'liketrek/TREK' && vars.NATIVE_RELEASE_ANDROID == '1'
runs-on: ubuntu-24.04
timeout-minutes: 40
environment: store
@@ -148,44 +151,31 @@ jobs:
ASC_KEY_P8_B64: ${{ secrets.ASC_KEY_P8_B64 }}
run: printf '%s' "$ASC_KEY_P8_B64" | base64 -d > "$RUNNER_TEMP/AuthKey.p8"
- name: Archive
working-directory: client/ios/App
- name: Distribution certificate
env:
APPLE_DIST_CERT_P12_B64: ${{ secrets.APPLE_DIST_CERT_P12_B64 }}
run: printf '%s' "$APPLE_DIST_CERT_P12_B64" | base64 -d > "$RUNNER_TEMP/dist.p12"
# Signs for distribution and uploads to TestFlight. Automatic signing cannot
# run on a device-less CI runner (it signs for development and then needs a
# development profile, which needs a registered device), so client/fastlane/Fastfile
# imports the stored distribution certificate and creates the App Store profile
# through the API key.
- name: Build and upload to TestFlight
working-directory: client
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_PATH: ${{ runner.temp }}/AuthKey.p8
CERT_P12_PATH: ${{ runner.temp }}/dist.p12
CERT_P12_PASSWORD: ${{ secrets.APPLE_DIST_CERT_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
run: >-
xcodebuild archive
-project App.xcodeproj
-scheme App
-configuration Release
-destination 'generic/platform=iOS'
-archivePath "$RUNNER_TEMP/TREK.xcarchive"
MARKETING_VERSION=${{ steps.version.outputs.version }}
CURRENT_PROJECT_VERSION=${{ steps.version.outputs.build }}
DEVELOPMENT_TEAM="$APPLE_TEAM_ID"
-allowProvisioningUpdates
-authenticationKeyPath "$RUNNER_TEMP/AuthKey.p8"
-authenticationKeyID "$ASC_KEY_ID"
-authenticationKeyIssuerID "$ASC_ISSUER_ID"
MARKETING_VERSION: ${{ steps.version.outputs.version }}
CURRENT_PROJECT_VERSION: ${{ steps.version.outputs.build }}
FASTLANE_SKIP_UPDATE_CHECK: '1'
FASTLANE_HIDE_CHANGELOG: '1'
run: fastlane ios beta
# ExportOptions.plist says destination=upload, so this step sends the build
# to App Store Connect, where it shows up in TestFlight after processing.
- name: Export and upload to App Store Connect
working-directory: client/ios/App
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
run: >-
xcodebuild -exportArchive
-archivePath "$RUNNER_TEMP/TREK.xcarchive"
-exportOptionsPlist ../ExportOptions.plist
-exportPath "$RUNNER_TEMP/export"
-allowProvisioningUpdates
-authenticationKeyPath "$RUNNER_TEMP/AuthKey.p8"
-authenticationKeyID "$ASC_KEY_ID"
-authenticationKeyIssuerID "$ASC_ISSUER_ID"
- name: Remove the API key
- name: Remove secrets
if: always()
run: rm -f "$RUNNER_TEMP/AuthKey.p8"
run: rm -f "$RUNNER_TEMP/AuthKey.p8" "$RUNNER_TEMP/dist.p12"
+99
View File
@@ -0,0 +1,99 @@
# iOS release signing and TestFlight upload, driven by .github/workflows/native-release.yml.
#
# Automatic signing cannot work on a CI runner with no registered devices: it signs
# the archive for development and then needs a development provisioning profile, which
# requires a device. So this signs manually for distribution instead. The distribution
# certificate and the App Store provisioning profile are created (or reused) through the
# App Store Connect API key, which means no certificate or profile has to be stored as a
# secret: the key the workflow already holds is enough.
#
# The lane expects these in the environment (the workflow sets them):
# ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH — the App Store Connect API key
# APPLE_TEAM_ID — the developer team
# MARKETING_VERSION, CURRENT_PROJECT_VERSION — the version and build number
# RUNNER_TEMP — a writable scratch directory
default_platform(:ios)
APP_IDENTIFIER = "com.liketrek.trek"
PROJECT = "ios/App/App.xcodeproj"
platform :ios do
desc "Build the signed Release app and upload it to TestFlight (internal testing)."
lane :beta do
key = app_store_connect_api_key(
key_id: ENV.fetch("ASC_KEY_ID"),
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
key_filepath: ENV.fetch("ASC_KEY_PATH"),
in_house: false,
)
# A throwaway keychain with a known password, so fastlane can unlock the imported
# private key for code signing (without the password it cannot set the key's access
# list and the certificate is never usable).
keychain_name = "trek_ci.keychain"
keychain_password = "trek-ci-#{ENV.fetch('GITHUB_RUN_ID', 'local')}"
create_keychain(
name: keychain_name,
password: keychain_password,
default_keychain: true,
unlock: true,
timeout: 3600,
lock_when_sleeps: false,
)
keychain_path = File.expand_path("~/Library/Keychains/#{keychain_name}-db")
# Import the one distribution certificate from its stored .p12, rather than
# minting a new one each run — Apple caps distribution certificates at two, so
# a fresh one per build would quickly hit the limit. The matching App Store
# profile is created (or reused) through the API key.
import_certificate(
certificate_path: ENV.fetch("CERT_P12_PATH"),
certificate_password: ENV.fetch("CERT_P12_PASSWORD"),
keychain_name: keychain_name,
keychain_path: keychain_path,
keychain_password: keychain_password,
)
get_provisioning_profile(
api_key: key,
app_identifier: APP_IDENTIFIER,
development: false,
)
# Pin the project to that profile for this build; automatic signing stays the default
# a developer sees when they open the project in Xcode.
update_code_signing_settings(
path: PROJECT,
use_automatic_signing: false,
team_id: ENV.fetch("APPLE_TEAM_ID"),
code_sign_identity: "Apple Distribution",
bundle_identifier: APP_IDENTIFIER,
profile_name: lane_context[SharedValues::SIGH_NAME],
targets: ["App"],
)
build_app(
project: PROJECT,
scheme: "App",
configuration: "Release",
export_method: "app-store",
output_directory: File.join(ENV.fetch("RUNNER_TEMP"), "export"),
xcargs: [
"MARKETING_VERSION=#{ENV.fetch('MARKETING_VERSION')}",
"CURRENT_PROJECT_VERSION=#{ENV.fetch('CURRENT_PROJECT_VERSION')}",
].join(" "),
export_options: {
signingStyle: "manual",
provisioningProfiles: { APP_IDENTIFIER => lane_context[SharedValues::SIGH_NAME] },
},
)
# Upload to App Store Connect; it shows up in TestFlight after Apple finishes processing.
# Internal testing needs no App Review, so nothing here submits the app for review.
upload_to_testflight(
api_key: key,
skip_waiting_for_build_processing: true,
skip_submission: true,
)
end
end
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+62 -23
View File
@@ -3,6 +3,8 @@ import { Haptics, ImpactStyle, NotificationType } from '@capacitor/haptics';
import { SplashScreen } from '@capacitor/splash-screen';
import { StatusBar, Style } from '@capacitor/status-bar';
import logoUrl from '../public/icons/icon-white.svg';
import logoBlack from '../public/logo-dark.svg'; // #000 wordmark, for light backgrounds
import logoWhite from '../public/logo-light.svg'; // #fff wordmark, for dark backgrounds
import { getIapClient, productIdFor, type Purchase } from '../src/native/iap';
import { CLOUD_URL, PRIVACY_URL, SERVER_DOCS_URL } from '../src/native/links';
import { probeDemo, probeServer, type ProbeFailure, type ProbeGet } from '../src/native/serverAddress';
@@ -147,11 +149,17 @@ function topbar(onBack: () => void): HTMLElement {
// ── Welcome ─────────────────────────────────────────────────────────────────
function showWelcome(opts: { back?: boolean } = {}): void {
const hero = el('img', { src: logoUrl, alt: '', className: 'hero-mark' });
// The full TREK wordmark, in the variant that reads on the current scheme.
const hero = el(
'span',
{ className: 'hero-logo' },
el('img', { src: logoWhite, alt: 'TREK', className: 'on-dark' }),
el('img', { src: logoBlack, alt: '', className: 'on-light' })
);
const connect = el(
'button',
{ type: 'button', className: 'btn' },
{ type: 'button', className: 'btn btn-ghost' },
el(
'span',
{ className: 'btn-row' },
@@ -167,7 +175,7 @@ function showWelcome(opts: { back?: boolean } = {}): void {
const cloud = el(
'button',
{ type: 'button', className: 'btn btn-ghost' },
{ type: 'button', className: 'btn' },
el(
'span',
{ className: 'btn-row' },
@@ -181,7 +189,7 @@ function showWelcome(opts: { back?: boolean } = {}): void {
showCloud();
});
const demo = el('button', { type: 'button', className: 'linkbtn' }, t('native.connect.tryDemo'));
const demo = el('button', { type: 'button', className: 'demo-link' }, t('native.connect.tryDemo'));
demo.addEventListener('click', () => runDemo(demo));
show(
@@ -189,10 +197,10 @@ function showWelcome(opts: { back?: boolean } = {}): void {
'div',
{},
hero,
el('h1', { className: 'title' }, t('native.welcome.headline')),
el('h1', { className: 'title brand-title' }, t('native.welcome.headline')),
el('p', { className: 'sub' }, t('native.welcome.sub')),
connect,
cloud,
connect,
demo,
footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))
),
@@ -248,9 +256,6 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
fail(result.reason);
});
const demo = el('button', { type: 'button', className: 'linkbtn' }, t('native.connect.tryDemo'));
demo.addEventListener('click', () => runDemo(demo, error));
show(
el(
'div',
@@ -259,7 +264,6 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
el('h1', { className: 'title-sm' }, t('native.connect.title')),
el('p', { className: 'sub' }, t('native.connect.hint')),
form,
demo,
footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))
),
opts
@@ -295,7 +299,13 @@ function showCloud(opts: { back?: boolean } = {}): void {
'div',
{},
topbar(() => showWelcome({ back: true })),
el('h1', { className: 'title-sm' }, t('native.cloud.title')),
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',
@@ -326,15 +336,27 @@ function money(cents: number): string {
return new Intl.NumberFormat(locale, { style: 'currency', currency: 'EUR' }).format(cents / 100);
}
// A sample catalogue for the dev preview only: the cloud site is behind a gate
// during development, so its /api/plans is unreachable from a local build. Never
// used in a real build (see the import.meta.env.DEV guard below).
const SAMPLE_PLANS: CloudPlan[] = [
{ id: 'start', name: 'Start', monthlyCents: 599, yearlyCents: 5990, aiDocuments: 0, storageGb: 10 },
{ id: 'plus', name: 'Plus', monthlyCents: 999, yearlyCents: 9990, aiDocuments: 100, storageGb: 50 },
{ id: 'pro', name: 'Pro', monthlyCents: 1599, yearlyCents: 15990, aiDocuments: 500, storageGb: 200 },
{ id: 'studio', name: 'Studio', monthlyCents: 2399, yearlyCents: 23990, aiDocuments: 2000, storageGb: 1000 },
];
async function fetchPlans(): Promise<CloudPlan[] | null> {
try {
const res = await get(`${CLOUD_URL}/api/plans`);
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
const plans = (data as { plans?: CloudPlan[] })?.plans;
return Array.isArray(plans) && plans.length ? plans : null;
if (Array.isArray(plans) && plans.length) return plans;
} catch {
return null;
// Fall through to the preview fallback.
}
if (import.meta.env?.DEV) return SAMPLE_PLANS;
return null;
}
function subscribe(plan: CloudPlan, interval: 'month' | 'year'): void {
@@ -352,6 +374,9 @@ function planCard(plan: CloudPlan, interval: 'month' | 'year', featured: boolean
const price = interval === 'year' ? plan.yearlyCents : plan.monthlyCents;
const per = interval === 'year' ? t('native.plans.perYear') : t('native.plans.perMonth');
const feat = (text: string) => el('span', {}, icon(I.check, 16), text);
// An absent feature (e.g. a plan with no AI documents): a muted dash, not a
// green tick, so "none" never reads as a perk.
const featOff = (text: string) => el('span', { className: 'off' }, icon('M5 12h14', 16), text);
const sub = el('button', { type: 'button', className: 'btn' }, t('native.plans.subscribe'));
sub.addEventListener('click', () => subscribe(plan, interval));
return el(
@@ -368,7 +393,9 @@ function planCard(plan: CloudPlan, interval: 'month' | 'year', featured: boolean
{ className: 'plan-feats' },
feat(t('native.plans.unlimited')),
feat(t('native.plans.storage', { n: String(plan.storageGb) })),
feat(plan.aiDocuments > 0 ? t('native.plans.aiDocs', { n: String(plan.aiDocuments) }) : t('native.plans.noAi'))
plan.aiDocuments > 0
? feat(t('native.plans.aiDocs', { n: String(plan.aiDocuments) }))
: featOff(t('native.plans.noAi'))
),
sub
);
@@ -381,12 +408,7 @@ function showPlans(opts: { back?: boolean } = {}): void {
const content = el('div', { className: 'loading' }, el('span', { className: 'spinner' }));
const monthBtn = el('button', { type: 'button' }, t('native.plans.monthly'));
const yearBtn = el(
'button',
{ type: 'button' },
t('native.plans.yearly'),
el('span', { className: 'save' }, t('native.plans.save'))
);
const yearBtn = el('button', { type: 'button' }, t('native.plans.yearly'));
const seg = el('div', { className: 'segmented' }, monthBtn, yearBtn);
const paintSeg = () => {
@@ -397,7 +419,7 @@ function showPlans(opts: { back?: boolean } = {}): void {
const list = plans;
if (!list) return;
content.className = 'plans';
content.replaceChildren(...list.map((p, i) => planCard(p, interval, i === list.length - 1)));
content.replaceChildren(...list.map((p) => planCard(p, interval, false)));
};
const choose = (next: 'month' | 'year') => {
if (interval === next) return;
@@ -494,6 +516,7 @@ function showClaim(plan: CloudPlan, interval: 'month' | 'year', opts: { back?: b
autocapitalize: 'off',
spellcheck: false,
value: '',
placeholder: t('native.claim.addressLabel'),
});
slugInput.setAttribute('inputmode', 'url');
slugInput.setAttribute('autocomplete', 'off');
@@ -507,7 +530,13 @@ function showClaim(plan: CloudPlan, interval: 'month' | 'year', opts: { back?: b
);
const slugNote = el('p', { className: 'hint' });
const emailInput = el('input', { type: 'email', value: '', autocapitalize: 'off', spellcheck: false });
const emailInput = el('input', {
type: 'email',
value: '',
autocapitalize: 'off',
spellcheck: false,
placeholder: t('native.claim.emailLabel'),
});
emailInput.setAttribute('inputmode', 'email');
emailInput.setAttribute('autocomplete', 'email');
emailInput.setAttribute('enterkeyhint', 'go');
@@ -782,7 +811,17 @@ async function start(): Promise<void> {
iap.isAvailable().then((ok) => {
iapAvailable = ok;
});
const { url } = await TrekShell.getServer();
let url: string | null = null;
try {
({ url } = await TrekShell.getServer());
} catch {
// No native shell under us (a web preview, or a simulator without the
// plugin): there is no remembered server, so start at the welcome screen
// rather than leaving a blank page.
showWelcome();
await SplashScreen.hide().catch(() => {});
return;
}
if (!url) {
showWelcome();
} else {
+281 -28
View File
@@ -4,6 +4,49 @@
* background and never flashes. Everything past this screen is the UI the
* chosen server delivers.
*/
/*
* TREK's own type, self-hosted (latin subset, bundled — the shell loads offline
* and hotlinks nothing). Poppins is the display face across the app and
* liketrek.com; MuseoModerno draws the "cloud" of the TREK Cloud wordmark.
*/
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('./fonts/poppins-latin-500.woff2') format('woff2');
}
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('./fonts/poppins-latin-600.woff2') format('woff2');
}
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('./fonts/poppins-latin-700.woff2') format('woff2');
}
@font-face {
font-family: 'MuseoModerno';
font-style: normal;
font-weight: 600 700;
font-display: swap;
src: url('./fonts/museomoderno-latin-600.woff2') format('woff2');
}
/* Italic, subset to the letters of "cloud" — the wordmark's badge, nothing else. */
@font-face {
font-family: 'MuseoModerno';
font-style: italic;
font-weight: 600;
font-display: swap;
src: url('./fonts/museomoderno-italic-600.woff2') format('woff2');
}
:root {
--bg: #09090b;
--bg-2: #101013;
@@ -18,6 +61,11 @@
--accent-ink: #09090b;
--danger: #f87171;
--ok: #4ade80;
/* The hairline the ambient grid is drawn in, and a travel-dusk wash for depth. */
--grid: rgba(255, 255, 255, 0.055);
--wash-1: rgba(129, 140, 248, 0.12);
--wash-2: rgba(244, 114, 182, 0.08);
--font: 'Poppins', -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, system-ui, sans-serif;
--radius: 16px;
--ease: cubic-bezier(0.22, 1, 0.36, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
@@ -39,6 +87,9 @@
--accent-ink: #ffffff;
--danger: #dc2626;
--ok: #16a34a;
--grid: rgba(0, 0, 0, 0.045);
--wash-1: rgba(79, 70, 229, 0.07);
--wash-2: rgba(219, 39, 119, 0.05);
color-scheme: light;
}
.shell-logo,
@@ -56,9 +107,11 @@ html,
body {
margin: 0;
min-height: 100%;
background: var(--bg);
background:
radial-gradient(90% 55% at 50% -12%, var(--wash-1), transparent 62%),
radial-gradient(80% 50% at 50% 112%, var(--wash-2), transparent 64%), var(--bg);
color: var(--ink);
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, system-ui, sans-serif;
font-family: var(--font);
-webkit-text-size-adjust: 100%;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
@@ -66,17 +119,55 @@ body {
-webkit-user-select: none;
}
/* An ambient glow behind the content, so the flat dark gains a little depth. */
/*
* The backdrop, in two fixed layers behind the content. First a soft dusk wash
* at top and bottom — a trace of the colour the app's photos carry, never loud
* enough to read as decoration. Then a quiet grid that fades out through a
* radial mask: depth, the way liketrek.com's hero carries it.
*/
/* The grid, strongest at the top-right and fading left and down — the half the
photo leaves open, so the two meet in a soft seam down the middle. */
body::before {
content: '';
position: fixed;
inset: 0;
background:
radial-gradient(60% 50% at 50% -5%, color-mix(in srgb, var(--ink) 7%, transparent), transparent 70%),
radial-gradient(50% 40% at 50% 110%, color-mix(in srgb, var(--ink) 4%, transparent), transparent 70%);
inset: 0 0 auto;
height: 440px;
background-image:
linear-gradient(var(--grid) 1px, transparent 1px), linear-gradient(90deg, var(--grid) 1px, transparent 1px);
background-size: 54px 54px;
-webkit-mask-image: radial-gradient(135% 95% at 100% 0%, #000 34%, transparent 80%);
mask-image: radial-gradient(135% 95% at 100% 0%, #000 34%, transparent 80%);
pointer-events: none;
z-index: 0;
}
/*
* A travel photo as a top band (Kyoto at dusk, the same image liketrek.com uses
* in its pricing section), under a dark gradient and faded out at the bottom so
* the headline stays legible. Dark-first; in light mode it steps back to the
* quiet grid instead, which reads better on a white page.
*/
/* The travel photo (Kyoto at dusk, as on liketrek.com), anchored left and faded
out toward the right so it hands over to the grid, and down into the page.
Dark-first; a dark photo over a white page reads badly, so light mode drops it
and keeps the grid alone. */
body::after {
content: '';
position: fixed;
inset: 0 0 auto;
height: 440px;
background:
linear-gradient(180deg, rgba(9, 9, 11, 0.4) 0%, rgba(9, 9, 11, 0.72) 62%, var(--bg) 100%),
url('./img/kyoto.webp') left 28% / cover no-repeat;
-webkit-mask-image: linear-gradient(90deg, #000 0%, #000 40%, transparent 80%);
mask-image: linear-gradient(90deg, #000 0%, #000 40%, transparent 80%);
pointer-events: none;
z-index: 0;
}
@media (prefers-color-scheme: light) {
body::after {
display: none;
}
}
.shell {
position: relative;
@@ -213,11 +304,47 @@ body::before {
/* The big hero mark on the welcome screen, with a gentle breathing float. */
.hero-mark {
width: 76px;
height: 76px;
margin-bottom: 18px;
width: 80px;
height: 80px;
margin-bottom: 20px;
animation: floaty 6s ease-in-out infinite;
filter: drop-shadow(0 10px 30px color-mix(in srgb, var(--ink) 18%, transparent));
filter: drop-shadow(0 14px 36px color-mix(in srgb, var(--wash-1) 90%, transparent))
drop-shadow(0 6px 16px color-mix(in srgb, var(--ink) 22%, transparent));
}
/* The full TREK wordmark on the welcome screen, in the variant that reads on the scheme. */
.hero-logo {
display: block;
margin-bottom: 22px;
filter: drop-shadow(0 14px 36px color-mix(in srgb, var(--wash-1) 90%, transparent))
drop-shadow(0 6px 16px color-mix(in srgb, var(--ink) 22%, transparent));
animation: logoRise 0.7s var(--ease) both;
}
.hero-logo img {
display: block;
height: 62px;
width: auto;
}
.hero-logo .on-light {
display: none;
}
@media (prefers-color-scheme: light) {
.hero-logo .on-dark {
display: none;
}
.hero-logo .on-light {
display: block;
}
}
@keyframes logoRise {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes floaty {
0%,
@@ -231,24 +358,69 @@ body::before {
/* ── Type ────────────────────────────────────────────────────────────────── */
.title {
font-size: 1.9rem;
line-height: 1.12;
font-size: 2.05rem;
line-height: 1.1;
font-weight: 700;
letter-spacing: -0.02em;
letter-spacing: -0.03em;
margin: 0;
/* Honour the newline in the headline string (e.g. "Deine Reisen.\nDein Plan."). */
white-space: pre-line;
}
/* The welcome headline as the brand says it: MuseoModerno, lower case. */
.brand-title {
font-family: 'MuseoModerno', 'Poppins', sans-serif;
font-weight: 700;
text-transform: lowercase;
letter-spacing: -0.01em;
line-height: 1.08;
}
/* The TREK Cloud lockup (cloud intro screen): the wordmark plus the "cloud"
badge in italic MuseoModerno, an inverted pill, exactly as on liketrek.com. */
.cloud-wordmark {
display: inline-flex;
align-items: center;
gap: 9px;
}
.cloud-wordmark img {
display: block;
height: 38px;
width: auto;
}
.cloud-wordmark .on-light {
display: none;
}
@media (prefers-color-scheme: light) {
.cloud-wordmark .on-dark {
display: none;
}
.cloud-wordmark .on-light {
display: block;
}
}
.cloud-tag {
font-family: 'MuseoModerno', system-ui, sans-serif;
font-style: italic;
font-weight: 600;
font-size: 0.95rem;
line-height: 1.35;
padding: 2px 11px;
border-radius: 999px;
color: var(--bg);
background: var(--ink);
}
.title-sm {
font-size: 1.45rem;
line-height: 1.2;
font-size: 1.5rem;
line-height: 1.18;
font-weight: 700;
letter-spacing: -0.015em;
letter-spacing: -0.025em;
margin: 0;
}
.sub {
color: var(--muted);
margin: 0;
line-height: 1.55;
font-size: 1.02rem;
font-size: 0.9rem;
overflow-wrap: anywhere;
}
@@ -263,10 +435,14 @@ body::before {
background: var(--accent);
color: var(--accent-ink);
cursor: pointer;
box-shadow:
0 8px 22px -14px rgba(0, 0, 0, 0.55),
0 1px 2px rgba(0, 0, 0, 0.25);
transition:
transform 0.14s var(--ease),
opacity 0.2s,
background 0.2s,
box-shadow 0.2s,
border-color 0.2s;
display: inline-flex;
align-items: center;
@@ -284,6 +460,9 @@ body::before {
background: var(--surface);
color: var(--ink);
border-color: var(--edge);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.22),
inset 0 0 0 1px color-mix(in srgb, var(--ink) 4%, transparent);
}
.btn-ghost:active {
background: var(--surface-2);
@@ -318,6 +497,34 @@ body::before {
align-self: center;
transition: color 0.2s;
}
/* "Try the demo": a tertiary action, but a tidy pill so it reads as a choice, not stray text. */
.demo-link {
font: inherit;
font-weight: 600;
font-size: 0.95rem;
align-self: stretch;
width: 100%;
margin-top: 2px;
padding: 9px 18px;
min-height: 40px;
border-radius: var(--radius);
border: 1px solid var(--edge);
background: transparent;
color: var(--muted);
cursor: pointer;
white-space: nowrap;
transition:
color 0.2s,
background 0.2s,
border-color 0.2s,
transform 0.14s var(--ease);
}
.demo-link:active {
transform: scale(0.97);
background: var(--surface-2);
color: var(--ink);
}
.linkbtn:active {
color: var(--ink);
}
@@ -329,14 +536,21 @@ a.linkbtn {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 2px 18px;
gap: 4px 22px;
margin-top: 6px;
padding-top: 16px;
border-top: 1px solid var(--edge);
font-size: 0.86rem;
}
.foot-links a {
color: var(--faint);
color: var(--muted);
font-weight: 500;
text-decoration: none;
padding: 8px 2px;
padding: 6px 2px;
transition: color 0.2s;
}
.foot-links a:active {
color: var(--ink);
}
/* ── Address form ────────────────────────────────────────────────────────── */
@@ -351,13 +565,16 @@ a.linkbtn {
.field input {
width: 100%;
font: inherit;
font-size: 1.05rem;
padding: 16px 18px;
font-size: 1rem;
padding: 13px 16px;
border-radius: var(--radius);
border: 1px solid var(--edge);
background: var(--surface);
color: var(--ink);
outline: none;
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.18),
inset 0 1px 0 color-mix(in srgb, var(--ink) 4%, transparent);
transition:
border-color 0.2s,
box-shadow 0.25s;
@@ -574,10 +791,19 @@ a.linkbtn {
border-radius: var(--radius);
padding: 16px;
background: var(--surface);
box-shadow:
0 2px 10px -4px rgba(0, 0, 0, 0.3),
inset 0 0 0 1px color-mix(in srgb, var(--ink) 3%, transparent);
transition:
border-color 0.2s,
box-shadow 0.2s,
transform 0.14s var(--ease);
}
.plan.featured {
box-shadow:
0 10px 30px -10px color-mix(in srgb, var(--wash-1) 85%, transparent),
0 2px 8px -3px rgba(0, 0, 0, 0.35);
}
.plan:active {
transform: scale(0.99);
}
@@ -604,7 +830,7 @@ a.linkbtn {
font-weight: 500;
font-size: 0.8rem;
color: var(--muted);
margin-left: 3px;
margin-left: 7px;
}
.plan-feats {
margin: 10px 0 14px;
@@ -623,6 +849,10 @@ a.linkbtn {
flex: none;
color: var(--ok);
}
.plan-feats .off,
.plan-feats .off svg {
color: var(--faint);
}
.plan .btn {
width: 100%;
min-height: 48px;
@@ -654,21 +884,44 @@ a.linkbtn {
}
/* ── Claim: address + email, and the provisioning wait ───────────────────── */
/* One rounded field holding the address input and its fixed ".liketrek.com"
suffix — the input itself is seamless, the box and its states live here. */
.slug-field {
display: flex;
align-items: center;
padding-right: 14px;
padding-right: 16px;
border-radius: var(--radius);
border: 1px solid var(--edge);
background: var(--surface);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.18),
inset 0 1px 0 color-mix(in srgb, var(--ink) 4%, transparent);
transition:
border-color 0.2s,
box-shadow 0.25s;
}
.slug-field .slug-input {
flex: 1;
min-width: 0;
text-align: right;
text-align: left;
border: 0;
background: transparent;
box-shadow: none;
}
.slug-field:focus-within {
border-color: var(--edge-strong);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--ink) 9%, transparent);
}
.slug-field.bad {
border-color: var(--danger);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 16%, transparent);
animation: shake 0.3s;
}
.slug-suffix {
flex: none;
color: var(--muted);
font-size: 0.98rem;
padding-left: 2px;
font-size: 0.95rem;
padding-left: 4px;
user-select: none;
}
.hint {
+6 -7
View File
@@ -6,7 +6,7 @@ const native: TranslationStrings = {
'native.connect.placeholder': 'https://trek.example.com',
'native.connect.submit': 'Verbinden',
'native.connect.checking': 'Wird geprüft…',
'native.connect.tryDemo': 'Noch keinen Server? Probier die Demo aus',
'native.connect.tryDemo': 'Demo ausprobieren',
'native.connect.whatIsServer': 'Was ist ein TREK-Server?',
'native.connect.errorInvalid': 'Das sieht nicht nach einer Webadresse aus.',
'native.connect.errorUnreachable':
@@ -25,9 +25,8 @@ const native: TranslationStrings = {
'native.privacy': 'Datenschutzerklärung',
'native.settings.server': 'Server', // same-as-en
'native.settings.serverHint': 'Diese App ist mit {server} verbunden.',
'native.welcome.headline': 'Reisen gemeinsam planen.',
'native.welcome.sub':
'TREK hält jeden Tag, Ort, jede Buchung und Kosten in einem gemeinsamen Plan, den eure ganze Gruppe live bearbeitet.',
'native.welcome.headline': 'Deine Reisen.\nDein Plan.',
'native.welcome.sub': 'Gemeinsam in Echtzeit planen: Touren, Budgets, Journal, Fotobuch — mit KI.',
'native.welcome.connect': 'Mit deinem TREK verbinden',
'native.welcome.connectSub': 'Die Adresse öffnen, die du schon nutzt',
'native.welcome.getCloud': 'TREK Cloud holen',
@@ -39,7 +38,7 @@ const native: TranslationStrings = {
'native.cloud.f2': 'In Minuten eingerichtet, Updates übernehmen wir',
'native.cloud.f3': 'Deine Daten bleiben deine — keine Werbung, kein Tracking',
'native.cloud.cta': 'Loslegen',
'native.cloud.haveServer': 'Ich habe schon ein TREK',
'native.cloud.haveServer': 'Ich habe schon einen TREK-Server',
'native.connecting': 'Verbinde mit {host}',
'native.back': 'Zurück',
'native.plans.title': 'Tarif wählen',
@@ -52,12 +51,12 @@ const native: TranslationStrings = {
'native.plans.aiDocs': '{n} KI-Dokumente im Monat',
'native.plans.noAi': 'Keine KI-Dokumente',
'native.plans.storage': '{n} GB Speicher',
'native.plans.unlimited': 'Unbegrenzt Orte, Reisen und Personen',
'native.plans.unlimited': 'Unbegrenzt Orte, Reisen & Personen',
'native.plans.subscribe': 'Abonnieren',
'native.plans.loadError': 'Die Tarife konnten nicht geladen werden. Prüfe deine Verbindung und versuch es erneut.',
'native.plans.retry': 'Erneut versuchen',
'native.plans.soon': 'In-App-Abos kommen bald. Vorerst auf der Website abschließen.',
'native.claim.title': 'Deine TREK einrichten',
'native.claim.title': 'Fast geschafft',
'native.claim.sub': 'Wähle deine Adresse. Wir richten sie sofort ein.',
'native.claim.addressLabel': 'Deine Adresse',
'native.claim.taken': 'Diese Adresse ist vergeben.',
+5 -6
View File
@@ -6,7 +6,7 @@ const native: TranslationStrings = {
'native.connect.placeholder': 'https://trek.example.com',
'native.connect.submit': 'Connect',
'native.connect.checking': 'Checking…',
'native.connect.tryDemo': 'No server yet? Try the demo',
'native.connect.tryDemo': 'Try the demo',
'native.connect.whatIsServer': 'What is a TREK server?',
'native.connect.errorInvalid': 'That does not look like a web address.',
'native.connect.errorUnreachable': 'No server answered at this address. Check the address and your connection.',
@@ -24,9 +24,8 @@ const native: TranslationStrings = {
'native.privacy': 'Privacy policy',
'native.settings.server': 'Server',
'native.settings.serverHint': 'This app is connected to {server}.',
'native.welcome.headline': 'Plan trips together.',
'native.welcome.sub':
'TREK keeps every day, place, booking and cost in one live plan your whole group edits together.',
'native.welcome.headline': 'Your Trip.\nYour Plan.',
'native.welcome.sub': 'Plan together in real time: tours, budgets, journals, photo book — with AI.',
'native.welcome.connect': 'Connect your TREK',
'native.welcome.connectSub': 'Open the address you already use',
'native.welcome.getCloud': 'Get TREK Cloud',
@@ -51,12 +50,12 @@ const native: TranslationStrings = {
'native.plans.aiDocs': '{n} AI documents a month',
'native.plans.noAi': 'No AI documents',
'native.plans.storage': '{n} GB storage',
'native.plans.unlimited': 'Unlimited places, trips and people',
'native.plans.unlimited': 'Unlimited places, trips & people',
'native.plans.subscribe': 'Subscribe',
'native.plans.loadError': 'Could not load the plans. Check your connection and try again.',
'native.plans.retry': 'Try again',
'native.plans.soon': 'In-app subscriptions are coming. For now, finish on the website.',
'native.claim.title': 'Create your TREK',
'native.claim.title': 'Almost there',
'native.claim.sub': 'Choose your address. We set it up right away.',
'native.claim.addressLabel': 'Your address',
'native.claim.taken': 'That address is taken.',