Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 14 additions & 13 deletions .ai/HANDOFF.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=()"
};
Expand Down
14 changes: 11 additions & 3 deletions server/tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"/);
Expand All @@ -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, /<meta name="google-site-verification" content="C9F75mNcVf450GCL_J5RBoD5NRkqRig1EwwXUrr0P-w" \/>/);
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);
Expand Down Expand Up @@ -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/],
Expand Down Expand Up @@ -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");

Expand Down
8 changes: 8 additions & 0 deletions server/tests/campaign.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, /<h1 id="hero-title">Take the road\.<br><span class="gold">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 });
Expand Down
23 changes: 23 additions & 0 deletions server/tests/verify-live.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
canonicalUrlForPath,
classifyConsoleError,
compareGeneratedAtSince,
evaluateLaunchBadge,
evaluateLoginUmami,
evaluatePublicPageHtml,
evaluateUmamiScripts,
Expand Down Expand Up @@ -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");
Expand All @@ -104,6 +113,14 @@ test("HTML helpers parse metadata, Umami, JSON-LD, and ignore generic trial term
<a href="https://apps.apple.com/us/app/journeydeck/id6806502526">Get the app</a>
<a href="https://apps.apple.com/us/app/journeydeck/id6806502526?see-all=reviews">Reviews</a>
<p>Available products, price, billing period, trial terms, and included features are shown before purchase.</p>
<footer><span style="display:inline-block;text-align:center;font-family:system-ui,sans-serif;line-height:1;">
<a href="https://tools.launchllama.co/products/journeydeck?utm_source=badge&utm_medium=referral" target="_blank" rel="noopener noreferrer" style="display:block;line-height:0;">
<img src="https://tools.launchllama.co/featured-badge-white.jpg?v=5"
alt="Featured on Launch"
width="200" height="52" style="display:block;border:0;" />
</a>
<a href="https://launchmcp.co/?utm_source=badge&utm_medium=referral" target="_blank" rel="noopener noreferrer" style="display:block;margin-top:-2px;padding-top:0;font-size:7px;font-weight:600;color:#888;text-decoration:none;line-height:1;letter-spacing:0.02em;">Launch MCP</a>
</span></footer>
</body></html>`;
const title = extractDocumentTitle(html);
assert.equal(title.count, 1);
Expand All @@ -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("<footer></footer>", 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", () => {
Expand Down
19 changes: 19 additions & 0 deletions tests/e2e/public-beta.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
9 changes: 7 additions & 2 deletions tools/verify-live/FEATURE_MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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 <viewport> campaign headline`, `browser <viewport> campaign app-store-hrefs`, `browser <viewport> default headline`, `browser <viewport> 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).
Expand Down
9 changes: 9 additions & 0 deletions tools/verify-live/expectations.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Loading