Changelog, dev startup, README, AgentHub 0.4.0, model catalog, and landing site (#7)
Branch-length batch covering tooling, the model layer, the Web App and the public surfaces. Highlights: - Changelog: a per-release `changelog/<version>/` tree, grouped by the surface each change touches, with a root CHANGELOG.md holding one line per release. - Dev startup: `scripts/dev-prebuild.mjs` serializes the skills+core prebuild behind a lock and keeps `pnpm install` current; `pnpm dev` runs server+web together. - AgentHub 0.3.3 -> 0.4.0: OmniMessage complete payloads carry one opaque `fidelity` object in place of item-level `signature`/`phase`, threaded verbatim through Trace, replay and resume; malformed classification adapted to the new error types. - Model layer: a model is always referenced by an explicit `(provider, model_id)` pair. The provider is never inferred, guessed or defaulted -- both the catalog inference and the unique-match config resolution are gone, and CLI, SDK, server routes and run_subagent all require the complete pair. Catalog gains the Qwen Token Plan, Qwen Pay-As-You-Go and Fireworks AI gateways, plus an expanded OpenRouter group. - Web App: catalog preset sync and per-group speed test on the Models page, positional slash commands, a markdown renderer, skill-library update reminders, and a vertically centred draft page whose upward menus size themselves to the room available. - Public surfaces: restructured READMEs, the penguin.ooo landing site and blog, refreshed benchmark results for both suites, and the demo videos playing on the landing page. Includes the fixes from a full review of the branch: 23 confirmed findings, among them a provider-inference bug that could send one vendor's API key to another vendor's endpoint, and an Escape handler that destroyed the composer's contents unrecoverably. Verified on the branch head: pnpm test (1127 passing, 7 packages), pnpm typecheck and pnpm format:check clean, Playwright e2e 14/14.
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Render the landing Cases tab's "penguin sled game" finished-product shot.
|
||||
*
|
||||
* Same pattern as capture-readme-demo.mjs: penguin-game-mockup.html is a static,
|
||||
* dependency-free mockup of the example game's play screen, captured per language
|
||||
* (zh / en) and theme (light = polar day / dark = polar night) into
|
||||
* packages/landing/src/assets as game-<lang>-<theme>.webp.
|
||||
*
|
||||
* Prereqs: Playwright's chromium only (no server, no build). Run:
|
||||
* `node scripts/capture-game-mockup.mjs [--png-dir <dir>]`.
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { chromium } from "@playwright/test";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const OUT_DIR = path.resolve(HERE, "../src/assets");
|
||||
const PAGE = pathToFileURL(path.join(HERE, "penguin-game-mockup.html")).href;
|
||||
|
||||
const pngDirArg = process.argv.indexOf("--png-dir");
|
||||
const PNG_DIR = pngDirArg >= 0 ? process.argv[pngDirArg + 1] : null;
|
||||
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
if (PNG_DIR) mkdirSync(PNG_DIR, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const encoderPage = await browser.newPage();
|
||||
async function saveWebp(pngBuffer, fileName) {
|
||||
const dataUrl = await encoderPage.evaluate(async (b64) => {
|
||||
const img = new Image();
|
||||
img.src = `data:image/png;base64,${b64}`;
|
||||
await img.decode();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
canvas.getContext("2d").drawImage(img, 0, 0);
|
||||
return canvas.toDataURL("image/webp", 0.9);
|
||||
}, pngBuffer.toString("base64"));
|
||||
writeFileSync(path.join(OUT_DIR, fileName), Buffer.from(dataUrl.split(",")[1], "base64"));
|
||||
console.log(`[game] ${fileName}`);
|
||||
}
|
||||
|
||||
for (const lang of ["en", "zh"]) {
|
||||
for (const theme of ["light", "dark"]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 760 },
|
||||
deviceScaleFactor: 1.5,
|
||||
locale: lang === "zh" ? "zh-CN" : "en-US",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${PAGE}?lang=${lang}&theme=${theme}`);
|
||||
await page.waitForTimeout(300);
|
||||
const png = await page.screenshot();
|
||||
if (PNG_DIR) writeFileSync(path.join(PNG_DIR, `game-${lang}-${theme}.png`), png);
|
||||
await saveWebp(png, `game-${lang}-${theme}.webp`);
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log("[game] done");
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Render the README "build an Agent app in one sentence" finished-product shot.
|
||||
*
|
||||
* The image shows the RESULT of the example — a Claude Code docs-expert RAG app —
|
||||
* rather than the build conversation: rag-app-mockup.html is a static, dependency-free
|
||||
* mockup of the generated app's UI (per the web-design skill's Penguin visual language),
|
||||
* captured per language (zh / en) and theme (light / dark) into assets/readme/ at the
|
||||
* repo root as rag-app-<lang>-<theme>.webp (README.md uses en, README.zh.md uses zh).
|
||||
*
|
||||
* Prereqs: Playwright's chromium only (no server, no build). Run:
|
||||
* `node scripts/capture-readme-demo.mjs [--png-dir <dir>]` (--png-dir also saves
|
||||
* lossless PNG copies, handy for reviewing the shots).
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { chromium } from "@playwright/test";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(HERE, "../../..");
|
||||
const OUT_DIR = path.resolve(ROOT, "assets/readme");
|
||||
const PAGE = pathToFileURL(path.join(HERE, "rag-app-mockup.html")).href;
|
||||
|
||||
const pngDirArg = process.argv.indexOf("--png-dir");
|
||||
const PNG_DIR = pngDirArg >= 0 ? process.argv[pngDirArg + 1] : null;
|
||||
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
if (PNG_DIR) mkdirSync(PNG_DIR, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const encoderPage = await browser.newPage();
|
||||
async function saveWebp(pngBuffer, fileName) {
|
||||
const dataUrl = await encoderPage.evaluate(async (b64) => {
|
||||
const img = new Image();
|
||||
img.src = `data:image/png;base64,${b64}`;
|
||||
await img.decode();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
canvas.getContext("2d").drawImage(img, 0, 0);
|
||||
return canvas.toDataURL("image/webp", 0.9);
|
||||
}, pngBuffer.toString("base64"));
|
||||
writeFileSync(path.join(OUT_DIR, fileName), Buffer.from(dataUrl.split(",")[1], "base64"));
|
||||
console.log(`[demo] ${fileName}`);
|
||||
}
|
||||
|
||||
for (const lang of ["en", "zh"]) {
|
||||
for (const theme of ["light", "dark"]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 760 },
|
||||
deviceScaleFactor: 1.5,
|
||||
locale: lang === "zh" ? "zh-CN" : "en-US",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${PAGE}?lang=${lang}&theme=${theme}`);
|
||||
await page.waitForTimeout(300);
|
||||
const png = await page.screenshot();
|
||||
if (PNG_DIR) writeFileSync(path.join(PNG_DIR, `rag-app-${lang}-${theme}.png`), png);
|
||||
await saveWebp(png, `rag-app-${lang}-${theme}.webp`);
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log("[demo] done");
|
||||
@@ -34,96 +34,172 @@ const MOCK = `http://127.0.0.1:${MOCK_PORT}`;
|
||||
// Commands are shared across languages (code is code) and really execute.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CMD_SCAFFOLD = `mkdir -p csv-analyst/src && cat > csv-analyst/package.json <<'EOF'
|
||||
const CMD_COLLECT = `git clone --depth 1 https://github.com/ericbuess/claude-code-docs \\
|
||||
claude-code-expert/corpus/claude-code-docs
|
||||
find claude-code-expert/corpus -type f ! -name '*.md' -delete
|
||||
rm -rf claude-code-expert/corpus/claude-code-docs/.git
|
||||
ls claude-code-expert/corpus/claude-code-docs | head -6`;
|
||||
|
||||
const CMD_APP = `mkdir -p claude-code-expert/src claude-code-expert/public
|
||||
cat > claude-code-expert/package.json <<'EOF'
|
||||
{
|
||||
"name": "csv-analyst",
|
||||
"name": "claude-code-expert",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": { "start": "tsx src/agent.ts" },
|
||||
"dependencies": { "@prismshadow/penguin-core": "^0.1.0" }
|
||||
"scripts": { "start": "tsx src/rag.ts" },
|
||||
"dependencies": { "@prismshadow/penguin-core": "^0.1.0", "tsx": "^4" }
|
||||
}
|
||||
EOF
|
||||
ls -R csv-analyst`;
|
||||
cat > claude-code-expert/src/rag.ts <<'EOF'
|
||||
// BM25 retrieval over corpus/ + a Session that answers with clickable [n] citations.
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { createAgent, isModelMessage, userText } from "@prismshadow/penguin-core";
|
||||
|
||||
const CMD_ENTRY = `cat > csv-analyst/src/agent.ts <<'EOF'
|
||||
import { createAgent, isCompleteModelMessage, userText } from "@prismshadow/penguin-core";
|
||||
const walk = (d) =>
|
||||
fs.readdirSync(d, { withFileTypes: true }).flatMap((e) =>
|
||||
e.isDirectory() ? walk(path.join(d, e.name)) : [path.join(d, e.name)]);
|
||||
const chunks = walk("corpus").flatMap((f) =>
|
||||
fs.readFileSync(f, "utf8").split(/\\n(?=#{1,3} )/).map((text) => ({ source: f, text })));
|
||||
|
||||
const agent = await createAgent({ agentId: "csv_analyst" });
|
||||
const session = await agent.createSession({ workspaceDir: process.cwd() });
|
||||
|
||||
for await (const out of session.run([userText("Analyze data.csv and write summary.md")], {
|
||||
approve: async () => "allow",
|
||||
})) {
|
||||
if (isCompleteModelMessage(out) && out.payload.type === "text") {
|
||||
console.log(out.payload.text);
|
||||
const tok = (s) => s.toLowerCase().match(/[a-z0-9]+|[一-鿿]/g) ?? [];
|
||||
const docs = chunks.map((c) => tok(c.text));
|
||||
const avg = docs.reduce((n, d) => n + d.length, 0) / Math.max(docs.length, 1);
|
||||
const df = new Map();
|
||||
for (const d of docs) for (const w of new Set(d)) df.set(w, (df.get(w) ?? 0) + 1);
|
||||
// BM25 (k1=1.2, b=0.75): idf weights rare terms, term frequency saturates, long chunks are penalized.
|
||||
const score = (q, i) => {
|
||||
const d = docs[i];
|
||||
let s = 0;
|
||||
for (const w of new Set(tok(q))) {
|
||||
const f = d.filter((x) => x === w).length;
|
||||
if (!f) continue;
|
||||
const n = df.get(w) ?? 0;
|
||||
s += Math.log(1 + (docs.length - n + 0.5) / (n + 0.5)) * (f * 2.2) / (f + 1.2 * (0.25 + 0.75 * d.length / avg));
|
||||
}
|
||||
}
|
||||
return s;
|
||||
};
|
||||
const agent = await createAgent({ root: "penguin_data" });
|
||||
|
||||
http.createServer(async (req, res) => {
|
||||
if (req.method !== "POST") { res.end(fs.readFileSync("public/index.html")); return; }
|
||||
let body = "";
|
||||
for await (const p of req) body += p;
|
||||
const { question } = JSON.parse(body);
|
||||
const hits = chunks.map((c, i) => [score(question, i), c]).filter(([s]) => s > 0)
|
||||
.sort((a, b) => b[0] - a[0]).slice(0, 6).map(([, c]) => c);
|
||||
res.writeHead(200, { "content-type": "text/event-stream" });
|
||||
const ctx = hits.map((c, i) => "[" + (i + 1) + "] " + c.source + "\\n" + c.text).join("\\n\\n");
|
||||
const session = await agent.createSession({ workspaceDir: process.cwd() });
|
||||
for await (const m of session.run([userText(ctx + "\\n\\nQ: " + question)], {
|
||||
approve: async () => "deny",
|
||||
})) {
|
||||
if (isModelMessage(m) && m.payload.type === "partial_text" && m.payload.event_type === "delta")
|
||||
res.write("data: " + JSON.stringify({ delta: m.payload.text }) + "\\n\\n");
|
||||
}
|
||||
res.write("data: " + JSON.stringify({ sources: hits.map((c) => c.source) }) + "\\n\\n");
|
||||
session.dispose();
|
||||
res.end();
|
||||
}).listen(4630);
|
||||
EOF
|
||||
wc -l csv-analyst/src/agent.ts`;
|
||||
cat > claude-code-expert/public/index.html <<'EOF'
|
||||
<!doctype html>
|
||||
<meta charset="utf-8" />
|
||||
<title>Claude Code docs expert</title>
|
||||
<body style="max-width:640px;margin:3rem auto;font-family:system-ui;line-height:1.5">
|
||||
<h3>Claude Code docs expert</h3>
|
||||
<div id="log"></div>
|
||||
<input id="q" style="width:100%;padding:.5rem" placeholder="Ask about Claude Code…" autofocus />
|
||||
<script>
|
||||
q.addEventListener("keydown", async (e) => {
|
||||
if (e.key !== "Enter" || !q.value.trim()) return;
|
||||
const question = q.value; q.value = "";
|
||||
const p = document.createElement("p"); log.append(p);
|
||||
const res = await fetch("/api/ask", { method: "POST", body: JSON.stringify({ question }) });
|
||||
const reader = res.body.getReader(), dec = new TextDecoder();
|
||||
for (let r; !(r = await reader.read()).done; )
|
||||
for (const chunk of dec.decode(r.value).split("\\n\\n")) {
|
||||
if (!chunk.startsWith("data:")) continue;
|
||||
const d = JSON.parse(chunk.slice(5));
|
||||
if (d.delta) p.textContent += d.delta;
|
||||
if (d.sources) p.textContent += " Sources: " + d.sources.join(", ");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
EOF
|
||||
wc -l claude-code-expert/src/rag.ts`;
|
||||
|
||||
const TREE = `\`\`\`text
|
||||
csv-analyst/
|
||||
claude-code-expert/
|
||||
├── package.json
|
||||
└── src/
|
||||
└── agent.ts
|
||||
├── corpus/claude-code-docs/ # the collected docs
|
||||
├── src/rag.ts
|
||||
└── public/index.html
|
||||
\`\`\``;
|
||||
|
||||
/** Per-language script: user prompt marker -> turns + session title. */
|
||||
const SCRIPTS = {
|
||||
zh: {
|
||||
marker: "数据分析 Agent 应用",
|
||||
prompt: "用 PenguinHarness SDK 创建一个数据分析 Agent 应用:读取 CSV 并输出汇总报告",
|
||||
title: "构建数据分析 Agent 应用",
|
||||
marker: "配置专家",
|
||||
prompt:
|
||||
"收集 https://github.com/ericbuess/claude-code-docs 的文档,做一个化身 Claude Code 配置专家、回答带来源引用的 RAG 问答应用。",
|
||||
title: "构建 Claude Code 文档专家",
|
||||
turns: [
|
||||
{
|
||||
thinking:
|
||||
"需求是基于 penguin-core 的数据分析 Agent 应用。先创建项目骨架:package.json 与源码目录。",
|
||||
text: "我来创建应用骨架:",
|
||||
cmd: CMD_SCAFFOLD,
|
||||
"一句话需求,penguin-sdk 技能给出了完整配方:先采集语料,再建 BM25 索引,回答经 Session 流式生成并带 [n] 引用直达原文。",
|
||||
text: "先把文档采集进语料库:",
|
||||
cmd: CMD_COLLECT,
|
||||
},
|
||||
{
|
||||
thinking:
|
||||
"骨架已建好。入口代码用 createAgent + createSession,把 CSV 分析任务交给 session.run。",
|
||||
text: "骨架就绪,写入 Agent 入口代码:创建 Session,把 CSV 分析任务交给 session.run 并流式输出。",
|
||||
cmd: CMD_ENTRY,
|
||||
"语料就绪。写应用:本地 BM25 检索(零 embedding 依赖)+ 引用回答 + Penguin 风格聊天界面,引用链接到 /corpus 原文。",
|
||||
text: "语料就绪,写检索与引用回答的应用入口:",
|
||||
cmd: CMD_APP,
|
||||
},
|
||||
{
|
||||
text: `数据分析 Agent 应用已创建完成:
|
||||
text: `Claude Code 文档专家已就绪:
|
||||
|
||||
${TREE}
|
||||
|
||||
- 入口 \`csv-analyst/src/agent.ts\`:创建 Agent 与 Session,任务经 \`session.run\` 流式执行,工具调用逐个审批;
|
||||
- 运行方式:\`cd csv-analyst && npm install && npm start\`;
|
||||
- 建议下一步:在评估中心为它配一组 CSV 任务 Benchmark,交给 Optimizer 持续优化。`,
|
||||
- 检索:本地 BM25 索引全部文档片段,中文提问同样支持;
|
||||
- 回答:每次提问经 Session 流式生成,引用 [1][2] 可点击直达 \`corpus/\` 原文;
|
||||
- 界面:Penguin 风格聊天页,空态内置示例问题;
|
||||
- 运行:\`cd claude-code-expert && npm install && npm start\`,浏览器打开 http://localhost:4630。`,
|
||||
},
|
||||
],
|
||||
},
|
||||
en: {
|
||||
marker: "data-analysis Agent app",
|
||||
marker: "configuration expert",
|
||||
prompt:
|
||||
"Use the PenguinHarness SDK to create a data-analysis Agent app that reads CSV files and writes a summary report",
|
||||
title: "Build a data-analysis Agent app",
|
||||
"Collect the docs from https://github.com/ericbuess/claude-code-docs and build a RAG app that answers Claude Code questions as a configuration expert, citing its sources.",
|
||||
// Stay under core's TITLE_MAX_CHARS (30): a longer title gets hard-clipped
|
||||
// mid-word in the shots (e.g. "…docs exper").
|
||||
title: "Build Claude Code docs expert",
|
||||
turns: [
|
||||
{
|
||||
thinking:
|
||||
"They want a data-analysis Agent app on penguin-core. Start with the project skeleton: package.json plus the source directory.",
|
||||
text: "Let me scaffold the app first:",
|
||||
cmd: CMD_SCAFFOLD,
|
||||
"One sentence is enough — the penguin-sdk skill has the full recipe: collect the corpus, build a BM25 index, answer through a Session with [n] citations linking to the originals.",
|
||||
text: "Collecting the docs into the corpus first:",
|
||||
cmd: CMD_COLLECT,
|
||||
},
|
||||
{
|
||||
thinking:
|
||||
"Skeleton is in place. The entry uses createAgent + createSession and hands the CSV task to session.run.",
|
||||
text: "Skeleton ready — now the Agent entry point: create a Session and hand the CSV analysis task to session.run, streaming the output.",
|
||||
cmd: CMD_ENTRY,
|
||||
"Corpus in place. Now the app: local BM25 retrieval (no embedding credential), cited answers, and a Penguin-style chat UI with citations linking to /corpus originals.",
|
||||
text: "Corpus ready — now the retrieval and cited-answer entry:",
|
||||
cmd: CMD_APP,
|
||||
},
|
||||
{
|
||||
text: `The data-analysis Agent app is ready:
|
||||
text: `The Claude Code docs expert is ready:
|
||||
|
||||
${TREE}
|
||||
|
||||
- Entry \`csv-analyst/src/agent.ts\`: creates the Agent and a Session; the task runs through \`session.run\` with per-tool approval;
|
||||
- Run it with \`cd csv-analyst && npm install && npm start\`;
|
||||
- Suggested next step: give it a CSV Benchmark suite in the evaluation center and let an Optimizer keep improving it.`,
|
||||
- Retrieval: a local BM25 index over every doc chunk, Chinese questions included;
|
||||
- Answers: each question streams through a Session, with [1][2] citations that link straight to the \`corpus/\` originals;
|
||||
- UI: a Penguin-style chat page with example questions in the empty state;
|
||||
- Run: \`cd claude-code-expert && npm install && npm start\`, then open http://localhost:4630.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -466,7 +542,7 @@ try {
|
||||
await waitFor(`${BASE}/`);
|
||||
console.log(`[shots] server ready on ${BASE}`);
|
||||
|
||||
const admin = await login("admin", "admin123");
|
||||
const admin = await login("admin", "penguin-2026");
|
||||
const browser = await chromium.launch();
|
||||
|
||||
// WebP encoder: Chromium re-encodes the PNG screenshot buffer via canvas, which
|
||||
@@ -549,17 +625,15 @@ try {
|
||||
await page.waitForTimeout(2000);
|
||||
await saveWebp(await page.screenshot(), `chat-${lang}-${theme}.webp`);
|
||||
|
||||
// Trace view: select the session in the list (deep-link selection is unreliable
|
||||
// right after a fresh navigation, so click explicitly — sidebar shows the same
|
||||
// title first in DOM order, hence .last()).
|
||||
await page.goto(`${BASE}/traces?sessionId=${sessionId}`);
|
||||
await page.waitForTimeout(1500);
|
||||
await page
|
||||
.getByText(script.title)
|
||||
.last()
|
||||
.click()
|
||||
.catch(() => {});
|
||||
await page.waitForTimeout(2500);
|
||||
// Trace view: the product's canonical deep link carries BOTH agentId and
|
||||
// sessionId (?sessionId= alone is ignored by TracesPage's focus wiring), and
|
||||
// auto-selects the Session once its trace list loads. Waiting for the
|
||||
// execution timeline's exec_command lanes guarantees every language captures
|
||||
// the same opened trace — stats + a timeline with tool calls — never the
|
||||
// empty "select a Session" state.
|
||||
await page.goto(`${BASE}/traces?agentId=default_agent&sessionId=${sessionId}`);
|
||||
await page.getByText("exec_command").first().waitFor({ timeout: 20000 });
|
||||
await page.waitForTimeout(2000);
|
||||
await saveWebp(await page.screenshot(), `traces-${lang}-${theme}.webp`);
|
||||
|
||||
// Evaluation center: open the pre-provisioned example Benchmark scoreboard.
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Static, dependency-free mockup of the "penguin sled" example game's play screen —
|
||||
the FINISHED PRODUCT of the draft-screen game example, captured by
|
||||
capture-game-mockup.mjs per language (?lang=zh|en) and theme (?theme=light|dark;
|
||||
light = polar day, dark = polar night with an aurora). A cute cartoon scene:
|
||||
an Antarctic penguin on a wooden sled mid-jump over snow-capped rocks, with a
|
||||
score/distance/speed HUD and a "Space to jump" hint.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Penguin Sled Run</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"PingFang SC",
|
||||
"Microsoft YaHei",
|
||||
sans-serif;
|
||||
}
|
||||
.stage {
|
||||
position: relative;
|
||||
width: 1280px;
|
||||
height: 760px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(#bfdbfe 0%, #e0f2fe 55%, #f8fafc 100%);
|
||||
}
|
||||
body.dark .stage {
|
||||
background: linear-gradient(#0b1220 0%, #16233b 60%, #1e293b 100%);
|
||||
}
|
||||
.hud {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
body.dark .hud {
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
border-color: rgba(148, 163, 184, 0.25);
|
||||
color: #e2e8f0;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.hud .label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
opacity: 0.62;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.hud .value {
|
||||
font-size: 24px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
#hud-score {
|
||||
top: 22px;
|
||||
left: 24px;
|
||||
}
|
||||
#hud-dist {
|
||||
top: 22px;
|
||||
left: 218px;
|
||||
}
|
||||
#hud-speed {
|
||||
top: 22px;
|
||||
right: 24px;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
body.dark #hud-speed {
|
||||
color: #93c5fd;
|
||||
}
|
||||
.hint {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 26px;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 18px;
|
||||
border-radius: 999px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
color: #334155;
|
||||
}
|
||||
body.dark .hint {
|
||||
background: rgba(15, 23, 42, 0.75);
|
||||
border-color: rgba(148, 163, 184, 0.25);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.hint kbd {
|
||||
padding: 3px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #94a3b8;
|
||||
border-bottom-width: 3px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
color: #0f172a;
|
||||
}
|
||||
body.dark .hint kbd {
|
||||
background: #1e293b;
|
||||
border-color: #64748b;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.title {
|
||||
position: absolute;
|
||||
top: 26px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
color: rgba(15, 23, 42, 0.55);
|
||||
}
|
||||
body.dark .title {
|
||||
color: rgba(226, 232, 240, 0.65);
|
||||
}
|
||||
.day-only {
|
||||
display: initial;
|
||||
}
|
||||
.night-only {
|
||||
display: none;
|
||||
}
|
||||
body.dark .day-only {
|
||||
display: none;
|
||||
}
|
||||
body.dark .night-only {
|
||||
display: initial;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="stage">
|
||||
<svg viewBox="0 0 1280 760" width="1280" height="760" aria-hidden="true">
|
||||
<!-- Polar day: sun; polar night: moon, stars and an aurora ribbon. -->
|
||||
<g class="day-only">
|
||||
<circle cx="1084" cy="150" r="58" fill="#fde68a" />
|
||||
<circle cx="1084" cy="150" r="44" fill="#fcd34d" />
|
||||
</g>
|
||||
<g class="night-only">
|
||||
<path
|
||||
d="M60 60 C 320 190, 680 30, 1020 150 C 1120 185, 1220 140, 1268 100 L 1268 20 L 60 20 Z"
|
||||
fill="#34d399"
|
||||
opacity="0.16"
|
||||
/>
|
||||
<path
|
||||
d="M100 100 C 360 220, 700 80, 1040 190 C 1130 218, 1210 180, 1260 150 L 1260 60 C 1000 160, 420 40, 100 100 Z"
|
||||
fill="#6ee7b7"
|
||||
opacity="0.12"
|
||||
/>
|
||||
<circle cx="1084" cy="140" r="46" fill="#e2e8f0" opacity="0.92" />
|
||||
<circle cx="1066" cy="128" r="12" fill="#cbd5e1" opacity="0.8" />
|
||||
<circle cx="1102" cy="156" r="8" fill="#cbd5e1" opacity="0.8" />
|
||||
<g fill="#e2e8f0">
|
||||
<circle cx="180" cy="120" r="3" />
|
||||
<circle cx="330" cy="70" r="2.4" />
|
||||
<circle cx="520" cy="130" r="2.8" />
|
||||
<circle cx="760" cy="64" r="2.4" />
|
||||
<circle cx="900" cy="120" r="3" />
|
||||
<circle cx="240" cy="220" r="2" />
|
||||
<circle cx="640" cy="200" r="2.2" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Distant snow hills. -->
|
||||
<path
|
||||
d="M0 430 Q 200 330 420 420 T 860 410 T 1280 400 L 1280 760 L 0 760 Z"
|
||||
class="hill-far"
|
||||
fill="#e2e8f0"
|
||||
/>
|
||||
<path
|
||||
d="M0 500 Q 260 400 560 490 T 1280 470 L 1280 760 L 0 760 Z"
|
||||
class="hill-near"
|
||||
fill="#f1f5f9"
|
||||
/>
|
||||
|
||||
<!-- Ice track. -->
|
||||
<rect x="0" y="560" width="1280" height="200" fill="#ffffff" />
|
||||
<rect x="0" y="560" width="1280" height="12" fill="#bae6fd" opacity="0.7" />
|
||||
<path
|
||||
d="M0 640 H 300 M420 640 H 700 M820 640 H 1100"
|
||||
stroke="#dbeafe"
|
||||
stroke-width="6"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
|
||||
<!-- Rocks ahead on the track (snow-capped). -->
|
||||
<g>
|
||||
<path d="M760 560 q 26 -52 62 -2 q 20 30 -8 30 l -60 0 q -14 -6 6 -28 Z" fill="#94a3b8" />
|
||||
<path d="M770 548 q 18 -26 40 -4 q -20 -10 -40 4 Z" fill="#f8fafc" />
|
||||
<path
|
||||
d="M980 560 q 34 -70 84 -4 q 24 36 -10 36 l -80 0 q -18 -8 6 -32 Z"
|
||||
fill="#64748b"
|
||||
/>
|
||||
<path d="M994 540 q 26 -34 54 -6 q -28 -14 -54 6 Z" fill="#f8fafc" />
|
||||
<path
|
||||
d="M1180 560 q 22 -44 52 -2 q 16 26 -6 26 l -52 0 q -12 -6 6 -24 Z"
|
||||
fill="#94a3b8"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- The star: a cute penguin on a wooden sled, mid-jump over the first rock.
|
||||
Drawn upright and compact so every part stays connected to the body. -->
|
||||
<g transform="translate(430 316) rotate(-8)">
|
||||
<!-- motion lines -->
|
||||
<path
|
||||
d="M-158 118 h 70 M-178 156 h 58 M-150 194 h 66"
|
||||
stroke="#93c5fd"
|
||||
stroke-width="8"
|
||||
stroke-linecap="round"
|
||||
opacity="0.7"
|
||||
/>
|
||||
<!-- snow spray behind the sled -->
|
||||
<g fill="#e0f2fe">
|
||||
<circle cx="-92" cy="214" r="9" />
|
||||
<circle cx="-116" cy="230" r="6" />
|
||||
<circle cx="-70" cy="232" r="5" />
|
||||
</g>
|
||||
<!-- sled: two wooden slats + a curled steel runner -->
|
||||
<path
|
||||
d="M-92 208 q -22 0 -20 -22 q 2 -14 20 -14 h 250 q 26 2 26 22 q 0 14 -24 14 Z"
|
||||
fill="none"
|
||||
stroke="#475569"
|
||||
stroke-width="9"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<rect x="-70" y="176" width="242" height="24" rx="12" fill="#b45309" />
|
||||
<rect x="-58" y="158" width="220" height="22" rx="11" fill="#d97706" />
|
||||
|
||||
<!-- penguin, centered on the sled -->
|
||||
<g transform="translate(52 60)">
|
||||
<!-- body (black egg) -->
|
||||
<path
|
||||
d="M0 -96 C 62 -96 82 -34 82 20 C 82 78 44 108 0 108 C -44 108 -82 78 -82 20 C -82 -34 -62 -96 0 -96 Z"
|
||||
fill="#111827"
|
||||
/>
|
||||
<!-- white belly, tucked inside the body outline -->
|
||||
<path
|
||||
d="M0 -52 C 40 -52 56 -10 56 30 C 56 66 30 88 0 88 C -30 88 -56 66 -56 30 C -56 -10 -40 -52 0 -52 Z"
|
||||
fill="#f8fafc"
|
||||
/>
|
||||
<!-- near wing, hugging the body -->
|
||||
<path d="M-66 -20 C -92 4 -92 52 -70 74 C -58 60 -58 16 -60 -8 Z" fill="#0b1220" />
|
||||
<!-- far wing, raised a little (whee!) -->
|
||||
<path d="M62 -30 C 92 -44 104 -16 96 12 C 82 6 70 -8 60 -18 Z" fill="#0b1220" />
|
||||
<!-- cheeks -->
|
||||
<circle cx="-30" cy="-14" r="11" fill="#fda4af" opacity="0.8" />
|
||||
<circle cx="30" cy="-14" r="11" fill="#fda4af" opacity="0.8" />
|
||||
<!-- eyes -->
|
||||
<circle cx="-20" cy="-40" r="9" fill="#f8fafc" />
|
||||
<circle cx="20" cy="-40" r="9" fill="#f8fafc" />
|
||||
<circle cx="-18" cy="-38" r="4.4" fill="#0b1220" />
|
||||
<circle cx="22" cy="-38" r="4.4" fill="#0b1220" />
|
||||
<circle cx="-16" cy="-40" r="1.5" fill="#f8fafc" />
|
||||
<circle cx="24" cy="-40" r="1.5" fill="#f8fafc" />
|
||||
<!-- beak -->
|
||||
<path d="M-11 -22 L 11 -22 L 0 -8 Z" fill="#f59e0b" />
|
||||
<!-- scarf around the neck (two bands, one short tail) -->
|
||||
<path d="M-40 4 Q 0 22 40 4 L 40 20 Q 0 38 -40 20 Z" fill="#ef4444" />
|
||||
<path d="M28 14 q 26 6 30 26 q -18 6 -34 -6 Z" fill="#f87171" />
|
||||
<!-- feet resting on the front slat -->
|
||||
<path d="M-26 104 q -14 12 4 20 q 20 4 26 -10 Z" fill="#fb923c" />
|
||||
<path d="M26 104 q -6 14 12 18 q 20 2 22 -12 Z" fill="#fb923c" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div class="title" id="title"></div>
|
||||
<div class="hud" id="hud-score">
|
||||
<span class="label" id="score-label"></span><span class="value">1280</span>
|
||||
</div>
|
||||
<div class="hud" id="hud-dist">
|
||||
<span class="label" id="dist-label"></span><span class="value">512 m</span>
|
||||
</div>
|
||||
<div class="hud" id="hud-speed"><span class="value">×2.4</span></div>
|
||||
<div class="hint"><kbd id="key"></kbd><span id="hint"></span></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const params = new URLSearchParams(location.search);
|
||||
const lang = params.get("lang") === "zh" ? "zh" : "en";
|
||||
if (params.get("theme") === "dark") document.body.classList.add("dark");
|
||||
const T = {
|
||||
zh: {
|
||||
title: "企 鹅 雪 橇 越 野",
|
||||
score: "得分",
|
||||
dist: "里程",
|
||||
key: "空格",
|
||||
hint: "起跳,跃过石头!",
|
||||
},
|
||||
en: {
|
||||
title: "P E N G U I N · S L E D · R U N",
|
||||
score: "SCORE",
|
||||
dist: "DISTANCE",
|
||||
key: "Space",
|
||||
hint: "jump the rocks!",
|
||||
},
|
||||
}[lang];
|
||||
document.documentElement.lang = lang === "zh" ? "zh-CN" : "en";
|
||||
document.getElementById("title").textContent = T.title;
|
||||
document.getElementById("score-label").textContent = T.score;
|
||||
document.getElementById("dist-label").textContent = T.dist;
|
||||
document.getElementById("key").textContent = T.key;
|
||||
document.getElementById("hint").textContent = T.hint;
|
||||
const dark = document.body.classList.contains("dark");
|
||||
if (dark) {
|
||||
for (const [sel, fill] of [
|
||||
[".hill-far", "#243b55"],
|
||||
[".hill-near", "#2c476b"],
|
||||
]) {
|
||||
document.querySelector(sel).setAttribute("fill", fill);
|
||||
}
|
||||
for (const rect of document.querySelectorAll("svg rect")) {
|
||||
if (rect.getAttribute("fill") === "#ffffff") rect.setAttribute("fill", "#dbeafe");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,439 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<!--
|
||||
Finished-product mockup of the "one sentence → RAG app" example (a Claude Code docs
|
||||
expert), rendered by capture-readme-demo.mjs into assets/readme/rag-app-<lang>-<theme>.webp.
|
||||
Pure static page — no build, no server; ?lang=zh|en&theme=light|dark picks the variant.
|
||||
Styling follows the web-design skill's Penguin visual language: solid backgrounds,
|
||||
1px hairlines, system fonts, one brand-blue accent, pure-black dark mode.
|
||||
-->
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Claude Code Docs Expert</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--brand-50: #e8f0fe;
|
||||
--brand-100: #d2e3fc;
|
||||
--brand-300: #8ab4f8;
|
||||
--brand-500: #4285f4;
|
||||
--brand-600: #1a73e8;
|
||||
--brand-700: #0b57d0;
|
||||
--bg: #ffffff;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f9fafb;
|
||||
--border: #e5e7eb;
|
||||
--control-border: #d1d5db;
|
||||
--fg: #111827;
|
||||
--fg-muted: #4b5563;
|
||||
--fg-faint: #6b7280;
|
||||
--accent-bg: #111827;
|
||||
--accent-fg: #ffffff;
|
||||
--bubble: #f3f4f6;
|
||||
--code-bg: #f9fafb;
|
||||
--chip-bg: #e8f0fe;
|
||||
--chip-fg: #0b57d0;
|
||||
--chip-border: #d2e3fc;
|
||||
--dots: rgb(26 115 232 / 0.14);
|
||||
}
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
--bg: #000000;
|
||||
--surface: #0d0d0d;
|
||||
--surface-2: #0d0d0d;
|
||||
--border: #1f1f1f;
|
||||
--control-border: #303030;
|
||||
--fg: #f3f4f6;
|
||||
--fg-muted: #9ca3af;
|
||||
--fg-faint: #6b7280;
|
||||
--accent-bg: #f3f4f6;
|
||||
--accent-fg: #111827;
|
||||
--bubble: #1f1f1f;
|
||||
--code-bg: #0d0d0d;
|
||||
--chip-bg: #041e49;
|
||||
--chip-fg: #8ab4f8;
|
||||
--chip-border: #062e6f;
|
||||
--dots: rgb(138 180 248 / 0.16);
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
}
|
||||
body {
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"PingFang SC",
|
||||
"Microsoft YaHei",
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
code,
|
||||
pre {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
nav {
|
||||
height: 56px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--bg) 85%, transparent);
|
||||
}
|
||||
.logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: var(--accent-bg);
|
||||
color: var(--accent-fg);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
}
|
||||
.logo svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.app-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.nav-chip {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--chip-fg);
|
||||
background: var(--chip-bg);
|
||||
border: 1px solid var(--chip-border);
|
||||
border-radius: 9999px;
|
||||
padding: 2px 10px;
|
||||
}
|
||||
.nav-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
/* Decorative dot grid fading out below the nav — the one flourish the design language
|
||||
allows. The ::after overlay fades to the page background, so the tail disappears
|
||||
cleanly in dark mode too (light dots on black stay visible far longer under a mask). */
|
||||
.dots-band {
|
||||
position: absolute;
|
||||
inset: 56px 0 auto 0;
|
||||
height: 150px;
|
||||
background-image: radial-gradient(var(--dots) 1px, transparent 1px);
|
||||
background-size: 22px 22px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dots-band::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--bg) 55%, transparent) 55%,
|
||||
var(--bg) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.chat {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding-top: 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
position: relative;
|
||||
}
|
||||
.msg-user {
|
||||
align-self: flex-end;
|
||||
max-width: 85%;
|
||||
background: var(--bubble);
|
||||
border-radius: 12px;
|
||||
padding: 9px 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.msg-assistant {
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
color: var(--fg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.msg-assistant p {
|
||||
max-width: 65ch;
|
||||
}
|
||||
.cite {
|
||||
color: var(--brand-600);
|
||||
font-size: 0.8em;
|
||||
vertical-align: super;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.dark .cite {
|
||||
color: var(--brand-300);
|
||||
}
|
||||
pre.block {
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.85;
|
||||
overflow-x: auto;
|
||||
}
|
||||
pre.block .c {
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
.sources {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.sources-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
.source-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--chip-fg);
|
||||
background: var(--chip-bg);
|
||||
border: 1px solid var(--chip-border);
|
||||
border-radius: 9999px;
|
||||
padding: 3px 11px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.source-chip .n {
|
||||
font-weight: 600;
|
||||
}
|
||||
.source-chip .path {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
flex: none;
|
||||
padding: 14px 0 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.suggests {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.suggest {
|
||||
font-size: 12px;
|
||||
color: var(--fg-muted);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 9999px;
|
||||
padding: 5px 12px;
|
||||
}
|
||||
.composer {
|
||||
border: 1px solid var(--control-border);
|
||||
background: var(--surface);
|
||||
border-radius: 12px;
|
||||
padding: 12px 12px 10px 14px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
.composer .ph {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--fg-faint);
|
||||
padding: 4px 0 6px;
|
||||
}
|
||||
.send {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--accent-bg);
|
||||
color: var(--accent-fg);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
}
|
||||
.hint {
|
||||
font-size: 11px;
|
||||
color: var(--fg-faint);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="dots-band"></div>
|
||||
<nav>
|
||||
<div class="logo" aria-hidden>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M4 19.5V6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v13.5" stroke="none" />
|
||||
<path
|
||||
d="M12 3c3.5 0 5.5 2.6 5.5 6.2 0 4.4 1.6 6.3 2.5 7.1-1.6 2.9-4.6 4.7-8 4.7s-6.4-1.8-8-4.7c.9-.8 2.5-2.7 2.5-7.1C6.5 5.6 8.5 3 12 3z"
|
||||
/>
|
||||
<circle cx="9.8" cy="8.6" r="0.6" fill="currentColor" stroke="none" />
|
||||
<circle cx="14.2" cy="8.6" r="0.6" fill="currentColor" stroke="none" />
|
||||
<path d="M10.6 11h2.8l-1.4 1.6z" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="app-name" data-t="appName"></span>
|
||||
<span class="nav-chip">RAG · claude-code-docs</span>
|
||||
<div class="nav-right"><span class="dot"></span><span data-t="corpus"></span></div>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<div class="chat">
|
||||
<div class="msg-user" data-t="question"></div>
|
||||
<div class="msg-assistant">
|
||||
<p>
|
||||
<span data-t="a1"></span>
|
||||
<a class="cite" href="#">[1]</a><span data-t="a1b"></span>
|
||||
</p>
|
||||
<pre class="block"><span class="c" data-t="codeC1"></span>
|
||||
claude mcp add github -- npx -y @modelcontextprotocol/server-github
|
||||
|
||||
<span class="c" data-t="codeC2"></span>
|
||||
claude mcp list</pre>
|
||||
<p>
|
||||
<span data-t="a2"></span>
|
||||
<a class="cite" href="#">[2]</a><span data-t="a2b"></span>
|
||||
</p>
|
||||
<div class="sources">
|
||||
<span class="sources-label" data-t="sources"></span>
|
||||
<a class="source-chip" href="#"
|
||||
><span class="n">[1]</span><span class="path">docs/mcp.md</span
|
||||
><span data-t="s1"></span
|
||||
></a>
|
||||
<a class="source-chip" href="#"
|
||||
><span class="n">[2]</span><span class="path">docs/settings.md</span
|
||||
><span data-t="s2"></span
|
||||
></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bottom">
|
||||
<div class="suggests">
|
||||
<span class="suggest" data-t="q1"></span>
|
||||
<span class="suggest" data-t="q2"></span>
|
||||
<span class="suggest" data-t="q3"></span>
|
||||
</div>
|
||||
<div class="composer">
|
||||
<div class="ph" data-t="placeholder"></div>
|
||||
<button class="send" aria-hidden>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 19V5M6 11l6-6 6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="hint" data-t="hint"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const T = {
|
||||
en: {
|
||||
appName: "Claude Code Docs Expert",
|
||||
corpus: "674 chunks · BM25",
|
||||
question: "How do I configure an MCP server for Claude Code?",
|
||||
a1: "Add the server with the CLI, then verify it from inside Claude Code ",
|
||||
a1b: ":",
|
||||
codeC1: "# add a server (local scope by default)",
|
||||
codeC2: "# verify",
|
||||
a2: "Project-scoped servers live in .mcp.json at the project root and are shared with your team; user scope makes a server available across all your projects ",
|
||||
a2b: ". Inside a session, /mcp shows each server's connection status.",
|
||||
sources: "Sources",
|
||||
s1: "— Add an MCP server",
|
||||
s2: "— Settings & scopes",
|
||||
q1: "What does CLAUDE.md do?",
|
||||
q2: "How do I create a custom slash command?",
|
||||
q3: "Where do hooks go?",
|
||||
placeholder: "Ask about Claude Code…",
|
||||
hint: "Answers cite the collected docs — click a source to open the original file.",
|
||||
},
|
||||
zh: {
|
||||
appName: "Claude Code 配置专家",
|
||||
corpus: "674 个片段 · BM25",
|
||||
question: "如何为 Claude Code 配置 MCP 服务器?",
|
||||
a1: "用 CLI 添加服务器,然后在 Claude Code 里验证 ",
|
||||
a1b: ":",
|
||||
codeC1: "# 添加服务器(默认 local 作用域)",
|
||||
codeC2: "# 验证",
|
||||
a2: "项目级服务器保存在项目根目录的 .mcp.json,可随仓库共享给团队;user 作用域则让服务器在你的所有项目中可用 ",
|
||||
a2b: "。会话内可用 /mcp 查看各服务器的连接状态。",
|
||||
sources: "来源",
|
||||
s1: "— 添加 MCP 服务器",
|
||||
s2: "— 设置与作用域",
|
||||
q1: "CLAUDE.md 是做什么的?",
|
||||
q2: "怎么创建自定义斜杠命令?",
|
||||
q3: "Hooks 配置放在哪里?",
|
||||
placeholder: "向 Claude Code 专家提问…",
|
||||
hint: "回答引用采集的文档——点击来源即可打开原文。",
|
||||
},
|
||||
};
|
||||
const params = new URLSearchParams(location.search);
|
||||
const lang = params.get("lang") === "zh" ? "zh" : "en";
|
||||
const theme = params.get("theme") === "dark" ? "dark" : "light";
|
||||
document.documentElement.lang = lang === "zh" ? "zh" : "en";
|
||||
if (theme === "dark") document.documentElement.classList.add("dark");
|
||||
for (const el of document.querySelectorAll("[data-t]")) {
|
||||
el.textContent = T[lang][el.dataset.t];
|
||||
}
|
||||
document.title = T[lang].appName;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Renders the README / blog benchmark chart from the same data the landing page uses
|
||||
* (src/lib/benchmark-data.ts), so the static SVGs cannot drift from the site when the
|
||||
* numbers are refreshed. Emits:
|
||||
* assets/readme/benchmark-light.svg
|
||||
* assets/readme/benchmark-dark.svg
|
||||
* packages/landing/public/blog-assets/benchmark-light.svg
|
||||
*
|
||||
* Two panels per suite (accuracy, cost), horizontal bars scaled linearly from zero — the
|
||||
* cost spread is ~70x, so the PenguinHarness bar is by far the shortest. That is the
|
||||
* result, not a defect; a zoomed baseline would flatter everyone else. Bars are floored at
|
||||
* MIN_BAR, though: at true scale the cost bar comes out ~2.5px, which reads as "no bar" and
|
||||
* loses the series entirely. The floor keeps it visible and unmistakably smallest, and the
|
||||
* exact figure is printed beside every bar, so nothing is overstated.
|
||||
*
|
||||
* Run: node packages/landing/scripts/render-benchmark-svg.mjs
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const LANDING = path.resolve(HERE, "..");
|
||||
const REPO = path.resolve(LANDING, "..", "..");
|
||||
|
||||
/**
|
||||
* The data module is TypeScript; rather than add a build step, read the two exported
|
||||
* arrays out of the source. Any shape change here fails loudly instead of silently
|
||||
* rendering a stale chart.
|
||||
*/
|
||||
function loadBench() {
|
||||
const src = readFileSync(path.join(LANDING, "src/lib/benchmark-data.ts"), "utf8");
|
||||
const consts = Object.fromEntries(
|
||||
[...src.matchAll(/^const (\w+) = "([^"]+)";$/gm)].map((m) => [m[1], m[2]]),
|
||||
);
|
||||
const pick = (name) => {
|
||||
const block = src.match(
|
||||
new RegExp(`export const ${name}: BenchResult\\[\\] = \\[([\\s\\S]*?)\\n\\];`),
|
||||
);
|
||||
if (!block) throw new Error(`could not find ${name} in benchmark-data.ts`);
|
||||
const rows = [...block[1].matchAll(/\{([\s\S]*?)\n {2}\}/g)].map((m) => {
|
||||
const body = m[1];
|
||||
const field = (k) => body.match(new RegExp(`${k}:\\s*([^,\\n]+)`))?.[1]?.trim();
|
||||
const str = (k) => {
|
||||
const v = field(k);
|
||||
if (!v) throw new Error(`missing ${k}`);
|
||||
return v.startsWith('"') ? v.slice(1, -1) : (consts[v] ?? v);
|
||||
};
|
||||
return {
|
||||
framework: str("framework"),
|
||||
model: str("model"),
|
||||
accuracyPct: Number(field("accuracyPct")),
|
||||
tokensM: Number(field("tokensM")),
|
||||
costUsd: Number(field("costUsd")),
|
||||
emphasized: /emphasized:\s*true/.test(body),
|
||||
};
|
||||
});
|
||||
if (rows.length !== 3) throw new Error(`${name}: expected 3 rows, got ${rows.length}`);
|
||||
for (const r of rows) {
|
||||
if (!Number.isFinite(r.accuracyPct) || !Number.isFinite(r.costUsd)) {
|
||||
throw new Error(`${name}: ${r.framework} has a non-numeric field`);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
return { DATA_BENCH: pick("DATA_BENCH"), CODE_BENCH: pick("CODE_BENCH") };
|
||||
}
|
||||
|
||||
const THEMES = {
|
||||
light: {
|
||||
strong: "#1f2328",
|
||||
mid: "#52514e",
|
||||
muted: "#898781",
|
||||
rule: "#c3c2b7",
|
||||
brand: "#2a78d6",
|
||||
bar: "#898781",
|
||||
},
|
||||
dark: {
|
||||
strong: "#f0f3f6",
|
||||
mid: "#c3c2b7",
|
||||
muted: "#898781",
|
||||
rule: "#383835",
|
||||
brand: "#3987e5",
|
||||
bar: "#6b6a64",
|
||||
},
|
||||
};
|
||||
|
||||
const FONT = "system-ui, -apple-system, 'Segoe UI', sans-serif";
|
||||
const W = 920;
|
||||
const H = 368;
|
||||
const BAR_H = 16;
|
||||
const ROW_H = 30;
|
||||
const MAX_BAR = 176;
|
||||
/** Shortest a bar may render, so a near-zero value still reads as a bar (~8% of full). */
|
||||
const MIN_BAR = 14;
|
||||
|
||||
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
function text(x, y, s, { size = 12, weight = 400, fill, anchor = "start" }) {
|
||||
return `<text x="${x}" y="${y}" font-size="${size}" font-weight="${weight}" fill="${fill}" text-anchor="${anchor}" font-family="${FONT}">${esc(s)}</text>`;
|
||||
}
|
||||
|
||||
/** Rounded-end horizontal bar starting at the axis. */
|
||||
function bar(x, y, w, fill) {
|
||||
const r = Math.min(4, w / 2);
|
||||
return `<path d="M${x} ${y} h${(w - r).toFixed(1)} a${r} ${r} 0 0 1 ${r} ${r} v${BAR_H - 2 * r} a${r} ${r} 0 0 1 -${r} ${r} h-${(w - r).toFixed(1)} z" fill="${fill}"/>`;
|
||||
}
|
||||
|
||||
/** One measure for one suite: label column, axis rule, three bars, value labels. */
|
||||
function panel(t, x0, yTop, rows, value, format) {
|
||||
const axis = x0 + 124;
|
||||
const max = Math.max(...rows.map(value));
|
||||
const out = [
|
||||
`<rect x="${axis - 1}" y="${yTop}" width="1" height="${ROW_H * rows.length + 2}" fill="${t.rule}"/>`,
|
||||
];
|
||||
rows.forEach((row, i) => {
|
||||
const y = yTop + 4 + i * ROW_H;
|
||||
const w = Math.max(MIN_BAR, (value(row) / max) * MAX_BAR);
|
||||
const weight = row.emphasized ? 600 : 400;
|
||||
out.push(
|
||||
text(axis - 12, y + 12.5, row.framework, {
|
||||
fill: row.emphasized ? t.strong : t.mid,
|
||||
weight,
|
||||
anchor: "end",
|
||||
}),
|
||||
bar(axis + 1, y, w, row.emphasized ? t.brand : t.bar),
|
||||
text(axis + w + 13, y + 12.5, format(value(row)), { fill: t.strong, weight }),
|
||||
);
|
||||
});
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
function suite(t, yTop, title, rows) {
|
||||
return [
|
||||
text(24, yTop, title, { size: 13, weight: 600, fill: t.strong }),
|
||||
panel(
|
||||
t,
|
||||
24,
|
||||
yTop + 10,
|
||||
rows,
|
||||
(r) => r.accuracyPct,
|
||||
(v) => `${v.toFixed(2)}%`,
|
||||
),
|
||||
panel(
|
||||
t,
|
||||
472,
|
||||
yTop + 10,
|
||||
rows,
|
||||
(r) => r.costUsd,
|
||||
(v) => `$${v.toFixed(2)}`,
|
||||
),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function render(theme, { DATA_BENCH, CODE_BENCH }) {
|
||||
const t = THEMES[theme];
|
||||
const alt =
|
||||
"Benchmark: PenguinHarness vs Claude Code vs OpenAI Codex on two suites — comparable accuracy at a small fraction of the cost";
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="${esc(alt)}">
|
||||
${text(148, 36, "Accuracy · suite total · higher is better", { fill: t.muted })}
|
||||
${text(596, 36, "Total cost (USD) · lower is better", { fill: t.muted })}
|
||||
${suite(t, 68, "Data analysis — 15 tasks, single run", DATA_BENCH)}
|
||||
${suite(t, 210, "Coding — 40 tasks × 2 runs", CODE_BENCH)}
|
||||
${text(24, 338, "Each harness runs the model it is normally paired with: PenguinHarness on DeepSeek V4 Pro, Claude Code on Claude Opus 4.8,", { fill: t.muted })}
|
||||
${text(24, 356, "OpenAI Codex on GPT-5.5. Accuracy, Tokens and cost are suite totals at official pricing.", { fill: t.muted })}
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
const bench = loadBench();
|
||||
const targets = [
|
||||
["light", path.join(REPO, "assets/readme/benchmark-light.svg")],
|
||||
["dark", path.join(REPO, "assets/readme/benchmark-dark.svg")],
|
||||
["light", path.join(LANDING, "public/blog-assets/benchmark-light.svg")],
|
||||
];
|
||||
for (const [theme, file] of targets) {
|
||||
writeFileSync(file, render(theme, bench));
|
||||
console.log(`wrote ${path.relative(REPO, file)}`);
|
||||
}
|
||||
Reference in New Issue
Block a user