diff --git a/.ai/HANDOFF.md b/.ai/HANDOFF.md index 6d1f218..311ba2a 100644 --- a/.ai/HANDOFF.md +++ b/.ai/HANDOFF.md @@ -1,31 +1,32 @@ -# JourneyDeck current handoff, October 12, 2026 +# JourneyDeck current handoff, October 13, 2026 ## Current objective -Ship four themed road-trip SEO landing pages in one draft PR. Do not merge or deploy. +Open a non-draft PR that adds the Launch Llama badge to the homepage footer. Do not merge. ## Checkout -- Branch `cursor/road-trip-journal-seo-pages-2843` off `main`. +- Branch `cursor/launch-llama-homepage-badge-4cdd` off `main`. ## Material changes -- New public pages: `/route-66-road-trip-journal`, `/pacific-coast-highway-road-trip-journal`, `/national-parks-road-trip-journal`, `/texas-hill-country-road-trip-journal`. -- Wired like #268/#270/#273/#276: `publicPaths`, `app.get` sendFile, sitemap, server tests, verify-live expectations, feature map, canonical/meta/OG/JSON-LD/Umami, plain App Store CTA plus `campaign.js`. -- Copy from the draft, minus unconfirmed personal lines, plus design-review FAQ and intro fixes. JSON-LD FAQ text matches the visible FAQ. No em/en dashes. Journeys start only when the user taps Start Journey. -- Cross-links from `/road-trip-journal-app`, `/private-driving-journal`, and the Apple Music soundtrack footer. +- Exact Launch Llama badge snippet in `web/beta.html` footer, centered via `.footer-badge`. +- CSP `img-src` allows `https://tools.launchllama.co` only. CSS cache-bust `beta.css?v=grand-tour-5`. +- verify-live `page:/ launch-badge` plus server and Playwright coverage. ## Verification -- `node --test --import tsx server/tests/api.test.ts` -- `node --test --import tsx server/tests/campaign.test.ts server/tests/verify-live.test.ts` -- Desktop 1280 and mobile 390 screenshots of each page in the PR body. +- `npm run check:server` +- `npm run lint:server` +- `npm run test:server` (66 pass) +- `npx playwright test tests/e2e/public-beta.spec.ts` (5 pass) +- Footer screenshots at 390 and 1280 before/after. ## Unresolved / owner decisions -- Keep the PR as a draft. Do not merge or deploy from this session. +- Keep the PR open. Do not merge or deploy from this session. ## Next steps -1. Keep the four-page SEO PR open for review. Do not merge or deploy. -2. Confirm JourneyDeck CI is green. +1. Confirm JourneyDeck CI is green. +2. Do not merge or deploy. diff --git a/server/src/app.ts b/server/src/app.ts index 09f7365..31baad6 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -26,7 +26,7 @@ const publicPaths = new Set(["/healthz", "/readyz", "/login", "/login.html", "/p const publicAuthPaths = new Set(["/api/auth/login", "/api/auth/passkey/options", "/api/auth/passkey/verify"]); const scheduledSyncPath = "/api/spotify/sync"; const securityHeaders = { - "content-security-policy": "default-src 'self'; style-src 'self' 'unsafe-inline' https://unpkg.com; script-src 'self' https://unpkg.com https://sdk.scdn.co https://open.spotify.com https://cloud.umami.is; connect-src 'self' https://tiles.openfreemap.org https://api.spotify.com https://*.spotify.com wss://*.spotify.com https://*.scdn.co https://gateway.umami.is; img-src 'self' data: blob: https://tiles.openfreemap.org https://i.scdn.co; media-src blob: https://*.scdn.co https://*.spotify.com; font-src 'self' data: https://tiles.openfreemap.org; worker-src 'self' blob:; child-src blob:; object-src 'none'; frame-src https://open.spotify.com; frame-ancestors 'none'; form-action 'self'; base-uri 'self'; manifest-src 'self'", + "content-security-policy": "default-src 'self'; style-src 'self' 'unsafe-inline' https://unpkg.com; script-src 'self' https://unpkg.com https://sdk.scdn.co https://open.spotify.com https://cloud.umami.is; connect-src 'self' https://tiles.openfreemap.org https://api.spotify.com https://*.spotify.com wss://*.spotify.com https://*.scdn.co https://gateway.umami.is; img-src 'self' data: blob: https://tiles.openfreemap.org https://i.scdn.co https://tools.launchllama.co; media-src blob: https://*.scdn.co https://*.spotify.com; font-src 'self' data: https://tiles.openfreemap.org; worker-src 'self' blob:; child-src blob:; object-src 'none'; frame-src https://open.spotify.com; frame-ancestors 'none'; form-action 'self'; base-uri 'self'; manifest-src 'self'", "x-content-type-options": "nosniff", "x-frame-options": "DENY", "referrer-policy": "no-referrer", "permissions-policy": "autoplay=(self \"https://open.spotify.com\"), encrypted-media=(self \"https://open.spotify.com\"), camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), bluetooth=(), midi=(), magnetometer=(), gyroscope=(), accelerometer=()" }; diff --git a/server/tests/api.test.ts b/server/tests/api.test.ts index c756e53..d28af49 100644 --- a/server/tests/api.test.ts +++ b/server/tests/api.test.ts @@ -335,7 +335,7 @@ test("hosted root serves the Grand Touring launch page while private routes stay for (const rel of ["api-catalog", "service-desc", "service-doc", "describedby"]) assert.match(links, new RegExp(`rel="${rel}"`)); assert.match(landing.body, /GRAND TOURING/); assert.match(landing.body, /JOURNEYDECK 4\.0/); - assert.match(landing.body, /href="\/beta\.css\?v=grand-tour-4"/); + assert.match(landing.body, /href="\/beta\.css\?v=grand-tour-5"/); assert.match(landing.body, /id="medallion-theme"/); assert.match(landing.body, /data-theme="redline"/); assert.match(landing.body, /aria-label="1 of 5: Today"/); @@ -348,6 +348,14 @@ test("hosted root serves the Grand Touring launch page while private routes stay assert.match(landing.body, /APPLE WATCH \/ PAIRED IPHONE/); assert.match(landing.body, //); assert.match(String(landing.headers["content-security-policy"]), /default-src 'self'/); + assert.match(String(landing.headers["content-security-policy"]), /img-src[^;]*https:\/\/tools\.launchllama\.co/); + assert.doesNotMatch(String(landing.headers["content-security-policy"]), /(?:script-src|connect-src)[^;]*https:\/\/tools\.launchllama\.co/); + assert.match(landing.body, /href="https:\/\/tools\.launchllama\.co\/products\/journeydeck\?utm_source=badge&utm_medium=referral"/); + assert.match(landing.body, /src="https:\/\/tools\.launchllama\.co\/featured-badge-white\.jpg\?v=5"/); + assert.match(landing.body, /alt="Featured on Launch"/); + assert.match(landing.body, /width="200" height="52"/); + assert.match(landing.body, /href="https:\/\/launchmcp\.co\/\?utm_source=badge&utm_medium=referral"/); + assert.match(landing.body, />Launch MCP<\/a>/); assert.match(landing.body, /rel="canonical" href="https:\/\/journeydeck\.me\/"/); assert.match(landing.body, /property="og:url" content="https:\/\/journeydeck\.me\/"/); assert.match(landing.body, /href="\/login"/i); @@ -379,7 +387,7 @@ test("hosted root serves the Grand Touring launch page while private routes stay assert.doesNotMatch(String(googleVerification.headers["content-security-policy"]), /google-site-verification/); for (const [url, mime] of [ - ["/beta.css?v=grand-tour-4", /text\/css/], + ["/beta.css?v=grand-tour-5", /text\/css/], ["/public-page.css?v=grand-tour-3", /text\/css/], ["/public-page.css?v=hero-lcp-1", /text\/css/], ["/site-mobile-nav.css?v=1", /text\/css/], @@ -531,7 +539,7 @@ test("hosted home and Support HTML do not advertise a 3-day Plus trial", async ( test("hosted cache headers distinguish versioned assets, unversioned images, and short-lived documents", async () => { const fixture = fixtureDatabase(), runtime = await createApp({ databasePath: fixture.filename, root, allowTestAuth: true, legacyUpstream: "", mode: "web" }); try { - const versionedCss = await runtime.app.inject({ method: "GET", url: "/beta.css?v=grand-tour-4" }); + const versionedCss = await runtime.app.inject({ method: "GET", url: "/beta.css?v=grand-tour-5" }); assert.equal(versionedCss.statusCode, 200, versionedCss.body); assert.equal(versionedCss.headers["cache-control"], "public, max-age=31536000, immutable"); diff --git a/server/tests/campaign.test.ts b/server/tests/campaign.test.ts index f8a3006..1663d82 100644 --- a/server/tests/campaign.test.ts +++ b/server/tests/campaign.test.ts @@ -316,11 +316,19 @@ test("served public pages keep the plain App Store URL and load the first-party assert.match(String(page.headers["content-security-policy"]), /script-src[^;]*https:\/\/cloud\.umami\.is/, url); assert.match(String(page.headers["content-security-policy"]), /connect-src[^;]*https:\/\/gateway\.umami\.is/, url); assert.doesNotMatch(String(page.headers["content-security-policy"]), /connect-src[^;]*https:\/\/cloud\.umami\.is/, url); + assert.match(String(page.headers["content-security-policy"]), /img-src[^;]*https:\/\/tools\.launchllama\.co/, url); + assert.doesNotMatch(String(page.headers["content-security-policy"]), /(?:script-src|connect-src)[^;]*https:\/\/tools\.launchllama\.co/, url); } const home = await runtime.app.inject({ method: "GET", url: "/" }); assert.match(home.body, /

Take the road\.
Keep the story\.<\/span><\/h1>/); assert.doesNotMatch(home.body, /Keep the drive/); + assert.match(home.body, /href="https:\/\/tools\.launchllama\.co\/products\/journeydeck\?utm_source=badge&utm_medium=referral"/); + assert.match(home.body, /src="https:\/\/tools\.launchllama\.co\/featured-badge-white\.jpg\?v=5"/); + assert.match(home.body, /alt="Featured on Launch"/); + assert.match(home.body, /width="200" height="52"/); + assert.match(home.body, /href="https:\/\/launchmcp\.co\/\?utm_source=badge&utm_medium=referral"/); + assert.match(home.body, />Launch MCP<\/a>/); for (const url of ["/login", "/index.html"]) { const privatePage = await runtime.app.inject({ method: "GET", url }); diff --git a/server/tests/verify-live.test.ts b/server/tests/verify-live.test.ts index c3ec3a6..86de7c0 100644 --- a/server/tests/verify-live.test.ts +++ b/server/tests/verify-live.test.ts @@ -11,6 +11,7 @@ import { canonicalUrlForPath, classifyConsoleError, compareGeneratedAtSince, + evaluateLaunchBadge, evaluateLoginUmami, evaluatePublicPageHtml, evaluateUmamiScripts, @@ -80,7 +81,15 @@ test("expectations loader reads the checked-in file and rejects incomplete objec assert.equal(expectations.headlines.default.accent, "Keep the story."); assert.equal(expectations.headlines.x.lead, "Keep the drive."); assert.equal(expectations.headlines.x.accent, "Keep the song."); + assert.equal(expectations.launchBadge.productHref, "https://tools.launchllama.co/products/journeydeck?utm_source=badge&utm_medium=referral"); + assert.equal(expectations.launchBadge.imageSrc, "https://tools.launchllama.co/featured-badge-white.jpg?v=5"); + assert.equal(expectations.launchBadge.imageAlt, "Featured on Launch"); + assert.equal(expectations.launchBadge.imageWidth, "200"); + assert.equal(expectations.launchBadge.imageHeight, "52"); + assert.equal(expectations.launchBadge.mcpHref, "https://launchmcp.co/?utm_source=badge&utm_medium=referral"); + assert.equal(expectations.launchBadge.mcpText, "Launch MCP"); assert.throws(() => validateExpectations({}), /publicPages/); + assert.throws(() => validateExpectations({ ...expectations, launchBadge: {} }), /launchBadge is incomplete/); const directory = fs.mkdtempSync(path.join(os.tmpdir(), "journeydeck-verify-live-")); const filePath = path.join(directory, "expectations.json"); fs.writeFileSync(filePath, JSON.stringify({ ...expectations, umami: { scriptSrc: expectations.umami.scriptSrc } }), "utf8"); @@ -104,6 +113,14 @@ test("HTML helpers parse metadata, Umami, JSON-LD, and ignore generic trial term Get the app Reviews

Available products, price, billing period, trial terms, and included features are shown before purchase.

+ `; const title = extractDocumentTitle(html); assert.equal(title.count, 1); @@ -127,6 +144,12 @@ test("HTML helpers parse metadata, Umami, JSON-LD, and ignore generic trial term assert.equal(headlineMatches("Take the road.\nKeep the story.", expectations.headlines.default), true); assert.equal(headlineMatches("Keep the drive. Keep the song.", expectations.headlines.x), true); assert.equal(headlineMatches("Take the road. Keep the story.", expectations.headlines.x), false); + const badgeCheck = checks.find((check) => check.name === "page:/ launch-badge"); + assert.equal(badgeCheck?.pass, true); + assert.equal(evaluateLaunchBadge("
", expectations).pass, false); + assert.equal(evaluateLaunchBadge(html, expectations).pass, true); + const supportChecks = evaluatePublicPageHtml(html, "/support", expectations, 200); + assert.equal(supportChecks.some((check) => check.name === "page:/support launch-badge"), false); }); test("App Store href assertions keep campaign tokens, reviews, and the served default hrefs", () => { diff --git a/tests/e2e/public-beta.spec.ts b/tests/e2e/public-beta.spec.ts index 0e5d033..22b6f91 100644 --- a/tests/e2e/public-beta.spec.ts +++ b/tests/e2e/public-beta.spec.ts @@ -42,6 +42,25 @@ test('phone layout offers medallions in place and preserves download, social and await expect(page.getByRole('link', { name: 'Follow @JourneyDeck on X', exact: true })).toHaveAttribute('href', 'https://x.com/JourneyDeck'); await expect(page.getByRole('link', { name: 'Apple Terms of Use', exact: true })).toHaveAttribute('href', 'https://www.apple.com/legal/internet-services/itunes/dev/stdeula/'); for (const href of ['/privacy', '/terms', '/support']) await expect(page.locator(`footer a[href="${href}"]`)).toHaveCount(1); + await expect(page.locator('footer img[alt="Featured on Launch"]')).toHaveAttribute('src', 'https://tools.launchllama.co/featured-badge-white.jpg?v=5'); + await expect(page.locator('footer img[alt="Featured on Launch"]')).toHaveAttribute('width', '200'); + await expect(page.locator('footer img[alt="Featured on Launch"]')).toHaveAttribute('height', '52'); + await expect(page.locator('footer a[href="https://tools.launchllama.co/products/journeydeck?utm_source=badge&utm_medium=referral"]')).toHaveCount(1); + await expect(page.getByRole('link', { name: 'Launch MCP', exact: true })).toHaveAttribute('href', 'https://launchmcp.co/?utm_source=badge&utm_medium=referral'); +}); + +test('homepage footer centers the Launch Llama badge at 390 and 1280', async ({ page }) => { + for (const { width, height } of [{ width: 390, height: 844 }, { width: 1280, height: 800 }]) { + await page.setViewportSize({ width, height }); + await page.goto('/beta.html'); + const badge = page.locator('footer .footer-badge > span'); + await badge.scrollIntoViewIfNeeded(); + await expect(badge).toBeVisible(); + const box = await badge.boundingBox(); + expect(box, `badge box at ${width}`).toBeTruthy(); + const center = (box?.x ?? 0) + ((box?.width ?? 0) / 2); + expect(Math.abs(center - (width / 2))).toBeLessThan(12); + } }); test('reduced motion keeps all content visible and the feature tour manually usable', async ({ page }) => { diff --git a/tools/verify-live/FEATURE_MAP.md b/tools/verify-live/FEATURE_MAP.md index 07ebdc4..56b5e86 100644 --- a/tools/verify-live/FEATURE_MAP.md +++ b/tools/verify-live/FEATURE_MAP.md @@ -20,7 +20,7 @@ Public HTML is served by `server/src/app.ts` in web mode. The public route allow | Page | Source | CLI checks | | --- | --- | --- | -| `/` | `web/beta.html` via `server/src/app.ts` `GET /` | `page:/ status`, `page:/ title`, `page:/ meta-description`, `page:/ canonical`, `page:/ og:title`, `page:/ og:description`, `page:/ og:url`, `page:/ og:image`, `page:/ json-ld`, `page:/ umami`, `page:/ trial-wording` | +| `/` | `web/beta.html` via `server/src/app.ts` `GET /` | `page:/ status`, `page:/ title`, `page:/ meta-description`, `page:/ canonical`, `page:/ og:title`, `page:/ og:description`, `page:/ og:url`, `page:/ og:image`, `page:/ json-ld`, `page:/ umami`, `page:/ trial-wording`, `page:/ launch-badge` | | `/apple-music-soundtrack` | `web/apple-music-soundtrack.html` | `page:/apple-music-soundtrack status`, `page:/apple-music-soundtrack title`, `page:/apple-music-soundtrack meta-description`, `page:/apple-music-soundtrack canonical`, `page:/apple-music-soundtrack og:title`, `page:/apple-music-soundtrack og:description`, `page:/apple-music-soundtrack og:url`, `page:/apple-music-soundtrack og:image`, `page:/apple-music-soundtrack json-ld`, `page:/apple-music-soundtrack umami`, `page:/apple-music-soundtrack trial-wording` | | `/private-driving-journal` | `web/private-driving-journal.html` | `page:/private-driving-journal status`, `page:/private-driving-journal title`, `page:/private-driving-journal meta-description`, `page:/private-driving-journal canonical`, `page:/private-driving-journal og:title`, `page:/private-driving-journal og:description`, `page:/private-driving-journal og:url`, `page:/private-driving-journal og:image`, `page:/private-driving-journal json-ld`, `page:/private-driving-journal umami`, `page:/private-driving-journal trial-wording` | | `/road-trip-journal-app` | `web/road-trip-journal-app.html` | `page:/road-trip-journal-app status`, `page:/road-trip-journal-app title`, `page:/road-trip-journal-app meta-description`, `page:/road-trip-journal-app canonical`, `page:/road-trip-journal-app og:title`, `page:/road-trip-journal-app og:description`, `page:/road-trip-journal-app og:url`, `page:/road-trip-journal-app og:image`, `page:/road-trip-journal-app json-ld`, `page:/road-trip-journal-app umami`, `page:/road-trip-journal-app trial-wording` | @@ -63,9 +63,14 @@ JSON-LD blocks live in `web/beta.html`, `web/apple-music-soundtrack.html`, `web/ - X visit: headline `Keep the drive.` / `Keep the song.`. - Checks, at `390x844` and `1280x800`: `browser campaign headline`, `browser campaign app-store-hrefs`, `browser default headline`, `browser default app-store-hrefs`. +## Launch Llama badge + +- Owner: the homepage footer in `web/beta.html`. The product href, image `src`, `alt`, `width`, `height`, and Launch MCP link must match `expectations.launchBadge` exactly. +- Checks: `page:/ launch-badge` only. Other public pages are not required to include the badge. + ## Content Security Policy -- Owner: `securityHeaders` in `server/src/app.ts`. `script-src` allows `https://cloud.umami.is`. `connect-src` allows `https://gateway.umami.is` and does not allow `https://cloud.umami.is`. +- Owner: `securityHeaders` in `server/src/app.ts`. `script-src` allows `https://cloud.umami.is`. `connect-src` allows `https://gateway.umami.is` and does not allow `https://cloud.umami.is`. `img-src` allows `https://tools.launchllama.co` for the homepage Launch Llama badge and does not add that host to other directives. - The verifier lets `cloud.umami.is/script.js` load so the policy is actually exercised. It aborts `gateway.umami.is` so a run never sends analytics. It also aborts navigations to `apps.apple.com`. - The aborted gateway request is expected to appear as `net::ERR_FAILED`. That must not fail the run. It is listed as `blocked by verifier`. - Checks: `csp console` (no console errors that mention Content Security Policy, and no `securitypolicyviolation` events). diff --git a/tools/verify-live/expectations.json b/tools/verify-live/expectations.json index cf087b8..54708af 100644 --- a/tools/verify-live/expectations.json +++ b/tools/verify-live/expectations.json @@ -65,5 +65,14 @@ "apps.apple.com", "www.apps.apple.com" ] + }, + "launchBadge": { + "productHref": "https://tools.launchllama.co/products/journeydeck?utm_source=badge&utm_medium=referral", + "imageSrc": "https://tools.launchllama.co/featured-badge-white.jpg?v=5", + "imageAlt": "Featured on Launch", + "imageWidth": "200", + "imageHeight": "52", + "mcpHref": "https://launchmcp.co/?utm_source=badge&utm_medium=referral", + "mcpText": "Launch MCP" } } diff --git a/tools/verify-live/lib.mjs b/tools/verify-live/lib.mjs index 2c4af05..6acb45e 100644 --- a/tools/verify-live/lib.mjs +++ b/tools/verify-live/lib.mjs @@ -84,6 +84,17 @@ export function validateExpectations(raw) { if (!raw.headlines?.x?.lead || !raw.headlines?.x?.accent) throw new Error("expectations.headlines.x is incomplete"); if (!Array.isArray(raw.viewports) || raw.viewports.length === 0) throw new Error("expectations.viewports must be a non-empty array"); if (!raw.blocked?.analyticsHost || !Array.isArray(raw.blocked?.appStoreHosts)) throw new Error("expectations.blocked is incomplete"); + if ( + !raw.launchBadge?.productHref + || !raw.launchBadge?.imageSrc + || !raw.launchBadge?.imageAlt + || raw.launchBadge.imageWidth == null + || raw.launchBadge.imageHeight == null + || !raw.launchBadge?.mcpHref + || !raw.launchBadge?.mcpText + ) { + throw new Error("expectations.launchBadge is incomplete"); + } return raw; } @@ -342,6 +353,24 @@ export function classifyConsoleError(text, blockedUrls = []) { return { fail: false, label: "ignored" }; } +export function evaluateLaunchBadge(html, expectations) { + const badge = expectations.launchBadge; + const footer = html.match(/]*>[\s\S]*?<\/footer>/i)?.[0] ?? ""; + const observed = { + productHref: footer.includes(`href="${badge.productHref}"`), + imageSrc: footer.includes(`src="${badge.imageSrc}"`), + imageAlt: footer.includes(`alt="${badge.imageAlt}"`), + imageWidth: footer.includes(`width="${badge.imageWidth}"`), + imageHeight: footer.includes(`height="${badge.imageHeight}"`), + mcpHref: footer.includes(`href="${badge.mcpHref}"`), + mcpText: footer.includes(`>${badge.mcpText}`) + }; + return { + pass: Object.values(observed).every(Boolean), + observed + }; +} + export function evaluatePublicPageHtml(html, pathname, expectations, statusCode) { const expectedCanonical = canonicalUrlForPath(pathname, expectations); const title = extractDocumentTitle(html); @@ -354,7 +383,7 @@ export function evaluatePublicPageHtml(html, pathname, expectations, statusCode) const jsonLd = parseJsonLdBlocks(extractJsonLdBlocks(html)); const umami = evaluateUmamiScripts(findUmamiScripts(html, expectations), expectations, { requireOne: true }); const trialHits = findForbiddenTrialPhrases(html, expectations.forbiddenTrialPhrases); - return [ + const checks = [ makeCheck(`page:${pathname} status`, statusCode === 200, statusCode), makeCheck(`page:${pathname} title`, title.count === 1 && Boolean(title.value), title), makeCheck(`page:${pathname} meta-description`, description.count === 1 && Boolean(description.value), description), @@ -367,6 +396,11 @@ export function evaluatePublicPageHtml(html, pathname, expectations, statusCode) makeCheck(`page:${pathname} umami`, umami.pass, umami.observed), makeCheck(`page:${pathname} trial-wording`, trialHits.length === 0, { forbidden: trialHits }) ]; + if (pathname === "/") { + const badge = evaluateLaunchBadge(html, expectations); + checks.push(makeCheck(`page:${pathname} launch-badge`, badge.pass, badge.observed)); + } + return checks; } export function evaluateLoginUmami(html, expectations, statusCode) { diff --git a/web/beta.css b/web/beta.css index 9f3f847..b434ec3 100644 --- a/web/beta.css +++ b/web/beta.css @@ -209,6 +209,8 @@ figure, h1, h2, h3, p { margin: 0; } .footer-bottom { margin-top: 29px; font-size: 10px; color: var(--muted); } .footer-bottom nav { display: flex; gap: 23px; flex-wrap: wrap; } .footer-bottom a:hover { color: var(--ivory); } +.footer-badge { display: flex; justify-content: center; width: 100%; margin-top: 28px; text-align: center; } +.footer-badge img { max-width: none; } @media (prefers-reduced-motion: no-preference) { html.motion-ready [data-reveal] { opacity: 0; transform: perspective(1400px) translateY(38px) rotateX(5deg); transition: opacity .85s var(--ease), transform 1.05s var(--ease); transition-delay: var(--reveal-delay,0ms); } @@ -305,6 +307,7 @@ figure, h1, h2, h3, p { margin: 0; } .closing { padding-top: 0; padding-bottom: 60px; } .closing-rule { margin-bottom: 58px; } .closing h2 { font-size: clamp(40px,11vw,58px); margin-top: 22px; } .closing-actions { flex-direction: column; gap: 27px; margin-top: 31px; } .closing .button { font-size: 13px; gap: 21px; } .closing .text-link { font-size: 11px; } .closing > .small { font-size: 9px; max-width: 275px; margin: 26px auto 0; } .footer-top, .footer-bottom { flex-direction: column; align-items: start; gap: 18px; } + .footer-badge { margin-top: 22px; } .footer-bottom { gap: 23px; } .footer-bottom nav { justify-content: start; gap: 17px 20px; font-size: 11px; line-height: 1.7; } .footer-bottom a { padding-block: 5px; } .site-footer { padding-top: 27px; } .footer-top > p { font-size: 10px; } } @media (max-width: 360px) { diff --git a/web/beta.html b/web/beta.html index 2edbf15..3e998cb 100644 --- a/web/beta.html +++ b/web/beta.html @@ -25,7 +25,7 @@ - + @@ -156,6 +156,13 @@

Take the road.
Keep the story.<

PERSONAL BY NATURE. PRIVATE BY DESIGN.

Your life.
Yours to keep.

Your journal starts on your device. Core recording works offline, without an account or a subscription.

  • Private iCloud continuity is your choice.
  • You decide which journeys to record.
  • Privacy controls help protect sensitive places when you share.
Read our privacy policy

EVERY ROAD HAS A SOUNDTRACK.

Go somewhere.
Keep something.

Explore JourneyDeck on the App Store.

- +