docs(readme,landing): lead the story with automatic agent building (#52)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,9 +4,7 @@
|
||||
|
||||
<h1 align="center">PenguinHarness</h1>
|
||||
|
||||
<p align="center"><b>With LangChain, you build agents by hand — at 1× speed.<br />With PenguinHarness, agents build agents — at 100×.</b></p>
|
||||
|
||||
<p align="center">A zero-code CLI and Web UI for building agents, connected to 1,000+ models.</p>
|
||||
<p align="center"><b>Your automated agent builder lives on your desktop, connected to 1,000+ models.</b></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.producthunt.com/products/penguinharness?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-penguinharness" target="_blank" rel="noopener noreferrer"><img alt="PenguinHarness - Let Agents Autonomously Build Better Agents for $0.02 | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1202577&theme=light&t=1784804711946" /></a>
|
||||
@@ -36,9 +34,11 @@
|
||||
|
||||
## Why PenguinHarness
|
||||
|
||||
> With LangChain, you build agents by hand — at 1× speed.<br />With PenguinHarness, agents build agents — at 100×.
|
||||
|
||||
Three reasons, in deliberate order — from task quality, to how agents get built, to how they keep improving.
|
||||
|
||||
### 1. 🏆 Comparable quality, one to two orders of magnitude cheaper
|
||||
### 1. 🏆 Outstanding results at tens of times less cost
|
||||
|
||||
A deliberately minimal toolset over clean low-level interfaces: fewer tool calls, fewer tokens — deeply tuned for open models like DeepSeek. Each harness on the model it is normally paired with, same tasks, head-to-head:
|
||||
|
||||
@@ -51,9 +51,9 @@ A deliberately minimal toolset over clean low-level interfaces: fewer tool calls
|
||||
|
||||
**Best accuracy on data analysis — at 1/70 of Claude Code's cost.**
|
||||
|
||||
### 2. ⚡ One sentence, and an Agent builds your Agent app
|
||||
### 2. ⚡ One sentence, and an agent builds your agent app
|
||||
|
||||
Type one sentence, and an Agent builds the complete Agent application for you — scaffold, code, and run instructions, end to end:
|
||||
Type one sentence, and an agent builds the complete agent application for you — scaffold, code, and run instructions, end to end:
|
||||
|
||||
```text
|
||||
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.
|
||||
@@ -67,13 +67,13 @@ https://github.com/user-attachments/assets/9b7033e8-f08a-4c3f-bd33-547896664e6e
|
||||
|
||||
### 3. 🧬 Self-evolution: it gets stronger with use
|
||||
|
||||
With PenguinHarness Skills, an Agent evaluates and optimizes itself: run the benchmark, find the lost points, ship version N+1 — with a snapshot before every round, and every request observable in the Trace view.
|
||||
With PenguinHarness Skills, an agent evaluates and optimizes itself: run the benchmark, find the lost points, ship version N+1 — with a snapshot before every round, and every request observable in the Trace view.
|
||||
|
||||
https://github.com/user-attachments/assets/922d13a6-5ffc-4685-9a39-352f02f9afc0
|
||||
|
||||
## Built-in Skills
|
||||
|
||||
Four Skill groups ship in the box ([docs](https://penguin.ooo/docs/skills)); Agents can also write and optimize their own:
|
||||
Four Skill groups ship in the box ([docs](https://penguin.ooo/docs/skills)); agents can also write and optimize their own:
|
||||
|
||||
| Group | Skills |
|
||||
| -------------------- | --------------------------------------------------------------------------------- |
|
||||
@@ -110,7 +110,7 @@ Each family's latest generation only — the app's **Models** page lists every b
|
||||
|
||||
### 🌐 Web App — for humans
|
||||
|
||||
🚀 Install and launch the full experience (multi-session chat, Agent/skill/model management, usage stats, Trace observability, evaluation center):
|
||||
🚀 Install and launch the full experience (multi-session chat, agent/skill/model management, usage stats, Trace observability, evaluation center):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://penguin.ooo/install.sh | sh
|
||||
|
||||
+4
-4
@@ -4,9 +4,7 @@
|
||||
|
||||
<h1 align="center">PenguinHarness</h1>
|
||||
|
||||
<p align="center"><b>使用 LangChain,以 1 倍速度人工构建 Agent;<br />使用 PenguinHarness,以 100 倍速度用 Agent 构建 Agent。</b></p>
|
||||
|
||||
<p align="center">用于构建 Agent 的零代码 CLI 与 Web UI,连接 1,000+ 模型。</p>
|
||||
<p align="center"><b>全自动 Agent 构建器,住在你的桌面上,连接 1,000+ 模型。</b></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.producthunt.com/products/penguinharness?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-penguinharness" target="_blank" rel="noopener noreferrer"><img alt="PenguinHarness - Let Agents Autonomously Build Better Agents for $0.02 | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1202577&theme=light&t=1784804711946" /></a>
|
||||
@@ -36,9 +34,11 @@
|
||||
|
||||
## 为什么选择 PenguinHarness
|
||||
|
||||
> 使用 LangChain,以 1 倍速度人工构建 Agent;<br />使用 PenguinHarness,以 100 倍速度用 Agent 构建 Agent。
|
||||
|
||||
三个递进的理由——从任务效果,到构建方式,再到进化能力。
|
||||
|
||||
### 1. 🏆 效果同级,成本低一到两个数量级
|
||||
### 1. 🏆 以几十分之一的成本,跑出优异的效果
|
||||
|
||||
刻意精简的工具集配合干净的底层接口:更少的工具调用、更少的 Token,对 DeepSeek 等开放模型深度适配。各自搭配常用模型、同一批任务,正面对比:
|
||||
|
||||
|
||||
@@ -20,7 +20,15 @@ import { GitHubIcon, MenuIcon, XIcon } from "./icons";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
import { LangToggle } from "./lang-toggle";
|
||||
|
||||
const SECTION_IDS = ["highlights", "quickstart", "benchmark", "contract", "features"] as const;
|
||||
const SECTION_IDS = [
|
||||
"highlights",
|
||||
"quickstart",
|
||||
"cases",
|
||||
"scenarios",
|
||||
"benchmark",
|
||||
"contract",
|
||||
"features",
|
||||
] as const;
|
||||
|
||||
export function Nav({ menuOpen, onToggleMenu }: { menuOpen: boolean; onToggleMenu: () => void }) {
|
||||
const pillRef = useRef<HTMLSpanElement | null>(null);
|
||||
@@ -29,6 +37,8 @@ export function Nav({ menuOpen, onToggleMenu }: { menuOpen: boolean; onToggleMen
|
||||
const sectionLabel: Record<(typeof SECTION_IDS)[number], string> = {
|
||||
highlights: S.nav.highlights,
|
||||
quickstart: S.nav.quickstart,
|
||||
cases: S.nav.cases,
|
||||
scenarios: S.nav.scenarios,
|
||||
benchmark: S.nav.benchmark,
|
||||
contract: S.nav.contract,
|
||||
features: S.nav.features,
|
||||
@@ -102,7 +112,7 @@ export function Nav({ menuOpen, onToggleMenu }: { menuOpen: boolean; onToggleMen
|
||||
</a>
|
||||
|
||||
<nav
|
||||
className="relative ml-4 hidden items-center gap-0.5 md:flex"
|
||||
className="relative ml-4 hidden items-center gap-0.5 xl:flex"
|
||||
aria-label="Primary"
|
||||
onMouseLeave={hidePill}
|
||||
>
|
||||
|
||||
@@ -7,6 +7,8 @@ export const en: Strings = {
|
||||
nav: {
|
||||
highlights: "Highlights",
|
||||
quickstart: "Quick start",
|
||||
cases: "Cases",
|
||||
scenarios: "Scenarios",
|
||||
benchmark: "Benchmark",
|
||||
contract: "CONTRACT.md",
|
||||
features: "Features",
|
||||
|
||||
@@ -13,6 +13,8 @@ export const zh = {
|
||||
// (same items, same order) so the two sites link into each other seamlessly.
|
||||
highlights: "特色",
|
||||
quickstart: "快速开始",
|
||||
cases: "案例",
|
||||
scenarios: "应用场景",
|
||||
benchmark: "评测",
|
||||
contract: "CONTRACT.md",
|
||||
features: "功能",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@@ -37,6 +37,8 @@ export function Nav() {
|
||||
const sectionLabel: Record<(typeof SECTION_IDS)[number], string> = {
|
||||
highlights: S.nav.highlights,
|
||||
quickstart: S.nav.quickstart,
|
||||
cases: S.nav.cases,
|
||||
scenarios: S.nav.scenarios,
|
||||
benchmark: S.nav.benchmark,
|
||||
contract: S.nav.contract,
|
||||
features: S.nav.features,
|
||||
@@ -152,7 +154,7 @@ export function Nav() {
|
||||
</Link>
|
||||
|
||||
<nav
|
||||
className="relative ml-4 hidden items-center gap-0.5 md:flex"
|
||||
className="relative ml-4 hidden items-center gap-0.5 xl:flex"
|
||||
aria-label="Primary"
|
||||
onMouseLeave={hidePill}
|
||||
>
|
||||
@@ -184,7 +186,7 @@ export function Nav() {
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label={open ? S.nav.closeMenu : S.nav.openMenu}
|
||||
aria-expanded={open}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-transparent text-gray-600 transition-colors hover:border-gray-200 hover:bg-gray-50 md:hidden dark:text-gray-400 dark:hover:border-gray-800 dark:hover:bg-gray-900"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-transparent text-gray-600 transition-colors hover:border-gray-200 hover:bg-gray-50 xl:hidden dark:text-gray-400 dark:hover:border-gray-800 dark:hover:bg-gray-900"
|
||||
>
|
||||
{open ? <XIcon className="h-5 w-5" /> : <MenuIcon className="h-5 w-5" />}
|
||||
</button>
|
||||
@@ -193,7 +195,7 @@ export function Nav() {
|
||||
|
||||
{open && (
|
||||
<nav
|
||||
className="anim-fade border-t border-gray-200 bg-white px-4 py-2 md:hidden dark:border-gray-800 dark:bg-gray-950"
|
||||
className="anim-fade border-t border-gray-200 bg-white px-4 py-2 xl:hidden dark:border-gray-800 dark:bg-gray-950"
|
||||
aria-label="Mobile"
|
||||
>
|
||||
<div className="flex flex-col py-1">{mobileLinks}</div>
|
||||
|
||||
@@ -13,7 +13,8 @@ export function Section({
|
||||
id?: string;
|
||||
eyebrow?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
/** A list renders one line per entry (deliberate breaks, e.g. the 1×/100× pair); a string wraps naturally. */
|
||||
subtitle?: string | readonly string[];
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
@@ -35,7 +36,13 @@ export function Section({
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="mt-4 text-base leading-7 text-pretty text-gray-600 dark:text-gray-400">
|
||||
{subtitle}
|
||||
{typeof subtitle === "string"
|
||||
? subtitle
|
||||
: subtitle.map((line) => (
|
||||
<span key={line} className="block">
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
export const SECTION_IDS = [
|
||||
"highlights",
|
||||
"quickstart",
|
||||
"cases",
|
||||
"scenarios",
|
||||
"benchmark",
|
||||
"contract",
|
||||
"features",
|
||||
|
||||
@@ -20,6 +20,8 @@ export const en: Strings = {
|
||||
nav: {
|
||||
highlights: "Highlights",
|
||||
quickstart: "Quick start",
|
||||
cases: "Cases",
|
||||
scenarios: "Scenarios",
|
||||
benchmark: "Benchmark",
|
||||
contract: "CONTRACT.md",
|
||||
features: "Features",
|
||||
@@ -45,11 +47,13 @@ export const en: Strings = {
|
||||
},
|
||||
|
||||
hero: {
|
||||
badge: "Build AI agents, with an Agent",
|
||||
titlePrefix: "Efficient Self-Improving Harness for ",
|
||||
titleWords: ["Developers", "Enterprises"],
|
||||
badge: "Agents building agents",
|
||||
// The trailing space lives in the PREFIX (a breakable space before the nowrap
|
||||
// span) — inside titleNoWrap it would glue "Builder" to the unbreakable chunk.
|
||||
titlePrefix: "Your Automated Agent Builder ",
|
||||
titleNoWrap: "Lives on Your ",
|
||||
titleWords: ["Desktop", "Server"],
|
||||
titleSuffix: "",
|
||||
titleSuffixNoWrap: "",
|
||||
keywords: ["Lightweight", "Efficient", "Open Source"],
|
||||
ctaPrimary: "Get started",
|
||||
ctaGithub: "GitHub",
|
||||
@@ -99,8 +103,10 @@ export const en: Strings = {
|
||||
compare: {
|
||||
eyebrow: "vs. LangChain",
|
||||
title: "The gap between 1× and 100×",
|
||||
subtitle:
|
||||
"With LangChain, you build agents by hand — at 1× speed. With PenguinHarness, agents build agents — at 100×.",
|
||||
subtitle: [
|
||||
"With LangChain, you build agents by hand — at 1× speed.",
|
||||
"With PenguinHarness, agents build agents — at 100×.",
|
||||
],
|
||||
langchain: {
|
||||
name: "LangChain",
|
||||
speed: "1×",
|
||||
@@ -212,6 +218,24 @@ export const en: Strings = {
|
||||
],
|
||||
},
|
||||
|
||||
scenarios: {
|
||||
eyebrow: "Scenarios",
|
||||
title: "From screening centers to factory floors",
|
||||
subtitle: "Real deployments in production — not concept demos.",
|
||||
items: [
|
||||
{
|
||||
title: "Screening-report QC",
|
||||
alt: "A CT scanner room at a health screening center",
|
||||
body: "A health-screening group handed report QC to an agent on a locally deployed Qwen3 14B — data never leaves the facility. A review round that took 30 minutes by hand now clears 30 reports a minute, with findings in line with medical experts and several times the review capacity.",
|
||||
},
|
||||
{
|
||||
title: "Production-line inspection",
|
||||
alt: "An automated semiconductor production line",
|
||||
body: "A manufacturer runs inspection agents across its production lines, watching equipment around the clock and trying automated recovery first when something goes wrong. Downtime is down 65%; output roughly doubled.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
contract: {
|
||||
eyebrow: "A contract for stable evolution",
|
||||
title: "CONTRACT.md",
|
||||
@@ -266,7 +290,7 @@ export const en: Strings = {
|
||||
|
||||
benchmark: {
|
||||
eyebrow: "Benchmark",
|
||||
title: "Each with its flagship model — comparable quality, 1-2 orders of magnitude cheaper",
|
||||
title: "Outstanding results at tens of times less cost",
|
||||
subtitle:
|
||||
"Every product runs the model it is normally paired with, head-to-head against Claude Code and OpenAI Codex on two suites: comparable accuracy, tens of times the difference in spend.",
|
||||
higherBetter: "higher is better",
|
||||
|
||||
@@ -21,6 +21,8 @@ export const zh = {
|
||||
nav: {
|
||||
highlights: "特色",
|
||||
quickstart: "快速开始",
|
||||
cases: "案例",
|
||||
scenarios: "应用场景",
|
||||
benchmark: "评测",
|
||||
contract: "CONTRACT.md",
|
||||
features: "功能",
|
||||
@@ -48,14 +50,15 @@ export const zh = {
|
||||
hero: {
|
||||
badge: "让 Agent 为你构建 Agent",
|
||||
/**
|
||||
* Rotating headline: {titlePrefix}{word}{titleSuffix}{titleSuffixNoWrap} — the
|
||||
* word cycles through titleWords with a gaussian-blur crossfade; titleSuffixNoWrap
|
||||
* renders as an unbreakable span so CJK line-breaking lands on the phrase boundary.
|
||||
* Headline: {titlePrefix}<nowrap>{titleNoWrap}{word}{titleSuffix}</nowrap>, the word
|
||||
* rotating through titleWords (桌面/服务器 — where the builder lives; the README
|
||||
* states Desktop only). The nowrap span pins the wrap point in front of it, so a
|
||||
* break never splits "Agent Builder" / "Lives on Your Desktop" mid-phrase.
|
||||
*/
|
||||
titlePrefix: "专为",
|
||||
titleWords: ["开发者", "企业"],
|
||||
titleSuffix: "设计的",
|
||||
titleSuffixNoWrap: "高效自进化 Harness",
|
||||
titlePrefix: "全自动 Agent 构建器,",
|
||||
titleNoWrap: "住在你的",
|
||||
titleWords: ["桌面", "服务器"],
|
||||
titleSuffix: "上",
|
||||
keywords: ["轻量", "高效", "开源"],
|
||||
ctaPrimary: "快速开始",
|
||||
ctaGithub: "GitHub",
|
||||
@@ -103,8 +106,10 @@ export const zh = {
|
||||
compare: {
|
||||
eyebrow: "对比 LangChain",
|
||||
title: "1× 与 100× 的差距",
|
||||
subtitle:
|
||||
"使用 LangChain,以 1 倍速度人工构建 Agent;使用 PenguinHarness,以 100 倍速度用 Agent 构建 Agent。",
|
||||
subtitle: [
|
||||
"使用 LangChain,以 1 倍速度人工构建 Agent;",
|
||||
"使用 PenguinHarness,以 100 倍速度用 Agent 构建 Agent。",
|
||||
],
|
||||
langchain: {
|
||||
name: "LangChain",
|
||||
speed: "1×",
|
||||
@@ -208,6 +213,24 @@ export const zh = {
|
||||
],
|
||||
},
|
||||
|
||||
scenarios: {
|
||||
eyebrow: "应用场景",
|
||||
title: "从体检中心到工厂车间",
|
||||
subtitle: "都是已经上线的真实部署,不是概念演示。",
|
||||
items: [
|
||||
{
|
||||
title: "体检报告质控",
|
||||
alt: "体检中心的 CT 检查室",
|
||||
body: "一家体检机构把报告质控交给了 Agent,跑在本地部署的 Qwen3 14B 上,数据不出机房。过去一轮人工复核要 30 分钟,现在一分钟自动过 30 份,核查结论与医学专家基本一致,审核产能提升数倍。",
|
||||
},
|
||||
{
|
||||
title: "产线设备巡检",
|
||||
alt: "自动化半导体生产线",
|
||||
body: "一家制造企业在各条流水线上部署巡检 Agent,全天候盯着设备状态,异常时先尝试自动恢复。停机时间减少 65%,产出提升约 2 倍。",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
contract: {
|
||||
eyebrow: "稳定进化的契约",
|
||||
title: "CONTRACT.md",
|
||||
@@ -260,7 +283,7 @@ export const zh = {
|
||||
|
||||
benchmark: {
|
||||
eyebrow: "Benchmark",
|
||||
title: "各用各的旗舰模型,效果同级,成本低一到两个数量级",
|
||||
title: "以几十分之一的成本,跑出优异的效果",
|
||||
subtitle:
|
||||
"每个产品搭配它常用的模型,与 Claude Code、OpenAI Codex 在两套题库上正面对比:准确率同级,花的钱差出几十倍。",
|
||||
higherBetter: "越高越好",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Compare } from "../sections/compare";
|
||||
import { SelfImprove } from "../sections/self-improve";
|
||||
import { Quickstart } from "../sections/quickstart";
|
||||
import { Cases } from "../sections/cases";
|
||||
import { Scenarios } from "../sections/scenarios";
|
||||
import { Benchmark } from "../sections/benchmark";
|
||||
import { Contract } from "../sections/contract";
|
||||
import { Features } from "../sections/features";
|
||||
@@ -22,6 +23,7 @@ export function HomePage() {
|
||||
<SelfImprove />
|
||||
<Quickstart />
|
||||
<Cases />
|
||||
<Scenarios />
|
||||
<Benchmark />
|
||||
<Contract />
|
||||
<Features />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Hero: enlarged logo + product name, the slogan inside the pill badge, then a
|
||||
* bilingual headline whose rotating word crossfades through a gaussian blur
|
||||
* (Developers <-> Enterprises), three keywords, the install one-liner and stats.
|
||||
* The rotating word is a stacked inline-grid so line width never jumps.
|
||||
* Hero: enlarged logo + product name, the slogan inside the pill badge, then the
|
||||
* one-line headline whose rotating word crossfades through a gaussian blur
|
||||
* (Desktop <-> Server / 桌面 <-> 服务器), three keywords, the install one-liner
|
||||
* and stats. The rotating word is a stacked inline-grid so line width never jumps.
|
||||
*/
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
@@ -57,16 +57,19 @@ export function Hero() {
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-brand-500" aria-hidden="true" />
|
||||
{S.hero.badge}
|
||||
</p>
|
||||
{/* No text-balance: balance may break inside the breakable prefix ("…Agent /
|
||||
Builder…"); greedy wrapping + the nowrap span pins the desktop break to
|
||||
"Your Automated Agent Builder / Lives on Your Desktop". */}
|
||||
<h1
|
||||
className="anim-rise mx-auto mt-6 max-w-full text-3xl font-semibold tracking-tight text-balance sm:text-5xl"
|
||||
className="anim-rise mx-auto mt-6 max-w-full text-3xl font-semibold tracking-tight sm:text-5xl"
|
||||
style={{ animationDelay: "80ms" }}
|
||||
>
|
||||
{S.hero.titlePrefix}
|
||||
<RotatingWord words={S.hero.titleWords} />
|
||||
{S.hero.titleSuffix}
|
||||
{S.hero.titleSuffixNoWrap && (
|
||||
<span className="whitespace-nowrap">{S.hero.titleSuffixNoWrap}</span>
|
||||
)}
|
||||
<span className="whitespace-nowrap">
|
||||
{S.hero.titleNoWrap}
|
||||
<RotatingWord words={S.hero.titleWords} />
|
||||
{S.hero.titleSuffix}
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Deployment scenarios as an auto-cycling overlapped pair: the front card sits
|
||||
* left at 92% width, the other slides right so its strip peeks past the front
|
||||
* card's right edge, tucked underneath — on swap the two cross horizontally
|
||||
* (both live in one grid cell, so the container takes the tallest card's height
|
||||
* and nothing jumps). The front card holds for ROTATE_MS (long enough to actually
|
||||
* read the paragraph); hovering the deck pauses the timer — nobody loses their
|
||||
* place mid-read; clicking the peeking card (or a dot) switches manually. The
|
||||
* covered card is a pointer-only shortcut and stays aria-hidden — dots carry the
|
||||
* accessible switching — and prefers-reduced-motion disables auto-advance.
|
||||
* SHOTS is index-aligned with S.scenarios.items; the photos are theme-agnostic,
|
||||
* so a single asset serves light and dark (unlike the Cases screenshots).
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { S } from "../lib/strings";
|
||||
import { Section } from "../components/section";
|
||||
import medicalShot from "../assets/case-medical-qc.webp";
|
||||
import lineShot from "../assets/case-line-inspection.webp";
|
||||
|
||||
const SHOTS = [medicalShot, lineShot];
|
||||
const ROTATE_MS = 12000;
|
||||
|
||||
export function Scenarios() {
|
||||
const items = S.scenarios.items;
|
||||
const [active, setActive] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused || items.length < 2) return;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
const timer = setInterval(() => setActive((i) => (i + 1) % items.length), ROTATE_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [paused, items.length]);
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="scenarios"
|
||||
eyebrow={S.scenarios.eyebrow}
|
||||
title={S.scenarios.title}
|
||||
subtitle={S.scenarios.subtitle}
|
||||
>
|
||||
<div
|
||||
className="mx-auto max-w-5xl"
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
>
|
||||
<div className="grid">
|
||||
{items.map((c, i) => {
|
||||
const front = i === active;
|
||||
return (
|
||||
<figure
|
||||
key={c.title}
|
||||
aria-hidden={!front}
|
||||
// The peeking card is itself the switch target (its visible strip is
|
||||
// small, so the whole card surface accepts the click).
|
||||
onClick={front ? undefined : () => setActive(i)}
|
||||
// Cards are 92% wide in a shared cell: the front one holds the left
|
||||
// edge; the covered one shifts its own 8.7% (= the container's 8%)
|
||||
// so exactly that strip peeks past the front card's right edge.
|
||||
className={`col-start-1 row-start-1 w-[92%] overflow-hidden rounded-2xl border bg-white transition-all duration-500 dark:bg-gray-900 ${
|
||||
front
|
||||
? "z-10 translate-x-0 border-gray-200 opacity-100 dark:border-gray-800"
|
||||
: "z-0 translate-x-[8.7%] scale-[0.96] cursor-pointer border-gray-200/70 opacity-60 dark:border-gray-800/70"
|
||||
}`}
|
||||
>
|
||||
<div className="md:flex md:min-h-[340px] md:items-stretch">
|
||||
<img
|
||||
src={SHOTS[i]}
|
||||
alt={c.alt}
|
||||
loading="lazy"
|
||||
className="aspect-[16/9] w-full object-cover md:aspect-auto md:w-[48%]"
|
||||
/>
|
||||
<figcaption className="p-7 md:flex md:flex-1 md:flex-col md:justify-center md:p-12">
|
||||
<h3 className="text-xl font-semibold sm:text-2xl">{c.title}</h3>
|
||||
<p className="mt-4 text-[15px] leading-7 text-gray-600 dark:text-gray-400">
|
||||
{c.body}
|
||||
</p>
|
||||
</figcaption>
|
||||
</div>
|
||||
</figure>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Manual switch doubles as the position indicator */}
|
||||
<div className="mt-6 flex justify-center gap-2">
|
||||
{items.map((c, i) => (
|
||||
<button
|
||||
key={c.title}
|
||||
type="button"
|
||||
aria-label={c.title}
|
||||
aria-current={i === active}
|
||||
onClick={() => setActive(i)}
|
||||
className={`h-1.5 rounded-full transition-all duration-300 ${
|
||||
i === active
|
||||
? "w-6 bg-gray-800 dark:bg-gray-200"
|
||||
: "w-3 bg-gray-300 hover:bg-gray-400 dark:bg-gray-700 dark:hover:bg-gray-500"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user