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:
Yaowei Zheng
2026-07-21 17:43:31 +08:00
committed by GitHub
parent abf0a2f248
commit d4faee3a1e
214 changed files with 9406 additions and 1688 deletions
+16 -9
View File
@@ -1,9 +1,9 @@
/**
* Benchmark section: two suites (complex data analysis + coding tasks), same model
* everywhere, rendered in one identical format — three small multiples (accuracy /
* Tokens / cost, one measure per axis) plus a five-column table of per-run means
* (framework / model / accuracy % / Tokens M / cost $). Suite specifics (case count,
* runs, thinking level, timeout, pricing) sit in a small footnote under each table.
* Benchmark section: two suites (complex data analysis + coding tasks), each harness on
* the model it is normally paired with, rendered in one identical format — three small
* multiples (accuracy / Tokens / cost, one measure per axis) plus a five-column table of
* suite totals (framework / model / accuracy % / Tokens M / cost $). Suite specifics
* (case count, runs, thinking level, timeout, pricing) sit in a footnote under each table.
* Emphasis form: PenguinHarness wears the brand hue, competitors the de-emphasis
* gray; per-bar identity comes from logo+name labels, every cap is value-labeled,
* and the exact table relieves the sub-3:1 gray fills.
@@ -40,6 +40,13 @@ const VIEW_H = 210;
const PLOT_TOP = 28;
const BASELINE = 168;
const BAR_W = 24;
/**
* Shortest a bar may render. At true scale the cost panels put PenguinHarness at ~1.6px
* against a 140px plot (the spread is ~70x), which reads as an empty slot rather than a
* winning series. The floor keeps it visible while still plainly the smallest bar, and
* every cap carries its exact value.
*/
const MIN_BAR_H = 12;
function BarPanel({
title,
@@ -77,7 +84,7 @@ function BarPanel({
>
{rows.map((row, i) => {
const v = values[i] ?? 0;
const h = ((v - lo) / (hi - lo)) * (BASELINE - PLOT_TOP);
const h = Math.max(MIN_BAR_H, ((v - lo) / (hi - lo)) * (BASELINE - PLOT_TOP));
const yTop = BASELINE - h;
const cx = slot * i + slot / 2;
return (
@@ -248,9 +255,9 @@ export function Benchmark() {
title={S.benchmark.dataTitle}
desc={S.benchmark.dataDesc}
rows={DATA_BENCH}
accDp={1}
accDp={2}
tokenDp={2}
costDp={3}
costDp={2}
footnote={S.benchmark.dataFootnote}
/>
<SuiteBlock
@@ -259,7 +266,7 @@ export function Benchmark() {
rows={CODE_BENCH}
accDp={2}
tokenDp={2}
costDp={3}
costDp={2}
footnote={S.benchmark.codeFootnote}
/>
</div>
+142
View File
@@ -0,0 +1,142 @@
/**
* Use-case gallery as switchable tabs — the RAG docs expert and the penguin sled
* game, each shown as its FINISHED PRODUCT (mockup shots matched to the visitor's
* locale and theme) under its condensed one-sentence prompt. Tab order follows
* S.cases.tabs; CASE_SHOTS is index-aligned with it. A tab may carry a `cost`
* line (the RAG one does) — an emphasized token-cost hook under the caption.
*/
import { useState } from "react";
import { S } from "../lib/strings";
import { useLocale } from "../state/locale";
import type { Locale } from "../state/locale";
import { Section } from "../components/section";
import { BrowserFrame } from "../components/browser-frame";
import { demoVideoUrl } from "../lib/links";
import ragAppZhLight from "../assets/rag-app-zh-light.webp";
import ragAppZhDark from "../assets/rag-app-zh-dark.webp";
import ragAppEnLight from "../assets/rag-app-en-light.webp";
import ragAppEnDark from "../assets/rag-app-en-dark.webp";
import gameZhLight from "../assets/game-zh-light.webp";
import gameZhDark from "../assets/game-zh-dark.webp";
import gameEnLight from "../assets/game-en-light.webp";
import gameEnDark from "../assets/game-en-dark.webp";
type ShotSet = Record<Locale, { light: string; dark: string }>;
/**
* Demo recording for a case, index-aligned with S.cases.tabs; null where the finished
* product is still shown as a still. The community-hosted file only downloads on play
* (preload="none"), and the matching screenshot doubles as the poster — so a video tab
* costs a visitor exactly what an image tab already did until they press play.
*/
const CASE_VIDEOS: Array<Record<Locale, string> | null> = [
{ zh: demoVideoUrl("rag_zh"), en: demoVideoUrl("rag_en") },
null,
];
/** Finished-product shots, index-aligned with S.cases.tabs. */
const CASE_SHOTS: ShotSet[] = [
{
zh: { light: ragAppZhLight, dark: ragAppZhDark },
en: { light: ragAppEnLight, dark: ragAppEnDark },
},
{
zh: { light: gameZhLight, dark: gameZhDark },
en: { light: gameEnLight, dark: gameEnDark },
},
];
export function Cases() {
const { locale } = useLocale();
const tabs = S.cases.tabs;
const [active, setActive] = useState(0);
const tab = tabs[active] ?? tabs[0]!;
const shots = (CASE_SHOTS[active] ?? CASE_SHOTS[0]!)[locale];
const video = (CASE_VIDEOS[active] ?? null)?.[locale];
return (
<Section id="cases" eyebrow={S.cases.eyebrow} title={S.cases.title} subtitle={S.cases.subtitle}>
<div
role="tablist"
aria-label={S.cases.eyebrow}
className="flex flex-wrap justify-center gap-2"
>
{tabs.map((t, i) => (
<button
key={t.label}
type="button"
role="tab"
aria-selected={i === active}
onClick={() => setActive(i)}
className={`rounded-full border px-3.5 py-1.5 text-sm transition-colors ${
i === active
? "border-gray-900 bg-gray-900 text-white dark:border-gray-100 dark:bg-gray-100 dark:text-gray-900"
: "border-gray-200 text-gray-600 hover:bg-gray-50 dark:border-gray-800 dark:text-gray-400 dark:hover:bg-gray-900"
}`}
>
{t.label}
</button>
))}
</div>
<figure role="tabpanel" className="anim-fade mx-auto mt-8 max-w-5xl" key={tab.label}>
<div className="mx-auto mb-5 w-fit max-w-full overflow-x-auto rounded-xl border border-gray-200 bg-gray-50 px-4 py-2.5 dark:border-gray-800 dark:bg-gray-900">
<code className="font-mono text-[13px] whitespace-nowrap text-gray-800 dark:text-gray-200">
<span className="mr-2 text-gray-400 select-none dark:text-gray-500">&gt;</span>
{tab.prompt}
</code>
</div>
<BrowserFrame>
{/* Light/dark are two elements rather than one with a swapped source: a <video>
takes a single poster, and the theme-matched still is what stands in for the
recording before play. Only the visible one's poster is ever fetched. */}
{video ? (
<>
<video
src={video}
poster={shots.light}
controls
preload="none"
playsInline
aria-label={tab.caption}
className="block h-auto w-full bg-gray-950 dark:hidden"
/>
<video
src={video}
poster={shots.dark}
controls
preload="none"
playsInline
aria-label={tab.caption}
className="hidden h-auto w-full bg-gray-950 dark:block"
/>
</>
) : (
<>
<img
src={shots.light}
alt={tab.caption}
loading="lazy"
className="block h-auto w-full dark:hidden"
/>
<img
src={shots.dark}
alt={tab.caption}
loading="lazy"
className="hidden h-auto w-full dark:block"
/>
</>
)}
</BrowserFrame>
<figcaption className="mt-3 text-center text-xs text-gray-500 dark:text-gray-400">
{tab.caption}
</figcaption>
{tab.cost && (
<p className="mt-2 text-center text-[13px] font-semibold text-brand-700 dark:text-brand-300">
{tab.cost}
</p>
)}
</figure>
</Section>
);
}
@@ -0,0 +1,49 @@
/**
* Community links, closing the page: Discord / X / WeChat group / GitHub as
* outbound cards — discuss, follow, and build with us.
*/
import { S } from "../lib/strings";
import { REPO_URL } from "../lib/links";
import { Section } from "../components/section";
import { ArrowRightIcon } from "../components/icons";
const HREFS = {
discord: "https://discord.gg/eFHKqqcU3D",
x: "https://x.com/code_hiyouga",
wechat: "https://github.com/Prism-Shadow/penguin-harness-community/blob/main/wechat/group.jpg",
github: REPO_URL,
} as const;
export function Community() {
const items = (Object.keys(HREFS) as Array<keyof typeof HREFS>).map((key) => ({
key,
href: HREFS[key],
...S.community.items[key],
}));
return (
<Section
id="community"
eyebrow={S.community.eyebrow}
title={S.community.title}
subtitle={S.community.subtitle}
>
<div className="mx-auto grid max-w-4xl gap-4 sm:grid-cols-2 lg:grid-cols-4">
{items.map((item) => (
<a
key={item.key}
href={item.href}
target="_blank"
rel="noreferrer"
className="group rounded-xl border border-gray-200 bg-white p-5 transition-colors hover:border-gray-300 dark:border-gray-800 dark:bg-gray-900 dark:hover:border-gray-700"
>
<p className="flex items-center justify-between text-[15px] font-semibold tracking-tight">
{item.name}
<ArrowRightIcon className="h-4 w-4 text-gray-400 transition-transform group-hover:translate-x-0.5 group-hover:text-gray-600 dark:group-hover:text-gray-300" />
</p>
<p className="mt-1.5 text-sm leading-6 text-gray-600 dark:text-gray-400">{item.desc}</p>
</a>
))}
</div>
</Section>
);
}
+126
View File
@@ -0,0 +1,126 @@
/**
* LangChain comparison: the 1x/100x story as two cards with a looping
* "construction" animation — on a shared cycle, the LangChain side lays one
* block at a time (still unfinished when the cycle resets) while the
* PenguinHarness side raises a whole skyline almost instantly and holds it.
* Reduced-motion users see both skylines complete.
*/
import type { CSSProperties, ReactNode } from "react";
import { S } from "../lib/strings";
import { Section } from "../components/section";
/** Shared build cycle (s): both sides reset together so the pace gap is obvious. */
const CYCLE_S = 7.2;
function Block({
delay,
className,
}: {
/** Seconds into the cycle when this block lands (applied as a negative delay). */
delay: number;
className: string;
}) {
return (
<span
className={`anim-block rounded-[3px] ${className}`}
style={{ "--cycle": `${CYCLE_S}s`, animationDelay: `${delay - CYCLE_S}s` } as CSSProperties}
/>
);
}
/** One slow tower: a block every ~1.15s — the cycle ends before it tops out. */
function SlowBuild() {
return (
<div aria-hidden="true" className="flex h-28 items-end justify-center">
<span className="flex flex-col-reverse gap-1">
{Array.from({ length: 6 }, (_, i) => (
<Block key={i} delay={0.4 + i * 1.15} className="h-3 w-10 bg-gray-300 dark:bg-gray-600" />
))}
</span>
</div>
);
}
/** A skyline of towers raised in a burst: every block lands within ~1.6s. */
function FastBuild() {
const towers = [7, 9, 6, 8];
return (
<div aria-hidden="true" className="flex h-28 items-end justify-center gap-1.5">
{towers.map((height, k) => (
<span key={k} className="flex flex-col-reverse gap-1">
{Array.from({ length: height }, (_, j) => (
<Block
key={j}
delay={0.4 + k * 0.1 + j * 0.14}
className="h-2 w-7 bg-brand-500 dark:bg-brand-400"
/>
))}
</span>
))}
</div>
);
}
function CompareCard({
name,
speed,
mode,
note,
emphasized = false,
children,
}: {
name: string;
speed: string;
mode: string;
note: string;
emphasized?: boolean;
children: ReactNode;
}) {
return (
<article
className={`rounded-xl border p-6 text-center sm:p-8 ${
emphasized
? "border-brand-300 bg-brand-25 dark:border-brand-800 dark:bg-brand-950/40"
: "border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
}`}
>
<p
className={`text-sm font-semibold tracking-tight ${
emphasized ? "text-brand-700 dark:text-brand-300" : "text-gray-500 dark:text-gray-400"
}`}
>
{name}
</p>
<p
className={`mt-2 text-4xl font-semibold tracking-tight tabular-nums sm:text-5xl ${
emphasized ? "text-brand-600 dark:text-brand-300" : "text-gray-400 dark:text-gray-600"
}`}
>
{speed}
</p>
<div className="mt-5">{children}</div>
<p className="mt-5 text-[15px] font-medium text-gray-900 dark:text-gray-100">{mode}</p>
<p className="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-400">{note}</p>
</article>
);
}
export function Compare() {
return (
<Section
id="compare"
eyebrow={S.compare.eyebrow}
title={S.compare.title}
subtitle={S.compare.subtitle}
>
<div className="mx-auto grid max-w-4xl gap-5 sm:grid-cols-2">
<CompareCard {...S.compare.langchain}>
<SlowBuild />
</CompareCard>
<CompareCard {...S.compare.penguin} emphasized>
<FastBuild />
</CompareCard>
</div>
</Section>
);
}
+128 -9
View File
@@ -1,10 +1,15 @@
/**
* Feature grid mirroring the Web App's menu: multi-session chat, agent hub, skill
* library, scheduled tasks, subagents, cost center, trace view, agent evaluation,
* multi-user management — icon + title + one-line description.
* Feature section in two layers: the three features with real captures
* (multi-session chat, trace view, agent evaluation) form switchable tabs whose
* active panel embeds the locale/theme-matched screenshot; the remaining
* features sit below as a plain card grid, closed by an "and more…" card.
*/
import { useState } from "react";
import { S } from "../lib/strings";
import { useLocale } from "../state/locale";
import type { Locale } from "../state/locale";
import { Section } from "../components/section";
import { BrowserFrame } from "../components/browser-frame";
import {
ActivityIcon,
BarChartIcon,
@@ -16,6 +21,62 @@ import {
SparklesIcon,
UsersIcon,
} from "../components/icons";
import chatZhLight from "../assets/shots/chat-zh-light.webp";
import chatZhDark from "../assets/shots/chat-zh-dark.webp";
import chatEnLight from "../assets/shots/chat-en-light.webp";
import chatEnDark from "../assets/shots/chat-en-dark.webp";
import tracesZhLight from "../assets/shots/traces-zh-light.webp";
import tracesZhDark from "../assets/shots/traces-zh-dark.webp";
import tracesEnLight from "../assets/shots/traces-en-light.webp";
import tracesEnDark from "../assets/shots/traces-en-dark.webp";
import benchmarkZhLight from "../assets/shots/benchmark-zh-light.webp";
import benchmarkZhDark from "../assets/shots/benchmark-zh-dark.webp";
import benchmarkEnLight from "../assets/shots/benchmark-en-light.webp";
import benchmarkEnDark from "../assets/shots/benchmark-en-dark.webp";
type ShotSet = Record<Locale, { light: string; dark: string }>;
const SHOTS: Record<"chat" | "traces" | "benchmark", ShotSet> = {
chat: {
zh: { light: chatZhLight, dark: chatZhDark },
en: { light: chatEnLight, dark: chatEnDark },
},
traces: {
zh: { light: tracesZhLight, dark: tracesZhDark },
en: { light: tracesEnLight, dark: tracesEnDark },
},
benchmark: {
zh: { light: benchmarkZhLight, dark: benchmarkZhDark },
en: { light: benchmarkEnLight, dark: benchmarkEnDark },
},
};
function ThemedShot({ set, alt }: { set: ShotSet; alt: string }) {
const { locale } = useLocale();
const pair = set[locale];
// Explicit intrinsic size (all shots are 1920x1200): reserves layout before the
// image loads, so switching tabs doesn't shift the page.
return (
<>
<img
src={pair.light}
alt={alt}
width={1920}
height={1200}
loading="lazy"
className="block h-auto w-full dark:hidden"
/>
<img
src={pair.dark}
alt={alt}
width={1920}
height={1200}
loading="lazy"
className="hidden h-auto w-full dark:block"
/>
</>
);
}
/** Icon order matches S.features.items. */
const ICONS = [
@@ -30,7 +91,21 @@ const ICONS = [
UsersIcon,
];
/** Item indexes with a real capture, in tab order. */
const SHOT_TABS: Array<{ index: number; shot: keyof typeof SHOTS }> = [
{ index: 0, shot: "chat" },
{ index: 6, shot: "traces" },
{ index: 7, shot: "benchmark" },
];
/** The rest of the grid, in the original item order. */
const PLAIN_INDEXES = [1, 2, 3, 4, 5, 8];
export function Features() {
const [active, setActive] = useState(0);
const tab = SHOT_TABS[active] ?? SHOT_TABS[0]!;
const item = S.features.items[tab.index]!;
return (
<Section
id="features"
@@ -38,12 +113,53 @@ export function Features() {
title={S.features.title}
subtitle={S.features.subtitle}
>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{S.features.items.map((item, i) => {
const IconCmp = ICONS[i] ?? SparklesIcon;
<div
role="tablist"
aria-label={S.features.eyebrow}
className="flex flex-wrap justify-center gap-2"
>
{SHOT_TABS.map((t, i) => {
const tabItem = S.features.items[t.index]!;
const TabIcon = ICONS[t.index] ?? SparklesIcon;
const activeTab = i === active;
return (
<button
key={t.shot}
type="button"
role="tab"
aria-selected={activeTab}
onClick={() => setActive(i)}
className={`inline-flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-sm transition-colors ${
activeTab
? "border-gray-900 bg-gray-900 text-white dark:border-gray-100 dark:bg-gray-100 dark:text-gray-900"
: "border-gray-200 text-gray-600 hover:bg-gray-50 dark:border-gray-800 dark:text-gray-400 dark:hover:bg-gray-900"
}`}
>
<TabIcon className="h-3.5 w-3.5 shrink-0" />
{tabItem.title}
</button>
);
})}
</div>
<div role="tabpanel" className="anim-fade mx-auto mt-6 max-w-5xl" key={tab.shot}>
{/* Centered feature text above the shot — the tab chip already names it. */}
<p className="mx-auto mb-5 max-w-2xl text-center text-sm leading-6 text-gray-600 dark:text-gray-400">
{item.desc}
</p>
<BrowserFrame>
<ThemedShot set={SHOTS[tab.shot]} alt={item.title} />
</BrowserFrame>
</div>
{/* Features without a capture: the classic card grid, closed by "and more…". */}
<div className="mt-10 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{PLAIN_INDEXES.map((index) => {
const plain = S.features.items[index]!;
const IconCmp = ICONS[index] ?? SparklesIcon;
return (
<article
key={item.title}
key={plain.title}
className="relative overflow-hidden rounded-xl border border-gray-200 bg-white p-5 transition-colors hover:border-gray-300 dark:border-gray-800 dark:bg-gray-900 dark:hover:border-gray-700"
>
{/* Oversized faint icon as the card backdrop (decorative). */}
@@ -51,13 +167,16 @@ export function Features() {
strokeWidth={1.25}
className="pointer-events-none absolute -right-5 -bottom-5 h-26 w-26 text-gray-100 dark:text-gray-800"
/>
<h3 className="relative text-[15px] font-semibold tracking-tight">{item.title}</h3>
<h3 className="relative text-[15px] font-semibold tracking-tight">{plain.title}</h3>
<p className="relative mt-2 text-sm leading-6 text-gray-600 dark:text-gray-400">
{item.desc}
{plain.desc}
</p>
</article>
);
})}
<article className="flex items-center justify-center rounded-xl border border-dashed border-gray-300 p-5 text-sm text-gray-400 dark:border-gray-700 dark:text-gray-500">
{S.features.more}
</article>
</div>
</Section>
);
+107 -21
View File
@@ -6,8 +6,14 @@
* real axes and ticks, each line growing from zero rightwards on a slow loop.
* Illustrative; all colors are theme-aware Tailwind fill/stroke classes.
*/
import { useEffect, useRef } from "react";
import { S } from "../lib/strings";
import { Section } from "../components/section";
import { DemoVideo } from "../components/demo-video";
import { demoVideoUrl } from "../lib/links";
import { useLocale } from "../state/locale";
import evoPosterZh from "../assets/evo-poster-zh.webp";
import evoPosterEn from "../assets/evo-poster-en.webp";
/** Rounded node with a centered label and an optional version pill. */
function Node({
@@ -146,9 +152,17 @@ const EDGE_BENCH_OLD = "M295,208 L163,294";
const EDGE_BENCH_NEW = "M425,208 L557,294";
const EDGE_EVOLVE = "M216,324 L504,324";
/** Draw-loop timing, mirroring the old CSS keyframes: grow, hold, fade, repeat. */
const CYCLE_S = 4.6;
const DRAW_END = 0.55;
const HOLD_END = 0.84;
const easeInOut = (t: number) => (t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t));
/**
* Outcome trend chart with proper axes and ticks; the line grows from zero
* rightwards (pathLength-normalized draw animation looping in styles.css).
* Outcome trend chart with proper axes and ticks; a single rAF clock grows the
* line from zero rightwards and carries a value label on the moving head — the
* number changes as the curve climbs (or falls), so the trend reads at a glance.
* Reduced-motion users see the finished line with its final value.
*/
function TrendChart({
label,
@@ -156,28 +170,75 @@ function TrendChart({
points,
yTicks,
xTicks,
delay,
phase,
format,
}: {
label: string;
hint: string;
points: number[];
yTicks: number[];
xTicks: Array<{ at: number; text: string }>;
delay: string;
/** Seconds this chart runs ahead of the shared cycle (staggers the trio). */
phase: number;
format: (v: number) => string;
}) {
const W = 210;
const H = 112;
const M = { left: 34, right: 10, top: 10, bottom: 20 };
const M = { left: 34, right: 10, top: 16, bottom: 20 };
const yMax = Math.max(...yTicks);
const yMin = Math.min(...yTicks);
const px = (i: number) => M.left + (i / (points.length - 1)) * (W - M.left - M.right);
const py = (v: number) => M.top + ((yMax - v) / (yMax - yMin || 1)) * (H - M.top - M.bottom);
const path = points.map((v, i) => `${i === 0 ? "M" : "L"}${px(i)},${py(v)}`).join(" ");
const lastX = px(points.length - 1);
const lastY = py(points[points.length - 1] ?? 0);
const axis = "stroke-gray-200 dark:stroke-gray-800";
const tickText = "fill-gray-400 text-[8.5px] tabular-nums dark:fill-gray-500";
const fadeRef = useRef<SVGGElement>(null);
const pathRef = useRef<SVGPathElement>(null);
const headRef = useRef<SVGGElement>(null);
const labelRef = useRef<SVGTextElement>(null);
useEffect(() => {
const fade = fadeRef.current;
const line = pathRef.current;
const head = headRef.current;
const value = labelRef.current;
if (!fade || !line || !head || !value) return;
const apply = (p: number, opacity: number) => {
line.style.strokeDashoffset = String(1 - p);
const f = p * (points.length - 1);
const i0 = Math.floor(f);
const i1 = Math.min(points.length - 1, i0 + 1);
const v = (points[i0] ?? 0) + ((points[i1] ?? 0) - (points[i0] ?? 0)) * (f - i0);
const x = px(f);
const y = py(v);
head.setAttribute("transform", `translate(${x}, ${y})`);
value.textContent = format(v);
value.setAttribute("x", String(Math.min(W - M.right - 10, Math.max(M.left + 12, x))));
value.setAttribute("y", String(Math.max(10, y - 9)));
fade.style.opacity = String(opacity);
};
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
apply(1, 1);
return;
}
let raf = 0;
const t0 = performance.now();
const tick = (now: number) => {
const t = (((now - t0) / 1000 + phase) % CYCLE_S) / CYCLE_S;
if (t < DRAW_END) apply(easeInOut(t / DRAW_END), 1);
else if (t < HOLD_END) apply(1, 1);
else apply(1, 1 - (t - HOLD_END) / (1 - HOLD_END));
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
// px/py are pure functions of the props below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [points, phase, format]);
return (
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 dark:border-gray-800 dark:bg-gray-900">
<div className="flex items-baseline justify-between">
@@ -232,19 +293,28 @@ function TrendChart({
</text>
</g>
))}
<path
d={path}
pathLength={1}
fill="none"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="anim-line-draw stroke-brand-600 dark:stroke-brand-400"
style={{ animationDelay: delay }}
/>
<g className="anim-line-dot" style={{ animationDelay: delay }}>
<circle cx={lastX} cy={lastY} r={5} className="fill-white dark:fill-gray-900" />
<circle cx={lastX} cy={lastY} r={3.5} className="fill-brand-600 dark:fill-brand-400" />
<g ref={fadeRef}>
<path
ref={pathRef}
d={path}
pathLength={1}
fill="none"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="stroke-brand-600 dark:stroke-brand-400"
style={{ strokeDasharray: 1, strokeDashoffset: 1 }}
/>
<g ref={headRef}>
<circle r={5} className="fill-white dark:fill-gray-900" />
<circle r={3.5} className="fill-brand-600 dark:fill-brand-400" />
</g>
{/* The moving value label riding the curve head. */}
<text
ref={labelRef}
textAnchor="middle"
className="fill-brand-700 text-[10px] font-semibold tabular-nums dark:fill-brand-300"
/>
</g>
</svg>
</div>
@@ -255,6 +325,7 @@ const TRENDS: Array<{
points: number[];
yTicks: number[];
xTicks: Array<{ at: number; text: string }>;
format: (v: number) => string;
}> = [
{
points: [52, 58, 56, 64, 70, 75, 79],
@@ -264,6 +335,7 @@ const TRENDS: Array<{
{ at: 3, text: "v4" },
{ at: 6, text: "v7" },
],
format: (v) => `${v.toFixed(1)}%`,
},
{
points: [0.42, 0.38, 0.39, 0.33, 0.3, 0.27, 0.25],
@@ -273,6 +345,7 @@ const TRENDS: Array<{
{ at: 3, text: "v4" },
{ at: 6, text: "v7" },
],
format: (v) => `$${v.toFixed(2)}`,
},
{
points: [115, 107, 109, 98, 92, 87, 83],
@@ -282,10 +355,12 @@ const TRENDS: Array<{
{ at: 3, text: "v4" },
{ at: 6, text: "v7" },
],
format: (v) => `${v.toFixed(1)}s`,
},
];
export function SelfImprove() {
const { locale } = useLocale();
return (
<Section
id="self-improvement"
@@ -416,11 +491,22 @@ export function SelfImprove() {
points={TRENDS[i]?.points ?? []}
yTicks={TRENDS[i]?.yTicks ?? [0, 1]}
xTicks={TRENDS[i]?.xTicks ?? []}
delay={`${-i * 1.4}s`}
phase={i * 1.4}
format={TRENDS[i]?.format ?? ((v) => v.toFixed(0))}
/>
))}
</div>
</div>
{/* The loop above is a diagram; this is the same loop actually running. Replaces the
"demo video coming soon" pill that stood here while the recording was in progress. */}
<div className="mt-10">
<DemoVideo
src={demoVideoUrl(locale === "zh" ? "evo_zh" : "evo_en")}
poster={locale === "zh" ? evoPosterZh : evoPosterEn}
label={S.selfImprove.videoLabel}
caption={S.selfImprove.videoCaption}
/>
</div>
</Section>
);
}
-112
View File
@@ -1,112 +0,0 @@
/**
* Use cases: daily tasks + zero-code AI development, illustrated with real
* screenshots captured from the Web App with Playwright. Twelve variants — three
* views (chat building an Agent app / trace view / evaluation center) x two UI
* languages x two themes (WebP) — and the one shown always matches the visitor's
* active locale and theme. Each figure carries a scenario tag.
*/
import { S } from "../lib/strings";
import { useLocale } from "../state/locale";
import type { Locale } from "../state/locale";
import { Section } from "../components/section";
import { BrowserFrame } from "../components/browser-frame";
import chatZhLight from "../assets/shots/chat-zh-light.webp";
import chatZhDark from "../assets/shots/chat-zh-dark.webp";
import chatEnLight from "../assets/shots/chat-en-light.webp";
import chatEnDark from "../assets/shots/chat-en-dark.webp";
import tracesZhLight from "../assets/shots/traces-zh-light.webp";
import tracesZhDark from "../assets/shots/traces-zh-dark.webp";
import tracesEnLight from "../assets/shots/traces-en-light.webp";
import tracesEnDark from "../assets/shots/traces-en-dark.webp";
import benchmarkZhLight from "../assets/shots/benchmark-zh-light.webp";
import benchmarkZhDark from "../assets/shots/benchmark-zh-dark.webp";
import benchmarkEnLight from "../assets/shots/benchmark-en-light.webp";
import benchmarkEnDark from "../assets/shots/benchmark-en-dark.webp";
type ShotSet = Record<Locale, { light: string; dark: string }>;
const SHOTS: Record<"chat" | "traces" | "benchmark", ShotSet> = {
chat: {
zh: { light: chatZhLight, dark: chatZhDark },
en: { light: chatEnLight, dark: chatEnDark },
},
traces: {
zh: { light: tracesZhLight, dark: tracesZhDark },
en: { light: tracesEnLight, dark: tracesEnDark },
},
benchmark: {
zh: { light: benchmarkZhLight, dark: benchmarkZhDark },
en: { light: benchmarkEnLight, dark: benchmarkEnDark },
},
};
function ThemedShot({ set, alt }: { set: ShotSet; alt: string }) {
const { locale } = useLocale();
const pair = set[locale];
// Explicit intrinsic size (all shots are 1920x1200): reserves layout before the
// image loads, so in-page anchor jumps don't drift when the showcase pops in.
return (
<>
<img
src={pair.light}
alt={alt}
width={1920}
height={1200}
loading="lazy"
className="block h-auto w-full dark:hidden"
/>
<img
src={pair.dark}
alt={alt}
width={1920}
height={1200}
loading="lazy"
className="hidden h-auto w-full dark:block"
/>
</>
);
}
function Caption({ tag, text }: { tag: string; text: string }) {
return (
<figcaption className="mt-3 flex flex-wrap items-center justify-center gap-2 text-center text-xs text-gray-500 dark:text-gray-400">
<span className="rounded-full border border-brand-200 bg-brand-50 px-2 py-0.5 text-[11px] font-medium text-brand-700 dark:border-brand-800 dark:bg-brand-950 dark:text-brand-300">
{tag}
</span>
{text}
</figcaption>
);
}
export function Showcase() {
return (
<Section
id="showcase"
eyebrow={S.showcase.eyebrow}
title={S.showcase.title}
subtitle={S.showcase.subtitle}
>
<figure className="mx-auto max-w-5xl">
<BrowserFrame>
<ThemedShot set={SHOTS.chat} alt={S.showcase.captionChat} />
</BrowserFrame>
<Caption tag={S.showcase.tagChat} text={S.showcase.captionChat} />
</figure>
<div className="mx-auto mt-10 grid max-w-5xl gap-8 sm:grid-cols-2">
<figure>
<BrowserFrame>
<ThemedShot set={SHOTS.traces} alt={S.showcase.captionTraces} />
</BrowserFrame>
<Caption tag={S.showcase.tagTraces} text={S.showcase.captionTraces} />
</figure>
<figure>
<BrowserFrame>
<ThemedShot set={SHOTS.benchmark} alt={S.showcase.captionBenchmark} />
</BrowserFrame>
<Caption tag={S.showcase.tagBenchmark} text={S.showcase.captionBenchmark} />
</figure>
</div>
</Section>
);
}