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:
@@ -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>
|
||||
|
||||
@@ -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">></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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user