diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx
index a1fe6bca48..638609fcf1 100644
--- a/packages/website/src/components/landing-page.tsx
+++ b/packages/website/src/components/landing-page.tsx
@@ -56,13 +56,15 @@ import { AGENT_PAGES } from "~/data/agent-pages";
import {
appStoreUrl,
playStoreUrl,
- getDownloadOptions,
- useDetectedPlatform,
+ getDesktopDownload,
+ MOBILE_STORES,
AppleIcon,
PlayStoreIcon,
TerminalIcon,
} from "~/downloads";
-import { useRelease } from "~/routes/__root";
+import type { DesktopPlatform, MobilePlatform } from "~/platform";
+import { isMobilePlatform } from "~/platform";
+import { useRelease, useVisitorPlatform } from "~/routes/__root";
import { HeroMockup } from "~/components/hero-mockup";
import {
ClaudeCodeIcon,
@@ -315,7 +317,7 @@ function SectionTitle({
)}
-
{links.map((link) => (
@@ -414,13 +416,15 @@ function SocialProofCard({ tweet, inert }: { tweet: SocialProofTweet; inert?: bo
);
}
+const PROVIDER_ICON_CLASS = "h-5 w-5 sm:h-7 sm:w-7";
+
function MultiProviderSection() {
const providers = [
- { name: "Claude Code", icon:
},
- { name: "Codex", icon:
},
- { name: "OpenCode", icon:
},
- { name: "Pi", icon:
},
- { name: "Cursor", icon:
},
+ { name: "Claude Code", icon:
},
+ { name: "Codex", icon:
},
+ { name: "OpenCode", icon:
},
+ { name: "Pi", icon:
},
+ { name: "Cursor", icon:
},
];
return (
@@ -428,21 +432,21 @@ function MultiProviderSection() {
title="Works with your tools"
description="Bring your subscriptions, skills and configuration"
>
-
+
@@ -842,31 +846,24 @@ function ExtensibleCard({
}
function GetStarted() {
+ const platform = useVisitorPlatform();
return (
-
-
-
-
-
-
-
-
-
+ {/* The primary call to action owns its own row on phones so the small icon
+ buttons never wrap and orphan one of themselves onto a line alone. It
+ still hugs its label rather than stretching across the row. */}
+
+ {isMobilePlatform(platform) ? (
+
+ ) : (
+
+ )}
+
+ {isMobilePlatform(platform) ? : }
+
+
-
+
Supports
@@ -877,7 +874,7 @@ function GetStarted() {
+{ADDITIONAL_AGENT_COUNT} more
@@ -886,27 +883,74 @@ function GetStarted() {
);
}
-function DownloadButton() {
- const release = useRelease();
- const detectedPlatform = useDetectedPlatform();
- const primary = getDownloadOptions(release).find((o) => o.platform === detectedPlatform)!;
- const PrimaryIcon = primary.icon;
+const PRIMARY_CTA_CLASS =
+ "inline-flex items-center justify-center gap-2 rounded-lg bg-foreground px-4 py-2.5 text-sm font-medium text-background hover:bg-foreground/90 transition-colors";
+const SECONDARY_CTA_CLASS =
+ "inline-flex items-center justify-center gap-2 rounded-lg border border-white/12 px-3 py-2.5 text-sm text-white hover:bg-white/10 transition-colors";
+function DesktopDownloadButton({ platform }: { platform: DesktopPlatform }) {
+ const download = getDesktopDownload(useRelease(), platform);
+ const Icon = download.icon;
return (
-
-
- Download for {primary.label}
+
+
+ Download for {download.label}
);
}
+function StoreButton({ platform }: { platform: MobilePlatform }) {
+ const store = MOBILE_STORES[platform];
+ const Icon = store.icon;
+ return (
+
+
+ Get the {store.label} app
+
+ );
+}
+
+// On a phone the desktop build is the secondary path, so it points at /download
+// instead of handing the visitor a .dmg they cannot open.
+function DesktopAppLink() {
+ return (
+
+
+ Desktop app
+
+ );
+}
+
+function StoreIconLinks() {
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+}
+
const SERVER_INSTALL_TRIGGER = (
-
+
);
@@ -989,11 +1033,10 @@ function PhoneShowcase() {
>
-
- When you want to step away from your desk,
-
you can.
+
+ When you want to step away from your desk, you can.
-
+
The native mobile app has full feature parity with desktop.
diff --git a/packages/website/src/downloads.tsx b/packages/website/src/downloads.tsx
index 3a19085126..cce40a7791 100644
--- a/packages/website/src/downloads.tsx
+++ b/packages/website/src/downloads.tsx
@@ -1,4 +1,5 @@
import * as React from "react";
+import type { DesktopPlatform, MobilePlatform } from "~/platform";
export function releaseBase(version: string) {
return `https://github.com/getpaseo/paseo/releases/download/v${version}`;
@@ -30,68 +31,31 @@ export const appStoreUrl = "https://apps.apple.com/app/paseo-pocket-engineer/id6
export const playStoreUrl = "https://play.google.com/store/apps/details?id=sh.paseo";
export const webAppUrl = "https://app.paseo.sh";
-type Platform = "mac-silicon" | "mac-intel" | "windows" | "linux";
-
-export interface DownloadOption {
- platform: Platform;
+export interface PrimaryDownload {
label: string;
href: string;
icon: (props: React.SVGProps) => React.ReactElement;
}
-export function getDownloadOptions(release: ReleaseAssetInfo): DownloadOption[] {
+export function getDesktopDownload(
+ release: ReleaseAssetInfo,
+ platform: DesktopPlatform,
+): PrimaryDownload {
const urls = downloadUrls(release);
- return [
- {
- platform: "mac-silicon",
- label: "Mac",
- href: urls.macAppleSilicon,
- icon: AppleIcon,
- },
- {
- platform: "mac-intel",
- label: "Mac Intel",
- href: urls.macIntel,
- icon: AppleIcon,
- },
- {
- platform: "windows",
- label: "Windows",
- href: urls.windowsExeX64,
- icon: WindowsIcon,
- },
- {
- platform: "linux",
- label: "Linux",
- href: urls.linuxAppImage,
- icon: LinuxIcon,
- },
- ];
+ switch (platform) {
+ case "windows":
+ return { label: "Windows", href: urls.windowsExeX64, icon: WindowsIcon };
+ case "linux":
+ return { label: "Linux", href: urls.linuxAppImage, icon: LinuxIcon };
+ case "mac":
+ return { label: "Mac", href: urls.macAppleSilicon, icon: AppleIcon };
+ }
}
-export function useDetectedPlatform(): Platform {
- const [platform, setPlatform] = React.useState("mac-silicon");
-
- React.useEffect(() => {
- const ua = navigator.userAgent.toLowerCase();
- if (ua.includes("win")) {
- setPlatform("windows");
- } else if (ua.includes("linux")) {
- setPlatform("linux");
- } else if (ua.includes("mac")) {
- // Check for Apple Silicon vs Intel
- // navigator.platform is deprecated but still the most reliable check
- const isARM =
- /arm|aarch64/i.test(navigator.platform) ||
- // Chrome/Edge on Apple Silicon report x86 platform but have ARM in userAgentData
- (navigator as unknown as { userAgentData?: { architecture?: string } }).userAgentData
- ?.architecture === "arm";
- setPlatform(isARM ? "mac-silicon" : "mac-silicon"); // Default to Silicon for modern Macs
- }
- }, []);
-
- return platform;
-}
+export const MOBILE_STORES: Record = {
+ ios: { label: "iPhone", href: appStoreUrl, icon: AppleIcon },
+ android: { label: "Android", href: playStoreUrl, icon: PlayStoreIcon },
+};
export function AppleIcon(props: React.SVGProps) {
return (
diff --git a/packages/website/src/platform.test.ts b/packages/website/src/platform.test.ts
new file mode 100644
index 0000000000..36c91acb18
--- /dev/null
+++ b/packages/website/src/platform.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from "vitest";
+import { detectPlatform } from "./platform";
+
+const AGENTS = {
+ android:
+ "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Mobile Safari/537.36",
+ iphone:
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
+ windows:
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
+ linux:
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
+ mac: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
+};
+
+describe("detectPlatform", () => {
+ it.each([
+ [AGENTS.android, "android"],
+ [AGENTS.iphone, "ios"],
+ [AGENTS.windows, "windows"],
+ [AGENTS.linux, "linux"],
+ [AGENTS.mac, "mac"],
+ ])("resolves %s to %s", (userAgent, expected) => {
+ expect(detectPlatform(userAgent)).toBe(expected);
+ });
+
+ it("prefers the mobile OS over the kernel or vendor the same agent also claims", () => {
+ // Android agents say "Linux" and iOS ones say "like Mac OS X", so a
+ // desktop-first check hands a phone a .dmg or an AppImage.
+ expect(detectPlatform(AGENTS.android)).not.toBe("linux");
+ expect(detectPlatform(AGENTS.iphone)).not.toBe("mac");
+ });
+
+ it("falls back to mac for an unknown user agent", () => {
+ expect(detectPlatform("")).toBe("mac");
+ });
+});
diff --git a/packages/website/src/platform.ts b/packages/website/src/platform.ts
new file mode 100644
index 0000000000..3886539183
--- /dev/null
+++ b/packages/website/src/platform.ts
@@ -0,0 +1,35 @@
+import { createServerFn } from "@tanstack/react-start";
+import { getRequestHeader } from "@tanstack/react-start/server";
+
+export type DesktopPlatform = "mac" | "windows" | "linux";
+export type MobilePlatform = "ios" | "android";
+export type VisitorPlatform = DesktopPlatform | MobilePlatform;
+
+export function isMobilePlatform(platform: VisitorPlatform): platform is MobilePlatform {
+ return platform === "ios" || platform === "android";
+}
+
+/**
+ * Order matters: Android user agents also say "Linux", and iOS ones also say
+ * "like Mac OS X". iPadOS Safari sends a desktop Mac user agent with no mobile
+ * marker at all, so an iPad is detected as a Mac — there is no server-side
+ * signal that separates them.
+ */
+export function detectPlatform(userAgent: string): VisitorPlatform {
+ const ua = userAgent.toLowerCase();
+ if (/android/.test(ua)) return "android";
+ if (/iphone|ipad|ipod/.test(ua)) return "ios";
+ if (/windows|win32|win64/.test(ua)) return "windows";
+ if (/linux|x11|cros/.test(ua)) return "linux";
+ return "mac";
+}
+
+/**
+ * Resolved during SSR so the first paint already shows the right call to
+ * action. The worker renders every HTML response per request and sets no
+ * cache-control, so varying the markup on the user agent is safe — if the
+ * homepage ever gains an edge cache, it has to vary on `user-agent` too.
+ */
+export const getVisitorPlatform = createServerFn({ method: "GET" }).handler(
+ (): VisitorPlatform => detectPlatform(getRequestHeader("user-agent") ?? ""),
+);
diff --git a/packages/website/src/routes/__root.tsx b/packages/website/src/routes/__root.tsx
index 82b9de5132..1f80338275 100644
--- a/packages/website/src/routes/__root.tsx
+++ b/packages/website/src/routes/__root.tsx
@@ -2,6 +2,8 @@ import type { ReactNode } from "react";
import { createContext, useContext } from "react";
import { Outlet, createRootRoute, HeadContent, Scripts } from "@tanstack/react-router";
import type { ReleaseChannels, ReleaseInfo } from "~/latest-release";
+import type { VisitorPlatform } from "~/platform";
+import { getVisitorPlatform } from "~/platform";
import { getLatestRelease } from "~/release";
import { getStarCount } from "~/stars";
@@ -19,6 +21,7 @@ const ReleaseCtx = createContext({
beta: null,
});
const StarsCtx = createContext({ stars: "" });
+const PlatformCtx = createContext("mac");
const PLAUSIBLE_INIT_SCRIPT = {
__html: `window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};plausible.init()`,
@@ -38,10 +41,19 @@ export function useStars(): StarsContext {
return useContext(StarsCtx);
}
+/** The platform the visitor is browsing from, resolved from the request user agent during SSR. */
+export function useVisitorPlatform(): VisitorPlatform {
+ return useContext(PlatformCtx);
+}
+
export const Route = createRootRoute({
loader: async () => {
- const [release, stars] = await Promise.all([getLatestRelease(), getStarCount()]);
- return { release, ...stars };
+ const [release, stars, platform] = await Promise.all([
+ getLatestRelease(),
+ getStarCount(),
+ getVisitorPlatform(),
+ ]);
+ return { release, platform, ...stars };
},
head: () => ({
meta: [
@@ -68,9 +80,11 @@ function RootComponent() {
return (
-
-
-
+
+
+
+
+
);
diff --git a/packages/website/src/server-entry.ts b/packages/website/src/server-entry.ts
index 1bd82b8113..75f46e2450 100644
--- a/packages/website/src/server-entry.ts
+++ b/packages/website/src/server-entry.ts
@@ -33,6 +33,32 @@ function docSlugFromMarkdownPath(pathname: string): string | null {
return match ? match[1] : null;
}
+/**
+ * SSR reads the request user agent to pick the download call to action (see
+ * `~/platform`), so two visitors on the same URL get different markup. Nothing
+ * caches these responses today — the zone cache sits behind the Worker and
+ * Workers Cache is off — but an absent `cache-control` is not `no-store`: a
+ * shared cache may assign heuristic freshness (RFC 9111 §4.2.2), and enabling
+ * Workers Cache would store a headerless 200 for two hours under a key that
+ * ignores `user-agent`. Either way one Android visitor could pin the Play Store
+ * button for every Mac visitor after them.
+ *
+ * `private` is what actually stops that. `Vary` records the contract so a
+ * compliant cache stays correct if the policy is ever loosened. Static assets
+ * never reach this Worker, so their long-lived `_headers` rules are untouched.
+ */
+function withoutSharedCaching(response: Response): Response {
+ const result = new Response(response.body, response);
+ result.headers.set("cache-control", "private, no-store");
+ result.headers.set("vary", "user-agent");
+ return result;
+}
+
+function variesByUserAgent(pathname: string, response: Response): boolean {
+ if (pathname.startsWith("/_serverFn/")) return true;
+ return response.headers.get("content-type")?.includes("text/html") ?? false;
+}
+
export default {
async fetch(request: Request, env: WebsiteEnv, context: ExecutionContext): Promise {
const url = new URL(request.url);
@@ -73,6 +99,7 @@ export default {
return markdownResponse(doc.content);
}
- return startEntry.fetch(request);
+ const response = await startEntry.fetch(request);
+ return variesByUserAgent(url.pathname, response) ? withoutSharedCaching(response) : response;
},
};