feat(landing): desktop-first homepage — download CTA leads, CLI below the fold (#221)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Coarse client-side OS detection, shared by the hero's platform-aware download
|
||||
* button and the download page's platform cards. Detection only tunes emphasis
|
||||
* (which label the hero button carries, which card gets badged) — absent or wrong
|
||||
* detection never blocks a download.
|
||||
*/
|
||||
export type Platform = "mac" | "windows" | "linux";
|
||||
|
||||
export function detectPlatform(): Platform | null {
|
||||
const ua = navigator.userAgent;
|
||||
if (/Windows/i.test(ua)) return "windows";
|
||||
if (/Macintosh|Mac OS X/i.test(ua)) return "mac";
|
||||
if (/Linux|X11/.test(ua) && !/Android/i.test(ua)) return "linux";
|
||||
return null;
|
||||
}
|
||||
@@ -54,9 +54,14 @@ export const en: Strings = {
|
||||
titleWords: ["Desktop", "Server"],
|
||||
titleSuffix: "",
|
||||
subtitle: "Create Self-Evolving Agents in One Click",
|
||||
ctaPrimary: "Get started",
|
||||
/** Primary CTA: base label, and the platform-aware variant once the OS is detected. */
|
||||
downloadCta: "Download the desktop app",
|
||||
downloadCtaFor: (platform: string) => `Download for ${platform}`,
|
||||
ctaQuickstart: "Get started",
|
||||
ctaGithub: "GitHub",
|
||||
installHint: "One-line install (bundled Node runtime — unpack and run)",
|
||||
/** Line under the CTAs: all installers on /download, CLI / self-hosted below the fold. */
|
||||
downloadAll: "All platforms (macOS / Windows / Linux)",
|
||||
cliAlt: "CLI and self-hosted install ↓",
|
||||
stats: [
|
||||
{ value: "1000+", label: "supported models" },
|
||||
{ value: "1×CPU", label: "minimum footprint" },
|
||||
@@ -80,7 +85,7 @@ export const en: Strings = {
|
||||
},
|
||||
offlineRelease: "Download offline packages from GitHub Releases",
|
||||
desktopNote:
|
||||
"Prefer a standalone app? The desktop app packages the Web interface and the server into a terminal-free installer (macOS / Windows / Linux), sharing the same local data as a CLI install.",
|
||||
"Already on the desktop app? A CLI install shares the same local data with it, and the two can be used side by side.",
|
||||
desktopPage: "Go to the desktop download page",
|
||||
},
|
||||
|
||||
@@ -485,7 +490,8 @@ export const en: Strings = {
|
||||
title: "Complex AI development, made ever simpler",
|
||||
subtitle:
|
||||
"Through continuous evolution, PenguinHarness gives you a more efficient, more reliable, lower-hallucination and lower-cost Agent productivity engine.",
|
||||
install: "Install now",
|
||||
download: "Download the desktop app",
|
||||
quickstart: "Get started",
|
||||
docs: "Read the docs",
|
||||
},
|
||||
|
||||
|
||||
@@ -59,9 +59,14 @@ export const zh = {
|
||||
titleWords: ["桌面", "服务器"],
|
||||
titleSuffix: "上",
|
||||
subtitle: "一键创建自进化 Agent",
|
||||
ctaPrimary: "快速开始",
|
||||
/** Primary CTA: base label, and the platform-aware variant once the OS is detected. */
|
||||
downloadCta: "下载桌面版",
|
||||
downloadCtaFor: (platform: string) => `下载桌面版(${platform})`,
|
||||
ctaQuickstart: "快速开始",
|
||||
ctaGithub: "GitHub",
|
||||
installHint: "一行命令安装(内嵌 Node 运行时,解压即用)",
|
||||
/** Line under the CTAs: all installers on /download, CLI / self-hosted below the fold. */
|
||||
downloadAll: "全部平台下载(macOS / Windows / Linux)",
|
||||
cliAlt: "命令行与自托管安装 ↓",
|
||||
stats: [
|
||||
{ value: "1000+", label: "支持模型数量" },
|
||||
{ value: "1×CPU", label: "最低运行配置" },
|
||||
@@ -70,7 +75,7 @@ export const zh = {
|
||||
],
|
||||
},
|
||||
|
||||
/** Install-method switcher (hero + quick start): OS tabs, online/offline methods. */
|
||||
/** Install-method switcher (quick start step 1): OS tabs, online/offline methods. */
|
||||
install: {
|
||||
linux: "Linux",
|
||||
macos: "macOS",
|
||||
@@ -85,8 +90,7 @@ export const zh = {
|
||||
windows: "解压后也可直接双击 install.cmd 完成安装。",
|
||||
},
|
||||
offlineRelease: "前往 GitHub Releases 下载离线安装包",
|
||||
desktopNote:
|
||||
"更喜欢独立应用?桌面端把 Web 界面与服务端打包成免终端的安装程序(macOS / Windows / Linux),与 CLI 安装共用同一份本地数据。",
|
||||
desktopNote: "已经装了桌面版?CLI 安装与桌面版共用同一份本地数据,两者可以随时并用。",
|
||||
desktopPage: "前往桌面端下载页",
|
||||
},
|
||||
|
||||
@@ -475,7 +479,8 @@ export const zh = {
|
||||
title: "让复杂的 AI 开发越来越简单",
|
||||
subtitle:
|
||||
"通过不断进化,PenguinHarness 为你提供更高效、更可靠、更低幻觉、更低成本的 Agent 生产力引擎。",
|
||||
install: "立即安装",
|
||||
download: "下载桌面版",
|
||||
quickstart: "快速开始",
|
||||
docs: "阅读文档",
|
||||
},
|
||||
|
||||
|
||||
@@ -23,22 +23,14 @@ import {
|
||||
OSS_ORIGIN,
|
||||
RELEASES_URL,
|
||||
} from "../lib/links";
|
||||
import { detectPlatform } from "../lib/platform";
|
||||
import type { Platform } from "../lib/platform";
|
||||
import { Section } from "../components/section";
|
||||
import { CodeCard } from "../components/code-card";
|
||||
import { ChevronDownIcon, DownloadIcon, ExternalLinkIcon } from "../components/icons";
|
||||
|
||||
type Platform = "mac" | "windows" | "linux";
|
||||
const PLATFORMS: Platform[] = ["mac", "windows", "linux"];
|
||||
|
||||
/** Coarse OS detection, used only to badge one card; absent or wrong detection changes nothing else. */
|
||||
function detectPlatform(): Platform | null {
|
||||
const ua = navigator.userAgent;
|
||||
if (/Windows/i.test(ua)) return "windows";
|
||||
if (/Macintosh|Mac OS X/i.test(ua)) return "mac";
|
||||
if (/Linux|X11/.test(ua) && !/Android/i.test(ua)) return "linux";
|
||||
return null;
|
||||
}
|
||||
|
||||
interface Mirror {
|
||||
tag: string;
|
||||
base: string;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/** Closing call-to-action: the productivity-engine pitch + install / docs buttons. */
|
||||
/** Closing call-to-action: the productivity-engine pitch + download / quick start / docs buttons. */
|
||||
import { Link } from "react-router";
|
||||
import { S } from "../lib/strings";
|
||||
import { DOCS_URL } from "../lib/links";
|
||||
import { Section } from "../components/section";
|
||||
import { ArrowRightIcon } from "../components/icons";
|
||||
import { DownloadIcon } from "../components/icons";
|
||||
|
||||
export function Cta() {
|
||||
const secondary =
|
||||
"inline-flex h-11 items-center rounded-lg border border-gray-200 bg-white px-5 text-sm font-medium text-gray-900 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:bg-gray-800";
|
||||
return (
|
||||
<Section>
|
||||
<div className="mx-auto max-w-3xl rounded-2xl border border-gray-200 bg-gray-50/60 px-6 py-12 text-center sm:px-12 dark:border-gray-800 dark:bg-gray-900/40">
|
||||
@@ -17,16 +19,16 @@ export function Cta() {
|
||||
</p>
|
||||
<div className="mt-7 flex flex-wrap items-center justify-center gap-3">
|
||||
<Link
|
||||
to="/#quickstart"
|
||||
to="/download"
|
||||
className="inline-flex h-11 items-center gap-2 rounded-lg bg-gray-900 px-5 text-sm font-medium text-white transition-colors hover:bg-gray-700 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
|
||||
>
|
||||
{S.cta.install}
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
<DownloadIcon className="h-4 w-4" />
|
||||
{S.cta.download}
|
||||
</Link>
|
||||
<a
|
||||
href={DOCS_URL}
|
||||
className="inline-flex h-11 items-center rounded-lg border border-gray-200 bg-white px-5 text-sm font-medium text-gray-900 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
<Link to="/#quickstart" className={secondary}>
|
||||
{S.cta.quickstart}
|
||||
</Link>
|
||||
<a href={DOCS_URL} className={secondary}>
|
||||
{S.cta.docs}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,66 +1,20 @@
|
||||
/**
|
||||
* Hero: enlarged logo + product name, the one-line headline whose rotating word
|
||||
* crossfades through a gaussian blur (Desktop <-> Server, localized per dictionary),
|
||||
* the one-sentence subtitle, the install one-liner behind an OS switcher, and stats.
|
||||
* the desktop-first CTAs — a platform-aware download button pointing at /download
|
||||
* (artifact resolution stays on that page), an all-platforms line and a link down to
|
||||
* the quick start for CLI / self-hosted installs — and stats.
|
||||
* The rotating word is a stacked inline-grid so line width never jumps.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { S } from "../lib/strings";
|
||||
import { INSTALL_CMD, INSTALL_CMD_WINDOWS, REPO_URL } from "../lib/links";
|
||||
import { CopyButton } from "../components/copy-button";
|
||||
import { ArrowRightIcon, GitHubIcon } from "../components/icons";
|
||||
import { REPO_URL } from "../lib/links";
|
||||
import { detectPlatform } from "../lib/platform";
|
||||
import { ArrowRightIcon, DownloadIcon, GitHubIcon } from "../components/icons";
|
||||
|
||||
const ROTATE_MS = 2600;
|
||||
|
||||
type InstallOs = "linux" | "macos" | "windows";
|
||||
|
||||
/**
|
||||
* Install one-liner behind an OS tab row: one command visible at a time (Linux and
|
||||
* macOS share the POSIX one-liner; the tabs still name them apart so nobody has to
|
||||
* know that). Prompt char follows the shell: $ for POSIX, > for PowerShell.
|
||||
*/
|
||||
function InstallBox() {
|
||||
const [os, setOs] = useState<InstallOs>("linux");
|
||||
const cmd = os === "windows" ? INSTALL_CMD_WINDOWS : INSTALL_CMD;
|
||||
const osBtn = (active: boolean) =>
|
||||
`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
active
|
||||
? "bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-gray-100"
|
||||
: "text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
}`;
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-gray-50 text-left dark:border-gray-800 dark:bg-gray-900">
|
||||
<div
|
||||
className="flex items-center gap-1 border-b border-gray-200 bg-gray-100/70 px-2 py-1.5 dark:border-gray-800 dark:bg-gray-800/40"
|
||||
role="tablist"
|
||||
>
|
||||
{(["linux", "macos", "windows"] as const).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={os === key}
|
||||
className={osBtn(os === key)}
|
||||
onClick={() => setOs(key)}
|
||||
>
|
||||
{S.install[key]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 py-2.5 pr-2.5 pl-4">
|
||||
<code className="min-w-0 flex-1 overflow-x-auto font-mono text-[13px] whitespace-nowrap text-gray-800 dark:text-gray-200">
|
||||
<span className="mr-2 text-gray-400 select-none dark:text-gray-500">
|
||||
{os === "windows" ? ">" : "$"}
|
||||
</span>
|
||||
{cmd}
|
||||
</code>
|
||||
<CopyButton text={cmd} className="shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RotatingWord({ words }: { words: string[] }) {
|
||||
const [active, setActive] = useState(0);
|
||||
useEffect(() => {
|
||||
@@ -86,6 +40,15 @@ function RotatingWord({ words }: { words: string[] }) {
|
||||
}
|
||||
|
||||
export function Hero() {
|
||||
// Platform-aware label only; every link goes to /download, where the platform
|
||||
// cards and the GitHub/OSS artifact resolution live.
|
||||
const detected = detectPlatform();
|
||||
const downloadLabel = detected
|
||||
? S.hero.downloadCtaFor(S.download.platforms[detected].name)
|
||||
: S.hero.downloadCta;
|
||||
const textLink =
|
||||
"inline-flex items-center gap-1 text-brand-700 underline decoration-brand-300 underline-offset-2 transition-colors hover:text-brand-600 dark:text-brand-300 dark:decoration-brand-700";
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden">
|
||||
<div className="hero-dots pointer-events-none absolute inset-0" aria-hidden="true" />
|
||||
@@ -125,10 +88,17 @@ export function Hero() {
|
||||
style={{ animationDelay: "200ms" }}
|
||||
>
|
||||
<Link
|
||||
to="/#quickstart"
|
||||
to="/download"
|
||||
className="inline-flex h-11 items-center gap-2 rounded-lg bg-gray-900 px-5 text-sm font-medium text-white transition-colors hover:bg-gray-700 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
|
||||
>
|
||||
{S.hero.ctaPrimary}
|
||||
<DownloadIcon className="h-4 w-4" />
|
||||
{downloadLabel}
|
||||
</Link>
|
||||
<Link
|
||||
to="/#quickstart"
|
||||
className="inline-flex h-11 items-center gap-2 rounded-lg border border-gray-200 bg-white px-5 text-sm font-medium text-gray-900 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
{S.hero.ctaQuickstart}
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
</Link>
|
||||
<a
|
||||
@@ -142,13 +112,18 @@ export function Hero() {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="anim-rise mx-auto mt-10 w-fit max-w-full"
|
||||
<p
|
||||
className="anim-rise mt-5 text-sm text-gray-600 dark:text-gray-400"
|
||||
style={{ animationDelay: "240ms" }}
|
||||
>
|
||||
<InstallBox />
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">{S.hero.installHint}</p>
|
||||
</div>
|
||||
<Link to="/download" className={textLink}>
|
||||
{S.hero.downloadAll}
|
||||
</Link>
|
||||
<span className="mx-2 text-gray-300 dark:text-gray-700">·</span>
|
||||
<Link to="/#quickstart" className={textLink}>
|
||||
{S.hero.cliAlt}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<dl
|
||||
className="anim-rise mx-auto mt-12 grid max-w-3xl grid-cols-2 gap-x-6 gap-y-8 sm:grid-cols-4"
|
||||
|
||||
@@ -200,8 +200,9 @@ export function Quickstart() {
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{/* Below both install methods: the desktop app replaces this whole flow for
|
||||
users who never want a terminal, so it stays visible on every switch. */}
|
||||
{/* Below both install methods: the desktop app is the headline install path
|
||||
(hero + /download); this note tells CLI installers the two share one data
|
||||
root, and still routes anyone terminal-averse back to the download page. */}
|
||||
<p className="mt-2 text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||
{S.install.desktopNote}{" "}
|
||||
<Link
|
||||
|
||||
Reference in New Issue
Block a user