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:
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* "Copy Markdown" button: puts the page's Markdown source on the clipboard with a
|
||||
* transient "copied" state — so a page can be pasted into a model context, an issue
|
||||
* or a note as clean Markdown rather than rendered HTML.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { S } from "../lib/strings";
|
||||
import { CheckIcon, CopyIcon } from "./icons";
|
||||
|
||||
export function CopyMarkdownButton({ text }: { text: 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.doc.copied : S.doc.copyMarkdown}
|
||||
aria-label={copied ? S.doc.copied : S.doc.copyMarkdown}
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-gray-200 bg-white px-2.5 py-1.5 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"
|
||||
>
|
||||
{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.doc.copied : S.doc.copyMarkdown}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Slim docs footer: copyright + repo / license / main-site links on one line. */
|
||||
import { S } from "../lib/strings";
|
||||
import { LICENSE_URL, REPO_URL, SITE_URL } from "../lib/links";
|
||||
|
||||
export function Footer() {
|
||||
const link = "transition-colors hover:text-gray-900 dark:hover:text-gray-100 whitespace-nowrap";
|
||||
return (
|
||||
<footer className="border-t border-gray-200 dark:border-gray-800">
|
||||
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-x-6 gap-y-2 px-4 py-6 text-xs text-gray-400 sm:px-6 dark:text-gray-500">
|
||||
<p>{S.footer.copyright}</p>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<a href={SITE_URL} className={link}>
|
||||
{S.footer.site}
|
||||
</a>
|
||||
<a href={REPO_URL} target="_blank" rel="noreferrer" className={link}>
|
||||
{S.footer.repo}
|
||||
</a>
|
||||
<a href={LICENSE_URL} target="_blank" rel="noreferrer" className={link}>
|
||||
{S.footer.license}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Inline icon set (lucide-style 24x24 stroke icons + the GitHub mark), the subset of
|
||||
* the landing page's set that the docs UI needs. Kept local so the docs site 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 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>
|
||||
);
|
||||
}
|
||||
@@ -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-fade 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,59 @@
|
||||
/**
|
||||
* Sticky top bar: logo + site name + "Docs" badge, then (right) a link back to the
|
||||
* main site, GitHub, language/theme toggles and — on small screens — the sidebar
|
||||
* toggle. The sidebar itself lives in the layout (router.tsx); this bar only flips
|
||||
* its open state.
|
||||
*/
|
||||
import { Link } from "react-router";
|
||||
import { S } from "../lib/strings";
|
||||
import { REPO_URL, SITE_URL } from "../lib/links";
|
||||
import { GitHubIcon, MenuIcon, XIcon } from "./icons";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
import { LangToggle } from "./lang-toggle";
|
||||
|
||||
export function Nav({ menuOpen, onToggleMenu }: { menuOpen: boolean; onToggleMenu: () => void }) {
|
||||
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-7xl items-center gap-2 px-4 sm:px-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleMenu}
|
||||
aria-label={menuOpen ? S.nav.closeMenu : S.nav.openMenu}
|
||||
aria-expanded={menuOpen}
|
||||
className="mr-1 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 lg:hidden dark:text-gray-400 dark:hover:border-gray-800 dark:hover:bg-gray-900"
|
||||
>
|
||||
{menuOpen ? <XIcon className="h-5 w-5" /> : <MenuIcon className="h-5 w-5" />}
|
||||
</button>
|
||||
|
||||
<Link to="/" 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>
|
||||
<span className="rounded-full border border-brand-200 bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-700 dark:border-brand-900 dark:bg-brand-950 dark:text-brand-300">
|
||||
{S.docsBadge}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<a
|
||||
href={SITE_URL}
|
||||
className="hidden rounded-md px-2.5 py-1.5 text-sm text-gray-600 transition-colors hover:text-gray-900 sm:inline-block dark:text-gray-400 dark:hover:text-gray-100"
|
||||
>
|
||||
{S.nav.home}
|
||||
</a>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Docs sidebar: sections + page links from DOCS_NAV, titles resolved from the active
|
||||
* locale's frontmatter. Desktop: sticky column. Mobile: the layout renders it as an
|
||||
* overlay panel under the top bar; onNavigate closes that panel.
|
||||
*/
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { S } from "../lib/strings";
|
||||
import { useLocale } from "../state/locale";
|
||||
import { DOCS_NAV, HOME_SLUG } from "../lib/nav";
|
||||
import { docTitle } from "../lib/docs";
|
||||
|
||||
export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const { locale } = useLocale();
|
||||
const { pathname } = useLocation();
|
||||
const activeSlug = pathname.replace(/^\/|\/$/g, "") || HOME_SLUG;
|
||||
|
||||
return (
|
||||
<nav aria-label="Docs" className="text-sm">
|
||||
{DOCS_NAV.map((section) => (
|
||||
<div key={section.id} className="mb-6">
|
||||
<p className="mb-2 text-xs font-semibold tracking-wide text-gray-400 uppercase dark:text-gray-500">
|
||||
{S.sections[section.id]}
|
||||
</p>
|
||||
<ul className="space-y-0.5 border-l border-gray-200 dark:border-gray-800">
|
||||
{section.slugs.map((slug) => {
|
||||
const active = slug === activeSlug;
|
||||
return (
|
||||
<li key={slug}>
|
||||
<Link
|
||||
to={slug === HOME_SLUG ? "/" : `/${slug}`}
|
||||
onClick={onNavigate}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`-ml-px block border-l py-1 pl-3 transition-colors ${
|
||||
active
|
||||
? "border-brand-600 font-medium text-brand-700 dark:border-brand-400 dark:text-brand-300"
|
||||
: "border-transparent text-gray-600 hover:border-gray-300 hover:text-gray-900 dark:text-gray-400 dark:hover:border-gray-700 dark:hover:text-gray-100"
|
||||
}`}
|
||||
>
|
||||
{docTitle(slug, locale)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -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