Website: 16-bit pixel-art rebuild with a live Three.js office, downloads from Gitea v1.5.1
CI / Typecheck, lint, tests, e2e (push) Failing after 3m51s
CI / Typecheck, lint, tests, e2e (push) Failing after 3m51s
- Title screen over a real-time voxel office rendered at low resolution and quantized to a rotating 32-colour palette with Bayer dithering; day part and season re-light the scene and every surface. - 16-bit UI kit: name-plate dialogs, menu cursor, inventory, XP bar, badges, NPC speech, GAME OVER with CONTINUE, hand-placed 12x12 icons, Fusion Pixel font (Latin subset preloaded). - Sections as a game: quest log (onboarding), character select (AI CEO / AI Co-Founder), inventory (features), party roster (providers), live download XP, save point (all v1.5.1 builds), FAQ as NPC dialog, credits. - Downloads and source point to the Gitea release; the download function only redirects to git.zacharyzhang.com. - Tailwind and the old components removed; unit tests for platform/downloads, rewritten e2e suite. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e374bc1e8e
commit
2d7af224dd
112 files changed
+5692
-4223
No files matched your search
@@ -1,101 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ShieldCheck } from "reicon-react/icons/ShieldCheck";
|
||||
import { Container } from "../ui/container";
|
||||
import { Reveal } from "../ui/reveal";
|
||||
import { Eyebrow } from "../ui/eyebrow";
|
||||
import { SectionHeading } from "../ui/section-heading";
|
||||
import { ArtStage } from "../ui/art-stage";
|
||||
import { featureSections, type FeatureSection } from "../../lib/landing-content";
|
||||
|
||||
// Explicit order + column spans so the cells pack into full 12-column rows.
|
||||
const layout: { id: string; span: string }[] = [
|
||||
{ id: "org-chart", span: "lg:col-span-7" },
|
||||
{ id: "cost-controls", span: "lg:col-span-5" },
|
||||
{ id: "office", span: "lg:col-span-5" },
|
||||
{ id: "messages", span: "lg:col-span-7" },
|
||||
{ id: "connectors", span: "lg:col-span-8" },
|
||||
];
|
||||
|
||||
const featureById = new Map(featureSections.map((feature) => [feature.id, feature]));
|
||||
|
||||
function CornerGlow() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -right-20 -top-20 h-56 w-56 rounded-full opacity-70"
|
||||
style={{ filter: "blur(44px)", background: "radial-gradient(circle, var(--aurora-mint) 0%, transparent 70%)" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BentoCard({ feature, span }: { feature: FeatureSection; span: string }) {
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex flex-col overflow-hidden rounded-2xl border border-[var(--border)] bg-[var(--panel)] p-6 transition-colors duration-300 hover:border-[var(--border-strong)] lg:p-7 ${span}`}
|
||||
>
|
||||
<CornerGlow />
|
||||
<div className="relative flex flex-col gap-1.5">
|
||||
<Eyebrow>{feature.eyebrow}</Eyebrow>
|
||||
<h3 className="text-xl font-medium tracking-[-0.02em] text-[var(--text)]">{feature.title}</h3>
|
||||
<p className="max-w-[46ch] text-sm leading-relaxed text-[var(--muted)]">{feature.description}</p>
|
||||
</div>
|
||||
<ArtStage
|
||||
src={feature.image}
|
||||
alt={feature.imageAlt}
|
||||
className="mt-6"
|
||||
stageClassName="flex aspect-[16/10] items-center justify-center p-4 sm:p-5"
|
||||
contentClassName="max-h-full w-auto max-w-full object-contain transition-transform duration-500 group-hover:scale-[1.02]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LocalFirstCard() {
|
||||
return (
|
||||
<div
|
||||
className="relative flex flex-col justify-between overflow-hidden rounded-2xl border border-[var(--border)] p-7 lg:col-span-4"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(160deg, var(--aurora-mint) -40%, transparent 55%), var(--panel)",
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex h-11 w-11 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[var(--accent)]">
|
||||
<ShieldCheck size={22} />
|
||||
</span>
|
||||
<div className="mt-8">
|
||||
<h3 className="text-xl font-medium tracking-[-0.02em] text-[var(--text)]">Local-first and private</h3>
|
||||
<p className="mt-2 max-w-[36ch] text-sm leading-relaxed text-[var(--muted)]">
|
||||
Every company runs on your machine. Data lives in a local SQLite database, secrets are held in your OS
|
||||
keychain, and the only network calls go to the AI provider you connect.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Bento() {
|
||||
return (
|
||||
<section id="features" className="scroll-mt-20 py-20 md:py-28 lg:py-32">
|
||||
<Container>
|
||||
<Reveal>
|
||||
<SectionHeading
|
||||
eyebrow="The platform"
|
||||
title="Everything a company needs, on your desktop"
|
||||
description="One goal in — an org that hires itself, works in the open, plugs into your stack, and holds its own budget."
|
||||
align="center"
|
||||
className="mx-auto"
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={80} className="mt-14 grid grid-cols-1 gap-5 lg:grid-cols-12">
|
||||
{layout.map(({ id, span }) => {
|
||||
const feature = featureById.get(id);
|
||||
return feature ? <BentoCard key={id} feature={feature} span={span} /> : null;
|
||||
})}
|
||||
<LocalFirstCard />
|
||||
</Reveal>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
.roster {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 48px;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
transition: transform 240ms steps(3);
|
||||
}
|
||||
|
||||
.card[data-selected="false"] {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.card[data-selected="true"] {
|
||||
transform: translateY(-12px);
|
||||
}
|
||||
|
||||
.player {
|
||||
height: 24px;
|
||||
font-size: var(--fs-1);
|
||||
line-height: 2;
|
||||
color: var(--ui-select);
|
||||
animation: bob 1s steps(2, jump-none) infinite;
|
||||
}
|
||||
|
||||
@keyframes bob {
|
||||
50% {
|
||||
transform: translateY(6px);
|
||||
}
|
||||
}
|
||||
|
||||
.portrait {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
margin-bottom: 24px;
|
||||
color: var(--ink-gold);
|
||||
background: var(--ink-ink);
|
||||
box-shadow: inset 0 -12px 0 var(--ink-shadow);
|
||||
}
|
||||
|
||||
.class {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.pitch {
|
||||
margin: 0 0 24px;
|
||||
font-size: var(--fs-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-bottom: 6px;
|
||||
font-size: var(--fs-1);
|
||||
line-height: 2;
|
||||
border-bottom: 3px dotted currentColor;
|
||||
}
|
||||
|
||||
.stat dt {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.stat dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.special {
|
||||
margin: 0 0 24px;
|
||||
font-size: var(--fs-1);
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.special span {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.npc {
|
||||
max-width: 768px;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.roster {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.card[data-selected="true"] {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.pitch {
|
||||
font-size: var(--fs-1);
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.portrait {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.player {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Badge, Dialog, Icon, NpcSay, PixelButton } from "../pixel";
|
||||
import { modes, type Mode } from "../../lib/content";
|
||||
import { Reveal } from "./reveal";
|
||||
import { SectionHeader } from "./section-header";
|
||||
import section from "./section.module.css";
|
||||
import styles from "./character-select.module.css";
|
||||
|
||||
const verdict: Record<Mode["id"], string[]> = {
|
||||
ceo: ["Bold. The CEO hires, approves and ships while you get a snack.", "It still can't close a goal by itself. The auditor holds that key."],
|
||||
cofounder: ["Classic co-op. The CEO does the grinding and pings you before anything risky.", "Big hires and risky work land in your Approvals. Deliverables wait for your review."],
|
||||
};
|
||||
|
||||
export function CharacterSelect() {
|
||||
const [picked, setPicked] = useState<Mode["id"]>("cofounder");
|
||||
|
||||
return (
|
||||
<section id="modes" className={section.section} aria-labelledby="modes-title">
|
||||
<div className={section.inner}>
|
||||
<Reveal>
|
||||
<SectionHeader
|
||||
id="modes-title"
|
||||
stage="World 2 · Character select"
|
||||
title="Choose your CEO"
|
||||
intro="Same company, two play styles. You pick one during setup."
|
||||
/>
|
||||
<div className={styles.roster}>
|
||||
{modes.map((mode, index) => {
|
||||
const selected = mode.id === picked;
|
||||
return (
|
||||
<div key={mode.id} className={styles.card} data-selected={selected}>
|
||||
<span className={styles.player} aria-hidden="true">
|
||||
{selected ? `P${index + 1} ▼` : ""}
|
||||
</span>
|
||||
<Dialog title={mode.name} tone={selected ? "accent" : "default"} as="div">
|
||||
<div className={styles.portrait} aria-hidden="true">
|
||||
<Icon name={mode.icon} size={4} />
|
||||
</div>
|
||||
<p className={styles.class}>
|
||||
<Badge>Class: {mode.className}</Badge>
|
||||
</p>
|
||||
<p className={styles.pitch}>{mode.pitch}</p>
|
||||
<dl className={styles.stats}>
|
||||
{mode.stats.map((stat) => (
|
||||
<div key={stat.label} className={styles.stat}>
|
||||
<dt>{stat.label}</dt>
|
||||
<dd>{stat.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<p className={styles.special}>
|
||||
<span>Special:</span> {mode.special}
|
||||
</p>
|
||||
<PixelButton variant={selected ? "primary" : "ghost"} onClick={() => setPicked(mode.id)} icon={selected ? "check" : "sword"}>
|
||||
{selected ? `${mode.name} selected` : `Select ${mode.name}`}
|
||||
</PixelButton>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className={styles.npc} aria-live="polite">
|
||||
<NpcSay key={picked} speaker="Bit, the intern" portrait="bot" lines={verdict[picked]} />
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
.footer {
|
||||
position: relative;
|
||||
padding: 0 24px 72px;
|
||||
text-align: center;
|
||||
color: var(--ink-star);
|
||||
background: var(--ink-void);
|
||||
}
|
||||
|
||||
.sky {
|
||||
height: 48px;
|
||||
margin: 0 -24px 72px;
|
||||
background: linear-gradient(
|
||||
var(--ink-void) 0 12px,
|
||||
var(--ink-sky-0) 12px 24px,
|
||||
var(--ink-sky-1) 24px 36px,
|
||||
var(--ink-sky-0) 36px 42px,
|
||||
var(--ink-void) 42px
|
||||
);
|
||||
}
|
||||
|
||||
.roll {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 36px;
|
||||
max-width: 768px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.theEnd {
|
||||
margin: 0;
|
||||
font-size: var(--fs-3);
|
||||
line-height: 1.333;
|
||||
color: var(--ink-gold);
|
||||
text-shadow: 3px 3px 0 var(--ink-accent-dark);
|
||||
}
|
||||
|
||||
.cast {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:global([data-reveal="shown"]) .line {
|
||||
animation: rise 720ms steps(6) backwards;
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(36px);
|
||||
}
|
||||
}
|
||||
|
||||
.line dt {
|
||||
font-size: var(--fs-1);
|
||||
line-height: 2;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-glow);
|
||||
}
|
||||
|
||||
.line dd {
|
||||
margin: 0;
|
||||
font-size: var(--fs-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 12px 36px;
|
||||
font-size: var(--fs-1);
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.links a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--ink-mint);
|
||||
}
|
||||
|
||||
.links a:hover,
|
||||
.links a:focus-visible {
|
||||
color: var(--ink-gold);
|
||||
}
|
||||
|
||||
.fine {
|
||||
margin: 0;
|
||||
font-size: var(--fs-1);
|
||||
line-height: 2;
|
||||
color: var(--ink-glow);
|
||||
}
|
||||
|
||||
.fine a {
|
||||
color: var(--ink-mint);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.line {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Icon } from "../pixel";
|
||||
import { credits } from "../../lib/content";
|
||||
import { RELEASE_PAGE_URL, SOURCE_URL } from "../../lib/release-links";
|
||||
import { CONTACT_EMAIL } from "../../lib/site-constants";
|
||||
import { Reveal } from "./reveal";
|
||||
import styles from "./credits.module.css";
|
||||
|
||||
export function Credits() {
|
||||
return (
|
||||
<footer className={styles.footer}>
|
||||
<div className={styles.sky} aria-hidden="true" />
|
||||
<Reveal>
|
||||
<div className={styles.roll}>
|
||||
<p className={styles.theEnd}>Thanks for playing</p>
|
||||
<dl className={styles.cast}>
|
||||
{credits.map((line, index) => (
|
||||
<div key={line.role} className={styles.line} style={{ animationDelay: `${index * 160}ms` }}>
|
||||
<dt>{line.role}</dt>
|
||||
<dd>{line.name}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<nav className={styles.links} aria-label="Project">
|
||||
<a href={SOURCE_URL}>
|
||||
<Icon name="chest" size={1} /> Source on Gitea
|
||||
</a>
|
||||
<a href={RELEASE_PAGE_URL}>
|
||||
<Icon name="flag" size={1} /> Release page
|
||||
</a>
|
||||
<a href={`mailto:${CONTACT_EMAIL}`}>
|
||||
<Icon name="chat" size={1} /> {CONTACT_EMAIL}
|
||||
</a>
|
||||
<a href="#top">
|
||||
<Icon name="arrowUpRight" size={1} /> Continue? Back to title
|
||||
</a>
|
||||
</nav>
|
||||
<p className={styles.fine}>
|
||||
Fusion Pixel font by TakWolf, used under the{" "}
|
||||
<a href="/fonts/fusion-pixel-licenses/OFL.txt">SIL Open Font License 1.1</a>.
|
||||
</p>
|
||||
<p className={styles.fine}>© AgentCompany</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Download } from "reicon-react/icons/Download";
|
||||
import { Container } from "../ui/container";
|
||||
import { Reveal } from "../ui/reveal";
|
||||
import { SectionHeading } from "../ui/section-heading";
|
||||
import { platformDownloads, releaseVersion } from "../../lib/release-links.generated";
|
||||
import { DOWNLOAD_COUNT_SEED, getTrackedDownloadHref } from "../../lib/downloads";
|
||||
|
||||
export function DownloadSection() {
|
||||
const [downloadCount, setDownloadCount] = useState(DOWNLOAD_COUNT_SEED);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadDownloadCount() {
|
||||
try {
|
||||
const response = await fetch("/api/download-stats", { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch download count: ${response.status}`);
|
||||
}
|
||||
const data = (await response.json()) as { downloads?: number };
|
||||
if (!cancelled && typeof data.downloads === "number") {
|
||||
setDownloadCount(data.downloads);
|
||||
}
|
||||
} catch {
|
||||
// Keep the seeded count visible when the dynamic endpoint is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
void loadDownloadCount();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section id="download" className="scroll-mt-20 py-20 md:py-28">
|
||||
<Container>
|
||||
<Reveal>
|
||||
<SectionHeading
|
||||
eyebrow="Download"
|
||||
title="Install the build for your machine"
|
||||
align="center"
|
||||
className="mx-auto"
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={60}>
|
||||
<p className="eyebrow mt-4 text-center text-sm text-[var(--muted)]">
|
||||
Version {releaseVersion} · {downloadCount.toLocaleString("en-US")} downloads
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={120} className="mt-12 grid gap-5 md:grid-cols-3">
|
||||
{platformDownloads.map((platform) => (
|
||||
<div key={platform.id} className="rounded-2xl border border-[var(--border)] bg-[var(--panel)] p-6">
|
||||
<p className="eyebrow text-sm text-[var(--text)]">{platform.title}</p>
|
||||
<p className="mt-2 text-sm text-[var(--muted)]">{platform.note}</p>
|
||||
<div className="mt-5 space-y-2.5">
|
||||
{platform.assets.map((asset) => (
|
||||
<a
|
||||
key={asset.id}
|
||||
href={getTrackedDownloadHref(asset.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between gap-2 rounded-xl border border-[var(--border)] bg-[var(--bg)] px-4 py-3 text-sm text-[var(--text)] transition-colors hover:border-[var(--accent)] hover:text-[var(--accent)]"
|
||||
>
|
||||
<span className="font-medium tracking-tight">{asset.label}</span>
|
||||
<Download size={16} className="shrink-0 opacity-50" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Reveal>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 36px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Dialog, MenuList, NpcSay } from "../pixel";
|
||||
import { faqs } from "../../lib/content";
|
||||
import { Reveal } from "./reveal";
|
||||
import { SectionHeader } from "./section-header";
|
||||
import section from "./section.module.css";
|
||||
import styles from "./faq-section.module.css";
|
||||
|
||||
export function FaqSection() {
|
||||
const [active, setActive] = useState(0);
|
||||
const faq = faqs[active]!;
|
||||
|
||||
return (
|
||||
<section id="faq" className={section.section} aria-labelledby="faq-title">
|
||||
<div className={section.inner}>
|
||||
<Reveal>
|
||||
<SectionHeader
|
||||
id="faq-title"
|
||||
stage="World 7 · Talk to the NPC"
|
||||
title="Ask the guide anything (from this list)"
|
||||
intro="Pick a question. The guide answers in character, but the facts are real."
|
||||
/>
|
||||
<div className={styles.layout}>
|
||||
<Dialog title="Ask about…" as="div">
|
||||
<MenuList
|
||||
label="Questions"
|
||||
items={faqs.map((item, index) => ({ label: item.question, onSelect: () => setActive(index) }))}
|
||||
/>
|
||||
</Dialog>
|
||||
<div aria-live="polite">
|
||||
<NpcSay key={active} speaker="Bit, the intern" portrait="bot" lines={faq.answer} typewriter />
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus } from "reicon-react/icons/Plus";
|
||||
import { Container } from "../ui/container";
|
||||
import { Reveal } from "../ui/reveal";
|
||||
import { SectionHeading } from "../ui/section-heading";
|
||||
import { platformSupportSummary } from "../../lib/platform-support";
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
question: "What is AgentCompany?",
|
||||
answer:
|
||||
"AgentCompany is a local-first desktop application for launching and operating autonomous AI companies. You define the mission, an AI CEO organizes the team, delegates work, and coordinates delivery across engineering, design, research, and operations.",
|
||||
},
|
||||
{
|
||||
question: "How do I download AgentCompany?",
|
||||
answer: platformSupportSummary.downloadAnswer,
|
||||
},
|
||||
{
|
||||
question: "What platforms does AgentCompany support?",
|
||||
answer: platformSupportSummary.platformAnswer,
|
||||
},
|
||||
{
|
||||
question: "What AI models can AgentCompany use?",
|
||||
answer:
|
||||
"AgentCompany supports multiple AI runtimes as first-class connectors: Claude Code (Anthropic), Codex CLI (OpenAI), and Gemini CLI (Google). You can connect your preferred provider during setup.",
|
||||
},
|
||||
{
|
||||
question: "How does the AI CEO work?",
|
||||
answer:
|
||||
"When you create a company with a goal, the AI CEO agent analyzes the objective, creates the necessary departments (Engineering, Design, Marketing, etc.), hires specialized agents for each role, builds an org chart, and delegates tasks. The CEO monitors progress, handles blockers, and coordinates cross-department work — all autonomously.",
|
||||
},
|
||||
{
|
||||
question: "Is my data private? Does it leave my machine?",
|
||||
answer:
|
||||
"AgentCompany is local-first. All data is stored in a local SQLite database on your machine. Secrets are encrypted via an OS-level keychain. The only external communication is between your machine and your AI provider's API — no data is sent to AgentCompany servers.",
|
||||
},
|
||||
{
|
||||
question: "What can an AI company actually deliver?",
|
||||
answer:
|
||||
"AI companies built with AgentCompany can produce code (full applications, scripts, APIs), content (blog posts, marketing copy, social media), research (competitive analysis, market research), business documents (business plans, technical specs, strategies), and more. The deliverables depend on the goal you set and the AI runtime you connect.",
|
||||
},
|
||||
{
|
||||
question: "How are updates delivered?",
|
||||
answer:
|
||||
"Desktop updates are published to a Cloudflare-hosted release lane. macOS builds include the signed update metadata required for in-app update checks, while the Windows installer is distributed from the same release host.",
|
||||
},
|
||||
];
|
||||
|
||||
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="border-b border-[var(--border)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-expanded={open}
|
||||
className="group flex w-full cursor-pointer items-center justify-between gap-4 py-5 text-left"
|
||||
>
|
||||
<span className="text-base font-medium tracking-tight text-[var(--text)] md:text-lg">{question}</span>
|
||||
<span
|
||||
className={`shrink-0 text-[var(--muted)] transition-transform duration-300 group-hover:text-[var(--text)] ${open ? "rotate-45" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Plus size={20} />
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
className={`grid overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] ${open ? "grid-rows-[1fr] pb-5" : "grid-rows-[0fr]"}`}
|
||||
>
|
||||
<div className="min-h-0">
|
||||
<p className="max-w-[68ch] pr-8 leading-relaxed text-[var(--muted)]">{answer}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FAQ() {
|
||||
return (
|
||||
<section id="faq" className="scroll-mt-20 py-20 md:py-28">
|
||||
<Container>
|
||||
<Reveal>
|
||||
<SectionHeading eyebrow="FAQ" title="Frequently asked questions" align="center" className="mx-auto" />
|
||||
</Reveal>
|
||||
<Reveal delay={80} className="mx-auto mt-12 max-w-3xl border-t border-[var(--border)]">
|
||||
{faqs.map((faq) => (
|
||||
<FaqItem key={faq.question} question={faq.question} answer={faq.answer} />
|
||||
))}
|
||||
</Reveal>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Container } from "../ui/container";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Button } from "../ui/button";
|
||||
import { ArtStage } from "../ui/art-stage";
|
||||
import { landingHeroNarrative } from "../../lib/product-narrative";
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<section className="relative overflow-hidden">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 70% 50% at 50% -5%, var(--glow-warm) 0%, transparent 70%), radial-gradient(ellipse 50% 40% at 50% 10%, var(--glow-accent) 0%, transparent 60%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<Container className="relative pb-16 pt-20 md:pb-24 md:pt-28 lg:pt-36">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<Badge className="enter-rise">{landingHeroNarrative.eyebrow}</Badge>
|
||||
|
||||
<h1
|
||||
className="enter-rise mt-7 max-w-[17ch] text-balance text-[40px] font-medium leading-[1.04] tracking-[-0.03em] text-[var(--text)] md:text-[54px] lg:text-[64px]"
|
||||
style={{ animationDelay: "0.06s" }}
|
||||
>
|
||||
{landingHeroNarrative.titleLine1} {landingHeroNarrative.titleLine2}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
className="enter-rise mt-6 max-w-[60ch] text-lg leading-relaxed text-[var(--muted)] md:text-xl"
|
||||
style={{ animationDelay: "0.12s" }}
|
||||
>
|
||||
{landingHeroNarrative.description}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="enter-rise mt-9 flex flex-wrap items-center justify-center gap-3"
|
||||
style={{ animationDelay: "0.18s" }}
|
||||
>
|
||||
<Button href="#download">Download</Button>
|
||||
<Button href="#how-it-works" variant="ghost">
|
||||
How it works
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="eyebrow enter-rise mt-6 text-sm text-[var(--muted)]"
|
||||
style={{ animationDelay: "0.24s" }}
|
||||
>
|
||||
{landingHeroNarrative.availability}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="enter-rise mt-14 md:mt-20" style={{ animationDelay: "0.3s" }}>
|
||||
<ArtStage
|
||||
src="/images/dashboard.webp"
|
||||
alt="AgentCompany desktop dashboard showing the quarter goal, budget, agent workload, and recent runs"
|
||||
priority
|
||||
stageClassName="p-5 sm:p-8 md:p-12 lg:p-14"
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Container } from "../ui/container";
|
||||
import { Reveal } from "../ui/reveal";
|
||||
import { SectionHeading } from "../ui/section-heading";
|
||||
import { landingHowItWorksNarrative } from "../../lib/product-narrative";
|
||||
|
||||
const steps = landingHowItWorksNarrative.steps;
|
||||
|
||||
export function HowItWorks() {
|
||||
return (
|
||||
<section id="how-it-works" className="scroll-mt-20 bg-[var(--bg-subtle)] py-20 md:py-28">
|
||||
<Container>
|
||||
<Reveal>
|
||||
<SectionHeading
|
||||
eyebrow="How it works"
|
||||
title={landingHowItWorksNarrative.heading}
|
||||
align="center"
|
||||
className="mx-auto"
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={80} className="mx-auto mt-14 max-w-2xl">
|
||||
<div className="relative">
|
||||
<div className="absolute bottom-3 left-5 top-3 w-px bg-[var(--border)]" aria-hidden="true" />
|
||||
<ol className="space-y-8">
|
||||
{steps.map((step) => (
|
||||
<li key={step.number} className="relative flex gap-5">
|
||||
<span className="relative z-10 flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[var(--accent)] text-sm font-medium text-[var(--text-on-accent)]">
|
||||
{step.number}
|
||||
</span>
|
||||
<div className="pt-1.5">
|
||||
<h3 className="text-base font-medium tracking-tight text-[var(--text)]">{step.title}</h3>
|
||||
<p className="mt-1 leading-relaxed text-[var(--muted)]">{step.description}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</Reveal>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 2fr;
|
||||
gap: 36px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Inventory, NpcSay } from "../pixel";
|
||||
import { items } from "../../lib/content";
|
||||
import { Reveal } from "./reveal";
|
||||
import { SectionHeader } from "./section-header";
|
||||
import section from "./section.module.css";
|
||||
import styles from "./inventory-section.module.css";
|
||||
|
||||
export function InventorySection() {
|
||||
return (
|
||||
<section id="items" className={section.section} aria-labelledby="items-title">
|
||||
<div className={section.inner}>
|
||||
<Reveal>
|
||||
<SectionHeader
|
||||
id="items-title"
|
||||
stage="World 3 · Inventory"
|
||||
title="Everything in the bag"
|
||||
intro="Nine items, zero microtransactions. Select a slot to read the description."
|
||||
/>
|
||||
<div className={styles.layout}>
|
||||
<Inventory items={items} />
|
||||
<NpcSay
|
||||
speaker="Bit, the intern"
|
||||
portrait="bot"
|
||||
lines={[
|
||||
"Arrow keys move the cursor. Enter inspects. No, you can't sell the auditor to a merchant.",
|
||||
"My favourite is the pixel office. At night the desk screens glow. Same as real offices, minus the overtime.",
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import styles from "./title-screen.module.css";
|
||||
|
||||
const GLYPHS: Record<string, string> = {
|
||||
A: ".###. #...# #...# ##### #...# #...# #...#",
|
||||
G: ".###. #...# #.... #.### #...# #...# .###.",
|
||||
E: "##### #.... #.... ####. #.... #.... #####",
|
||||
N: "#...# ##..# #.#.# #..## #...# #...# #...#",
|
||||
T: "##### ..#.. ..#.. ..#.. ..#.. ..#.. ..#..",
|
||||
C: ".###. #...# #.... #.... #.... #...# .###.",
|
||||
O: ".###. #...# #...# #...# #...# #...# .###.",
|
||||
M: "#...# ##.## #.#.# #.#.# #...# #...# #...#",
|
||||
P: "####. #...# #...# ####. #.... #.... #....",
|
||||
Y: "#...# #...# .#.#. ..#.. ..#.. ..#.. ..#..",
|
||||
};
|
||||
|
||||
const BLOCK = 0.84;
|
||||
|
||||
function glyphPath(letter: string, offsetX: number, offsetY: number): string {
|
||||
return GLYPHS[letter]!.split(" ")
|
||||
.flatMap((row, y) =>
|
||||
[...row].map((cell, x) => (cell === "#" ? `M${offsetX + x} ${offsetY + y}h${BLOCK}v${BLOCK}h-${BLOCK}z` : "")),
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function Word({ text, tone, start }: { text: string; tone: "accent" | "paper"; start: number }) {
|
||||
const width = text.length * 6 - 1 + 1;
|
||||
return (
|
||||
<svg className={styles.word} data-tone={tone} viewBox={`-0.4 -0.4 ${width} 8`} shapeRendering="crispEdges">
|
||||
{[...text].map((letter, index) => (
|
||||
<g key={index} className={styles.letter} style={{ animationDelay: `${(start + index) * 70}ms` }}>
|
||||
<path className={styles.blockShadow} d={glyphPath(letter, index * 6 + 0.6, 0.6)} />
|
||||
<path className={styles.blockOutline} d={glyphPath(letter, index * 6, 0)} />
|
||||
<path className={styles.block} d={glyphPath(letter, index * 6, 0)} />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** The AGENTCOMPANY wordmark, laid brick by brick like a title screen. */
|
||||
export function Logotype() {
|
||||
return (
|
||||
<span className={styles.logotype} aria-hidden="true">
|
||||
<Word text="AGENT" tone="accent" start={0} />
|
||||
<Word text="COMPANY" tone="paper" start={5} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
align-items: stretch;
|
||||
gap: 24px;
|
||||
margin: 0 0 36px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.grid > :first-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.grid > li > * {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.grid > :first-child .members {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.blurb {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 12px;
|
||||
margin: 0 0 24px;
|
||||
font-size: var(--fs-1);
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.members {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: var(--fs-1);
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.members li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.members li:hover {
|
||||
color: var(--ui-select);
|
||||
}
|
||||
|
||||
.npc {
|
||||
max-width: 768px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.grid > :first-child .members {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Badge, Dialog, Icon, NpcSay } from "../pixel";
|
||||
import { party } from "../../lib/content";
|
||||
import { Reveal } from "./reveal";
|
||||
import { SectionHeader } from "./section-header";
|
||||
import section from "./section.module.css";
|
||||
import styles from "./party-roster.module.css";
|
||||
|
||||
export function PartyRoster() {
|
||||
const total = party.reduce((sum, group) => sum + group.members.length, 0);
|
||||
return (
|
||||
<section id="party" className={section.section} aria-labelledby="party-title">
|
||||
<div className={section.inner}>
|
||||
<Reveal>
|
||||
<SectionHeader
|
||||
id="party-title"
|
||||
stage="World 4 · Party roster"
|
||||
title="Recruit any model into the party"
|
||||
intro={`${total} ways to power your CEO, sorted by how they join.`}
|
||||
/>
|
||||
<ul className={styles.grid}>
|
||||
{party.map((group) => (
|
||||
<li key={group.name}>
|
||||
<Dialog title={group.name} as="div">
|
||||
<p className={styles.blurb}>
|
||||
<Badge>{group.badge}</Badge>
|
||||
<span>{group.blurb}</span>
|
||||
</p>
|
||||
<ul className={styles.members}>
|
||||
{group.members.map((member) => (
|
||||
<li key={member}>
|
||||
<Icon name={group.icon} size={1} />
|
||||
<span>{member}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Dialog>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className={styles.npc}>
|
||||
<NpcSay
|
||||
speaker="Bit, the intern"
|
||||
portrait="bot"
|
||||
lines={["One provider is enough to start. Settings has a Providers panel for when you want to recruit more."]}
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { PixelButton } from "../pixel";
|
||||
import { getTrackedDownloadHref, type ReleaseDownloadAsset } from "../../lib/downloads";
|
||||
import { detectClientPlatform, recommendedAsset, type DetectedPlatform } from "../../lib/platform";
|
||||
import styles from "./title-screen.module.css";
|
||||
|
||||
/** Server renders "Choose your platform"; the browser swaps in the build for this machine. */
|
||||
export function PlatformCta() {
|
||||
const [detected, setDetected] = useState<{ platform: DetectedPlatform; asset: ReleaseDownloadAsset | null } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void detectClientPlatform().then((platform) => setDetected({ platform, asset: recommendedAsset(platform) }));
|
||||
}, []);
|
||||
|
||||
const asset = detected?.asset ?? null;
|
||||
if (!asset) {
|
||||
const mac = detected?.platform.os === "macos";
|
||||
return (
|
||||
<span className={styles.cta}>
|
||||
<PixelButton href="#save-point" icon="download">
|
||||
{mac ? "Choose your Mac build" : "Choose your platform"}
|
||||
</PixelButton>
|
||||
<span className={styles.ctaNote}>{mac ? "Apple Silicon or Intel" : "macOS · Windows · Linux"}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={styles.cta}>
|
||||
<PixelButton href={getTrackedDownloadHref(asset.id)} icon="download">
|
||||
Download for {asset.platformTitle}
|
||||
</PixelButton>
|
||||
<span className={styles.ctaNote}>
|
||||
{asset.label} · {asset.size} · <a href="#save-point">other builds</a>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
.map {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 12px;
|
||||
margin: 0 0 48px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.map::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 34px;
|
||||
left: 8%;
|
||||
right: 8%;
|
||||
height: 6px;
|
||||
background: repeating-linear-gradient(90deg, var(--ui-muted) 0 12px, transparent 12px 24px);
|
||||
}
|
||||
|
||||
.stop {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.node {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--ui-muted);
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.flag {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
color: var(--ui-text);
|
||||
background: var(--ui-panel);
|
||||
box-shadow:
|
||||
0 -6px 0 0 var(--ui-line),
|
||||
0 6px 0 0 var(--ui-line),
|
||||
-6px 0 0 0 var(--ui-line),
|
||||
6px 0 0 0 var(--ui-line),
|
||||
6px 12px 0 0 var(--ui-drop);
|
||||
}
|
||||
|
||||
.stop[data-state="cleared"] .flag {
|
||||
color: var(--ink-void);
|
||||
background: var(--ink-mint);
|
||||
}
|
||||
|
||||
.stop[data-state="current"] .flag {
|
||||
color: var(--ink-void);
|
||||
background: var(--ink-gold);
|
||||
box-shadow:
|
||||
0 -6px 0 0 var(--ink-accent),
|
||||
0 6px 0 0 var(--ink-accent),
|
||||
-6px 0 0 0 var(--ink-accent),
|
||||
6px 0 0 0 var(--ink-accent),
|
||||
6px 12px 0 0 var(--ui-drop);
|
||||
animation: hop 800ms steps(2, jump-none) infinite;
|
||||
}
|
||||
|
||||
@keyframes hop {
|
||||
50% {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
}
|
||||
|
||||
.stageNo {
|
||||
font-size: var(--fs-1);
|
||||
line-height: 2;
|
||||
color: var(--ui-select);
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: var(--fs-2);
|
||||
line-height: 1;
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.stop[data-state="current"] .name::before {
|
||||
content: "▶";
|
||||
color: var(--ui-select);
|
||||
}
|
||||
|
||||
.detail {
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 2fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.body {
|
||||
margin: 0 0 24px;
|
||||
min-height: 108px;
|
||||
font-size: var(--fs-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.map {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
row-gap: 36px;
|
||||
}
|
||||
|
||||
.map::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.detail {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.flag {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: var(--fs-1);
|
||||
}
|
||||
|
||||
.body {
|
||||
min-height: 0;
|
||||
font-size: var(--fs-1);
|
||||
line-height: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.stop[data-state="current"] .flag {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, type KeyboardEvent } from "react";
|
||||
import { Dialog, Icon, NpcSay, XpBar } from "../pixel";
|
||||
import { quests } from "../../lib/content";
|
||||
import { Reveal } from "./reveal";
|
||||
import { SectionHeader } from "./section-header";
|
||||
import section from "./section.module.css";
|
||||
import styles from "./quest-log.module.css";
|
||||
|
||||
export function QuestLog() {
|
||||
const [active, setActive] = useState(0);
|
||||
const nodes = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const quest = quests[active]!;
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];
|
||||
if (!step) return;
|
||||
event.preventDefault();
|
||||
const next = (active + step + quests.length) % quests.length;
|
||||
setActive(next);
|
||||
nodes.current[next]!.focus();
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="quests" className={section.section} aria-labelledby="quests-title">
|
||||
<div className={section.inner}>
|
||||
<Reveal>
|
||||
<SectionHeader
|
||||
id="quests-title"
|
||||
stage="World 1 · New game"
|
||||
title="Quest log: six checkpoints to your first company"
|
||||
intro="The setup wizard is the tutorial level. One key, a few names, one goal. Then the CEO takes the controller."
|
||||
/>
|
||||
<ol className={styles.map} aria-label="Onboarding steps" onKeyDown={onKeyDown}>
|
||||
{quests.map((item, index) => (
|
||||
<li key={item.stage} className={styles.stop} data-state={index < active ? "cleared" : index === active ? "current" : "ahead"}>
|
||||
<button
|
||||
ref={(node) => {
|
||||
nodes.current[index] = node;
|
||||
}}
|
||||
type="button"
|
||||
className={styles.node}
|
||||
aria-current={index === active ? "step" : undefined}
|
||||
tabIndex={index === active ? 0 : -1}
|
||||
onClick={() => setActive(index)}
|
||||
>
|
||||
<span className={styles.flag}>
|
||||
<Icon name={item.icon} size={3} />
|
||||
</span>
|
||||
<span className={styles.stageNo}>{item.stage}</span>
|
||||
<span className={styles.name}>{item.title}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<div className={styles.detail}>
|
||||
<Dialog title={`Stage ${quest.stage} · ${quest.title}`} tone={quest.optional ? "default" : "accent"} as="div">
|
||||
<p className={styles.body} aria-live="polite">
|
||||
{quest.body}
|
||||
</p>
|
||||
<XpBar value={active + 1} max={quests.length} label="Setup progress" />
|
||||
</Dialog>
|
||||
<NpcSay
|
||||
speaker="Bit, the intern"
|
||||
portrait="bot"
|
||||
lines={[
|
||||
"Six steps. I've seen speedrunners finish before their coffee cooled.",
|
||||
"You only need one provider. The connectors at the end are a side quest, so Skip is a legal move.",
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import styles from "./section.module.css";
|
||||
|
||||
type RevealState = "static" | "armed" | "shown";
|
||||
|
||||
/** Wipes its children in, scanline by scanline, the first time they scroll into view; the unclipped outer box is what gets observed. */
|
||||
export function Reveal({ children }: { children: ReactNode }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [state, setState] = useState<RevealState>("static");
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current!;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
if (element.getBoundingClientRect().top < window.innerHeight) return;
|
||||
setState("armed");
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry!.isIntersecting) return;
|
||||
setState("shown");
|
||||
observer.disconnect();
|
||||
},
|
||||
{ rootMargin: "0px 0px -12% 0px" },
|
||||
);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<div className={styles.reveal} data-reveal={state}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Container } from "../ui/container";
|
||||
import { Reveal } from "../ui/reveal";
|
||||
|
||||
const runtimes = [
|
||||
{ name: "Claude Code", provider: "Anthropic" },
|
||||
{ name: "Codex CLI", provider: "OpenAI" },
|
||||
{ name: "Gemini CLI", provider: "Google" },
|
||||
{ name: "Kimi", provider: "Moonshot AI" },
|
||||
{ name: "GLM", provider: "Zhipu AI" },
|
||||
{ name: "MiniMax", provider: "MiniMax" },
|
||||
{ name: "Ollama", provider: "Local" },
|
||||
{ name: "Anthropic-compatible API", provider: "Any provider" },
|
||||
];
|
||||
|
||||
export function Runtimes() {
|
||||
return (
|
||||
<section className="border-y border-[var(--border)]">
|
||||
<Container className="py-12 md:py-16">
|
||||
<Reveal>
|
||||
<p className="eyebrow text-center text-sm text-[var(--muted)]">Supported runtimes</p>
|
||||
<ul className="mt-8 flex flex-wrap items-center justify-center gap-x-8 gap-y-4 md:gap-x-10 lg:gap-x-12">
|
||||
{runtimes.map((runtime) => (
|
||||
<li key={runtime.name} className="flex items-baseline gap-2">
|
||||
<span className="text-base font-medium tracking-tight text-[var(--text)]">{runtime.name}</span>
|
||||
<span className="hidden text-sm text-[var(--muted)] sm:inline">{runtime.provider}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Reveal>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 24px;
|
||||
font: inherit;
|
||||
font-size: var(--fs-2);
|
||||
line-height: 1;
|
||||
color: var(--ui-text);
|
||||
background: var(--ui-panel);
|
||||
border: 0;
|
||||
box-shadow: inset 0 0 0 var(--u) var(--ui-line), inset 0 -6px 0 var(--ui-lo);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab[aria-selected="true"] {
|
||||
color: var(--ink-void);
|
||||
background: var(--ink-gold);
|
||||
box-shadow: inset 0 0 0 var(--u) var(--ui-line), inset 0 -6px 0 var(--ink-accent);
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--ui-select);
|
||||
}
|
||||
|
||||
.tab[aria-selected="true"]:hover {
|
||||
color: var(--ink-void);
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.note {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0 0 24px;
|
||||
font-size: var(--fs-1);
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.assets {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.asset {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 12px 0;
|
||||
border-top: 3px dashed currentColor;
|
||||
}
|
||||
|
||||
.asset:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.chest {
|
||||
color: var(--ui-select);
|
||||
}
|
||||
|
||||
.info {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--fs-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.file {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: var(--fs-1);
|
||||
line-height: 2;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.specs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: var(--fs-1);
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.srOnly {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 2fr;
|
||||
gap: 36px;
|
||||
align-items: start;
|
||||
margin-top: 36px;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.asset {
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 12px 24px;
|
||||
}
|
||||
|
||||
.specs,
|
||||
.asset > :last-child {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.footer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.tab {
|
||||
padding: 12px;
|
||||
font-size: var(--fs-1);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--fs-1);
|
||||
line-height: 2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { Badge, Dialog, Icon, NpcSay, PixelButton, type IconName } from "../pixel";
|
||||
import { getTrackedDownloadHref } from "../../lib/downloads";
|
||||
import { detectClientPlatform } from "../../lib/platform";
|
||||
import { platformDownloads, RELEASE_PAGE_URL, releaseVersion, SOURCE_URL, type PlatformDownload } from "../../lib/release-links";
|
||||
import { Reveal } from "./reveal";
|
||||
import { SectionHeader } from "./section-header";
|
||||
import section from "./section.module.css";
|
||||
import styles from "./save-point.module.css";
|
||||
|
||||
type PlatformId = PlatformDownload["id"];
|
||||
|
||||
const platformIcon: Record<PlatformId, IconName> = { macos: "apple", windows: "windows", linux: "linux" };
|
||||
|
||||
export function SavePoint() {
|
||||
const [selected, setSelected] = useState<PlatformId>("macos");
|
||||
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const platform = platformDownloads.find((item) => item.id === selected)!;
|
||||
|
||||
useEffect(() => {
|
||||
void detectClientPlatform().then(({ os }) => {
|
||||
if (os !== "unknown") setSelected(os);
|
||||
});
|
||||
}, []);
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
const step = { ArrowRight: 1, ArrowLeft: -1 }[event.key];
|
||||
if (!step) return;
|
||||
event.preventDefault();
|
||||
const index = platformDownloads.findIndex((item) => item.id === selected);
|
||||
const next = (index + step + platformDownloads.length) % platformDownloads.length;
|
||||
setSelected(platformDownloads[next]!.id);
|
||||
tabs.current[next]!.focus();
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="save-point" className={section.section} aria-labelledby="save-title">
|
||||
<div className={section.inner}>
|
||||
<Reveal>
|
||||
<SectionHeader
|
||||
id="save-title"
|
||||
stage="World 6 · Save point"
|
||||
title="Save point reached. Grab your build."
|
||||
intro={`Version ${releaseVersion}, served straight from the project's Gitea releases.`}
|
||||
/>
|
||||
<div role="tablist" aria-label="Platform" className={styles.tabs} onKeyDown={onKeyDown}>
|
||||
{platformDownloads.map((item, index) => (
|
||||
<button
|
||||
key={item.id}
|
||||
ref={(node) => {
|
||||
tabs.current[index] = node;
|
||||
}}
|
||||
id={`tab-${item.id}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={item.id === selected}
|
||||
aria-controls="save-panel"
|
||||
tabIndex={item.id === selected ? 0 : -1}
|
||||
className={styles.tab}
|
||||
onClick={() => setSelected(item.id)}
|
||||
>
|
||||
<Icon name={platformIcon[item.id]} size={2} />
|
||||
{item.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div id="save-panel" role="tabpanel" aria-labelledby={`tab-${selected}`} className={styles.panel}>
|
||||
<Dialog title={`${platform.title} builds`} tone="accent" as="div">
|
||||
<p className={styles.note}>
|
||||
{platform.id === "macos" ? <Badge tone="mint">Signed + notarized</Badge> : null}
|
||||
<span>{platform.note}</span>
|
||||
</p>
|
||||
<ul className={styles.assets}>
|
||||
{platform.assets.map((asset) => (
|
||||
<li key={asset.id} className={styles.asset}>
|
||||
<span className={styles.chest} aria-hidden="true">
|
||||
<Icon name="chest" size={2} />
|
||||
</span>
|
||||
<span className={styles.info}>
|
||||
<span className={styles.label}>{asset.label}</span>
|
||||
<span className={styles.file}>{asset.fileName}</span>
|
||||
</span>
|
||||
<span className={styles.specs}>
|
||||
{asset.architectures.map((arch) => (
|
||||
<Badge key={arch}>{arch}</Badge>
|
||||
))}
|
||||
<span>{asset.size}</span>
|
||||
</span>
|
||||
<PixelButton href={getTrackedDownloadHref(asset.id)} icon="download">
|
||||
Download<span className={styles.srOnly}> {asset.label}</span>
|
||||
</PixelButton>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Dialog>
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
<NpcSay
|
||||
speaker="Bit, the intern"
|
||||
portrait="bot"
|
||||
lines={[
|
||||
"Save point! Your company, your keys and your progress all live on this machine.",
|
||||
"Apple Silicon or Intel? Apple menu, About This Mac. I'll wait. I'm an NPC, waiting is my whole job.",
|
||||
]}
|
||||
/>
|
||||
<div className={styles.links}>
|
||||
<PixelButton href={RELEASE_PAGE_URL} variant="ghost" icon="flag">
|
||||
Full release notes
|
||||
</PixelButton>
|
||||
<PixelButton href={SOURCE_URL} variant="ghost" icon="chest">
|
||||
Source on Gitea
|
||||
</PixelButton>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import styles from "./section.module.css";
|
||||
|
||||
export function SectionHeader({ id, stage, title, intro }: { id: string; stage: string; title: string; intro?: string }) {
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<div className={styles.banner}>
|
||||
<span className={styles.cloud} aria-hidden="true" />
|
||||
<span className={`${styles.cloud} ${styles.cloudFar}`} aria-hidden="true" />
|
||||
<p className={styles.stage}>{stage}</p>
|
||||
</div>
|
||||
<h2 id={id} className={styles.title}>
|
||||
{title}
|
||||
</h2>
|
||||
{intro ? <p className={styles.intro}>{intro}</p> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
.section {
|
||||
position: relative;
|
||||
padding: 0 24px 72px;
|
||||
scroll-margin-top: 0;
|
||||
}
|
||||
|
||||
.inner {
|
||||
max-width: 1152px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.header > :not(.banner) {
|
||||
max-width: 768px;
|
||||
}
|
||||
|
||||
.banner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 120px;
|
||||
margin: 0 calc(50% - 50vw) 36px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
repeating-linear-gradient(90deg, var(--ink-leaf-2) 0 12px, transparent 12px 18px, var(--ink-leaf-2) 18px 24px, transparent 24px 36px) 0 calc(100% - 12px) / 100% 6px no-repeat,
|
||||
linear-gradient(var(--ink-leaf-1) 0 0) 0 calc(100% - 6px) / 100% 6px no-repeat,
|
||||
linear-gradient(var(--ink-leaf-0) 0 0) 0 100% / 100% 6px no-repeat,
|
||||
linear-gradient(var(--ink-sky-0) 0 24px, var(--ink-sky-1) 24px 48px, var(--ink-sky-2) 48px 72px, var(--ink-sky-3) 72px);
|
||||
}
|
||||
|
||||
.cloud {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
left: 0;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
color: var(--ink-cloud);
|
||||
box-shadow:
|
||||
12px 0, 18px 0, 6px 6px, 12px 6px, 18px 6px, 24px 6px, 36px 6px, 42px 6px,
|
||||
0 12px, 6px 12px, 12px 12px, 18px 12px, 24px 12px, 30px 12px, 36px 12px, 42px 12px, 48px 12px;
|
||||
animation: drift 90s steps(180) infinite;
|
||||
}
|
||||
|
||||
.cloudFar {
|
||||
top: 54px;
|
||||
animation-duration: 140s;
|
||||
animation-delay: -70s;
|
||||
}
|
||||
|
||||
@keyframes drift {
|
||||
from {
|
||||
transform: translateX(-60px);
|
||||
}
|
||||
to {
|
||||
transform: translateX(100vw);
|
||||
}
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0 0 12px;
|
||||
padding: 6px 24px;
|
||||
font-size: var(--fs-1);
|
||||
line-height: 2;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-gold);
|
||||
background: var(--ink-void);
|
||||
box-shadow: 0 0 0 var(--u) var(--ink-star), var(--u) calc(3 * var(--u)) 0 var(--u) var(--ink-void);
|
||||
}
|
||||
|
||||
.stage::before {
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--ink-accent);
|
||||
box-shadow: 12px 0 0 var(--ink-gold), 24px 0 0 var(--ink-mint);
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--fs-3);
|
||||
line-height: 1.333;
|
||||
font-weight: normal;
|
||||
color: var(--ui-text);
|
||||
text-shadow: var(--u) var(--u) 0 var(--ink-accent);
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin: 0;
|
||||
font-size: var(--fs-2);
|
||||
line-height: 1.5;
|
||||
color: var(--ui-muted);
|
||||
}
|
||||
|
||||
.reveal[data-reveal="armed"] {
|
||||
clip-path: inset(0 0 100% 0);
|
||||
}
|
||||
|
||||
.reveal[data-reveal="shown"] {
|
||||
animation: wipe 600ms steps(10) backwards;
|
||||
}
|
||||
|
||||
@keyframes wipe {
|
||||
from {
|
||||
clip-path: inset(0 0 100% 0);
|
||||
transform: translateY(24px);
|
||||
}
|
||||
to {
|
||||
clip-path: inset(0 0 0 0);
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.section {
|
||||
padding: 0 16px 48px;
|
||||
}
|
||||
|
||||
.banner {
|
||||
height: 96px;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--fs-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.intro {
|
||||
font-size: var(--fs-1);
|
||||
line-height: 2;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Container } from "../ui/container";
|
||||
import { Reveal } from "../ui/reveal";
|
||||
import { Aurora } from "../ui/aurora";
|
||||
|
||||
export function Showcase() {
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-[var(--band)] text-[var(--band-text)]">
|
||||
<Aurora />
|
||||
<Container className="relative py-24 md:py-32 lg:py-40">
|
||||
<Reveal className="mx-auto max-w-4xl text-center">
|
||||
<p className="eyebrow text-sm opacity-60">One human in the loop</p>
|
||||
<h2 className="mt-6 font-serif text-[clamp(2.25rem,1.4rem+3.6vw,4.5rem)] font-light leading-[1.05] tracking-[-0.02em]">
|
||||
Set the goal.
|
||||
<br />
|
||||
The company forms around it.
|
||||
</h2>
|
||||
<p className="mx-auto mt-6 max-w-[52ch] text-lg leading-relaxed opacity-75">
|
||||
An AI CEO hires the roles the mission needs, delegates the work, and reports back — while you keep the final
|
||||
say on every decision that matters.
|
||||
</p>
|
||||
</Reveal>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Dialog, MenuList } from "../pixel";
|
||||
import { nav } from "../../lib/content";
|
||||
import styles from "./top-bar.module.css";
|
||||
|
||||
/** Phone navigation: a MENU button that opens a pause-menu window; Escape or a pick closes it. */
|
||||
export function SiteMenu() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const button = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
root.current!.querySelector<HTMLElement>('[role="menuitem"]')!.focus();
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
setOpen(false);
|
||||
button.current!.focus();
|
||||
};
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (!root.current!.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
document.addEventListener("pointerdown", onPointerDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.removeEventListener("pointerdown", onPointerDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={root} className={styles.menu}>
|
||||
<button
|
||||
ref={button}
|
||||
type="button"
|
||||
className={styles.menuButton}
|
||||
aria-expanded={open}
|
||||
aria-controls="site-menu"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
{open ? "▼" : "▶"} Menu
|
||||
</button>
|
||||
{open ? (
|
||||
<div id="site-menu" className={styles.popover}>
|
||||
<Dialog title="Paused" as="div">
|
||||
<MenuList label="Sections" items={nav.map((link) => ({ label: link.label, href: link.href, onSelect: () => setOpen(false) }))} />
|
||||
</Dialog>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
.hero {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-height: max(640px, min(100svh, 960px));
|
||||
overflow: hidden;
|
||||
background: var(--ink-sky-1);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.world {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 120px 24px 84px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.content a,
|
||||
.content button {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.srOnly {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.logotype {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 12px 36px;
|
||||
}
|
||||
|
||||
.word {
|
||||
height: 96px;
|
||||
width: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.block {
|
||||
fill: var(--ink-star);
|
||||
}
|
||||
|
||||
.word[data-tone="accent"] .block {
|
||||
fill: var(--ink-gold);
|
||||
}
|
||||
|
||||
.blockShadow {
|
||||
fill: var(--ink-void);
|
||||
stroke: var(--ink-void);
|
||||
stroke-width: 0.4;
|
||||
}
|
||||
|
||||
.blockOutline {
|
||||
fill: var(--ink-void);
|
||||
stroke: var(--ink-void);
|
||||
stroke-width: 0.5;
|
||||
}
|
||||
|
||||
.letter {
|
||||
animation: drop 560ms steps(7) backwards;
|
||||
}
|
||||
|
||||
@keyframes drop {
|
||||
from {
|
||||
transform: translateY(-12px);
|
||||
opacity: 0;
|
||||
}
|
||||
60% {
|
||||
transform: translateY(1px);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.window {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-width: 768px;
|
||||
padding: 12px 24px;
|
||||
background: color-mix(in srgb, var(--ink-void) 72%, transparent);
|
||||
}
|
||||
|
||||
.tagline,
|
||||
.sub {
|
||||
margin: 0;
|
||||
color: var(--ink-star);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: var(--fs-3);
|
||||
line-height: 1.333;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: var(--fs-2);
|
||||
line-height: 1.5;
|
||||
color: var(--ink-glow);
|
||||
}
|
||||
|
||||
.dock {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 24px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.press {
|
||||
margin: 0;
|
||||
font-size: var(--fs-2);
|
||||
line-height: 1.5;
|
||||
color: var(--ink-gold);
|
||||
text-shadow: 3px 3px 0 var(--ink-void);
|
||||
animation: blink 1s steps(2, jump-none) infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ctaNote {
|
||||
padding: 0 12px;
|
||||
font-size: var(--fs-1);
|
||||
line-height: 2;
|
||||
color: var(--ink-star);
|
||||
background: color-mix(in srgb, var(--ink-void) 72%, transparent);
|
||||
}
|
||||
|
||||
.ctaNote a {
|
||||
color: var(--ink-gold);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 0 12px;
|
||||
font-size: var(--fs-1);
|
||||
line-height: 2;
|
||||
color: var(--ink-star);
|
||||
background: color-mix(in srgb, var(--ink-void) 72%, transparent);
|
||||
}
|
||||
|
||||
.ground {
|
||||
position: absolute;
|
||||
inset: auto 0 0;
|
||||
height: 36px;
|
||||
pointer-events: none;
|
||||
background:
|
||||
repeating-linear-gradient(90deg, transparent 0 108px, var(--ink-bloom) 108px 114px, transparent 114px 204px) 0 0 / auto 6px no-repeat,
|
||||
repeating-linear-gradient(90deg, var(--ink-leaf-2) 0 12px, transparent 12px 18px, var(--ink-leaf-2) 18px 24px, transparent 24px 36px) 0 6px / auto 6px no-repeat,
|
||||
linear-gradient(var(--ink-leaf-1) 0 0) 0 12px / 100% 6px no-repeat,
|
||||
linear-gradient(var(--ink-leaf-0) 0 0) 0 18px / 100% 6px no-repeat,
|
||||
linear-gradient(var(--ink-wood-dark) 0 0) 0 24px / 100% 6px no-repeat,
|
||||
linear-gradient(var(--ink-void) 0 0) 0 30px / 100% 6px no-repeat;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.content {
|
||||
padding: 96px 16px 72px;
|
||||
}
|
||||
|
||||
.word {
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.logotype {
|
||||
gap: 12px 24px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: var(--fs-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: var(--fs-1);
|
||||
line-height: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.content {
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
width: min(100%, 1440px);
|
||||
margin: 0 auto;
|
||||
padding: 120px 48px 96px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.content > * {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.logotype {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.word {
|
||||
height: 72px;
|
||||
}
|
||||
|
||||
.window {
|
||||
padding: 12px 24px;
|
||||
background: color-mix(in srgb, var(--ink-void) 55%, transparent);
|
||||
box-shadow:
|
||||
inset 0 0 0 var(--u) color-mix(in srgb, var(--ink-star) 60%, transparent),
|
||||
var(--u) var(--u) 0 color-mix(in srgb, var(--ink-void) 40%, transparent);
|
||||
}
|
||||
|
||||
.dock {
|
||||
justify-items: start;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.cta {
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.meta {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.letter,
|
||||
.press {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Badge, PixelButton } from "../pixel";
|
||||
import { PixelWorld } from "../world";
|
||||
import { releaseVersion, SOURCE_URL } from "../../lib/release-links";
|
||||
import { Logotype } from "./logotype";
|
||||
import { PlatformCta } from "./platform-cta";
|
||||
import styles from "./title-screen.module.css";
|
||||
|
||||
export function TitleScreen() {
|
||||
return (
|
||||
<section id="top" className={styles.hero} aria-labelledby="title-heading">
|
||||
<PixelWorld className={styles.world} focus="right" />
|
||||
<div className={styles.content}>
|
||||
<h1 id="title-heading" className={styles.heading}>
|
||||
<span className={styles.srOnly}>AgentCompany</span>
|
||||
<Logotype />
|
||||
</h1>
|
||||
<div className={styles.window}>
|
||||
<p className={styles.tagline}>Run an autonomous AI company on your desktop.</p>
|
||||
<p className={styles.sub}>
|
||||
Set a goal. An AI CEO hires the team and hands out the work. Nobody clears the level until an isolated
|
||||
auditor approves the evidence.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.dock}>
|
||||
<p className={styles.press} aria-hidden="true">
|
||||
▶ PRESS START
|
||||
</p>
|
||||
<div className={styles.actions}>
|
||||
<PlatformCta />
|
||||
<PixelButton href={SOURCE_URL} variant="ghost" icon="chest">
|
||||
View source on Gitea
|
||||
</PixelButton>
|
||||
</div>
|
||||
<p className={styles.meta}>
|
||||
<Badge tone="accent">v{releaseVersion}</Badge>
|
||||
<span>No account · Keys stay local · EN / 中文</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.ground} aria-hidden="true" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
.bar {
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
font-size: var(--fs-1);
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.brand,
|
||||
.nav a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 12px;
|
||||
color: var(--ink-star);
|
||||
text-decoration: none;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
background: color-mix(in srgb, var(--ink-void) 72%, transparent);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.nav a:hover,
|
||||
.nav a:focus-visible {
|
||||
color: var(--ink-gold);
|
||||
}
|
||||
|
||||
.nav a:hover::before,
|
||||
.nav a:focus-visible::before {
|
||||
content: "▶";
|
||||
}
|
||||
|
||||
.hud {
|
||||
margin: 0 calc(2 * var(--u)) calc(2 * var(--u)) auto;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: relative;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menuButton {
|
||||
padding: 0 12px;
|
||||
font-size: var(--fs-1);
|
||||
line-height: 2;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-void);
|
||||
background: var(--ink-gold);
|
||||
border: 0;
|
||||
box-shadow: inset 0 calc(-1 * var(--u)) 0 var(--ink-accent-dark);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 12px);
|
||||
left: 0;
|
||||
min-width: 192px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bar {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.brand span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Hud, Icon } from "../pixel";
|
||||
import { nav } from "../../lib/content";
|
||||
import { SiteMenu } from "./site-menu";
|
||||
import styles from "./top-bar.module.css";
|
||||
|
||||
export function TopBar() {
|
||||
return (
|
||||
<header className={styles.bar}>
|
||||
<a className={styles.brand} href="#top">
|
||||
<Icon name="building" size={2} />
|
||||
<span>AgentCompany</span>
|
||||
</a>
|
||||
<nav className={styles.nav} aria-label="Sections">
|
||||
{nav.map((link) => (
|
||||
<a key={link.href} href={link.href}>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
<SiteMenu />
|
||||
<div className={styles.hud}>
|
||||
<Hud />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
.panel {
|
||||
max-width: 768px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
margin: 0;
|
||||
font-size: var(--fs-2);
|
||||
line-height: 1.5;
|
||||
color: var(--ui-select);
|
||||
animation: blink 1s steps(2, jump-none) infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
.score {
|
||||
margin: 0 0 24px;
|
||||
font-size: var(--fs-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.number {
|
||||
font-size: var(--fs-4);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0 0 12px;
|
||||
font-size: var(--fs-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.score,
|
||||
.error {
|
||||
font-size: var(--fs-1);
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.number {
|
||||
font-size: var(--fs-3);
|
||||
line-height: 1.333;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.loading {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Dialog, PixelButton, XpBar } from "../pixel";
|
||||
import { Reveal } from "./reveal";
|
||||
import { SectionHeader } from "./section-header";
|
||||
import section from "./section.module.css";
|
||||
import styles from "./xp-section.module.css";
|
||||
|
||||
const XP_PER_LEVEL = 1000;
|
||||
|
||||
type Stats = { phase: "loading" } | { phase: "ready"; downloads: number } | { phase: "error"; message: string };
|
||||
|
||||
async function fetchDownloads(): Promise<number> {
|
||||
const response = await fetch("/api/download-stats", { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`The scoreboard answered HTTP ${response.status}.`);
|
||||
const body = (await response.json()) as { downloads?: unknown };
|
||||
if (typeof body.downloads !== "number") throw new Error("The scoreboard sent a score we can't read.");
|
||||
return body.downloads;
|
||||
}
|
||||
|
||||
export function XpSection() {
|
||||
const [stats, setStats] = useState<Stats>({ phase: "loading" });
|
||||
|
||||
const load = useCallback(() => {
|
||||
setStats({ phase: "loading" });
|
||||
fetchDownloads().then(
|
||||
(downloads) => setStats({ phase: "ready", downloads }),
|
||||
(error: Error) => setStats({ phase: "error", message: error.message }),
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(load, [load]);
|
||||
|
||||
return (
|
||||
<section id="xp" className={section.section} aria-labelledby="xp-title">
|
||||
<div className={section.inner}>
|
||||
<Reveal>
|
||||
<SectionHeader
|
||||
id="xp-title"
|
||||
stage="World 5 · Experience"
|
||||
title="Every download is one XP"
|
||||
intro="A live tally from the download counter, dressed up as a level bar because we couldn't help ourselves."
|
||||
/>
|
||||
<div className={styles.panel} aria-live="polite" aria-busy={stats.phase === "loading"}>
|
||||
{stats.phase === "loading" ? <p className={styles.loading}>Loading save data…</p> : null}
|
||||
{stats.phase === "ready" ? <Level downloads={stats.downloads} /> : null}
|
||||
{stats.phase === "error" ? (
|
||||
<Dialog title="Scoreboard offline" tone="danger" as="div">
|
||||
<p className={styles.error}>
|
||||
<strong>GAME OVER</strong> for the counter, not for you. {stats.message}
|
||||
</p>
|
||||
<p className={styles.error}>Downloads still work. Continue?</p>
|
||||
<div className={styles.actions}>
|
||||
<PixelButton onClick={load} icon="arrowRight">
|
||||
Retry
|
||||
</PixelButton>
|
||||
<PixelButton href="#save-point" variant="ghost" icon="download">
|
||||
Continue to downloads
|
||||
</PixelButton>
|
||||
</div>
|
||||
</Dialog>
|
||||
) : null}
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Level({ downloads }: { downloads: number }) {
|
||||
const level = Math.floor(downloads / XP_PER_LEVEL);
|
||||
return (
|
||||
<Dialog title={`Community LV ${level}`} tone="accent" as="div">
|
||||
<p className={styles.score}>
|
||||
<span className={styles.number}>{downloads.toLocaleString("en-US")}</span> downloads counted
|
||||
</p>
|
||||
<XpBar value={downloads % XP_PER_LEVEL} max={XP_PER_LEVEL} label={`XP to LV ${level + 1}`} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user