Initialize repository with harness code and assets

Initial import of all source code, config, and README assets: the
packages workspace (cli, core, server, web, docs, landing, skills),
build scripts, tooling config, and CI workflows.

Includes the data-layout revision made on this branch: the local data
root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the
installer keeps its binaries in ~/.penguin), and every Agent lives
under <project>/agents/<agent>/ — path helpers, the three
agent-enumeration scans, the system prompt, built-in Skills, tests
and docs all follow the new layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
This commit is contained in:
Yaowei ZhengandClaude Opus 4.8 authored and GitHub committed 2026-07-19 14:06:53 +08:00
1 parent 056bed7aeb
commit 45bfae6e94
543 files changed
+92949

No files matched your search

@@ -0,0 +1,31 @@
/** Fake browser chrome around product screenshots: traffic dots + address pill. */
import type { ReactNode } from "react";
export function BrowserFrame({
children,
url = "127.0.0.1:7364",
className = "",
}: {
children: ReactNode;
url?: string;
className?: string;
}) {
return (
<div
className={`overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900 ${className}`}
>
<div className="flex items-center gap-3 border-b border-gray-200 px-3.5 py-2.5 dark:border-gray-800">
<span className="flex gap-1.5" aria-hidden="true">
<span className="h-2.5 w-2.5 rounded-full bg-gray-300 dark:bg-gray-700" />
<span className="h-2.5 w-2.5 rounded-full bg-gray-300 dark:bg-gray-700" />
<span className="h-2.5 w-2.5 rounded-full bg-gray-300 dark:bg-gray-700" />
</span>
<span className="min-w-0 flex-1 truncate rounded-md bg-gray-100 px-3 py-1 text-center font-mono text-[11px] text-gray-500 dark:bg-gray-800 dark:text-gray-400">
{url}
</span>
<span className="w-10" aria-hidden="true" />
</div>
{children}
</div>
);
}
@@ -0,0 +1,18 @@
/** Blog category badge: product news (brand tint) vs release notes (neutral tint). */
import { S } from "../lib/strings";
import type { BlogCategory } from "../lib/blog";
export function CategoryBadge({ category }: { category: BlogCategory }) {
const isNews = category === "news";
return (
<span
className={`rounded-full border px-2 py-0.5 text-[11px] font-medium ${
isNews
? "border-brand-200 bg-brand-50 text-brand-700 dark:border-brand-800 dark:bg-brand-950 dark:text-brand-300"
: "border-gray-200 bg-gray-50 text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
}`}
>
{isNews ? S.blog.news : S.blog.changelog}
</span>
);
}
@@ -0,0 +1,51 @@
/**
* Terminal-style code card: rounded border, a slim header with a label + copy button,
* and a monospace body. Comment lines (starting with #) render muted; no highlighter
* dependency — landing snippets are short shell commands.
*/
import { TerminalIcon } from "./icons";
import { CopyButton } from "./copy-button";
export function CodeCard({
code,
label,
className = "",
}: {
code: string;
label?: string;
className?: string;
}) {
const lines = code.split("\n");
return (
<div
className={`overflow-hidden rounded-xl border border-gray-200 bg-gray-50 text-left dark:border-gray-800 dark:bg-gray-900 ${className}`}
>
<div className="flex items-center justify-between gap-2 border-b border-gray-200 px-3 py-2 dark:border-gray-800">
<span className="inline-flex min-w-0 items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<TerminalIcon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate font-mono">{label ?? "shell"}</span>
</span>
<CopyButton text={code} />
</div>
<pre className="overflow-x-auto px-4 py-3 text-[13px] leading-6">
<code>
{lines.map((line, i) => {
const isComment = line.trimStart().startsWith("#");
return (
<span
key={i}
className={`block whitespace-pre ${
isComment
? "text-gray-400 dark:text-gray-500"
: "text-gray-800 dark:text-gray-200"
}`}
>
{line.length > 0 ? line : " "}
</span>
);
})}
</code>
</pre>
</div>
);
}
@@ -0,0 +1,50 @@
/** Copy-to-clipboard button with a transient "copied" state. */
import { useEffect, useRef, useState } from "react";
import { S } from "../lib/strings";
import { CheckIcon, CopyIcon } from "./icons";
export function CopyButton({ text, className = "" }: { text: string; className?: string }) {
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
},
[],
);
const onCopy = async () => {
try {
await navigator.clipboard.writeText(text);
} catch {
// Clipboard API unavailable (e.g. non-secure context): fall back to a hidden textarea.
const ta = document.createElement("textarea");
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
ta.remove();
}
setCopied(true);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), 1600);
};
return (
<button
type="button"
onClick={() => void onCopy()}
title={copied ? S.copy.copied : S.copy.copy}
aria-label={copied ? S.copy.copied : S.copy.copy}
className={`inline-flex items-center gap-1.5 rounded-md border border-gray-200 bg-white px-2 py-1 text-xs text-gray-600 transition-colors hover:bg-gray-50 hover:text-gray-900 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100 ${className}`}
>
{copied ? (
<CheckIcon className="h-3.5 w-3.5 text-green-600 dark:text-green-400" />
) : (
<CopyIcon className="h-3.5 w-3.5" />
)}
<span>{copied ? S.copy.copied : S.copy.copy}</span>
</button>
);
}
@@ -0,0 +1,93 @@
/** Site footer: brand + product/resource link columns + copyright. */
import { Link, useLocation } from "react-router";
import { S } from "../lib/strings";
import { DOCS_URL, LICENSE_URL, RELEASES_URL, REPO_URL } from "../lib/links";
export function Footer() {
const { pathname } = useLocation();
const onHome = pathname === "/";
const anchor = (id: string, label: string) =>
onHome ? (
<a href={`#${id}`} className="hover:text-gray-900 dark:hover:text-gray-100">
{label}
</a>
) : (
<Link to={`/#${id}`} className="hover:text-gray-900 dark:hover:text-gray-100">
{label}
</Link>
);
return (
<footer className="border-t border-gray-200 dark:border-gray-800">
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<div className="flex flex-col gap-10 sm:flex-row sm:justify-between">
<div className="max-w-xs">
<div className="flex items-center gap-2">
<img src={`${import.meta.env.BASE_URL}penguin-logo.svg`} alt="" className="h-7 w-7" />
<span className="text-[15px] font-semibold tracking-tight">{S.siteName}</span>
</div>
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">{S.footer.tagline}</p>
</div>
<div className="grid grid-cols-2 gap-10 text-sm">
<div>
<p className="mb-3 font-medium">{S.footer.product}</p>
<ul className="space-y-2 text-gray-500 dark:text-gray-400">
<li>{anchor("quickstart", S.footer.quickstart)}</li>
<li>{anchor("features", S.footer.features)}</li>
<li>{anchor("benchmark", S.footer.benchmark)}</li>
<li>
<Link to="/blog" className="hover:text-gray-900 dark:hover:text-gray-100">
{S.footer.blog}
</Link>
</li>
</ul>
</div>
<div>
<p className="mb-3 font-medium">{S.footer.resources}</p>
<ul className="space-y-2 text-gray-500 dark:text-gray-400">
<li>
<a
href={REPO_URL}
target="_blank"
rel="noreferrer"
className="hover:text-gray-900 dark:hover:text-gray-100"
>
{S.footer.repo}
</a>
</li>
<li>
<a href={DOCS_URL} className="hover:text-gray-900 dark:hover:text-gray-100">
{S.footer.docs}
</a>
</li>
<li>
<a
href={RELEASES_URL}
target="_blank"
rel="noreferrer"
className="hover:text-gray-900 dark:hover:text-gray-100"
>
{S.footer.releases}
</a>
</li>
<li>
<a
href={LICENSE_URL}
target="_blank"
rel="noreferrer"
className="hover:text-gray-900 dark:hover:text-gray-100"
>
{S.footer.license}
</a>
</li>
</ul>
</div>
</div>
</div>
<p className="mt-10 border-t border-gray-200 pt-6 text-xs text-gray-400 dark:border-gray-800 dark:text-gray-500">
{S.footer.copyright}
</p>
</div>
</footer>
);
}
@@ -0,0 +1,30 @@
/**
* Harness brand marks for the benchmark comparison: PenguinHarness uses the product
* logo asset; Claude Code and Codex use their vendors' marks (simple-icons path data,
* rendered in currentColor so they stay neutral in both themes).
*/
export type HarnessKind = "penguin" | "claude" | "codex";
const ANTHROPIC_PATH =
"M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5527h3.7442L10.5359 3.541Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z";
const OPENAI_PATH =
"M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.073zM13.2599 22.4301a4.4755 4.4755 0 0 1-2.8764-1.04l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.8956zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654 2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z";
export function HarnessLogo({
kind,
className = "h-3.5 w-3.5",
}: {
kind: HarnessKind;
className?: string;
}) {
if (kind === "penguin") {
return <img src={`${import.meta.env.BASE_URL}penguin-logo.svg`} alt="" className={className} />;
}
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className={className}>
<path d={kind === "claude" ? ANTHROPIC_PATH : OPENAI_PATH} />
</svg>
);
}
+332
View File
@@ -0,0 +1,332 @@
/**
* Inline icon set (lucide-style 24x24 stroke icons + the GitHub mark). Kept local so
* the landing page has zero icon dependencies; all icons inherit currentColor.
*/
import type { ReactNode, SVGProps } from "react";
type IconProps = SVGProps<SVGSVGElement>;
function Icon({ children, ...props }: IconProps & { children: ReactNode }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
{children}
</svg>
);
}
export function GitHubIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
<path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.56 0-.28-.01-1.02-.02-2-3.2.7-3.88-1.54-3.88-1.54-.52-1.33-1.28-1.68-1.28-1.68-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.03 1.76 2.7 1.25 3.36.96.1-.75.4-1.25.72-1.54-2.55-.29-5.24-1.28-5.24-5.68 0-1.26.45-2.28 1.18-3.09-.12-.29-.51-1.46.11-3.05 0 0 .96-.31 3.15 1.18a10.9 10.9 0 0 1 5.74 0c2.18-1.49 3.14-1.18 3.14-1.18.63 1.59.24 2.76.12 3.05.74.81 1.18 1.83 1.18 3.09 0 4.41-2.69 5.38-5.25 5.67.41.35.78 1.05.78 2.12 0 1.53-.02 2.76-.02 3.14 0 .3.21.67.8.55A11.51 11.51 0 0 0 23.5 12C23.5 5.65 18.35.5 12 .5Z" />
</svg>
);
}
export function SunIcon(props: IconProps) {
return (
<Icon {...props}>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</Icon>
);
}
export function MoonIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</Icon>
);
}
export function MonitorIcon(props: IconProps) {
return (
<Icon {...props}>
<rect x="2" y="3" width="20" height="14" rx="2" />
<path d="M8 21h8M12 17v4" />
</Icon>
);
}
export function CopyIcon(props: IconProps) {
return (
<Icon {...props}>
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</Icon>
);
}
export function CheckIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="M20 6 9 17l-5-5" />
</Icon>
);
}
export function MenuIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="M4 6h16M4 12h16M4 18h16" />
</Icon>
);
}
export function XIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="M18 6 6 18M6 6l12 12" />
</Icon>
);
}
export function ArrowRightIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="M5 12h14M12 5l7 7-7 7" />
</Icon>
);
}
export function TerminalIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="m4 17 6-6-6-6M12 19h8" />
</Icon>
);
}
export function DownloadIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" />
</Icon>
);
}
export function SlidersIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4" />
</Icon>
);
}
export function PlayIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="M6 3l14 9-14 9V3Z" />
</Icon>
);
}
export function FeatherIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="M20.24 12.24a6 6 0 0 0-8.49-8.49L5 10.5V19h8.5ZM16 8 2 22M17.5 15H9" />
</Icon>
);
}
export function BotIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="M12 8V4H8" />
<rect x="4" y="8" width="16" height="12" rx="2" />
<path d="M2 14h2M20 14h2M15 13v2M9 13v2" />
</Icon>
);
}
export function RefreshIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16M8 16H3v5" />
</Icon>
);
}
export function CpuIcon(props: IconProps) {
return (
<Icon {...props}>
<rect x="5" y="5" width="14" height="14" rx="2" />
<rect x="9.5" y="9.5" width="5" height="5" />
<path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3" />
</Icon>
);
}
export function LayersIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="M12 2 2 7l10 5 10-5-10-5ZM2 12l10 5 10-5M2 17l10 5 10-5" />
</Icon>
);
}
export function BlocksIcon(props: IconProps) {
return (
<Icon {...props}>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="8.5" y="14" width="7" height="7" rx="1" />
</Icon>
);
}
export function ShareIcon(props: IconProps) {
return (
<Icon {...props}>
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<path d="m8.59 13.51 6.83 3.98M15.41 6.51 8.59 10.49" />
</Icon>
);
}
export function SparklesIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="M9.94 15.5a2 2 0 0 0-1.44-1.44L2.37 12.48a.5.5 0 0 1 0-.96L8.5 9.94A2 2 0 0 0 9.94 8.5l1.58-6.13a.5.5 0 0 1 .96 0l1.58 6.13a2 2 0 0 0 1.44 1.44l6.13 1.58a.5.5 0 0 1 0 .96l-6.13 1.58a2 2 0 0 0-1.44 1.44l-1.58 6.13a.5.5 0 0 1-.96 0Z" />
<path d="M20 3v4M22 5h-4" />
</Icon>
);
}
export function ActivityIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</Icon>
);
}
export function BarChartIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="M3 3v18h18M8 17v-3M13 17V9M18 17V5" />
</Icon>
);
}
export function HistoryIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8M3 3v5h5M12 7v5l4 2" />
</Icon>
);
}
export function ClockIcon(props: IconProps) {
return (
<Icon {...props}>
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
</Icon>
);
}
export function PieChartIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="M21.21 15.89A10 10 0 1 1 8 2.83M22 12A10 10 0 0 0 12 2v10Z" />
</Icon>
);
}
export function ShieldIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="M20 13c0 5-3.5 7.5-7.66 8.94a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1Z" />
</Icon>
);
}
export function ShieldCheckIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="M20 13c0 5-3.5 7.5-7.66 8.94a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1Z" />
<path d="m9 12 2 2 4-4" />
</Icon>
);
}
export function FrameIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="M6 2v20M18 2v20M2 6h20M2 18h20" />
</Icon>
);
}
export function FileCheckIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4M9 15l2 2 4-4" />
</Icon>
);
}
export function MessageSquareIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z" />
</Icon>
);
}
export function UsersIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
</Icon>
);
}
export function GlobeIcon(props: IconProps) {
return (
<Icon {...props}>
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10Z" />
</Icon>
);
}
export function ChevronDownIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="m6 9 6 6 6-6" />
</Icon>
);
}
export function ExternalLinkIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
</Icon>
);
}
export function KeyIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.78 7.78 5.5 5.5 0 0 1 7.78-7.78Zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3m-3.5 3.5L19 4" />
</Icon>
);
}
@@ -0,0 +1,74 @@
/**
* Language menu: 中文 / English / follow system, persisted via the locale context.
* A small dropdown (globe + current label); closes on outside click or selection.
* Scroll position across the locale remount is preserved by LocaleScope.
*/
import { useEffect, useRef, useState } from "react";
import { useLocale } from "../state/locale";
import type { LangPref } from "../state/locale";
import { S } from "../lib/strings";
import { CheckIcon, ChevronDownIcon, GlobeIcon } from "./icons";
export function LangToggle() {
const { lang, setLang } = useLocale();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const OPTIONS: Array<{ value: LangPref; label: string }> = [
{ value: "en", label: S.lang.en },
{ value: "zh", label: S.lang.zh },
{ value: "system", label: S.lang.system },
];
const current = OPTIONS.find((o) => o.value === lang) ?? OPTIONS[2]!;
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
title={S.lang.label}
aria-haspopup="menu"
aria-expanded={open}
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-transparent px-2.5 text-sm text-gray-600 transition-colors hover:border-gray-200 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:border-gray-800 dark:hover:bg-gray-900 dark:hover:text-gray-100"
>
<GlobeIcon className="h-[18px] w-[18px]" />
<span className="hidden sm:inline">{current.label}</span>
<ChevronDownIcon className="h-3.5 w-3.5" />
</button>
{open && (
<div
role="menu"
className="anim-pop absolute right-0 z-40 mt-1 w-36 rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900"
>
{OPTIONS.map((o) => (
<button
key={o.value}
type="button"
role="menuitemradio"
aria-checked={lang === o.value}
onClick={() => {
setLang(o.value);
setOpen(false);
}}
className="flex w-full items-center justify-between px-3 py-1.5 text-left text-sm text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800"
>
{o.label}
{lang === o.value && (
<CheckIcon className="h-3.5 w-3.5 text-brand-600 dark:text-brand-300" />
)}
</button>
))}
</div>
)}
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
/**
* Sticky top navigation: logo + section anchors + blog link + language/theme toggles +
* GitHub. Desktop links share a sliding hover pill (position animated between items);
* section anchors use plain hashes on the home page (native smooth scroll) and route
* back to "/#id" from other pages; a disclosure menu covers small screens.
*/
import { useState } from "react";
import type { MouseEvent } from "react";
import { Link, useLocation } from "react-router";
import { S } from "../lib/strings";
import { DOCS_URL, REPO_URL } from "../lib/links";
import { GitHubIcon, MenuIcon, XIcon } from "./icons";
import { ThemeToggle } from "./theme-toggle";
import { LangToggle } from "./lang-toggle";
const SECTION_IDS = ["highlights", "quickstart", "benchmark", "contract", "features"] as const;
interface Indicator {
left: number;
width: number;
}
export function Nav() {
const { pathname } = useLocation();
const onHome = pathname === "/";
const [open, setOpen] = useState(false);
const [indicator, setIndicator] = useState<Indicator | null>(null);
const sectionLabel: Record<(typeof SECTION_IDS)[number], string> = {
highlights: S.nav.highlights,
quickstart: S.nav.quickstart,
benchmark: S.nav.benchmark,
contract: S.nav.contract,
features: S.nav.features,
};
// Mobile menu links keep their own hover backgrounds; desktop links rely on the pill.
const mobileLinkCls =
"rounded-md px-2.5 py-1.5 text-sm text-gray-600 transition-colors hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-900 dark:hover:text-gray-100";
const deskLinkCls =
"relative z-10 rounded-md px-2.5 py-1.5 text-sm text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100";
const slideTo = (e: MouseEvent<HTMLElement>) => {
const el = e.currentTarget;
setIndicator({ left: el.offsetLeft, width: el.offsetWidth });
};
const desktopLinks = (
<>
{SECTION_IDS.map((id) =>
onHome ? (
<a key={id} href={`#${id}`} className={deskLinkCls} onMouseEnter={slideTo}>
{sectionLabel[id]}
</a>
) : (
<Link key={id} to={`/#${id}`} className={deskLinkCls} onMouseEnter={slideTo}>
{sectionLabel[id]}
</Link>
),
)}
<Link to="/blog" className={deskLinkCls} onMouseEnter={slideTo}>
{S.nav.blog}
</Link>
{/* Docs is a sibling SPA under /docs/ — a plain anchor, not a router Link. */}
<a href={DOCS_URL} className={deskLinkCls} onMouseEnter={slideTo}>
{S.nav.docs}
</a>
</>
);
const mobileLinks = (
<>
{SECTION_IDS.map((id) =>
onHome ? (
<a key={id} href={`#${id}`} className={mobileLinkCls} onClick={() => setOpen(false)}>
{sectionLabel[id]}
</a>
) : (
<Link key={id} to={`/#${id}`} className={mobileLinkCls} onClick={() => setOpen(false)}>
{sectionLabel[id]}
</Link>
),
)}
<Link to="/blog" className={mobileLinkCls} onClick={() => setOpen(false)}>
{S.nav.blog}
</Link>
<a href={DOCS_URL} className={mobileLinkCls} onClick={() => setOpen(false)}>
{S.nav.docs}
</a>
</>
);
return (
<header className="sticky top-0 z-40 border-b border-gray-200 bg-white/85 backdrop-blur dark:border-gray-800 dark:bg-gray-950/85">
<div className="mx-auto flex h-14 max-w-6xl items-center gap-2 px-4 sm:px-6">
<Link to="/" className="flex items-center gap-2" onClick={() => setOpen(false)}>
<img src={`${import.meta.env.BASE_URL}penguin-logo.svg`} alt="" className="h-7 w-7" />
<span className="text-[15px] font-semibold tracking-tight">{S.siteName}</span>
</Link>
<nav
className="relative ml-4 hidden items-center gap-0.5 md:flex"
aria-label="Primary"
onMouseLeave={() => setIndicator(null)}
>
{/* Sliding hover pill: moves between links, fades when the pointer leaves. */}
<span
aria-hidden="true"
className="absolute top-1/2 h-8 -translate-y-1/2 rounded-md bg-gray-100 transition-[left,width,opacity] duration-200 ease-out dark:bg-gray-800"
style={{
left: indicator?.left ?? 0,
width: indicator?.width ?? 0,
opacity: indicator ? 1 : 0,
}}
/>
{desktopLinks}
</nav>
<div className="ml-auto flex items-center gap-1">
<LangToggle />
<ThemeToggle />
<a
href={REPO_URL}
target="_blank"
rel="noreferrer"
title={S.nav.github}
aria-label={S.nav.github}
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-transparent text-gray-600 transition-colors hover:border-gray-200 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:border-gray-800 dark:hover:bg-gray-900 dark:hover:text-gray-100"
>
<GitHubIcon className="h-[18px] w-[18px]" />
</a>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-label={open ? S.nav.closeMenu : S.nav.openMenu}
aria-expanded={open}
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-transparent text-gray-600 transition-colors hover:border-gray-200 hover:bg-gray-50 md:hidden dark:text-gray-400 dark:hover:border-gray-800 dark:hover:bg-gray-900"
>
{open ? <XIcon className="h-5 w-5" /> : <MenuIcon className="h-5 w-5" />}
</button>
</div>
</div>
{open && (
<nav
className="anim-fade border-t border-gray-200 bg-white px-4 py-2 md:hidden dark:border-gray-800 dark:bg-gray-950"
aria-label="Mobile"
>
<div className="flex flex-col py-1">{mobileLinks}</div>
</nav>
)}
</header>
);
}
@@ -0,0 +1,15 @@
/**
* Ambient neon backdrop: faint blurred glows drifting slowly behind the whole page,
* anchored to the document (the layout root is position:relative) so they scroll away
* with the content. Styles in styles.css; reduced-motion freezes them. Decorative only.
*/
export function NeonBackground() {
return (
<div className="neon-bg" aria-hidden="true">
<span className="neon-blob neon-blob-a" />
<span className="neon-blob neon-blob-b" />
<span className="neon-blob neon-blob-c" />
<span className="neon-blob neon-blob-d" />
</div>
);
}
@@ -0,0 +1,47 @@
/** Shared section shell: anchor id + centered header (eyebrow / title / subtitle) + content. */
import type { ReactNode } from "react";
import { useReveal } from "../lib/reveal";
export function Section({
id,
eyebrow,
title,
subtitle,
children,
className = "",
}: {
id?: string;
eyebrow?: string;
title?: string;
subtitle?: string;
children: ReactNode;
className?: string;
}) {
const ref = useReveal<HTMLElement>();
return (
<section id={id} ref={ref} className={`px-4 py-16 sm:px-6 sm:py-24 ${className}`}>
<div className="mx-auto max-w-6xl">
{(eyebrow || title || subtitle) && (
<div className="mx-auto mb-10 max-w-3xl text-center sm:mb-14">
{eyebrow && (
<p className="mb-2 text-sm font-semibold tracking-wide text-brand-600 uppercase dark:text-brand-300">
{eyebrow}
</p>
)}
{title && (
<h2 className="text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
{title}
</h2>
)}
{subtitle && (
<p className="mt-4 text-base leading-7 text-pretty text-gray-600 dark:text-gray-400">
{subtitle}
</p>
)}
</div>
)}
{children}
</div>
</section>
);
}
@@ -0,0 +1,24 @@
/** Theme cycle button: light -> dark -> system, icon reflects the current mode. */
import { useTheme } from "../state/theme";
import type { ThemeMode } from "../state/theme";
import { S } from "../lib/strings";
import { MonitorIcon, MoonIcon, SunIcon } from "./icons";
const NEXT: Record<ThemeMode, ThemeMode> = { light: "dark", dark: "system", system: "light" };
export function ThemeToggle() {
const { mode, setMode } = useTheme();
const label = mode === "light" ? S.theme.light : mode === "dark" ? S.theme.dark : S.theme.system;
const IconCmp = mode === "light" ? SunIcon : mode === "dark" ? MoonIcon : MonitorIcon;
return (
<button
type="button"
onClick={() => setMode(NEXT[mode])}
title={`${S.theme.label}: ${label}`}
aria-label={`${S.theme.label}: ${label}`}
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-transparent text-gray-600 transition-colors hover:border-gray-200 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:border-gray-800 dark:hover:bg-gray-900 dark:hover:text-gray-100"
>
<IconCmp className="h-[18px] w-[18px]" />
</button>
);
}