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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user