Compare commits

..
7 Commits
Author SHA1 Message Date
ZacharyZhang-NYandClaude Opus 5.5 b21db0e07a Website: 8-bit Open Graph card drawn pixel by pixel; old screenshot assets removed
CI / Typecheck, lint, tests, e2e (push) Successful in 3m22s
landing/scripts/og.mjs paints a 240x126 NES-palette scene with a hand-placed 5x7 font and writes public/images/og.png at x5 (1200x630). npm run og regenerates it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:09:05 -04:00
ZacharyZhang-NYandClaude Opus 5.5 2d0bff3f98 Website: hero fills 98vh with smaller copy; mode cards share one size; save-point row centred
CI / Typecheck, lint, tests, e2e (push) Failing after 3m50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:51:11 -04:00
ZacharyZhang-NYandClaude Opus 5.5 2d7af224dd 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
- 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>
2026-10-09 23:37:23 -04:00
ZacharyZhang-NYandClaude Opus 5.5 e374bc1e8e Release: drop an empty CSC_LINK so the keychain identity signs the macOS build
Build Release Installers / Release for the tag (push) Successful in 11s
Build Release Installers / Windows installer (NSIS x64) (push) Successful in 1m19s
Build Release Installers / Linux packages (AppImage + deb x64) (push) Successful in 2m16s
CI / Typecheck, lint, tests, e2e (push) Successful in 3m25s
Build Release Installers / macOS installers (dmg + zip, x64 + arm64) (push) Successful in 7m22s
electron-builder read the empty secret as a certificate path and failed with "not a file". Also keeps lighting.ts types module-private.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:31:36 -04:00
ZacharyZhang-NYandClaude Opus 5.5 eca8693ac3 Release: build macOS installers on the login-session signing runner
Build Release Installers / macOS installers (dmg + zip, x64 + arm64) (push) Failing after 26s
Build Release Installers / Release for the tag (push) Successful in 11s
Build Release Installers / Windows installer (NSIS x64) (push) Successful in 1m14s
CI / Typecheck, lint, tests, e2e (push) Successful in 3m36s
Build Release Installers / Linux packages (AppImage + deb x64) (push) Successful in 1m56s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:27:22 -04:00
ZacharyZhang-NYandClaude Opus 5.5 7b07bcea02 Release: fail the macOS job when notarization secrets are set but the Developer ID identity is unreachable
CI / Typecheck, lint, tests, e2e (push) Successful in 3m37s
v1.5.1 shipped ad-hoc macOS installers with only a warning: the runner ran as a system LaunchDaemon, outside the login keychain's session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:24:50 -04:00
ZacharyZhang-NYandClaude Opus 5.5 245ff66336 1.5.1: refined office, chat in the app language, current OpenAI models, own connector keys
Build Release Installers / Release for the tag (push) Successful in 16s
Build Release Installers / Windows installer (NSIS x64) (push) Successful in 1m48s
CI / Typecheck, lint, tests, e2e (push) Successful in 3m52s
Build Release Installers / Linux packages (AppImage + deb x64) (push) Successful in 2m21s
Build Release Installers / macOS installers (dmg + zip, x64 + arm64) (push) Successful in 7m15s
- Home: the goal title box is two line boxes tall (2.8em), so a third line no longer peeks out.
- Chat messages, reply fallbacks and assignment DMs follow the app display language; tasks and documents keep the working language.
- OpenAI and GPT-account model lists match OpenAI's current line-up; default gpt-6.1-sol.
- Onboarding connectors step takes the operator's Composio and logo.dev keys, then lists the toolkits the Composio project enables; without a key Connectors leaves the sidebar.
- Google Antigravity removed end to end (migration 111), with the remote-revocation path only it used.
- Office: 2x pixel people with outlines, rim light, walk, typing and blink; dithered real-clock lighting; detailed furniture and floors.
- Per-agent model picker lists the provider's whole model list, live when connected.
- Chat wake context breaks same-millisecond ties by insertion order; the mid-run inbox dedupes by message id.
- The stored "clock" theme reads back instead of falling to "system".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:12:19 -04:00
207 changed files with 7158 additions and 5264 deletions

No files matched your search

+13 -6
View File
@@ -3,8 +3,8 @@ name: Build Release Installers
# A pushed v* tag builds Windows, Linux and macOS installers on the Gitea
# runners and attaches them to the tag's release. The tag must equal
# "v" + package.json version. macOS builds are signed and notarized when the
# Developer ID certificate and the APPLE_API_* secrets are present; otherwise
# they are ad-hoc signed.
# APPLE_API_* secrets are set (the Developer ID identity must then be reachable,
# or the job fails); without those secrets they are ad-hoc signed.
on:
push:
@@ -71,7 +71,8 @@ jobs:
macos:
name: macOS installers (dmg + zip, x64 + arm64)
needs: release
runs-on: macos-latest
# Runs in the login session, where the Developer ID identity lives.
runs-on: macos-signing
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
@@ -90,12 +91,16 @@ jobs:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
run: |
if { [ -n "$CSC_LINK" ] || security find-identity -v -p codesigning | grep -q "Developer ID Application: Yang Zhang (9PDD8M2SMS)"; } && [ -n "$APPLE_API_ISSUER" ] && [ -n "$APPLE_API_KEY_ID" ] && [ -n "$APPLE_API_KEY_P8" ]; then
if [ -z "$APPLE_API_ISSUER" ] || [ -z "$APPLE_API_KEY_ID" ] || [ -z "$APPLE_API_KEY_P8" ]; then
echo "signed=false" >> "$GITHUB_OUTPUT"
echo "::warning::No notarization secrets: building ad-hoc-signed, un-notarized macOS installers."
elif [ -n "$CSC_LINK" ] || security find-identity -v -p codesigning | grep -q "Developer ID Application: Yang Zhang (9PDD8M2SMS)"; then
echo "signed=true" >> "$GITHUB_OUTPUT"
echo "Developer ID certificate and notarization credentials found: building signed + notarized installers."
else
echo "signed=false" >> "$GITHUB_OUTPUT"
echo "::warning::Building ad-hoc-signed, un-notarized macOS installers."
security find-identity -v -p codesigning
echo "::error::Notarization secrets are set but this runner cannot reach the Developer ID identity; run the runner in the login session or set CSC_LINK."
exit 1
fi
- name: Build installers (signed + notarized)
if: steps.signing.outputs.signed == 'true'
@@ -107,6 +112,8 @@ jobs:
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
run: |
# An empty CSC_LINK reads as a certificate path; the keychain signs instead.
[ -n "$CSC_LINK" ] || unset CSC_LINK CSC_KEY_PASSWORD
printf '%s' "$APPLE_API_KEY_P8" > "$APPLE_API_KEY"
trap 'rm -f "$APPLE_API_KEY"' EXIT
pnpm build
-1
View File
@@ -21,7 +21,6 @@ The app ships with no keys. Add your own in **Settings > Integrations**:
|---|---|---|
| Composio API key | Connectors page and agent connector tools | platform.composio.dev |
| logo.dev publishable key | Connector logos | logo.dev |
| Google Antigravity OAuth client secret | Google Antigravity sign-in | Google |
Model provider keys and sign-ins live in **Settings > Providers**. All keys are encrypted with the OS keychain.
+42 -8
View File
@@ -1,14 +1,48 @@
# AgentCompany Landing
Next.js landing page for the AgentCompany desktop release lane.
The website for [AgentCompany](https://agent-company.dev): a 16-bit pixel-art landing page built with Next.js (static export), React and three.js, hosted on Cloudflare Pages.
## Copy Sources
## Structure
| Surface | Source |
| Path | What lives there |
|---|---|
| Download cards | `landing/lib/release-links.generated.ts` |
| Platform FAQ | `landing/lib/platform-support.ts` |
| Hero, How It Works, software JSON-LD features | `landing/lib/product-narrative.ts` |
| Runtime download URLs | generated from `scripts/generate-landing-release-links.mjs` |
| `app/` | Page shell (`layout.tsx`, `page.tsx`, `not-found.tsx`) and `globals.css` |
| `components/sections/` | Page sections, with copy in `lib/content.ts` |
| `components/pixel/` | The pixel UI kit: dialogs, NPC speech, menus, buttons, XP bars, badges, inventory, icons, GAME OVER, HUD, and the `PaletteClock` provider |
| `components/world/` | `PixelWorld`, the three.js office diorama rendered at low resolution and quantized to the palette with ordered dithering |
| `lib/palette.ts` | The 32-colour palette per day part and season; every colour on the site comes from its CSS variables |
| `lib/pixel-icons.ts` | Hand-placed 12×12 icons |
| `lib/release-links.ts` | Release version, Gitea source and release URLs, and every download asset |
| `lib/downloads.ts` | Flat asset list and tracked `/api/download` links |
| `lib/platform.ts` | Visitor OS/architecture detection and the recommended download |
| `functions/api/` | Cloudflare Pages Functions: `download` (counts a unique download in D1, then redirects to Gitea) and `download-stats` |
| `migrations/` | D1 schema for the download counter |
| `public/_headers` | Security and cache headers |
The public story mirrors the first-run desktop flow: Welcome, Requirements, Providers, Company, Team Models, Mode, Mission, Launch.
Append `?time=dawn|day|dusk|night&season=spring|summer|autumn|winter` to preview a palette.
## Downloads and source
Installers and source code live on Gitea, not GitHub: <https://git.zacharyzhang.com/ZacharyZhang-NY/AgentCompany>. Contributions go through AGit pull requests there.
## Updating the release
Edit `lib/release-links.ts`: bump `releaseVersion` and update each asset's file name and byte size from the release API:
```sh
curl -s https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/AgentCompany/releases/tags/v<version>
```
Asset ids stay stable across releases so existing `/api/download?asset=<id>` links keep working. The download function only redirects to `git.zacharyzhang.com`.
## Develop, test, build, deploy
```sh
npm install
npm run dev
node --import ./tests/unit/resolve-ts.mjs --test 'tests/unit/*.test.ts'
npm run build
npx wrangler pages deploy out --project-name agentcompany
```
`npm run build` writes the static site to `out/`. Pages Functions and the `DOWNLOAD_COUNTER_DB` D1 binding are configured in `wrangler.toml`; `npm run preview` serves `out/` with the functions locally.
+157 -219
View File
@@ -1,246 +1,184 @@
@import "tailwindcss";
/*
* AgentCompany landing — design system.
* Light theme is a faithful rendering of the adaline pebble/meadow palette
* (warm cream + dark green). Dark theme uses the matching Material-3 dark
* surfaces + mint primary. Components consume the CSS variables below, so the
* whole palette is defined once here.
*/
@theme {
--font-sans: var(--font-instrument-sans), ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
--font-serif: var(--font-newsreader), "Source Serif Pro", Georgia, "Times New Roman", serif;
--font-mono: var(--font-jetbrains-mono), ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
--font-display: var(--font-instrument-serif), "Cormorant Garamond", Georgia, serif;
/* Fusion Pixel 12px (OFL, see /fonts/fusion-pixel-licenses): a small Latin subset, and the full CJK file only when CJK text appears. */
@font-face {
font-family: "Fusion Pixel";
font-display: swap;
src: url("/fonts/FusionPixel-12px-zh_hans.woff2") format("woff2");
unicode-range: U+3000-303F, U+4E00-4E2C, U+4E2E-4F52, U+4F54-6586, U+6588-7B7F, U+7B81-9FFF, U+FF00-FFEF;
}
@font-face {
font-family: "Fusion Pixel";
font-display: swap;
src: url("/fonts/FusionPixel-12px-latin.woff2") format("woff2");
unicode-range: U+0020-007E, U+00A0-00FF, U+2010-2027, U+2030-203A, U+2122, U+2190-21FF, U+2500-26FF, U+4E2D, U+4F53, U+6587, U+7B80;
}
/* ── Light theme (pebble / meadow) ── */
/* Palette inks are registered colours so a palette swap fades in four hard steps, like a 16-bit palette cycle. */
@property --ink-void { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-shadow { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-ink { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-ink-soft { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-stone { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-stone-light { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-paper { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-white { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-sky-0 { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-sky-1 { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-sky-2 { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-sky-3 { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-sun { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-glow { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-cloud { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-star { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-wood { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-wood-dark { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-brick { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-rug { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-leaf-0 { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-leaf-1 { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-leaf-2 { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-bloom { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-accent { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-accent-dark { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-gold { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-mint { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-sea { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-berry { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-skin { syntax: "<color>"; inherits: true; initial-value: #000; }
@property --ink-screen { syntax: "<color>"; inherits: true; initial-value: #000; }
/* The day palette (lib/palette.ts) for first paint without script; PaletteClock overwrites these on <html>. */
:root {
--bg: #fbfdf6;
--bg-subtle: #f8f9f5;
--panel: #fdfefb;
--panel-soft: #f1f3ef;
--panel-illustration: #eff2e8;
--text: #0a1d08;
--text-secondary: #2e3b28;
--muted: #6b7860;
--muted-strong: #505c47;
--accent: #203b14;
--accent-hover: #13290e;
--accent-soft: #ecf2df;
--text-on-accent: #fbfdf6;
--cta: #203b14;
--cta-hover: #13290e;
--cta-soft: rgba(32, 59, 20, 0.06);
--text-on-cta: #fbfdf6;
--text-on-cta-muted: rgba(251, 253, 246, 0.75);
--text-on-cta-faint: rgba(251, 253, 246, 0.6);
--border: #e1e6df;
--border-strong: #c4cbc2;
--border-cool: #c4cbc2;
--band: #2a332a;
--band-text: #fbfdf6;
--band-border: rgba(253, 254, 251, 0.24);
--footer-bg: #050e11;
--footer-text: #fbfdf6;
--glow-warm: rgba(32, 59, 20, 0.05);
--glow-accent: rgba(99, 143, 61, 0.06);
--section-alt: rgba(32, 59, 20, 0.02);
--aurora-mint: rgba(94, 236, 151, 0.22);
--aurora-teal: rgba(45, 212, 191, 0.16);
--aurora-lime: rgba(137, 175, 84, 0.16);
--p0: #0b0a12; --p1: #1d1b2b; --p2: #2f2c40; --p3: #4a4660;
--p4: #6d6884; --p5: #9a95ad; --p6: #e9e2cf; --p7: #fbf7ec;
--p8: #3f7fd1; --p9: #68a2e6; --p10: #9cc6f2; --p11: #d4e8fa;
--p12: #ffe08a; --p13: #fff2c2; --p14: #f4f7fb; --p15: #ffffff;
--p16: #a8693c; --p17: #6e3f23; --p18: #b4533f; --p19: #5d6ea8;
--p20: #235c2e; --p21: #3f8f3f; --p22: #78c050; --p23: #ffd35c;
--p24: #e0663d; --p25: #9c3b22; --p26: #f2b632; --p27: #5fd3a8;
--p28: #2a8fa8; --p29: #c4466f; --p30: #f1c79a; --p31: #7ef0c2;
--ink-void: var(--p0); --ink-shadow: var(--p1); --ink-ink: var(--p2); --ink-ink-soft: var(--p3);
--ink-stone: var(--p4); --ink-stone-light: var(--p5); --ink-paper: var(--p6); --ink-white: var(--p7);
--ink-sky-0: var(--p8); --ink-sky-1: var(--p9); --ink-sky-2: var(--p10); --ink-sky-3: var(--p11);
--ink-sun: var(--p12); --ink-glow: var(--p13); --ink-cloud: var(--p14); --ink-star: var(--p15);
--ink-wood: var(--p16); --ink-wood-dark: var(--p17); --ink-brick: var(--p18); --ink-rug: var(--p19);
--ink-leaf-0: var(--p20); --ink-leaf-1: var(--p21); --ink-leaf-2: var(--p22); --ink-bloom: var(--p23);
--ink-accent: var(--p24); --ink-accent-dark: var(--p25); --ink-gold: var(--p26); --ink-mint: var(--p27);
--ink-sea: var(--p28); --ink-berry: var(--p29); --ink-skin: var(--p30); --ink-screen: var(--p31);
/* UI roles. Dawn, day and dusk are paper-and-ink; night (below) inverts to a lit-window look. */
--ui-ground: var(--ink-paper);
--ui-dither: var(--ink-white);
--ui-panel: var(--ink-white);
--ui-text: var(--ink-void);
--ui-muted: var(--ink-ink-soft);
--ui-line: var(--ink-void);
--ui-on-line: var(--ink-white);
--ui-hi: var(--ink-star);
--ui-lo: var(--ink-stone-light);
--ui-drop: var(--ink-stone-light);
--ui-focus: var(--ink-void);
--ui-danger: var(--ink-accent-dark);
--ui-select: var(--ink-accent-dark);
--ui-xp: var(--ink-mint);
--ui-xp-off: var(--ink-ink-soft);
/* --px snaps one font pixel to whole device pixels (set before paint by PaletteClock); --u is one 2× art pixel. */
--px: 1;
--u: calc(2px * var(--px));
--fs-1: calc(12px * var(--px));
--fs-2: calc(24px * var(--px));
--fs-3: calc(36px * var(--px));
--fs-4: calc(48px * var(--px));
color-scheme: light;
transition-property:
--ink-void, --ink-shadow, --ink-ink, --ink-ink-soft, --ink-stone, --ink-stone-light, --ink-paper, --ink-white,
--ink-sky-0, --ink-sky-1, --ink-sky-2, --ink-sky-3, --ink-sun, --ink-glow, --ink-cloud, --ink-star,
--ink-wood, --ink-wood-dark, --ink-brick, --ink-rug, --ink-leaf-0, --ink-leaf-1, --ink-leaf-2, --ink-bloom,
--ink-accent, --ink-accent-dark, --ink-gold, --ink-mint, --ink-sea, --ink-berry, --ink-skin, --ink-screen;
transition-duration: 600ms;
transition-timing-function: steps(4, jump-end);
}
/* ── Dark theme (Material-3 dark surfaces + mint primary) ── */
.dark {
--bg: #131515;
--bg-subtle: #171b1c;
--panel: #1d2226;
--panel-soft: #20262a;
--panel-illustration: #1d2226;
--text: #d8e3dd;
--text-secondary: #b2c3bf;
--muted: #93a49e;
--muted-strong: #b2c3bf;
--accent: #5eec97;
--accent-hover: #4af58f;
--accent-soft: #1c4534;
--text-on-accent: #124627;
--cta: #5eec97;
--cta-hover: #4af58f;
--cta-soft: rgba(94, 236, 151, 0.1);
--text-on-cta: #124627;
--text-on-cta-muted: rgba(18, 70, 39, 0.75);
--text-on-cta-faint: rgba(18, 70, 39, 0.55);
--border: #2c343b;
--border-strong: #3b454f;
--border-cool: #3b454f;
--band: #0b1510;
--band-text: #d8e3dd;
--band-border: rgba(216, 227, 221, 0.2);
--footer-bg: #05090b;
--footer-text: #d8e3dd;
--glow-warm: rgba(94, 236, 151, 0.05);
--glow-accent: rgba(94, 236, 151, 0.04);
--section-alt: rgba(94, 236, 151, 0.03);
--aurora-mint: rgba(94, 236, 151, 0.3);
--aurora-teal: rgba(45, 212, 191, 0.22);
--aurora-lime: rgba(137, 175, 84, 0.18);
:root[data-daypart="night"] {
--ui-ground: var(--ink-shadow);
--ui-dither: var(--ink-void);
--ui-panel: var(--ink-ink);
--ui-text: var(--ink-star);
--ui-muted: var(--ink-glow);
--ui-line: var(--ink-glow);
--ui-on-line: var(--ink-void);
--ui-hi: var(--ink-stone);
--ui-lo: var(--ink-void);
--ui-drop: var(--ink-void);
--ui-focus: var(--ink-gold);
--ui-danger: var(--ink-accent);
--ui-select: var(--ink-accent);
--ui-xp-off: var(--ink-stone);
color-scheme: dark;
}
/* ── Base ── */
html {
scroll-behavior: smooth;
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
background-color: var(--bg);
color: var(--text);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
line-height: 1.55;
margin: 0;
min-height: 100vh;
color: var(--ui-text);
font-family: "Fusion Pixel", ui-monospace, monospace;
font-size: var(--fs-1);
line-height: 1.5;
font-synthesis: none;
-webkit-font-smoothing: none;
text-rendering: optimizeSpeed;
image-rendering: pixelated;
overflow-x: clip;
/* Ground: a sparse two-colour dither, one art pixel lit in every 4×4 block on alternating diagonals. */
background:
conic-gradient(from 270deg at var(--u) var(--u), var(--ui-dither) 25%, transparent 0) 0 0 / calc(4 * var(--u)) calc(4 * var(--u)),
conic-gradient(from 270deg at var(--u) var(--u), var(--ui-dither) 25%, transparent 0) calc(2 * var(--u)) calc(2 * var(--u)) / calc(4 * var(--u)) calc(4 * var(--u)),
var(--ui-ground);
}
.font-display {
font-family: var(--font-display);
font-variant-ligatures: common-ligatures discretionary-ligatures;
h1, h2, h3, h4, h5, h6, b, strong, th {
font-weight: normal;
}
a {
color: inherit;
}
button, input, select, textarea {
font: inherit;
color: inherit;
}
img, svg, canvas, video {
image-rendering: pixelated;
}
::selection {
background-color: var(--accent);
color: var(--text-on-accent);
background: var(--ink-gold);
color: var(--ink-void);
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
outline: var(--u) solid var(--ui-focus);
outline-offset: var(--u);
}
:focus:not(:focus-visible) {
outline: none;
}
/* ── Eyebrow / small-caps mono label ── */
.eyebrow {
font-family: var(--font-mono);
font-variant-caps: all-small-caps;
letter-spacing: 0.06em;
}
/* ── Scrollbar ── */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--muted);
}
* {
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
/* ── Entrance + reveal motion (faithful to adaline: ease-out-quint) ── */
@keyframes nav-enter {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes rise-enter {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.enter-nav {
animation: nav-enter 0.52s cubic-bezier(0.22, 1, 0.36, 1) 0.1s both;
}
.enter-rise {
animation: rise-enter 0.55s cubic-bezier(0.22, 1, 0.36, 1) both;
}
/*
* Scroll-reveal primitive. Progressive enhancement: content is visible by
* default and only starts hidden once the `js` class is present, so the page
* stays fully readable if scripts never run.
*/
.reveal {
transition:
opacity 0.6s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
transition-delay: var(--reveal-delay, 0ms);
}
.js .reveal {
opacity: 0;
transform: translateY(16px);
}
.js .reveal[data-in-view="true"] {
opacity: 1;
transform: translateY(0);
}
/* ── Aurora: slow-drifting gradient light for dark showcase surfaces ── */
@keyframes aurora-drift {
0% {
transform: translate3d(0, 0, 0) scale(1);
}
50% {
transform: translate3d(-4%, 3%, 0) scale(1.12);
}
100% {
transform: translate3d(0, 0, 0) scale(1);
}
}
.aurora-layer {
animation: aurora-drift 20s ease-in-out infinite;
will-change: transform;
}
/* ── Reduced motion ── */
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
:root {
transition: none;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
.js .reveal {
opacity: 1;
transform: none;
animation: none !important;
transition: none !important;
scroll-behavior: auto !important;
}
}
+46 -202
View File
@@ -1,97 +1,29 @@
import type { Metadata } from "next";
import { Instrument_Sans, Instrument_Serif, Newsreader, JetBrains_Mono } from "next/font/google";
import type { Metadata, Viewport } from "next";
import type { ReactNode } from "react";
import "./globals.css";
import { ThemeProvider } from "../components/theme/theme-provider";
import { PaletteClock } from "../components/pixel";
import { description, faqs } from "../lib/content";
import { RELEASE_PAGE_URL, releaseVersion } from "../lib/release-links";
import { WEBSITE_URL } from "../lib/site-constants";
const instrumentSans = Instrument_Sans({
subsets: ["latin"],
variable: "--font-instrument-sans",
display: "swap",
weight: ["400", "500", "600", "700"],
});
const instrumentSerif = Instrument_Serif({
subsets: ["latin"],
variable: "--font-instrument-serif",
display: "swap",
weight: "400",
});
const newsreader = Newsreader({
subsets: ["latin"],
variable: "--font-newsreader",
display: "swap",
weight: ["300", "400", "500"],
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains-mono",
display: "swap",
weight: ["400", "500"],
});
const fontVariables = `${instrumentSans.variable} ${instrumentSerif.variable} ${newsreader.variable} ${jetbrainsMono.variable}`;
import {
landingHowItWorksNarrative,
landingStructuredDataNarrative,
} from "../lib/product-narrative";
import { platformSupportSummary } from "../lib/platform-support";
const description =
"Local-first desktop control plane for autonomous AI companies. Set a goal, let an AI CEO assemble specialists, delegate work, and deliver code, research, content, and operations from your desktop.";
const title = "AgentCompany · Run an autonomous AI company on your desktop";
export const metadata: Metadata = {
metadataBase: new URL("https://agent-company.dev"),
title: "AgentCompany - Launch an Autonomous AI Company from Your Desktop",
metadataBase: new URL(WEBSITE_URL),
title,
description,
keywords: [
"AI company",
"autonomous AI agents",
"AI automation",
"multi-agent system",
"AI CEO",
"AI agent orchestration",
"autonomous agents desktop app",
"AI task delegation",
"AI team management",
"AI code generation",
"AI content generation",
"AI research automation",
"self-organizing AI",
"AI workforce",
"Claude Code",
"Codex CLI",
"Gemini CLI",
"multi-agent framework",
"AI company builder",
],
alternates: { canonical: "/" },
openGraph: {
title: "AgentCompany - Launch an Autonomous AI Company from Your Desktop",
title,
description,
url: "https://agent-company.dev/",
url: "/",
siteName: "AgentCompany",
locale: "en_US",
images: [
{
url: "/images/og.jpg",
width: 1200,
height: 630,
alt: "AgentCompany desktop dashboard — quarter goal, budget, agent workload, and recent runs",
},
],
type: "website",
images: [{ url: "/images/og.png", width: 1200, height: 630, alt: "AgentCompany in 8-bit: run an autonomous AI company on your desktop" }],
},
twitter: {
card: "summary_large_image",
title: "AgentCompany - Launch an Autonomous AI Company from Your Desktop",
description,
images: ["/images/og.jpg"],
},
robots: {
index: true,
follow: true,
},
twitter: { card: "summary_large_image", title, description, images: ["/images/og.png"] },
robots: { index: true, follow: true },
icons: {
icon: [
{ url: "/favicon.ico?v=2", sizes: "any", type: "image/x-icon" },
@@ -101,130 +33,42 @@ export const metadata: Metadata = {
},
};
const themeScript = `
(function(){document.documentElement.classList.add("js");try{var t=localStorage.getItem("agentcompany-theme");if(t==="dark"||(!t&&window.matchMedia("(prefers-color-scheme:dark)").matches)){document.documentElement.classList.add("dark")}}catch(e){}})();
`;
export const viewport: Viewport = { width: "device-width", initialScale: 1 };
const softwareJsonLd = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: "AgentCompany",
applicationCategory: "BusinessApplication",
operatingSystem: "macOS, Windows",
url: "https://agent-company.dev",
downloadUrl: "https://agent-company.dev/#download",
screenshot: "https://agent-company.dev/images/dashboard.webp",
softwareVersion: landingStructuredDataNarrative.softwareVersion,
description,
featureList: landingStructuredDataNarrative.featureList,
author: { "@type": "Organization", name: "AgentCompany", url: "https://agent-company.dev" },
};
const jsonLd = [
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: "AgentCompany",
applicationCategory: "BusinessApplication",
operatingSystem: "macOS, Windows, Linux",
softwareVersion: releaseVersion,
url: WEBSITE_URL,
downloadUrl: RELEASE_PAGE_URL,
description,
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
inLanguage: ["en", "zh-Hans"],
},
{
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faqs.map((faq) => ({
"@type": "Question",
name: faq.question,
acceptedAnswer: { "@type": "Answer", text: faq.answer.join(" ") },
})),
},
];
const organizationJsonLd = {
"@context": "https://schema.org",
"@type": "Organization",
name: "AgentCompany",
url: "https://agent-company.dev",
logo: "https://agent-company.dev/icon.png",
};
const howToJsonLd = {
"@context": "https://schema.org",
"@type": "HowTo",
name: "How to Launch an Autonomous AI Company with AgentCompany",
description: "Complete the AgentCompany desktop first-run flow and queue the first mission.",
step: landingHowItWorksNarrative.steps.map((step) => ({
"@type": "HowToStep",
position: step.number,
name: step.title,
text: step.description,
})),
};
const faqJsonLd = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: [
{
"@type": "Question",
name: "What is AgentCompany?",
acceptedAnswer: {
"@type": "Answer",
text: "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.",
},
},
{
"@type": "Question",
name: "How do I download AgentCompany?",
acceptedAnswer: {
"@type": "Answer",
text: platformSupportSummary.downloadAnswer,
},
},
{
"@type": "Question",
name: "What platforms does AgentCompany support?",
acceptedAnswer: {
"@type": "Answer",
text: platformSupportSummary.platformAnswer,
},
},
{
"@type": "Question",
name: "What AI models can AgentCompany use?",
acceptedAnswer: {
"@type": "Answer",
text: "AgentCompany supports multiple AI runtimes as first-class connectors: Claude Code, Codex CLI, and Gemini CLI. You can connect your preferred provider during setup.",
},
},
{
"@type": "Question",
name: "How does the AI CEO work?",
acceptedAnswer: {
"@type": "Answer",
text: "When you create a company with a goal, the AI CEO agent analyzes the objective, creates the necessary departments, hires specialized agents for each role, builds an org chart, and delegates tasks. The CEO monitors progress, handles blockers, and coordinates cross-department work.",
},
},
{
"@type": "Question",
name: "Is my data private? Does it leave my machine?",
acceptedAnswer: {
"@type": "Answer",
text: "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.",
},
},
{
"@type": "Question",
name: "What can an AI company actually deliver?",
acceptedAnswer: {
"@type": "Answer",
text: "AI companies built with AgentCompany can produce code, content, research, business documents, and more. The deliverables depend on the goal you set and the AI runtime you connect.",
},
},
{
"@type": "Question",
name: "How are updates delivered?",
acceptedAnswer: {
"@type": "Answer",
text: "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 build is delivered from the same release host for manual installation.",
},
},
],
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) {
return (
<html lang="en" className={fontVariables} suppressHydrationWarning>
<html lang="en" suppressHydrationWarning>
<head>
<meta name="googlebot" content="index, follow, max-video-preview:-1, max-image-preview:large, max-snippet:-1" />
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(softwareJsonLd) }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationJsonLd) }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(howToJsonLd) }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }} />
<link rel="preload" href="/fonts/FusionPixel-12px-latin.woff2" as="font" type="font/woff2" crossOrigin="anonymous" />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
</head>
<body className="bg-[var(--bg)] text-[var(--text)] antialiased transition-colors duration-300">
<ThemeProvider>{children}</ThemeProvider>
<body>
<PaletteClock>{children}</PaletteClock>
</body>
</html>
);
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { GameOver } from "../components/pixel";
import styles from "./page.module.css";
export const metadata: Metadata = {
title: "Game over · AgentCompany",
robots: { index: false },
};
export default function NotFound() {
return (
<main className={`${styles.page} ${styles.lost}`}>
<GameOver
message="404: you wandered off the map. The CEO checked the org chart twice and nobody here owns this page."
continueHref="/"
/>
</main>
);
}
+34
View File
@@ -0,0 +1,34 @@
.page {
position: relative;
min-height: 100svh;
overflow-x: clip;
}
.page main:focus {
outline: none;
}
.skip {
position: fixed;
top: 12px;
left: 12px;
z-index: 100;
padding: 12px 24px;
font-size: var(--fs-1);
line-height: 2;
color: var(--ink-void);
background: var(--ink-gold);
}
.skip:not(:focus) {
clip-path: inset(50%);
}
.lost {
display: grid;
place-content: center;
justify-items: center;
gap: 24px;
padding: 48px 16px;
}
+25 -28
View File
@@ -1,36 +1,33 @@
import { Bento } from "../components/sections/bento";
import { DownloadSection } from "../components/sections/download-section";
import { FAQ } from "../components/sections/faq";
import { Footer } from "../components/layout/footer";
import { Hero } from "../components/sections/hero";
import { HowItWorks } from "../components/sections/how-it-works";
import { Navbar } from "../components/layout/navbar";
import { Runtimes } from "../components/sections/runtimes";
import { Showcase } from "../components/sections/showcase";
import { CharacterSelect } from "../components/sections/character-select";
import { Credits } from "../components/sections/credits";
import { FaqSection } from "../components/sections/faq-section";
import { InventorySection } from "../components/sections/inventory-section";
import { PartyRoster } from "../components/sections/party-roster";
import { QuestLog } from "../components/sections/quest-log";
import { SavePoint } from "../components/sections/save-point";
import { TitleScreen } from "../components/sections/title-screen";
import { TopBar } from "../components/sections/top-bar";
import { XpSection } from "../components/sections/xp-section";
import styles from "./page.module.css";
export default function Page() {
return (
<>
<a
href="#main"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:rounded-lg focus:bg-[var(--accent)] focus:text-[var(--text-on-accent)] focus:text-sm focus:font-medium"
>
<div className={styles.page}>
<a href="#main" className={styles.skip}>
Skip to content
</a>
<Navbar />
<main id="main">
<Hero />
<Runtimes />
<Bento />
<Showcase />
<HowItWorks />
<FAQ />
<DownloadSection />
<TopBar />
<main id="main" tabIndex={-1}>
<TitleScreen />
<QuestLog />
<CharacterSelect />
<InventorySection />
<PartyRoster />
<XpSection />
<SavePoint />
<FaqSection />
</main>
<Footer />
</>
<Credits />
</div>
);
}
-15
View File
@@ -1,15 +0,0 @@
/** AgentCompany glass-bloom brand mark. */
export function IconMark({ size = 32 }: { size?: number }) {
return (
<img
src="/icon.png?v=2"
alt=""
aria-hidden="true"
width={size}
height={size}
decoding="async"
className="block shrink-0"
style={{ width: size, height: size }}
/>
);
}
-96
View File
@@ -1,96 +0,0 @@
import { Container } from "../ui/container";
import { Reveal } from "../ui/reveal";
import { Button } from "../ui/button";
import { Aurora } from "../ui/aurora";
import { IconMark } from "../brand/icon-mark";
import { getTrackedDownloadHref } from "../../lib/downloads";
import { CONTACT_EMAIL, WEBSITE_URL } from "../../lib/site-constants";
type FooterLink = { label: string; href: string; external?: boolean };
const footerGroups: { title: string; links: FooterLink[] }[] = [
{
title: "Product",
links: [
{ label: "Features", href: "#features" },
{ label: "How it works", href: "#how-it-works" },
{ label: "FAQ", href: "#faq" },
{ label: "Download", href: "#download" },
],
},
{
title: "Downloads",
links: [
{ label: "Apple Silicon", href: getTrackedDownloadHref("macos-arm64-dmg"), external: true },
{ label: "Intel Mac", href: getTrackedDownloadHref("macos-x64-dmg"), external: true },
{ label: "Windows Installer", href: getTrackedDownloadHref("windows-x64-installer"), external: true },
],
},
{
title: "Company",
links: [
{ label: "Website", href: WEBSITE_URL, external: true },
{ label: "Contact", href: `mailto:${CONTACT_EMAIL}`, external: true },
{ label: "Download center", href: "#download" },
],
},
];
export function Footer() {
const year = new Date().getFullYear();
return (
<footer className="relative overflow-hidden bg-[var(--footer-bg)] text-[var(--footer-text)]">
<Aurora className="opacity-90" />
<Container className="relative">
<div className="border-b border-white/10 py-24 text-center md:py-32">
<Reveal>
<h2 className="font-display text-[clamp(2.75rem,1.8rem+4vw,5.5rem)] leading-[1] tracking-[-0.02em]">
Launch your company now.
</h2>
<div className="mt-8 flex justify-center">
<Button href="#download" variant="inverted">
Download
</Button>
</div>
</Reveal>
</div>
<div className="grid gap-10 py-14 md:grid-cols-[1.6fr_1fr_1fr_1fr]">
<div>
<a href="#" className="inline-flex items-center gap-2.5" aria-label="AgentCompany home">
<IconMark size={28} />
<span className="text-lg font-medium tracking-[-0.02em]">AgentCompany</span>
</a>
<p className="mt-4 max-w-[32ch] text-sm leading-relaxed opacity-60">
Local-first desktop control plane for autonomous AI companies.
</p>
</div>
{footerGroups.map((group) => (
<div key={group.title}>
<h3 className="eyebrow text-xs opacity-50">{group.title}</h3>
<ul className="mt-4 space-y-3">
{group.links.map((link) => (
<li key={link.label}>
<a
href={link.href}
{...(link.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className="text-sm opacity-75 transition-opacity hover:opacity-100"
>
{link.label}
</a>
</li>
))}
</ul>
</div>
))}
</div>
<div className="border-t border-white/10 py-6">
<p className="text-xs opacity-45">© {year} AgentCompany</p>
</div>
</Container>
</footer>
);
}
-116
View File
@@ -1,116 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Menu } from "reicon-react/icons/Menu";
import { Xmark } from "reicon-react/icons/Xmark";
import { Container } from "../ui/container";
import { Button } from "../ui/button";
import { IconMark } from "../brand/icon-mark";
import { ThemeToggle } from "../theme/theme-toggle";
const navLinks = [
{ label: "Features", href: "#features" },
{ label: "How it works", href: "#how-it-works" },
{ label: "FAQ", href: "#faq" },
];
export function Navbar() {
const [mobileOpen, setMobileOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8);
window.addEventListener("scroll", onScroll, { passive: true });
onScroll();
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
const onResize = () => {
if (window.innerWidth >= 768) {
setMobileOpen(false);
}
};
window.addEventListener("resize", onResize, { passive: true });
return () => window.removeEventListener("resize", onResize);
}, []);
return (
<nav
className={[
"enter-nav sticky top-0 z-50 transition-colors duration-300",
scrolled
? "border-b border-[var(--border)] bg-[var(--bg)]/85 backdrop-blur-xl"
: "border-b border-transparent bg-transparent",
].join(" ")}
>
<Container>
<div className="flex h-16 items-center justify-between">
<a href="#" className="flex shrink-0 items-center gap-2.5" aria-label="AgentCompany home">
<IconMark size={28} />
<span className="text-lg font-medium tracking-[-0.02em] text-[var(--text)]">AgentCompany</span>
</a>
<div className="hidden items-center gap-1 md:flex">
{navLinks.map(({ label, href }) => (
<a
key={href}
href={href}
className="eyebrow rounded-full px-3 py-2 text-sm text-[var(--muted)] transition-colors hover:text-[var(--text)]"
>
{label}
</a>
))}
</div>
<div className="hidden items-center gap-2 md:flex">
<ThemeToggle />
<Button href="#download" size="sm">
Download
</Button>
</div>
<div className="flex items-center gap-1 md:hidden">
<ThemeToggle />
<button
type="button"
onClick={() => setMobileOpen((value) => !value)}
aria-label={mobileOpen ? "Close navigation menu" : "Open navigation menu"}
aria-expanded={mobileOpen}
aria-controls="mobile-nav"
className="inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full text-[var(--text)] transition-colors hover:bg-[var(--panel-soft)]"
>
{mobileOpen ? <Xmark size={22} /> : <Menu size={22} />}
</button>
</div>
</div>
</Container>
<div
id="mobile-nav"
className={[
"overflow-hidden border-b transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] md:hidden",
mobileOpen
? "visible max-h-72 border-[var(--border)] bg-[var(--bg)]/95 backdrop-blur-xl"
: "invisible max-h-0 border-transparent",
].join(" ")}
>
<Container className="flex flex-col gap-1 pb-4 pt-1">
{navLinks.map(({ label, href }) => (
<a
key={href}
href={href}
onClick={() => setMobileOpen(false)}
className="eyebrow rounded-lg px-3 py-2.5 text-sm text-[var(--muted)] transition-colors hover:bg-[var(--panel-soft)] hover:text-[var(--text)]"
>
{label}
</a>
))}
<Button href="#download" size="sm" className="mt-2 w-full">
Download
</Button>
</Container>
</div>
</nav>
);
}
+9
View File
@@ -0,0 +1,9 @@
import type { ReactNode } from "react";
import styles from "./pixel.module.css";
export type BadgeTone = "default" | "accent" | "mint" | "danger";
/** A small notched tag, like a status chip on a game card. */
export function Badge({ children, tone = "default" }: { children: ReactNode; tone?: BadgeTone }) {
return <span className={styles.badge} data-tone={tone}>{children}</span>;
}
+26
View File
@@ -0,0 +1,26 @@
import { useId, type ReactNode } from "react";
import styles from "./pixel.module.css";
export type Tone = "default" | "accent" | "danger";
/** A framed pixel window; a section's title is its heading, worn as a name-plate tab on the top edge. */
export function Dialog({ title, children, tone = "default", as: Tag = "section" }: {
title?: ReactNode;
children: ReactNode;
tone?: Tone;
as?: "section" | "div";
}) {
const titleId = useId();
const Plate = Tag === "section" ? "h2" : "div";
return (
<Tag
className={`${styles.frame} ${styles.dialog}`}
data-tone={tone}
data-titled={title ? "" : undefined}
aria-labelledby={title && Tag === "section" ? titleId : undefined}
>
{title ? <Plate id={titleId} className={styles.plate}>{title}</Plate> : null}
{children}
</Tag>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { useId, type ReactNode } from "react";
import { Menu } from "./menu-list";
import styles from "./pixel.module.css";
/** A failure as a game-over screen; CONTINUE? offers YES and NO, and both continue. */
export function GameOver({ title = "GAME OVER", message, onContinue, continueHref }: {
title?: string;
message: ReactNode;
onContinue?: () => void;
continueHref?: string;
}) {
const titleId = useId();
const href = continueHref ?? (onContinue ? undefined : "/");
return (
<section className={`${styles.frame} ${styles.gameOver}`} data-tone="danger" aria-labelledby={titleId}>
<h1 id={titleId} className={styles.gameOverTitle}>{title}</h1>
<div className={styles.gameOverMessage}>{message}</div>
<div className={styles.gameOverPrompt}>
<span>CONTINUE?</span>
<Menu
label="Continue?"
orientation="horizontal"
items={[
{ label: "YES", href, onSelect: onContinue },
{ label: "NO", hint: "(the hero continues anyway)", href, onSelect: onContinue },
]}
/>
</div>
</section>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import type { CSSProperties } from "react";
import type { DayPart, Season } from "../../lib/palette";
import { Icon, type IconName } from "./icon";
import { usePaletteClock } from "./palette-context";
import styles from "./pixel.module.css";
const PARTS: DayPart[] = ["dawn", "day", "dusk", "night"];
const PART_ICON: Record<DayPart, IconName> = { dawn: "sunrise", day: "sun", dusk: "sunset", night: "moon" };
const SEASON_ICON: Record<Season, IconName> = { spring: "blossom", summer: "icecream", autumn: "maple", winter: "snowflake" };
/** The HUD: today's light and season plus the live palette; clicking skips to the next part of the day. */
export function Hud() {
const { dayPart, season, colours, setOverride } = usePaletteClock();
const next = PARTS[(PARTS.indexOf(dayPart) + 1) % PARTS.length]!;
return (
<button
type="button"
className={`${styles.frame} ${styles.hud}`}
onClick={() => setOverride(next, season)}
aria-label={`Light: ${dayPart}, ${season}. Switch to ${next}`}
>
<span className={styles.hudGlyphs}>
<Icon name={PART_ICON[dayPart]} size={2} />
<Icon name={SEASON_ICON[season]} size={2} />
</span>
<span className={styles.hudText}>
<span>{dayPart.toUpperCase()}</span>
<span className={styles.swatches} aria-hidden="true">
{colours.map((_, index) => (
<i key={index} style={{ "--swatch": `var(--p${index})` } as CSSProperties} />
))}
</span>
</span>
</button>
);
}
+33
View File
@@ -0,0 +1,33 @@
import type { CSSProperties } from "react";
import { PIXEL_ICONS } from "../../lib/pixel-icons";
import styles from "./pixel.module.css";
export type IconName = keyof typeof PIXEL_ICONS;
/** Each row's lit runs become one path, so an icon is a single crisp shape. */
function bitmapPath(bitmap: string): string {
return bitmap.split(" ").flatMap((row, y) =>
[...row.matchAll(/#+/g)].map((run) => `M${run.index} ${y}h${run[0].length}v1h-${run[0].length}z`),
).join("");
}
const PATHS = Object.fromEntries(
Object.entries(PIXEL_ICONS).map(([name, bitmap]) => [name, bitmapPath(bitmap)]),
) as Record<IconName, string>;
/** A hand-placed 12×12 icon at a whole-pixel scale; labelled icons are images, unlabelled ones are decoration. */
export function Icon({ name, size = 1, label }: { name: IconName; size?: 1 | 2 | 3 | 4; label?: string }) {
return (
<svg
viewBox="0 0 12 12"
className={styles.icon}
style={{ "--icon-scale": size } as CSSProperties}
fill="currentColor"
shapeRendering="crispEdges"
focusable="false"
{...(label ? { role: "img", "aria-label": label } : { "aria-hidden": true })}
>
<path d={PATHS[name]} />
</svg>
);
}
+12
View File
@@ -0,0 +1,12 @@
export { PaletteClock } from "./palette-clock";
export { usePaletteClock, type PaletteClockValue } from "./palette-context";
export { Dialog, type Tone } from "./dialog";
export { NpcSay } from "./npc-say";
export { MenuList, type MenuItem } from "./menu-list";
export { PixelButton } from "./pixel-button";
export { XpBar } from "./xp-bar";
export { Badge, type BadgeTone } from "./badge";
export { Inventory, type InventoryItem } from "./inventory";
export { Icon, type IconName } from "./icon";
export { GameOver } from "./game-over";
export { Hud } from "./hud";
+69
View File
@@ -0,0 +1,69 @@
"use client";
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { Icon, type IconName } from "./icon";
import styles from "./pixel.module.css";
export type InventoryItem = { icon: IconName; name: string; description: ReactNode };
/** An RPG item menu: a grid of slots, arrow keys move the selection, the box below describes the chosen item. */
export function Inventory({ items }: { items: InventoryItem[] }) {
const [selected, setSelected] = useState(0);
const refs = useRef<(HTMLLIElement | null)[]>([]);
const infoId = useId();
const item = items[selected]!;
const select = (index: number) => {
const next = Math.max(0, Math.min(items.length - 1, index));
setSelected(next);
refs.current[next]?.focus();
};
const columns = () => {
const tops = refs.current.map((slot) => slot?.offsetTop);
return tops.filter((top) => top === tops[0]).length;
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "ArrowRight") select(selected + 1);
else if (event.key === "ArrowLeft") select(selected - 1);
else if (event.key === "ArrowDown") select(selected + columns());
else if (event.key === "ArrowUp") select(selected - columns());
else if (event.key === "Home") select(0);
else if (event.key === "End") select(items.length - 1);
else return;
event.preventDefault();
};
return (
<div className={styles.inventory}>
<ul role="listbox" aria-label="Inventory" className={styles.slots} onKeyDown={onKeyDown}>
{items.map((entry, index) => (
<li
key={entry.name}
ref={(slot) => {
refs.current[index] = slot;
}}
role="option"
aria-selected={index === selected}
aria-describedby={index === selected ? infoId : undefined}
tabIndex={index === selected ? 0 : -1}
className={styles.slot}
onClick={() => select(index)}
onFocus={() => setSelected(index)}
>
<Icon name={entry.icon} size={3} />
<span className={styles.visuallyHidden}>{entry.name}</span>
</li>
))}
</ul>
<div id={infoId} className={`${styles.frame} ${styles.itemInfo}`} aria-live="polite">
<p className={styles.itemName}>
<Icon name={item.icon} />
{item.name}
</p>
<p>{item.description}</p>
</div>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import styles from "./pixel.module.css";
export type MenuItem = { label: ReactNode; href?: string; onSelect?: () => void; hint?: ReactNode };
/** A game menu: one tab stop, arrow keys move the blinking ▶ cursor, Enter picks. */
export function Menu({ items, label, orientation }: {
items: MenuItem[];
label: string;
orientation: "vertical" | "horizontal";
}) {
const [active, setActive] = useState(0);
const refs = useRef<(HTMLElement | null)[]>([]);
const moveTo = (index: number) => {
const next = (index + items.length) % items.length;
setActive(next);
refs.current[next]?.focus();
};
const onKeyDown = (event: KeyboardEvent) => {
const vertical = orientation === "vertical";
if (event.key === (vertical ? "ArrowDown" : "ArrowRight")) moveTo(active + 1);
else if (event.key === (vertical ? "ArrowUp" : "ArrowLeft")) moveTo(active - 1);
else if (event.key === "Home") moveTo(0);
else if (event.key === "End") moveTo(items.length - 1);
else return;
event.preventDefault();
};
return (
<ul role="menu" aria-label={label} aria-orientation={orientation} className={styles.menu} data-orientation={orientation} onKeyDown={onKeyDown}>
{items.map((item, index) => {
const shared = {
role: "menuitem",
tabIndex: index === active ? 0 : -1,
className: styles.menuItem,
"data-active": index === active ? "" : undefined,
ref: (element: HTMLElement | null) => {
refs.current[index] = element;
},
onFocus: () => setActive(index),
onMouseEnter: () => setActive(index),
onClick: item.onSelect,
};
const body = (
<>
<span>{item.label}</span>
{item.hint ? <span className={styles.menuHint}>{item.hint}</span> : null}
</>
);
return (
<li role="none" key={index}>
{item.href ? <a {...shared} href={item.href}>{body}</a> : <button type="button" {...shared}>{body}</button>}
</li>
);
})}
</ul>
);
}
export function MenuList({ items, label }: { items: MenuItem[]; label: string }) {
return <Menu items={items} label={label} orientation="vertical" />;
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Icon, type IconName } from "./icon";
import styles from "./pixel.module.css";
const CHAR_MS = 28;
/** A character's speech box: lines type out, ▼ blinks when one is done, a click or Enter skips ahead. */
export function NpcSay({ speaker, portrait = "bot", lines, typewriter = true }: {
speaker: string;
portrait?: IconName;
lines: string[];
typewriter?: boolean;
}) {
const boxRef = useRef<HTMLElement>(null);
const [line, setLine] = useState(0);
const [shown, setShown] = useState(0);
const [reduced, setReduced] = useState(false);
const [onScreen, setOnScreen] = useState(false);
const text = lines[line] ?? "";
const count = typewriter && !reduced ? Math.min(shown, text.length) : text.length;
const done = count === text.length;
const last = line === lines.length - 1;
useEffect(() => {
if (!typewriter) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return setReduced(true);
const observer = new IntersectionObserver(([entry]) => {
if (!entry?.isIntersecting) return;
setOnScreen(true);
observer.disconnect();
});
observer.observe(boxRef.current!);
return () => observer.disconnect();
}, [typewriter]);
useEffect(() => {
if (!onScreen || done) return;
const timer = window.setTimeout(() => setShown((value) => value + 1), CHAR_MS);
return () => window.clearTimeout(timer);
}, [onScreen, done, shown]);
const advance = () => {
if (!done) return setShown(text.length);
setLine(last ? 0 : line + 1);
setShown(0);
};
return (
<figure ref={boxRef} className={`${styles.frame} ${styles.npc}`} onClick={advance}>
<figcaption className={styles.plate}>{speaker}</figcaption>
<div className={styles.portrait} aria-hidden="true">
<Icon name={portrait} size={3} />
</div>
<blockquote className={styles.npcText}>
<p className={styles.visuallyHidden} aria-live="polite">{text}</p>
<p aria-hidden="true">
{text.slice(0, count)}
<span className={styles.ghost}>{text.slice(count)}</span>
</p>
</blockquote>
<button
type="button"
className={styles.advance}
data-done={done ? "" : undefined}
aria-label={!done ? "Show the whole line" : last ? "Hear it again" : "Next line"}
onClick={(event) => {
event.stopPropagation();
advance();
}}
/>
</figure>
);
}
+20
View File
@@ -0,0 +1,20 @@
import type { DayPart, Season } from "../../lib/palette";
/** Self-contained (no outer references) so the boot script can inline it with Function#toString. */
export function overrideFrom(search: string): { part: DayPart | null; season: Season | null } {
const query = new URLSearchParams(search);
const part = query.get("time");
const season = query.get("season");
return {
part: part === "dawn" || part === "day" || part === "dusk" || part === "night" ? part : null,
season: season === "spring" || season === "summer" || season === "autumn" || season === "winter" ? season : null,
};
}
/** Self-contained for the same reason: writes the palette variables and day-part attributes onto <html>. */
export function applyPalette(vars: Record<string, string>, part: DayPart, season: Season): void {
const root = document.documentElement;
for (const name in vars) root.style.setProperty(name, vars[name]!);
root.dataset.daypart = part;
root.dataset.season = season;
}
@@ -0,0 +1,23 @@
import type { ReactNode } from "react";
import { dayPartAt, paletteFor, seasonAt, type DayPart, type Season } from "../../lib/palette";
import { applyPalette, overrideFrom } from "./palette-boot";
import { PaletteClockProvider } from "./palette-context";
const PARTS: DayPart[] = ["dawn", "day", "dusk", "night"];
const SEASONS: Season[] = ["spring", "summer", "autumn", "winter"];
const TABLE = Object.fromEntries(
PARTS.flatMap((part) => SEASONS.map((season) => [`${part}-${season}`, paletteFor(part, season)])),
);
/** Runs before first paint so night visitors never see a flash of the day palette; also snaps --px to whole device pixels. */
const BOOT = `(function(){var t=${JSON.stringify(TABLE)},d=new Date(),o=(${overrideFrom})(location.search),p=o.part||(${dayPartAt})(d),s=o.season||(${seasonAt})(d),v={};t[p+"-"+s].forEach(function(c,i){v["--p"+i]=c});(${applyPalette})(v,p,s);var r=window.devicePixelRatio||1,e=document.documentElement;e.style.setProperty("--px",String(Math.max(1,Math.round(r))/r));e.dataset.js=""})()`;
/** Rotates the 32-colour palette with the visitor's clock (or ?time=&season=) and shares it via usePaletteClock. */
export function PaletteClock({ children }: { children: ReactNode }) {
return (
<>
<script dangerouslySetInnerHTML={{ __html: BOOT }} />
<PaletteClockProvider>{children}</PaletteClockProvider>
</>
);
}
@@ -0,0 +1,50 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { dayPartAt, paletteCssVars, paletteFor, seasonAt, type DayPart, type Season } from "../../lib/palette";
import { applyPalette, overrideFrom } from "./palette-boot";
type Override = { part: DayPart | null; season: Season | null };
export type PaletteClockValue = {
dayPart: DayPart;
season: Season;
colours: string[];
setOverride: (part: DayPart | null, season: Season | null) => void;
};
const PaletteClockContext = createContext<PaletteClockValue | null>(null);
const TICK_MS = 60_000;
export function PaletteClockProvider({ children }: { children: ReactNode }) {
const [now, setNow] = useState<Date | null>(null);
const [override, setOverrideState] = useState<Override>({ part: null, season: null });
useEffect(() => {
setOverrideState(overrideFrom(window.location.search));
setNow(new Date());
const timer = window.setInterval(() => setNow(new Date()), TICK_MS);
return () => window.clearInterval(timer);
}, []);
const dayPart = override.part ?? (now ? dayPartAt(now) : "day");
const season = override.season ?? (now ? seasonAt(now) : "summer");
const colours = useMemo(() => paletteFor(dayPart, season), [dayPart, season]);
useEffect(() => {
if (now) applyPalette(paletteCssVars(colours), dayPart, season);
}, [now, colours, dayPart, season]);
const setOverride = useCallback((part: DayPart | null, nextSeason: Season | null) => {
setOverrideState({ part, season: nextSeason });
}, []);
const value = useMemo(() => ({ dayPart, season, colours, setOverride }), [dayPart, season, colours, setOverride]);
return <PaletteClockContext.Provider value={value}>{children}</PaletteClockContext.Provider>;
}
export function usePaletteClock(): PaletteClockValue {
const value = useContext(PaletteClockContext);
if (!value) throw new Error("usePaletteClock must be used inside <PaletteClock>");
return value;
}
+23
View File
@@ -0,0 +1,23 @@
import type { MouseEventHandler, ReactNode } from "react";
import { Icon, type IconName } from "./icon";
import styles from "./pixel.module.css";
/** A chunky button that sinks into its base when pressed; with href it is a link that looks the part. */
export function PixelButton({ children, href, onClick, variant = "primary", icon }: {
children: ReactNode;
href?: string;
onClick?: MouseEventHandler<HTMLElement>;
variant?: "primary" | "ghost";
icon?: IconName;
}) {
const content = (
<>
{icon ? <Icon name={icon} /> : null}
<span>{children}</span>
</>
);
if (href) {
return <a className={styles.button} data-variant={variant} href={href} onClick={onClick}>{content}</a>;
}
return <button type="button" className={styles.button} data-variant={variant} onClick={onClick}>{content}</button>;
}
+427
View File
@@ -0,0 +1,427 @@
/* All sizes are whole art pixels (--u = 2 screen px at 1×); every colour is a palette role from app/globals.css. */
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.icon {
display: inline-block;
flex-shrink: 0;
width: calc(12px * var(--px) * var(--icon-scale));
height: calc(12px * var(--px) * var(--icon-scale));
vertical-align: calc(-2px * var(--px));
}
/* Window frame: notched outline, a full inner rim, a bottom-right inner shade and a hard drop shadow. */
.frame {
--frame-line: var(--ui-line);
--frame-rim: var(--ui-hi);
position: relative;
color: var(--ui-text);
background: var(--ui-panel);
box-shadow:
0 calc(-1 * var(--u)) 0 0 var(--frame-line),
0 var(--u) 0 0 var(--frame-line),
calc(-1 * var(--u)) 0 0 0 var(--frame-line),
var(--u) 0 0 0 var(--frame-line),
inset 0 0 0 var(--u) var(--frame-rim),
inset calc(-2 * var(--u)) calc(-2 * var(--u)) 0 0 var(--ui-lo),
calc(2 * var(--u)) calc(2 * var(--u)) 0 0 var(--ui-drop);
}
.frame[data-tone="accent"] { --frame-rim: var(--ink-gold); }
.frame[data-tone="danger"] { --frame-rim: var(--ink-accent); }
.plate {
--plate-bg: var(--ui-line);
--plate-fg: var(--ui-on-line);
position: absolute;
top: calc(-6 * var(--u));
left: calc(4 * var(--u));
margin: 0;
padding: var(--u) calc(4 * var(--u));
font-size: var(--fs-1);
line-height: calc(var(--fs-1) * 1.5);
color: var(--plate-fg);
background: var(--plate-bg);
box-shadow:
0 calc(-1 * var(--u)) 0 0 var(--frame-line),
0 var(--u) 0 0 var(--frame-line),
calc(-1 * var(--u)) 0 0 0 var(--frame-line),
var(--u) 0 0 0 var(--frame-line);
}
[data-tone="accent"] > .plate { --plate-bg: var(--ink-gold); --plate-fg: var(--ink-void); }
[data-tone="danger"] > .plate { --plate-bg: var(--ink-accent-dark); --plate-fg: var(--ink-star); }
.dialog {
margin: var(--u);
padding: calc(6 * var(--u));
}
.dialog[data-titled] {
margin-top: calc(7 * var(--u));
padding-top: calc(9 * var(--u));
}
/* Button: a face on a base; hover lifts the face one art pixel, :active sinks it flush with the base. */
.button {
--face: var(--ink-gold);
--face-text: var(--ink-void);
--face-hi: var(--ink-star);
--base: var(--ink-accent-dark);
--d: calc(2 * var(--u));
display: inline-flex;
align-items: center;
justify-content: center;
gap: calc(3 * var(--u));
min-height: calc(18 * var(--u));
margin: var(--u) var(--u) calc(3 * var(--u));
padding: calc(2 * var(--u)) calc(6 * var(--u));
border: 0;
border-radius: 0;
font-size: var(--fs-1);
line-height: 1.5;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
color: var(--face-text);
background: var(--face);
box-shadow:
inset 0 var(--u) 0 0 var(--face-hi),
0 var(--d) 0 0 var(--base),
0 calc(-1 * var(--u)) 0 0 var(--ui-line),
calc(-1 * var(--u)) 0 0 0 var(--ui-line),
var(--u) 0 0 0 var(--ui-line),
calc(-1 * var(--u)) var(--d) 0 0 var(--ui-line),
var(--u) var(--d) 0 0 var(--ui-line),
0 calc(var(--d) + var(--u)) 0 0 var(--ui-line);
}
.button[data-variant="ghost"] {
--face: var(--ui-panel);
--face-text: var(--ui-text);
--face-hi: var(--ui-hi);
--base: var(--ui-lo);
}
.button:hover {
--d: calc(3 * var(--u));
transform: translateY(calc(-1 * var(--u)));
}
.button:active {
--d: 0px;
transform: translateY(calc(2 * var(--u)));
}
.badge {
--badge-bg: var(--ui-line);
--badge-fg: var(--ui-on-line);
display: inline-flex;
align-items: center;
gap: var(--u);
padding: 0 calc(3 * var(--u));
font-size: var(--fs-1);
line-height: calc(var(--fs-1) * 1.5);
white-space: nowrap;
color: var(--badge-fg);
background: var(--badge-bg);
clip-path: polygon(
var(--u) 0, calc(100% - var(--u)) 0, calc(100% - var(--u)) var(--u), 100% var(--u),
100% calc(100% - var(--u)), calc(100% - var(--u)) calc(100% - var(--u)), calc(100% - var(--u)) 100%, var(--u) 100%,
var(--u) calc(100% - var(--u)), 0 calc(100% - var(--u)), 0 var(--u), var(--u) var(--u)
);
}
.badge[data-tone="accent"] { --badge-bg: var(--ink-gold); --badge-fg: var(--ink-void); }
.badge[data-tone="mint"] { --badge-bg: var(--ink-mint); --badge-fg: var(--ink-void); }
.badge[data-tone="danger"] { --badge-bg: var(--ink-accent-dark); --badge-fg: var(--ink-star); }
/* Menu: the ▶ cursor rests on the current item and blinks while the menu is in use. */
.menu {
display: grid;
gap: var(--u);
margin: 0;
padding: 0;
list-style: none;
}
.menu[data-orientation="horizontal"] {
display: flex;
flex-wrap: wrap;
gap: calc(4 * var(--u));
}
.menuItem {
position: relative;
display: flex;
align-items: baseline;
gap: calc(4 * var(--u));
width: 100%;
padding: var(--u) calc(2 * var(--u)) var(--u) calc(7 * var(--u));
border: 0;
background: none;
font: inherit;
text-align: left;
text-decoration: none;
color: inherit;
cursor: pointer;
}
.menuItem::before {
content: "";
position: absolute;
left: calc(2 * var(--u));
top: calc(50% - 2.5 * var(--u));
width: calc(3 * var(--u));
height: calc(5 * var(--u));
background: currentColor;
clip-path: polygon(
0 0, var(--u) 0, var(--u) var(--u), calc(2 * var(--u)) var(--u), calc(2 * var(--u)) calc(2 * var(--u)),
calc(3 * var(--u)) calc(2 * var(--u)), calc(3 * var(--u)) calc(3 * var(--u)), calc(2 * var(--u)) calc(3 * var(--u)),
calc(2 * var(--u)) calc(4 * var(--u)), var(--u) calc(4 * var(--u)), var(--u) calc(5 * var(--u)), 0 calc(5 * var(--u))
);
visibility: hidden;
}
.menuItem[data-active]::before { visibility: visible; }
.menu:is(:hover, :focus-within) .menuItem[data-active]::before {
animation: blink 0.8s steps(1, end) infinite;
}
.menuItem:focus-visible { outline-offset: 0; }
.menuHint {
margin-left: auto;
color: var(--ui-muted);
}
.menu[data-orientation="horizontal"] .menuItem { width: auto; }
.menu[data-orientation="horizontal"] .menuHint { margin-left: 0; opacity: 0; }
.menu[data-orientation="horizontal"] .menuItem[data-active] .menuHint { opacity: 1; }
/* NPC speech box: portrait on the left, name plate on the edge, ▼ bobbing once a line has finished. */
.npc {
display: grid;
grid-template-columns: auto 1fr;
gap: calc(6 * var(--u));
align-items: start;
margin: calc(7 * var(--u)) var(--u) var(--u);
padding: calc(9 * var(--u)) calc(6 * var(--u)) calc(8 * var(--u));
cursor: pointer;
}
.portrait {
display: grid;
place-items: center;
width: calc(24 * var(--u));
height: calc(24 * var(--u));
margin: var(--u);
color: var(--ui-text);
background: var(--ink-sky-2);
box-shadow:
0 calc(-1 * var(--u)) 0 0 var(--ui-line),
0 var(--u) 0 0 var(--ui-line),
calc(-1 * var(--u)) 0 0 0 var(--ui-line),
var(--u) 0 0 0 var(--ui-line),
inset 0 calc(-2 * var(--u)) 0 0 var(--ink-sky-1);
}
.npcText {
margin: 0;
min-height: calc(var(--fs-1) * 1.5 * 2);
}
.npcText p { margin: 0; }
:global(:root[data-js]) .ghost { visibility: hidden; }
.advance {
position: absolute;
right: calc(3 * var(--u));
bottom: calc(2 * var(--u));
padding: calc(2 * var(--u));
border: 0;
background: none;
color: var(--ui-muted);
cursor: pointer;
}
.advance::before {
content: "";
display: block;
width: calc(5 * var(--u));
height: calc(3 * var(--u));
background: currentColor;
clip-path: polygon(
0 0, calc(5 * var(--u)) 0, calc(5 * var(--u)) var(--u), calc(4 * var(--u)) var(--u), calc(4 * var(--u)) calc(2 * var(--u)),
calc(3 * var(--u)) calc(2 * var(--u)), calc(3 * var(--u)) calc(3 * var(--u)), calc(2 * var(--u)) calc(3 * var(--u)),
calc(2 * var(--u)) calc(2 * var(--u)), var(--u) calc(2 * var(--u)), var(--u) var(--u), 0 var(--u)
);
}
.advance[data-done] { color: var(--ui-text); }
.advance[data-done]::before { animation: bob 0.7s steps(1, end) infinite; }
/* Inventory: sunken slots in a tray, corner brackets on the chosen one, its description below. */
.inventory {
display: grid;
gap: calc(6 * var(--u));
}
.slots {
display: grid;
grid-template-columns: repeat(auto-fill, calc(24 * var(--u)));
gap: calc(4 * var(--u));
margin: var(--u);
padding: calc(5 * var(--u));
list-style: none;
background: var(--ui-panel);
box-shadow:
0 calc(-1 * var(--u)) 0 0 var(--ui-line),
0 var(--u) 0 0 var(--ui-line),
calc(-1 * var(--u)) 0 0 0 var(--ui-line),
var(--u) 0 0 0 var(--ui-line);
}
.slot {
position: relative;
display: grid;
place-items: center;
width: calc(24 * var(--u));
height: calc(24 * var(--u));
color: var(--ui-text);
background: var(--ui-ground);
box-shadow:
inset var(--u) var(--u) 0 0 var(--ui-lo),
inset calc(-1 * var(--u)) calc(-1 * var(--u)) 0 0 var(--ui-hi);
cursor: pointer;
}
.slot:focus-visible { outline: none; }
.slot[aria-selected="true"]::after {
--c: var(--ui-select);
--l: calc(4 * var(--u));
content: "";
position: absolute;
inset: calc(-2 * var(--u));
pointer-events: none;
background:
linear-gradient(var(--c) 0 0) 0 0 / var(--l) var(--u),
linear-gradient(var(--c) 0 0) 0 0 / var(--u) var(--l),
linear-gradient(var(--c) 0 0) 100% 0 / var(--l) var(--u),
linear-gradient(var(--c) 0 0) 100% 0 / var(--u) var(--l),
linear-gradient(var(--c) 0 0) 0 100% / var(--l) var(--u),
linear-gradient(var(--c) 0 0) 0 100% / var(--u) var(--l),
linear-gradient(var(--c) 0 0) 100% 100% / var(--l) var(--u),
linear-gradient(var(--c) 0 0) 100% 100% / var(--u) var(--l);
background-repeat: no-repeat;
}
.slot[aria-selected="true"]:focus-visible::after {
--c: var(--ui-focus);
animation: pulse 0.8s steps(1, end) infinite;
}
.itemInfo {
margin: var(--u);
padding: calc(6 * var(--u));
min-height: calc(var(--fs-1) * 1.5 * 3 + 12 * var(--u));
}
.itemInfo p { margin: 0; }
.itemName {
display: flex;
align-items: center;
gap: calc(3 * var(--u));
margin-bottom: calc(2 * var(--u)) !important;
color: var(--ui-muted);
}
/* XP bar: twenty blocks that light one after another the first time the bar is seen. */
.xp {
display: grid;
gap: calc(2 * var(--u));
width: max-content;
max-width: 100%;
}
.xpHead {
display: flex;
justify-content: space-between;
gap: calc(6 * var(--u));
}
.xpNumbers { color: var(--ui-muted); }
.xpTrack {
display: grid;
grid-template-columns: repeat(20, calc(4 * var(--u)));
gap: var(--u);
height: calc(7 * var(--u));
margin: 0 var(--u);
padding: var(--u);
background: var(--ink-void);
box-shadow:
0 calc(-1 * var(--u)) 0 0 var(--ink-void),
0 var(--u) 0 0 var(--ink-void);
}
.xpSegment { background: var(--ui-xp-off); }
.xpSegment[data-on] {
background: var(--ui-xp);
box-shadow: inset 0 var(--u) 0 0 var(--ink-screen);
animation: xp-tick 1ms steps(1, end) both paused;
animation-delay: calc(var(--i) * 60ms);
}
.xp[data-playing] .xpSegment[data-on] { animation-play-state: running; }
:global(:root:not([data-js])) .xpSegment { animation: none; }
/* Game over: the banner blinks three times, then CONTINUE? waits. */
.gameOver {
display: grid;
justify-items: center;
gap: calc(8 * var(--u));
margin: var(--u);
padding: calc(12 * var(--u)) calc(8 * var(--u)) calc(10 * var(--u));
text-align: center;
}
.gameOverTitle {
margin: 0;
font-size: var(--fs-4);
line-height: 1;
color: var(--ui-danger);
text-shadow: calc(2 * var(--u)) calc(2 * var(--u)) 0 var(--ui-line);
animation: blink 0.5s steps(1, end) 3;
}
.gameOverMessage { max-width: 48ch; }
.gameOverPrompt {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: calc(2 * var(--u)) calc(6 * var(--u));
}
/* HUD: inline where the page places it on phones, pinned to the top-right corner from 600px up. */
.hud {
display: flex;
align-items: center;
gap: calc(4 * var(--u));
padding: calc(3 * var(--u)) calc(5 * var(--u)) calc(4 * var(--u)) calc(4 * var(--u));
border: 0;
cursor: pointer;
}
.hud:active { transform: translateY(var(--u)); }
@media (min-width: 600px) {
.hud {
position: fixed;
top: calc(6 * var(--u));
right: calc(6 * var(--u));
z-index: 50;
}
}
.hudGlyphs {
display: flex;
gap: calc(2 * var(--u));
}
.hudText {
display: grid;
gap: var(--u);
text-align: left;
}
.swatches {
display: grid;
grid-template-columns: repeat(16, var(--u));
grid-auto-rows: var(--u);
box-shadow: 0 0 0 var(--u) var(--ui-line);
}
.swatches i { background: var(--swatch); }
@keyframes blink { 50% { visibility: hidden; } }
@keyframes bob { 50% { transform: translateY(var(--u)); } }
@keyframes pulse { 50% { inset: calc(-1 * var(--u)); } }
@keyframes xp-tick { from { background: var(--ui-xp-off); box-shadow: none; } }
@media (prefers-reduced-motion: reduce) {
:global(:root[data-js]) .ghost { visibility: visible; }
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useEffect, useId, useRef, useState, type CSSProperties } from "react";
import styles from "./pixel.module.css";
const SEGMENTS = 20;
/** A segmented XP bar that ticks up one block at a time the first time it scrolls into view. */
export function XpBar({ value, max, label }: { value: number; max: number; label: string }) {
const ref = useRef<HTMLDivElement>(null);
const [playing, setPlaying] = useState(false);
const labelId = useId();
const current = Math.max(0, Math.min(max, value));
const lit = Math.round((current / max) * SEGMENTS);
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (!entry?.isIntersecting) return;
setPlaying(true);
observer.disconnect();
});
observer.observe(ref.current!);
return () => observer.disconnect();
}, []);
return (
<div ref={ref} className={styles.xp} data-playing={playing ? "" : undefined}>
<div className={styles.xpHead}>
<span id={labelId}>{label}</span>
<span className={styles.xpNumbers}>{current}/{max}</span>
</div>
<div
role="progressbar"
aria-labelledby={labelId}
aria-valuemin={0}
aria-valuemax={max}
aria-valuenow={current}
className={styles.xpTrack}
>
{Array.from({ length: SEGMENTS }, (_, index) => (
<span
key={index}
className={styles.xpSegment}
data-on={index < lit ? "" : undefined}
style={{ "--i": index } as CSSProperties}
/>
))}
</div>
</div>
);
}
-101
View File
@@ -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,123 @@
.roster {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 48px;
margin-bottom: 36px;
}
.card {
display: grid;
grid-template-rows: auto 1fr;
gap: 12px;
}
.card[data-selected="false"] {
opacity: 0.72;
}
.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;
}
}
+46
View File
@@ -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>
);
}
-96
View File
@@ -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>
);
}
-66
View File
@@ -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>
);
}
+49
View File
@@ -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;
}
}
+77
View File
@@ -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>
);
}
+37
View File
@@ -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>
);
}
-33
View File
@@ -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: center;
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;
}
}
+121
View File
@@ -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;
}
}
-25
View File
@@ -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>
);
}
+54
View File
@@ -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,288 @@
.hero {
position: relative;
display: grid;
height: 98vh;
height: 98svh;
min-height: 560px;
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: 96px 48px 72px;
text-align: left;
}
.content > * {
max-width: 560px;
}
.logotype {
flex-direction: column;
align-items: flex-start;
}
.word {
height: 72px;
}
.tagline {
font-size: var(--fs-2);
line-height: 1.5;
}
.sub {
font-size: var(--fs-1);
line-height: 2;
}
.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;
}
}
+26
View File
@@ -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>
);
}
@@ -1,76 +0,0 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
const storageKey = "agentcompany-theme";
type ThemeMode = "light" | "dark";
type ThemeContextValue = {
theme: ThemeMode;
setTheme: (theme: ThemeMode) => void;
toggleTheme: () => void;
};
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
function resolveTheme(): ThemeMode {
if (typeof window === "undefined") {
return "light";
}
const stored = localStorage.getItem(storageKey);
if (stored === "light" || stored === "dark") {
return stored;
}
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<ThemeMode>(resolveTheme);
useEffect(() => {
const root = document.documentElement;
if (theme === "dark") {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
localStorage.setItem(storageKey, theme);
}, [theme]);
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = (event: MediaQueryListEvent) => {
if (!localStorage.getItem(storageKey)) {
setThemeState(event.matches ? "dark" : "light");
}
};
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, []);
const setTheme = useCallback((nextTheme: ThemeMode) => {
setThemeState(nextTheme);
}, []);
const toggleTheme = useCallback(() => {
setThemeState((currentTheme) => (currentTheme === "light" ? "dark" : "light"));
}, []);
return (
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}
-20
View File
@@ -1,20 +0,0 @@
"use client";
import { Sun } from "reicon-react/icons/Sun";
import { Moon } from "reicon-react/icons/Moon";
import { useTheme } from "./theme-provider";
export function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<button
type="button"
onClick={toggleTheme}
aria-label={`Switch to ${theme === "light" ? "dark" : "light"} mode`}
className="inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-full text-[var(--muted)] transition-colors hover:bg-[var(--panel-soft)] hover:text-[var(--text)]"
>
{theme === "light" ? <Moon size={18} /> : <Sun size={18} />}
</button>
);
}
-68
View File
@@ -1,68 +0,0 @@
// Fractal-noise film grain (inline data URI, allowed by img-src 'self' data:).
const GRAIN =
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E\")";
const AURORA_WASH =
"radial-gradient(60% 55% at 26% 16%, var(--aurora-mint) 0%, transparent 62%)," +
"radial-gradient(52% 52% at 84% 84%, var(--aurora-teal) 0%, transparent 60%)," +
"radial-gradient(46% 48% at 60% 50%, var(--aurora-lime) 0%, transparent 66%)";
/** Ambient color wash derived from `ambientSrc`, given the src's own path. */
export function ambientFor(src: string): string {
return src.replace("/images/", "/images/ambient/");
}
/**
* A product screenshot floating over an atmospheric wash painted from its own
* colours (a heavily blurred thumbnail), plus the aurora and a fine film grain.
*/
export function ArtStage({
src,
alt,
priority = false,
className = "",
stageClassName = "p-5 sm:p-8 md:p-10",
contentClassName = "w-full",
}: {
src: string;
alt: string;
priority?: boolean;
className?: string;
stageClassName?: string;
contentClassName?: string;
}) {
return (
<div className={`relative isolate overflow-hidden rounded-2xl border border-[var(--border)] ${className}`}>
<img
src={ambientFor(src)}
alt=""
aria-hidden="true"
className="absolute inset-0 -z-10 h-full w-full scale-125 object-cover opacity-75 blur-3xl saturate-[1.7]"
/>
<div aria-hidden="true" className="absolute inset-0 -z-10" style={{ background: AURORA_WASH }} />
<div
aria-hidden="true"
className="absolute inset-0 -z-10"
style={{ background: "linear-gradient(150deg, rgba(94,236,151,0.16) 0%, transparent 42%, rgba(45,212,191,0.12) 100%)" }}
/>
<div aria-hidden="true" className="absolute inset-0 -z-10 bg-[var(--bg)]/28" />
<div
aria-hidden="true"
className="absolute inset-0 -z-10 opacity-[0.14] mix-blend-overlay"
style={{ backgroundImage: GRAIN }}
/>
<div className={`relative ${stageClassName}`}>
<img
src={src}
alt={alt}
width={2400}
height={1600}
loading={priority ? "eager" : "lazy"}
decoding="async"
fetchPriority={priority ? "high" : "auto"}
className={`block rounded-lg border border-black/5 shadow-[0_30px_80px_-28px_rgba(10,29,8,0.5)] md:rounded-xl ${contentClassName}`}
/>
</div>
</div>
);
}
-20
View File
@@ -1,20 +0,0 @@
/**
* Northern-lights glow for dark showcase surfaces: three soft, slowly drifting
* radial blooms in mint / teal / lime. Purely decorative.
*/
export function Aurora({ className = "" }: { className?: string }) {
return (
<div aria-hidden="true" className={`pointer-events-none absolute inset-0 overflow-hidden ${className}`}>
<div
className="aurora-layer absolute -inset-[25%]"
style={{
filter: "blur(56px)",
background:
"radial-gradient(42% 55% at 22% 62%, var(--aurora-mint) 0%, transparent 62%)," +
"radial-gradient(38% 50% at 72% 38%, var(--aurora-teal) 0%, transparent 60%)," +
"radial-gradient(34% 44% at 52% 84%, var(--aurora-lime) 0%, transparent 58%)",
}}
/>
</div>
);
}
-24
View File
@@ -1,24 +0,0 @@
import type { ReactNode } from "react";
/** Rounded meadow pill, optionally a link. */
export function Badge({
children,
href,
className = "",
}: {
children: ReactNode;
href?: string;
className?: string;
}) {
const classes = `eyebrow inline-flex items-center gap-1.5 self-start rounded-full px-3 py-1.5 text-sm bg-[var(--accent-soft)] text-[var(--accent)] transition-colors ${className}`;
if (href) {
return (
<a href={href} className={classes}>
{children}
</a>
);
}
return <span className={classes}>{children}</span>;
}
-48
View File
@@ -1,48 +0,0 @@
import type { ReactNode } from "react";
type ButtonVariant = "primary" | "ghost" | "inverted";
type ButtonSize = "md" | "sm";
const base =
"eyebrow inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[20px] text-sm transition-colors";
const sizeClasses: Record<ButtonSize, string> = {
md: "h-11 px-6",
sm: "h-9 px-4",
};
const variantClasses: Record<ButtonVariant, string> = {
primary: "bg-[var(--cta)] text-[var(--text-on-cta)] hover:bg-[var(--cta-hover)]",
ghost: "border border-[var(--border-strong)] text-[var(--text-secondary)] hover:border-[var(--muted)]",
inverted: "bg-[#fbfdf6] text-[#0a1d08] hover:bg-[#fbfdf6]/90",
};
/** Link-styled call to action. Every landing CTA is an anchor. */
export function Button({
children,
href,
variant = "primary",
size = "md",
external = false,
className = "",
"aria-label": ariaLabel,
}: {
children: ReactNode;
href: string;
variant?: ButtonVariant;
size?: ButtonSize;
external?: boolean;
className?: string;
"aria-label"?: string;
}) {
return (
<a
href={href}
aria-label={ariaLabel}
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className={`${base} ${sizeClasses[size]} ${variantClasses[variant]} ${className}`}
>
{children}
</a>
);
}
-6
View File
@@ -1,6 +0,0 @@
import type { ReactNode } from "react";
/** Page-width wrapper: 1280px max, adaline grid margins (16px → 48px). */
export function Container({ children, className = "" }: { children: ReactNode; className?: string }) {
return <div className={`mx-auto w-full max-w-[1280px] px-4 md:px-12 ${className}`}>{children}</div>;
}
-10
View File
@@ -1,10 +0,0 @@
import type { ReactNode } from "react";
/** Small-caps mono label used above section headings. */
export function Eyebrow({ children, className = "" }: { children: ReactNode; className?: string }) {
return (
<span className={`eyebrow inline-flex items-center gap-1.5 text-sm text-[var(--muted)] ${className}`}>
{children}
</span>
);
}
-59
View File
@@ -1,59 +0,0 @@
"use client";
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
/**
* Scroll-reveal wrapper. Starts hidden (via the `.reveal` class) and marks
* itself in view the first time it intersects the viewport, letting CSS handle
* the rise-and-fade. Honours reduced-motion through the stylesheet.
*/
export function Reveal({
children,
delay = 0,
className = "",
}: {
children: ReactNode;
delay?: number;
className?: string;
}) {
const ref = useRef<HTMLDivElement | null>(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const node = ref.current;
if (!node) {
return;
}
if (typeof IntersectionObserver === "undefined") {
setInView(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setInView(true);
observer.disconnect();
break;
}
}
},
{ threshold: 0.2, rootMargin: "0px 0px -10% 0px" },
);
observer.observe(node);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
className={`reveal ${className}`}
data-in-view={inView}
style={delay ? ({ "--reveal-delay": `${delay}ms` } as CSSProperties) : undefined}
>
{children}
</div>
);
}
-32
View File
@@ -1,32 +0,0 @@
import type { ReactNode } from "react";
import { Eyebrow } from "./eyebrow";
/** Eyebrow + heading + subhead block shared by every section. */
export function SectionHeading({
eyebrow,
title,
description,
align = "left",
className = "",
}: {
eyebrow?: string;
title: ReactNode;
description?: ReactNode;
align?: "left" | "center";
className?: string;
}) {
const alignment =
align === "center" ? "items-center text-center mx-auto" : "items-start text-left";
return (
<div className={`flex max-w-[68ch] flex-col gap-3 md:gap-4 ${alignment} ${className}`}>
{eyebrow ? <Eyebrow>{eyebrow}</Eyebrow> : null}
<h2 className="text-balance text-[30px] font-medium leading-[1.12] tracking-[-0.02em] text-[var(--text)] md:text-[34px]">
{title}
</h2>
{description ? (
<p className="max-w-[62ch] text-pretty text-lg leading-relaxed text-[var(--muted)]">{description}</p>
) : null}
</div>
);
}
+133
View File
@@ -0,0 +1,133 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { dayPartAt, paletteFor, seasonAt, type DayPart, type Season } from "../../lib/palette";
import styles from "./world.module.css";
const DAY_PARTS: readonly DayPart[] = ["dawn", "day", "dusk", "night"];
const SEASONS: readonly Season[] = ["spring", "summer", "autumn", "winter"];
const TOWERS = [34, 52, 28, 70, 44, 86, 38, 58, 30, 64, 48, 76, 36, 54];
function attribute<T extends string>(name: string, allowed: readonly T[], clock: T): T {
const value = document.documentElement.getAttribute(name);
if (value === null) return clock;
if (!allowed.includes(value as T)) throw new Error(`PixelWorld: unknown ${name} "${value}"`);
return value as T;
}
function webglAvailable(): boolean {
const gl = document.createElement("canvas").getContext("webgl2");
gl?.getExtension("WEBGL_lose_context")?.loseContext();
return gl !== null;
}
function Skyline() {
return (
<div className={styles.fallback} aria-hidden="true">
{[0, 1, 2, 3].map((band) => (
<div key={band} className={styles.band} style={{ background: `var(--ink-sky-${band})` }} />
))}
<div className={styles.skyline}>
{TOWERS.map((height, index) => (
<div key={index} className={styles.tower} style={{ height: `${height}%` }} />
))}
</div>
</div>
);
}
/** Hero diorama of the AgentCompany office, re-lit live whenever the site palette rotates. */
export function PixelWorld({ className, focus = "center" }: { className?: string; focus?: "center" | "right" }) {
const frameRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const focusRef = useRef(focus);
const reframeRef = useRef<(() => void) | null>(null);
const [fallback, setFallback] = useState(false);
useEffect(() => {
focusRef.current = focus;
reframeRef.current?.();
}, [focus]);
useEffect(() => {
if (!webglAvailable()) {
setFallback(true);
return;
}
const frame = frameRef.current!;
const canvas = canvasRef.current!;
let cleanup: (() => void) | undefined;
let cancelled = false;
void import("./engine").then(({ createWorld }) => {
if (cancelled) return;
const world = createWorld(canvas);
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let visible = false;
let running = false;
const refresh = () => {
running = !reduced && visible && !document.hidden;
world.setRunning(running);
if (!running) world.render();
};
const repaint = () => {
const now = new Date();
const part = attribute("data-daypart", DAY_PARTS, dayPartAt(now));
world.setPalette(paletteFor(part, attribute("data-season", SEASONS, seasonAt(now))), part);
if (!running) world.render();
};
const fit = () => {
world.setFocusRight(focusRef.current === "right" && frame.clientWidth > 900);
world.resize(frame.clientWidth, frame.clientHeight);
};
const resize = () => {
fit();
if (!running) world.render();
};
const point = (event: PointerEvent) => world.setPointer((event.clientX / window.innerWidth) * 2 - 1, (event.clientY / window.innerHeight) * 2 - 1);
const scroll = () => world.setScroll(Math.min(1, Math.max(0, window.scrollY / Math.max(1, frame.clientHeight))));
fit();
repaint();
reframeRef.current = resize;
const sizes = new ResizeObserver(resize);
sizes.observe(frame);
const palette = new MutationObserver(repaint);
palette.observe(document.documentElement, { attributes: true, attributeFilter: ["data-daypart", "data-season"] });
const sight = new IntersectionObserver(([entry]) => {
visible = entry!.isIntersecting;
refresh();
});
sight.observe(frame);
document.addEventListener("visibilitychange", refresh);
if (!reduced) {
window.addEventListener("pointermove", point, { passive: true });
window.addEventListener("scroll", scroll, { passive: true });
}
refresh();
cleanup = () => {
reframeRef.current = null;
sizes.disconnect();
palette.disconnect();
sight.disconnect();
document.removeEventListener("visibilitychange", refresh);
window.removeEventListener("pointermove", point);
window.removeEventListener("scroll", scroll);
world.dispose();
};
});
return () => {
cancelled = true;
cleanup?.();
};
}, []);
return (
<div ref={frameRef} className={className ? `${styles.frame} ${className}` : styles.frame}>
{fallback ? <Skyline /> : <canvas ref={canvasRef} className={styles.canvas} aria-hidden="true" />}
</div>
);
}
+168
View File
@@ -0,0 +1,168 @@
import { Group, Vector2, type Object3D } from "three";
import { INK } from "../../lib/palette";
import { CEO_DESK, DESKS, SHARED_SPOTS, deskSpot, type Point, type Spot } from "./layout";
import { box, type Inks } from "./voxel";
interface Rig {
root: Group;
torso: Group;
head: Group;
legs: [Group, Group];
arms: [Group, Group];
}
interface Agent {
rig: Rig;
desk: Spot;
spot: Spot;
path: Vector2[];
wait: number;
stride: number;
seed: number;
}
const HIP = 0.36;
const SEAT = 0.48;
const SPEED = 1.7;
const SCALE = 1.25;
const TEAM = [
{ shirt: INK.accent, hair: INK.woodDark },
{ shirt: INK.sea, hair: INK.ink },
{ shirt: INK.mint, hair: INK.brick },
{ shirt: INK.berry, hair: INK.gold },
{ shirt: INK.rug, hair: INK.woodDark },
{ shirt: INK.leaf1, hair: INK.ink },
];
function limb(parent: Object3D, inks: Inks, ink: number, x: number, y: number, w: number, h: number): Group {
const pivot = new Group();
pivot.position.set(x, y, 0);
parent.add(pivot);
box(pivot, inks[ink]!, w, h, w + 0.02, 0, -h, 0);
return pivot;
}
function buildRig(inks: Inks, shirt: number, hair: number, ceo: boolean): Rig {
const root = new Group();
root.scale.setScalar(SCALE);
const torso = new Group();
root.add(torso);
const legs: [Group, Group] = [limb(root, inks, INK.inkSoft, -0.11, HIP, 0.17, HIP), limb(root, inks, INK.inkSoft, 0.11, HIP, 0.17, HIP)];
box(torso, inks[shirt]!, 0.46, 0.42, 0.3, 0, HIP, 0);
if (ceo) box(torso, inks[INK.gold]!, 0.08, 0.3, 0.02, 0, HIP + 0.08, 0.15);
const arms: [Group, Group] = [limb(torso, inks, shirt, -0.3, 0.76, 0.13, 0.36), limb(torso, inks, shirt, 0.3, 0.76, 0.13, 0.36)];
const head = new Group();
head.position.y = 0.78;
torso.add(head);
box(head, inks[INK.skin]!, 0.5, 0.46, 0.46, 0, 0, 0);
box(head, inks[hair]!, 0.54, 0.14, 0.5, 0, 0.4, 0);
box(head, inks[hair]!, 0.54, 0.3, 0.12, 0, 0.12, -0.21);
if (ceo) {
box(head, inks[INK.void]!, 0.44, 0.1, 0.04, 0, 0.2, 0.24);
box(head, inks[INK.gold]!, 0.4, 0.1, 0.4, 0, 0.54, 0);
for (const [x, z] of [[-0.15, -0.15], [0.15, -0.15], [-0.15, 0.15], [0.15, 0.15]] as Point[]) box(head, inks[INK.gold]!, 0.1, 0.16, 0.1, x, 0.64, z);
box(head, inks[INK.accent]!, 0.1, 0.1, 0.04, 0, 0.55, 0.21);
} else {
for (const x of [-0.12, 0.12]) box(head, inks[INK.void]!, 0.07, 0.1, 0.03, x, 0.18, 0.235);
}
return { root, torso, head, legs, arms };
}
function turn(current: number, target: number, amount: number): number {
const delta = Math.atan2(Math.sin(target - current), Math.cos(target - current));
return current + delta * Math.min(1, amount);
}
/** Seven blocky staff (CEO first, crowned) cycling desk → coffee / meeting → desk along corridor waypoints. */
export function createAgents(inks: Inks): { root: Group; update(dt: number, time: number): void } {
const root = new Group();
const taken = new Set<Spot>();
const desks = [CEO_DESK, ...DESKS].map(deskSpot);
const agents: Agent[] = desks.map((desk, i) => {
const look = i === 0 ? { shirt: INK.ink, hair: INK.ink } : TEAM[i - 1]!;
const rig = buildRig(inks, look.shirt, look.hair, i === 0);
root.add(rig.root);
rig.root.position.set(desk.at[0], 0, desk.at[1]);
rig.root.rotation.y = desk.face;
return { rig, desk, spot: desk, path: [], wait: 2 + i * 1.7, stride: 0, seed: i * 1.37 };
});
const route = (from: Spot, to: Spot) =>
[...from.exit, ...[...to.exit].reverse(), to.at].map(([x, z]) => new Vector2(x, z));
function choose(agent: Agent): void {
let next = agent.desk;
if (agent.spot === agent.desk) {
const kind = Math.random() < 0.45 ? "coffee" : "meeting";
const free = SHARED_SPOTS.filter((s) => s.kind === kind && !taken.has(s));
if (free.length === 0) {
agent.wait = 3;
return;
}
next = free[Math.floor(Math.random() * free.length)]!;
taken.add(next);
} else {
taken.delete(agent.spot);
}
agent.path = route(agent.spot, next);
agent.spot = next;
}
function pose(agent: Agent, dt: number, time: number): void {
const { rig, spot } = agent;
const walking = agent.path.length > 0;
const seated = !walking && spot.sit;
const swing = walking ? Math.sin(agent.stride) * 0.7 : 0;
rig.legs[0].rotation.x = seated ? -Math.PI / 2 : swing;
rig.legs[1].rotation.x = seated ? -Math.PI / 2 : -swing;
rig.root.position.y = seated ? SEAT - HIP * SCALE : walking ? Math.abs(Math.sin(agent.stride)) * 0.06 : 0;
const tap = Math.sin(time * 16 + agent.seed * 5);
if (walking) {
rig.arms[0].rotation.x = -swing * 0.8;
rig.arms[1].rotation.x = swing * 0.8;
} else if (spot.kind === "desk") {
rig.arms[0].rotation.x = -1.2 + tap * 0.15;
rig.arms[1].rotation.x = -1.2 - tap * 0.15;
} else if (spot.kind === "coffee") {
const sip = Math.sin(time * 1.3 + agent.seed) > 0.4;
rig.arms[0].rotation.x = 0;
rig.arms[1].rotation.x = sip ? -2.2 : -0.9;
} else {
rig.arms[0].rotation.x = rig.arms[1].rotation.x = -0.7;
}
rig.torso.position.y = !walking && spot.kind === "desk" ? (tap > 0.6 ? 0.03 : 0) : 0;
rig.head.rotation.x = !walking && spot.kind === "meeting" ? Math.max(0, Math.sin(time * 2 + agent.seed)) * 0.25 : 0;
if (!walking) rig.root.rotation.y = turn(rig.root.rotation.y, spot.face, dt * 8);
}
return {
root,
update(dt, time) {
for (const agent of agents) {
const { rig } = agent;
const target = agent.path[0];
if (target) {
const here = new Vector2(rig.root.position.x, rig.root.position.z);
const gap = target.clone().sub(here);
const step = SPEED * dt;
if (gap.length() <= step) {
rig.root.position.x = target.x;
rig.root.position.z = target.y;
agent.path.shift();
if (agent.path.length === 0) agent.wait = agent.spot === agent.desk ? 8 + Math.random() * 8 : 5 + Math.random() * 6;
} else {
gap.normalize();
rig.root.position.x += gap.x * step;
rig.root.position.z += gap.y * step;
rig.root.rotation.y = turn(rig.root.rotation.y, Math.atan2(gap.x, gap.y), dt * 12);
agent.stride += dt * 11;
}
} else {
agent.wait -= dt;
if (agent.wait <= 0) choose(agent);
}
pose(agent, dt, time);
}
},
};
}
+157
View File
@@ -0,0 +1,157 @@
import {
BasicShadowMap, Camera, HalfFloatType, Mesh, NearestFilter, OrthographicCamera, PlaneGeometry, Scene, Vector2,
WebGLRenderer, WebGLRenderTarget,
} from "three";
import type { DayPart } from "../../lib/palette";
import { createAgents } from "./agents";
import { applyLighting, createLights } from "./lighting";
import { buildOffice } from "./office";
import { createQuantizeMaterial, setQuantizePalette } from "./quantize";
import { createSky, setSkyPalette } from "./sky";
import { UNIT_BOX, createInks, paintInks } from "./voxel";
export interface World {
setPalette(colours: string[], part: DayPart): void;
resize(width: number, height: number): void;
setPointer(x: number, y: number): void;
setScroll(progress: number): void;
setFocusRight(right: boolean): void;
setRunning(running: boolean): void;
render(): void;
dispose(): void;
}
const ART_PIXELS = 320 * 180;
const MAX_DPR = 2;
const TARGET = { x: 1.6, y: 0.2, z: 0.4 };
const RIGHT_SHARE = 0.55;
/** Real-time office diorama drawn at ~320×180, then palette-quantized onto the canvas. */
export function createWorld(canvas: HTMLCanvasElement): World {
const renderer = new WebGLRenderer({ canvas, antialias: false, alpha: false, powerPreference: "low-power" });
renderer.setPixelRatio(1);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = BasicShadowMap;
const target = new WebGLRenderTarget(320, 180, { type: HalfFloatType, minFilter: NearestFilter, magFilter: NearestFilter });
const quantize = createQuantizeMaterial(target.texture);
const post = new Scene();
const postQuad = new Mesh(new PlaneGeometry(2, 2), quantize);
postQuad.frustumCulled = false;
post.add(postQuad);
const postCamera = new Camera();
const scene = new Scene();
const inks = createInks();
const office = buildOffice(inks);
const agents = createAgents(inks);
const lights = createLights();
const sky = createSky();
scene.add(sky, office.root, agents.root, lights.sun, lights.sun.target, lights.sky);
const camera = new OrthographicCamera(-1, 1, 1, -1, 1, 120);
const pointer = new Vector2();
const eased = new Vector2();
let scroll = 0;
let focusRight = false;
let aspect = 16 / 9;
let time = 0;
let last = 0;
let frame = 0;
function place(): void {
eased.lerp(pointer, 0.06);
const yaw = Math.PI / 4 + Math.sin(time * 0.08) * 0.16 + eased.x * 0.12;
const pitch = 0.56 + eased.y * 0.05;
camera.position.set(
TARGET.x + Math.sin(yaw) * Math.cos(pitch) * 50,
TARGET.y + Math.sin(pitch) * 50,
TARGET.z + Math.cos(yaw) * Math.cos(pitch) * 50,
);
camera.lookAt(TARGET.x, TARGET.y, TARGET.z);
const share = focusRight ? RIGHT_SHARE : 1;
const half = Math.max(10.6, 13 / (aspect * share)) / (1 + scroll * 0.18);
const width = half * aspect * 2;
const centre = focusRight ? 1 - share / 2 : 0.5;
camera.top = half;
camera.bottom = -half;
camera.left = -width * centre;
camera.right = width * (1 - centre);
camera.updateProjectionMatrix();
(sky.material.uniforms.offset!.value as Vector2).set(-eased.x * 14 - Math.sin(time * 0.08) * 16, 0);
sky.material.uniforms.time!.value = time;
}
function render(): void {
place();
office.update(time);
renderer.setRenderTarget(target);
renderer.render(scene, camera);
renderer.setRenderTarget(null);
renderer.render(post, postCamera);
}
function tick(now: number): void {
const dt = last ? Math.min((now - last) / 1000, 0.1) : 0;
last = now;
time += dt;
agents.update(dt, time);
render();
frame = requestAnimationFrame(tick);
}
return {
setPalette(colours, part) {
paintInks(inks, colours);
setSkyPalette(sky.material, colours, part);
setQuantizePalette(quantize, colours);
applyLighting(lights, office, colours, part);
},
resize(width, height) {
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
const deviceWidth = width * dpr;
const deviceHeight = height * dpr;
const scale = Math.max(1, Math.round(Math.sqrt((deviceWidth * deviceHeight) / ART_PIXELS)));
const artWidth = Math.ceil(deviceWidth / scale);
const artHeight = Math.ceil(deviceHeight / scale);
aspect = artWidth / artHeight;
renderer.setSize(artWidth, artHeight, false);
target.setSize(artWidth, artHeight);
(sky.material.uniforms.resolution!.value as Vector2).set(artWidth, artHeight);
canvas.style.width = `${(artWidth * scale) / dpr}px`;
canvas.style.height = `${(artHeight * scale) / dpr}px`;
},
setPointer(x, y) {
pointer.set(x, y);
},
setScroll(progress) {
scroll = progress;
},
setFocusRight(right) {
focusRight = right;
},
setRunning(running) {
cancelAnimationFrame(frame);
last = 0;
if (running) frame = requestAnimationFrame(tick);
},
render() {
eased.copy(pointer);
render();
},
dispose() {
cancelAnimationFrame(frame);
office.dispose();
inks.forEach((material) => material.dispose());
UNIT_BOX.dispose();
sky.geometry.dispose();
sky.material.dispose();
postQuad.geometry.dispose();
quantize.dispose();
lights.sun.dispose();
lights.sky.dispose();
target.dispose();
renderer.dispose();
},
};
}
+1
View File
@@ -0,0 +1 @@
export { PixelWorld } from "./PixelWorld";
+51
View File
@@ -0,0 +1,51 @@
/** Floor plan shared by the office builder and the agents: x runs right, z towards the camera, units are voxels. */
export type Point = [x: number, z: number];
export interface Desk {
x: number;
z: number;
/** +1 puts the chair on the +z side (screen faces the camera), -1 on the -z side. */
facing: 1 | -1;
}
export interface Spot {
kind: "desk" | "meeting" | "coffee";
at: Point;
face: number;
sit: boolean;
exit: Point[];
}
export const FLOOR = { halfX: 10, halfZ: 7, wall: 3.2 };
export const CORRIDOR = 1.2;
export const CEO_DESK: Desk = { x: -7, z: -4.9, facing: 1 };
export const DESKS: Desk[] = [
{ x: -2, z: -5, facing: 1 },
{ x: 2, z: -5, facing: 1 },
{ x: -5.5, z: 4.4, facing: -1 },
{ x: -2.5, z: 4.4, facing: -1 },
{ x: 2.5, z: 4.4, facing: -1 },
{ x: 5.5, z: 4.4, facing: -1 },
];
export const MEETING_TABLE: Point = [7, -4.2];
export const COFFEE_MACHINE: Point = [-9.5, 4.4];
export const WINDOWS = [-8.2, -5.4, -1.8, 1.8, 5.4, 8.2];
export function chairOf(desk: Desk): Point {
return [desk.x, desk.z + desk.facing * 0.85];
}
export function deskSpot(desk: Desk): Spot {
return { kind: "desk", at: chairOf(desk), face: desk.facing === 1 ? Math.PI : 0, sit: true, exit: [[desk.x, CORRIDOR]] };
}
const [tx, tz] = MEETING_TABLE;
const DOOR: Point = [tx, -2.1];
export const SHARED_SPOTS: Spot[] = [
{ kind: "meeting", at: [tx - 0.8, tz + 1.3], face: Math.PI, sit: true, exit: [DOOR, [tx, CORRIDOR]] },
{ kind: "meeting", at: [tx + 0.8, tz + 1.3], face: Math.PI, sit: true, exit: [DOOR, [tx, CORRIDOR]] },
{ kind: "meeting", at: [tx - 2.1, tz], face: Math.PI / 2, sit: true, exit: [[tx - 2.1, -2.1], DOOR, [tx, CORRIDOR]] },
{ kind: "meeting", at: [tx + 2.1, tz], face: -Math.PI / 2, sit: true, exit: [[tx + 2.1, -2.1], DOOR, [tx, CORRIDOR]] },
{ kind: "coffee", at: [-8.5, 4.4], face: -Math.PI / 2, sit: false, exit: [[-8.5, CORRIDOR]] },
{ kind: "coffee", at: [-8.5, 6.1], face: -Math.PI / 2, sit: false, exit: [[-7.8, 5.3], [-7.8, CORRIDOR]] },
];
+52
View File
@@ -0,0 +1,52 @@
import { AmbientLight, DirectionalLight, Vector3 } from "three";
import { INK, type DayPart } from "../../lib/palette";
import type { Office } from "./office";
interface Light {
sun: [number, number, number];
sunInk: number;
sunPower: number;
fill: number;
lampInk: number;
lamps: number;
screens: number;
glow: number;
}
const LIGHT: Record<DayPart, Light> = {
dawn: { sun: [-0.6, 0.5, 0.62], sunInk: INK.glow, sunPower: 1.6, fill: 2.0, lampInk: INK.glow, lamps: 0, screens: 0.85, glow: 0 },
day: { sun: [-0.35, 0.85, 0.4], sunInk: INK.white, sunPower: 1.55, fill: 1.85, lampInk: INK.glow, lamps: 0, screens: 0.75, glow: 0 },
dusk: { sun: [0.7, 0.45, 0.55], sunInk: INK.glow, sunPower: 1.5, fill: 2.0, lampInk: INK.glow, lamps: 4, screens: 1, glow: 0.8 },
night: { sun: [-0.3, 0.9, 0.3], sunInk: INK.sun, sunPower: 1.2, fill: 3.2, lampInk: INK.gold, lamps: 8, screens: 1, glow: 1.6 },
};
export function createLights(): { sun: DirectionalLight; sky: AmbientLight } {
const sun = new DirectionalLight();
sun.castShadow = true;
sun.shadow.mapSize.set(1024, 1024);
sun.shadow.bias = -0.0008;
sun.shadow.normalBias = 0.03;
Object.assign(sun.shadow.camera, { left: -16, right: 16, top: 16, bottom: -16, near: 1, far: 60 });
return { sun, sky: new AmbientLight() };
}
/** Points the sun, tints the ambient fill and switches lamps and screens for a day part. */
export function applyLighting(lights: { sun: DirectionalLight; sky: AmbientLight }, office: Office, colours: string[], part: DayPart): void {
const light = LIGHT[part];
lights.sun.position.copy(new Vector3(...light.sun).normalize().multiplyScalar(24));
lights.sun.color.set(colours[light.sunInk]!);
lights.sun.intensity = light.sunPower;
lights.sky.color.set(colours[INK.white]!);
lights.sky.intensity = light.fill;
for (const lamp of office.lamps) {
lamp.color.set(colours[light.lampInk]!);
lamp.intensity = light.lamps;
}
for (const glow of office.glows) {
glow.color.set(colours[INK.screen]!);
glow.intensity = light.glow;
}
office.bulbs.color.set(colours[light.lamps > 0 ? light.lampInk : INK.paper]!);
for (const screen of office.screens) screen.color.set(colours[INK.screen]!).multiplyScalar(light.screens);
office.steam.color.set(colours[INK.cloud]!);
}
+284
View File
@@ -0,0 +1,284 @@
import {
BufferGeometry, DataTexture, Float32BufferAttribute, Group, Mesh, MeshBasicMaterial, NearestFilter, PlaneGeometry,
PointLight, Points, PointsMaterial, RepeatWrapping, type Object3D,
} from "three";
import { INK } from "../../lib/palette";
import { CEO_DESK, COFFEE_MACHINE, CORRIDOR, DESKS, FLOOR, MEETING_TABLE, SHARED_SPOTS, WINDOWS, chairOf, type Desk, type Point } from "./layout";
import { box, type Inks } from "./voxel";
export interface Office {
root: Group;
screens: MeshBasicMaterial[];
lamps: PointLight[];
glows: PointLight[];
bulbs: MeshBasicMaterial;
steam: PointsMaterial;
update(time: number): void;
dispose(): void;
}
const CODE_ROWS = 32;
const STEAM = 7;
const LAMPS: Point[] = [[-9.3, -4], [3.4, -1.7], [8.9, 2.4], [-0.1, 6.5]];
function random(seed: number): () => number {
let state = seed;
return () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 4294967296;
};
}
function codeTexture(): DataTexture {
const width = 8;
const data = new Uint8Array(width * CODE_ROWS * 4);
const next = random(7);
for (let row = 0; row < CODE_ROWS; row++) {
const indent = Math.floor(next() * 3);
const length = 2 + Math.floor(next() * 5);
const level = next() > 0.7 ? 150 : 255;
for (let x = 0; x < width; x++) {
const on = row % 2 === 0 && x >= indent && x < indent + length;
data.fill(on ? level : 36, (row * width + x) * 4, (row * width + x) * 4 + 3);
data[(row * width + x) * 4 + 3] = 255;
}
}
const texture = new DataTexture(data, width, CODE_ROWS);
texture.magFilter = texture.minFilter = NearestFilter;
texture.wrapS = texture.wrapT = RepeatWrapping;
texture.repeat.set(1, 5 / CODE_ROWS);
texture.needsUpdate = true;
return texture;
}
/** Builds a piece locally (facing +z) and drops it into the room at (x, z) turned to `face`. */
function placed(parent: Object3D, x: number, z: number, face: number): Group {
const group = new Group();
group.position.set(x, 0, z);
group.rotation.y = face;
parent.add(group);
return group;
}
function chair(parent: Object3D, inks: Inks, [x, z]: Point, face: number, seat: number = INK.accentDark): void {
const g = placed(parent, x, z, face);
box(g, inks[INK.inkSoft]!, 0.08, 0.38, 0.08, 0, 0, 0);
box(g, inks[seat]!, 0.52, 0.1, 0.5, 0, 0.38, 0);
box(g, inks[seat]!, 0.52, 0.42, 0.1, 0, 0.48, -0.24);
}
function plant(parent: Object3D, inks: Inks, x: number, z: number, scale = 1): void {
const g = placed(parent, x, z, 0);
g.scale.setScalar(scale);
box(g, inks[INK.brick]!, 0.5, 0.42, 0.5, 0, 0, 0);
box(g, inks[INK.woodDark]!, 0.42, 0.04, 0.42, 0, 0.42, 0);
box(g, inks[INK.leaf0]!, 0.64, 0.42, 0.64, 0, 0.46, 0);
box(g, inks[INK.leaf1]!, 0.5, 0.36, 0.5, 0.06, 0.82, 0.04);
box(g, inks[INK.leaf2]!, 0.28, 0.26, 0.28, -0.08, 1.12, 0.1);
box(g, inks[INK.bloom]!, 0.12, 0.12, 0.12, 0.26, 0.74, 0.26);
box(g, inks[INK.bloom]!, 0.12, 0.12, 0.12, -0.2, 1.0, 0.24);
}
function desk(parent: Object3D, inks: Inks, d: Desk, screenGeometry: PlaneGeometry, screen: MeshBasicMaterial, ceo = false): void {
const g = placed(parent, d.x, d.z, d.facing === 1 ? 0 : Math.PI);
const width = ceo ? 2.3 : 1.6;
const top = inks[ceo ? INK.woodDark : INK.wood]!;
for (const sx of [-1, 1]) for (const sz of [-1, 1]) box(g, inks[INK.woodDark]!, 0.08, 0.7, 0.08, sx * (width / 2 - 0.1), 0, sz * 0.34);
box(g, top, width, 0.08, 0.9, 0, 0.7, 0);
if (ceo) {
box(g, inks[INK.woodDark]!, width, 0.55, 0.06, 0, 0.15, 0.4);
box(g, inks[INK.gold]!, width + 0.02, 0.06, 0.92, 0, 0.72, 0);
box(g, inks[INK.gold]!, 0.14, 0.12, 0.14, 0.85, 0.78, 0.1);
box(g, inks[INK.gold]!, 0.22, 0.14, 0.22, 0.85, 0.9, 0.1);
box(g, inks[INK.gold]!, 0.5, 0.1, 0.06, -0.6, 0.78, 0.32);
}
box(g, inks[INK.inkSoft]!, 0.3, 0.03, 0.2, 0, 0.78, -0.2);
box(g, inks[INK.inkSoft]!, 0.08, 0.16, 0.08, 0, 0.8, -0.22);
box(g, inks[INK.ink]!, 0.84, 0.52, 0.06, 0, 0.92, -0.2);
const panel = new Mesh(screenGeometry, screen);
panel.position.set(0, 1.18, -0.165);
g.add(panel);
box(g, inks[INK.stoneLight]!, 0.5, 0.03, 0.16, 0, 0.78, 0.16);
box(g, inks[INK.accent]!, 0.09, 0.11, 0.09, width / 2 - 0.22, 0.78, 0.12);
chair(parent, inks, chairOf(d), d.facing === 1 ? Math.PI : 0, ceo ? INK.accent : INK.accentDark);
}
function wallRun(parent: Object3D, inks: Inks, [x1, z1]: Point, [x2, z2]: Point, height: number, thick: number, body: number = INK.paper): void {
const w = Math.max(Math.abs(x2 - x1), thick);
const d = Math.max(Math.abs(z2 - z1), thick);
box(parent, inks[body]!, w, height, d, (x1 + x2) / 2, 0, (z1 + z2) / 2);
box(parent, inks[INK.inkSoft]!, w + 0.04, 0.1, d + 0.04, (x1 + x2) / 2, height, (z1 + z2) / 2);
}
function shell(root: Object3D, inks: Inks): void {
const { halfX, halfZ, wall } = FLOOR;
const W = halfX * 2 + 0.6;
const D = halfZ * 2 + 0.6;
box(root, inks[INK.stone]!, W, 1.1, D, 0, -1.6, 0);
box(root, inks[INK.brick]!, W, 0.4, D, 0, -0.5, 0);
box(root, inks[INK.wood]!, W, 0.1, D, 0, -0.1, 0);
for (let x = -halfX + 2; x < halfX; x += 2) box(root, inks[INK.woodDark]!, 0.1, 0.01, halfZ * 2, x, 0, 0);
box(root, inks[INK.berry]!, 5.6, 0.03, 5.6, -7, 0, -4);
box(root, inks[INK.stoneLight]!, 5.6, 0.03, 5.6, 7, 0, -4);
box(root, inks[INK.rug]!, halfX * 2 - 1.4, 0.03, 1.1, 0, 0, CORRIDOR);
const backZ = -halfZ - 0.15;
box(root, inks[INK.paper]!, W, 0.9, 0.3, 0, 0, backZ);
box(root, inks[INK.paper]!, W, wall - 2.6, 0.3, 0, 2.6, backZ);
box(root, inks[INK.inkSoft]!, W + 0.04, 0.12, 0.34, 0, wall, backZ);
const edges = [-W / 2, ...WINDOWS.flatMap((c) => [c - 1.1, c + 1.1]), W / 2];
for (let i = 0; i < edges.length; i += 2) box(root, inks[INK.paper]!, edges[i + 1]! - edges[i]!, 1.7, 0.3, (edges[i]! + edges[i + 1]!) / 2, 0.9, backZ);
for (const c of WINDOWS) {
box(root, inks[INK.white]!, 2.3, 0.1, 0.5, c, 0.82, backZ + 0.05);
box(root, inks[INK.white]!, 0.08, 1.7, 0.14, c, 0.9, backZ);
box(root, inks[INK.white]!, 2.2, 0.08, 0.14, c, 1.75, backZ);
}
const leftX = -halfX - 0.15;
box(root, inks[INK.paper]!, 0.3, wall, D, leftX, 0, 0);
box(root, inks[INK.inkSoft]!, 0.34, 0.12, D + 0.04, leftX, wall, 0);
box(root, inks[INK.stone]!, 0.04, 0.16, D, leftX + 0.17, 0, 0);
box(root, inks[INK.stone]!, W, 0.16, 0.04, 0, 0, backZ + 0.17);
const low = (a: Point, b: Point) => wallRun(root, inks, a, b, 0.9, 0.16, INK.stoneLight);
low([-halfX, -1], [-7.9, -1]);
low([-6.1, -1], [-4, -1]);
low([-4, -halfZ], [-4, -1]);
low([4, -1], [6.1, -1]);
low([7.9, -1], [halfX, -1]);
low([4, -halfZ], [4, -1]);
}
function furnish(root: Object3D, inks: Inks, bulbs: MeshBasicMaterial): void {
const shelf = placed(root, -9.72, -6.0, Math.PI / 2);
box(shelf, inks[INK.woodDark]!, 1.8, 2.1, 0.5, 0, 0, 0);
const books = [INK.accent, INK.sea, INK.gold, INK.berry, INK.mint, INK.rug, INK.paper, INK.accentDark];
const next = random(3);
for (const y of [0.12, 0.8, 1.45]) {
let x = -0.78;
while (x < 0.7) {
const w = 0.12 + next() * 0.1;
box(shelf, inks[books[Math.floor(next() * books.length)]!]!, w - 0.02, 0.36 + next() * 0.14, 0.4, x + w / 2, y, 0.08);
x += w;
}
}
const board = placed(root, -9.97, 1.6, Math.PI / 2);
box(board, inks[INK.stone]!, 2.6, 1.4, 0.06, 0, 1.05, 0);
box(board, inks[INK.white]!, 2.44, 1.24, 0.08, 0, 1.13, 0);
[INK.gold, INK.bloom, INK.mint, INK.sea, INK.gold].forEach((ink, i) => box(board, inks[ink]!, 0.24, 0.24, 0.1, -0.9 + i * 0.42, 1.65 - (i % 2) * 0.4, 0));
box(board, inks[INK.accent]!, 1.2, 0.06, 0.1, 0.3, 1.35, 0);
box(board, inks[INK.ink]!, 0.8, 0.06, 0.1, 0.1, 1.25, 0);
const [cx, cz] = COFFEE_MACHINE;
box(root, inks[INK.woodDark]!, 0.9, 0.9, 1.5, cx, 0, cz);
box(root, inks[INK.wood]!, 0.96, 0.06, 1.56, cx, 0.9, cz);
box(root, inks[INK.ink]!, 0.5, 0.7, 0.55, cx - 0.1, 0.96, cz - 0.1);
box(root, inks[INK.stone]!, 0.2, 0.1, 0.24, cx + 0.2, 1.32, cz - 0.1);
box(root, inks[INK.mint]!, 0.04, 0.08, 0.08, cx + 0.16, 1.5, cz + 0.08);
box(root, inks[INK.white]!, 0.12, 0.14, 0.12, cx + 0.24, 0.96, cz - 0.1);
box(root, inks[INK.accent]!, 0.12, 0.12, 0.12, cx + 0.1, 0.96, cz + 0.45);
box(root, inks[INK.stoneLight]!, 0.44, 0.9, 0.44, cx, 0, 6.1);
box(root, inks[INK.sea]!, 0.34, 0.46, 0.34, cx, 0.9, 6.1);
const [tx, tz] = MEETING_TABLE;
for (const sx of [-1, 1]) box(root, inks[INK.woodDark]!, 0.16, 0.7, 0.6, tx + sx * 1.1, 0, tz);
box(root, inks[INK.wood]!, 3.0, 0.1, 1.5, tx, 0.7, tz);
box(root, inks[INK.paper]!, 0.3, 0.02, 0.4, tx - 0.6, 0.8, tz + 0.2);
box(root, inks[INK.paper]!, 0.3, 0.02, 0.4, tx + 0.7, 0.8, tz - 0.1);
box(root, inks[INK.gold]!, 0.12, 0.12, 0.12, tx, 0.8, tz);
for (const spot of SHARED_SPOTS.filter((s) => s.kind === "meeting")) chair(root, inks, spot.at, spot.face, INK.sea);
for (const [x, z] of [[-4.6, -6.5], [-3.4, -6.5], [3.4, -6.5], [9.4, -6.5], [9.4, 6.5], [-9.4, -1.6]] as Point[]) plant(root, inks, x, z);
const tree = placed(root, 12.1, 5.2, 0);
box(tree, inks[INK.woodDark]!, 0.4, 1.6, 0.4, 0, 0, 0);
box(tree, inks[INK.leaf0]!, 2.0, 1.1, 2.0, 0, 1.2, 0);
box(tree, inks[INK.leaf1]!, 1.6, 0.9, 1.6, 0.1, 2.1, 0.1);
box(tree, inks[INK.leaf2]!, 0.9, 0.6, 0.9, -0.1, 2.9, 0.2);
box(tree, inks[INK.leaf1]!, 0.8, 0.7, 0.8, 0.9, 1.5, 0.6);
for (const [x, y, z] of [[0.7, 1.9, 0.85], [-0.6, 2.4, 0.7], [0.3, 3.0, 0.6], [-0.95, 1.5, 0.4]]) box(tree, inks[INK.bloom]!, 0.16, 0.16, 0.16, x!, y!, z!);
const bench = placed(root, 11.9, 2.6, -Math.PI / 2);
box(bench, inks[INK.inkSoft]!, 0.1, 0.4, 0.4, -0.6, 0, 0);
box(bench, inks[INK.inkSoft]!, 0.1, 0.4, 0.4, 0.6, 0, 0);
box(bench, inks[INK.wood]!, 1.5, 0.1, 0.5, 0, 0.4, 0);
box(bench, inks[INK.wood]!, 1.5, 0.4, 0.08, 0, 0.5, -0.22);
box(root, inks[INK.leaf1]!, 3.2, 0.12, 6.2, 11.9, -0.1, 4.2);
box(root, inks[INK.woodDark]!, 3.2, 1.5, 6.2, 11.9, -1.6, 4.2);
for (const [x, z] of LAMPS) {
const lamp = placed(root, x, z, 0);
box(lamp, inks[INK.inkSoft]!, 0.36, 0.06, 0.36, 0, 0, 0);
box(lamp, inks[INK.inkSoft]!, 0.06, 1.6, 0.06, 0, 0, 0);
box(lamp, bulbs, 0.44, 0.32, 0.44, 0, 1.6, 0).castShadow = false;
}
}
export function buildOffice(inks: Inks): Office {
const root = new Group();
const code = codeTexture();
const screenGeometry = new PlaneGeometry(0.74, 0.44);
const screens: MeshBasicMaterial[] = [];
const bulbs = new MeshBasicMaterial();
shell(root, inks);
for (const d of [CEO_DESK, ...DESKS]) {
const map = code.clone();
map.offset.y = screens.length * 6 / CODE_ROWS;
const screen = new MeshBasicMaterial({ map });
screens.push(screen);
desk(root, inks, d, screenGeometry, screen, d === CEO_DESK);
}
furnish(root, inks, bulbs);
const lamps = LAMPS.map(([x, z]) => {
const light = new PointLight(0xffffff, 0, 7, 1.4);
light.position.set(x, 1.6, z);
root.add(light);
return light;
});
const glows = [CEO_DESK, ...DESKS].map((d) => {
const light = new PointLight(0xffffff, 0, 2.8, 1.6);
light.position.set(d.x, 1.2, d.z + d.facing * 0.3);
root.add(light);
return light;
});
const steamGeometry = new BufferGeometry();
steamGeometry.setAttribute("position", new Float32BufferAttribute(new Float32Array(STEAM * 3), 3));
const steam = new PointsMaterial({ size: 1, sizeAttenuation: false });
const puffs = new Points(steamGeometry, steam);
puffs.frustumCulled = false;
root.add(puffs);
const [cx, cz] = COFFEE_MACHINE;
return {
root,
screens,
lamps,
glows,
bulbs,
steam,
update(time) {
screens.forEach((screen, i) => {
screen.map!.offset.y = (Math.floor(time * (2 + (i % 3)) + i * 5) * 2) / CODE_ROWS;
});
const position = steamGeometry.attributes.position!;
for (let i = 0; i < STEAM; i++) {
const life = (time * 0.45 + i / STEAM) % 1;
position.setXYZ(i, cx + 0.24 + Math.sin(life * 9 + i) * 0.08, 1.15 + life * 0.9, cz - 0.1 + Math.cos(life * 7 + i) * 0.06);
}
position.needsUpdate = true;
},
dispose() {
steamGeometry.dispose();
screens.forEach((screen) => {
screen.map!.dispose();
screen.dispose();
});
code.dispose();
screenGeometry.dispose();
bulbs.dispose();
steam.dispose();
[...lamps, ...glows].forEach((light) => light.dispose());
},
};
}
+64
View File
@@ -0,0 +1,64 @@
import { ShaderMaterial, Vector3, type Texture } from "three";
import { PALETTE_SIZE } from "../../lib/palette";
/** Snaps every scene pixel to the two nearest palette colours, choosing between them with a 4x4 Bayer matrix. */
export function createQuantizeMaterial(scene: Texture): ShaderMaterial {
return new ShaderMaterial({
uniforms: {
tScene: { value: scene },
palette: { value: Array.from({ length: PALETTE_SIZE }, () => new Vector3()) },
},
depthTest: false,
depthWrite: false,
vertexShader: /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`,
fragmentShader: /* glsl */ `
uniform sampler2D tScene;
uniform vec3 palette[${PALETTE_SIZE}];
varying vec2 vUv;
const float BAYER[16] = float[16](0., 8., 2., 10., 12., 4., 14., 6., 3., 11., 1., 9., 15., 7., 13., 5.);
const vec3 WEIGHT = vec3(0.55, 0.75, 0.4);
vec3 toSrgb(vec3 c) {
c = clamp(c, 0.0, 1.0);
return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));
}
void main() {
vec3 colour = toSrgb(texture2D(tScene, vUv).rgb);
float best = 1e9;
float next = 1e9;
vec3 first = palette[0];
vec3 second = palette[0];
for (int i = 0; i < ${PALETTE_SIZE}; i++) {
vec3 delta = (palette[i] - colour) * WEIGHT;
float distance = dot(delta, delta);
if (distance < best) {
next = best; second = first;
best = distance; first = palette[i];
} else if (distance < next) {
next = distance; second = palette[i];
}
}
vec3 span = second - first;
float along = clamp(dot(colour - first, span) / max(dot(span, span), 1e-5) * 2.0 - 0.45, 0.0, 1.0);
ivec2 cell = ivec2(mod(gl_FragCoord.xy, 4.0));
float threshold = (BAYER[cell.y * 4 + cell.x] + 0.5) / 16.0;
gl_FragColor = vec4(along > threshold ? second : first, 1.0);
}
`,
});
}
export function setQuantizePalette(material: ShaderMaterial, colours: string[]): void {
const slots = material.uniforms.palette!.value as Vector3[];
colours.forEach((hex, index) => {
const value = Number.parseInt(hex.slice(1), 16);
slots[index]!.set(((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255);
});
}
+102
View File
@@ -0,0 +1,102 @@
import { Color, Mesh, PlaneGeometry, ShaderMaterial, Vector2 } from "three";
import { INK, type DayPart } from "../../lib/palette";
const ORB: Record<DayPart, [number, number]> = { dawn: [0.16, 0.44], day: [0.18, 0.8], dusk: [0.84, 0.4], night: [0.8, 0.8] };
/** Screen-space backdrop: banded sky, sun or moon, drifting clouds, stars and a two-layer skyline with lit windows. */
export function createSky(): Mesh<PlaneGeometry, ShaderMaterial> {
const colour = () => ({ value: new Color() });
const material = new ShaderMaterial({
uniforms: {
sky0: colour(), sky1: colour(), sky2: colour(), sky3: colour(), orb: colour(), star: colour(),
cloud: colour(), far: colour(), near: colour(), lit: colour(),
night: { value: 0 }, moon: { value: 0 }, time: { value: 0 },
resolution: { value: new Vector2(320, 180) }, orbPos: { value: new Vector2() }, offset: { value: new Vector2() },
},
depthTest: false,
depthWrite: false,
vertexShader: /* glsl */ `
void main() { gl_Position = vec4(position.xy, 1.0, 1.0); }
`,
fragmentShader: /* glsl */ `
uniform vec3 sky0, sky1, sky2, sky3, orb, star, cloud, far, near, lit;
uniform float night, moon, time;
uniform vec2 resolution, orbPos, offset;
const float BAYER[16] = float[16](0., 8., 2., 10., 12., 4., 14., 6., 3., 11., 1., 9., 15., 7., 13., 5.);
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
vec3 band(vec3 low, vec3 high, float t, float threshold) {
return clamp((t - 0.5) * 2.2 + 0.5, 0.0, 1.0) > threshold ? high : low;
}
bool cloudAt(vec2 px, float k) {
float span = resolution.x + 80.0;
vec2 c = vec2(mod(k * 137.0 + time * (1.5 + k * 0.6), span) - 40.0, resolution.y * (0.6 + 0.09 * k));
if (px.y < c.y - 1.0) return false;
vec2 d = px - c;
return length(d * vec2(0.55, 1.0)) < 5.0 + k || length(d - vec2(8.0, -1.0)) < 4.0 || length(d + vec2(9.0, 1.0)) < 3.5;
}
void main() {
vec2 px = floor(gl_FragCoord.xy);
float v = px.y / resolution.y;
ivec2 cell4 = ivec2(mod(px, 4.0));
float threshold = (BAYER[cell4.y * 4 + cell4.x] + 0.5) / 16.0;
vec3 c = v > 0.74 ? band(sky1, sky0, (v - 0.74) / 0.26, threshold)
: v > 0.48 ? band(sky2, sky1, (v - 0.48) / 0.26, threshold)
: band(sky3, sky2, clamp((v - 0.2) / 0.28, 0.0, 1.0), threshold);
float h = hash(px + floor(offset * 0.3));
if (moon > 0.5 && v > 0.42 && h > 0.996) c = mix(c, star, step(0.35, fract(h * 97.0 + time * 0.4)));
for (float k = 0.0; k < 3.0; k++) if (cloudAt(px + floor(offset * 0.25), k)) c = cloud;
vec2 o = orbPos * resolution + floor(offset * 0.2);
float d = length(px + 0.5 - o);
if (moon > 0.5) {
if (d < 7.0 && length(px + 0.5 - o - vec2(3.0, 2.0)) > 6.0) c = orb;
} else {
if (d < 14.0 && threshold < 0.3) c = orb;
if (d < 9.0) c = orb;
}
float unit = min(resolution.y, resolution.x * 0.9);
float fx = px.x + floor(offset.x * 0.35);
float fid = floor(fx / 9.0);
if (px.y < unit * (0.2 + hash(vec2(fid, 3.0)) * 0.16)) {
c = far;
vec2 cell = vec2(mod(fx, 9.0), mod(px.y, 5.0));
if (night > 0.5 && cell.x > 2.0 && cell.x < 7.0 && cell.y == 2.0 && hash(vec2(fid, floor(px.y / 5.0))) > 0.7) c = lit;
}
float nx = px.x + floor(offset.x * 0.6);
float nid = floor(nx / 13.0);
float top = unit * (0.08 + hash(vec2(nid, 9.0)) * 0.15);
if (px.y < top) {
c = near;
vec2 cell = vec2(mod(nx, 13.0), mod(px.y, 4.0));
bool window = cell.x > 1.0 && cell.x < 11.0 && mod(cell.x, 3.0) == 2.0 && cell.y == 1.0 && px.y < top - 2.0;
if (window) c = night > 0.5 && hash(vec2(nx, floor(px.y / 4.0))) > 0.45 ? lit : mix(near, sky3, 0.35);
}
gl_FragColor = vec4(c, 1.0);
}
`,
});
const mesh = new Mesh(new PlaneGeometry(2, 2), material);
mesh.frustumCulled = false;
mesh.renderOrder = -1;
return mesh;
}
export function setSkyPalette(material: ShaderMaterial, colours: string[], part: DayPart): void {
const u = material.uniforms;
const set = (name: string, index: number) => (u[name]!.value as Color).set(colours[index]!);
set("sky0", INK.sky0); set("sky1", INK.sky1); set("sky2", INK.sky2); set("sky3", INK.sky3);
set("orb", INK.sun); set("star", INK.star); set("cloud", INK.cloud); set("lit", INK.gold);
(u.far!.value as Color).set(colours[INK.sky1]!).lerp(new Color(colours[INK.stone]!), 0.5);
(u.near!.value as Color).set(colours[INK.inkSoft]!).lerp(new Color(colours[INK.sky0]!), 0.3);
u.night!.value = part === "night" || part === "dusk" ? 1 : 0;
u.moon!.value = part === "night" ? 1 : 0;
(u.orbPos!.value as Vector2).set(...ORB[part]);
}
+26
View File
@@ -0,0 +1,26 @@
import { BoxGeometry, Mesh, MeshLambertMaterial, type Material, type Object3D } from "three";
import { PALETTE_SIZE } from "../../lib/palette";
export const UNIT_BOX = new BoxGeometry(1, 1, 1);
/** One Lambert material per palette index, recoloured in place whenever the palette rotates. */
export type Inks = MeshLambertMaterial[];
export function createInks(): Inks {
return Array.from({ length: PALETTE_SIZE }, () => new MeshLambertMaterial());
}
export function paintInks(inks: Inks, colours: string[]): void {
inks.forEach((material, index) => material.color.set(colours[index]!));
}
/** Adds a w×h×d box whose bottom sits at y. */
export function box(parent: Object3D, material: Material, w: number, h: number, d: number, x: number, y: number, z: number): Mesh {
const mesh = new Mesh(UNIT_BOX, material);
mesh.scale.set(w, h, d);
mesh.position.set(x, y + h / 2, z);
mesh.castShadow = true;
mesh.receiveShadow = true;
parent.add(mesh);
return mesh;
}
+48
View File
@@ -0,0 +1,48 @@
.frame {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: var(--ink-sky-2);
}
.canvas {
position: absolute;
top: 50%;
left: 50%;
display: block;
transform: translate(-50%, -50%);
image-rendering: pixelated;
}
.fallback {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
}
.band {
flex: 1;
}
.skyline {
position: absolute;
inset: auto 0 0;
display: flex;
align-items: flex-end;
height: 60%;
}
.tower {
--tower: var(--ink-ink-soft);
flex: 1;
background-color: var(--tower);
background-image: repeating-linear-gradient(0deg, var(--tower) 0 10px, transparent 10px 16px),
repeating-linear-gradient(90deg, var(--tower) 0 8px, var(--ink-gold) 8px 14px, var(--tower) 14px 20px);
box-shadow: inset 0 6px 0 var(--ink-ink);
}
.tower:nth-child(odd) {
--tower: var(--ink-stone);
}
+7 -1
View File
@@ -18,10 +18,16 @@ async function readDownloadCount(db: CounterDatabase) {
.bind(DOWNLOAD_COUNT_SEED)
.first<{ downloads: number }>();
return result?.downloads ?? DOWNLOAD_COUNT_SEED;
if (!result) {
throw new Error("Download count query returned no row.");
}
return result.downloads;
}
export const onRequestGet: PagesHandler<Env> = async (context) => {
if (!context.env.DOWNLOAD_COUNTER_DB) {
throw new Error("DOWNLOAD_COUNTER_DB binding is missing.");
}
const downloads = await readDownloadCount(context.env.DOWNLOAD_COUNTER_DB);
return Response.json(
{ downloads },
+14 -3
View File
@@ -1,12 +1,13 @@
import {
DOWNLOAD_DEDUP_WINDOW_MS,
getDownloadAsset,
isAllowedDownloadUrl,
} from "../../lib/downloads";
import type { CounterDatabase, PagesHandler } from "../types";
interface Env {
DOWNLOAD_COUNTER_DB: CounterDatabase;
DOWNLOAD_COUNTER_SALT?: string;
DOWNLOAD_COUNTER_SALT: string;
}
function getClientIp(request: Request) {
@@ -28,13 +29,13 @@ async function sha256Hex(input: string) {
return Array.from(new Uint8Array(buffer), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
async function countUniqueDownload(db: CounterDatabase, request: Request, assetId: string, salt: string | undefined) {
async function countUniqueDownload(db: CounterDatabase, request: Request, assetId: string, salt: string) {
const clientIp = getClientIp(request);
if (!clientIp) {
return;
}
const ipHash = await sha256Hex(`${salt ?? ""}:${clientIp}`);
const ipHash = await sha256Hex(`${salt}:${clientIp}`);
const windowKey = getWindowKey();
await db
@@ -51,6 +52,13 @@ async function countUniqueDownload(db: CounterDatabase, request: Request, assetI
}
export const onRequestGet: PagesHandler<Env> = async (context) => {
if (!context.env.DOWNLOAD_COUNTER_DB) {
throw new Error("DOWNLOAD_COUNTER_DB binding is missing.");
}
if (!context.env.DOWNLOAD_COUNTER_SALT) {
throw new Error("DOWNLOAD_COUNTER_SALT variable is missing.");
}
const url = new URL(context.request.url);
const assetId = url.searchParams.get("asset")?.trim();
if (!assetId) {
@@ -61,6 +69,9 @@ export const onRequestGet: PagesHandler<Env> = async (context) => {
if (!asset) {
return new Response("Unknown asset.", { status: 404 });
}
if (!isAllowedDownloadUrl(asset.href)) {
throw new Error(`Asset ${asset.id} points outside the release host: ${asset.href}`);
}
await countUniqueDownload(
context.env.DOWNLOAD_COUNTER_DB,
+155
View File
@@ -0,0 +1,155 @@
import type { IconName } from "../components/pixel";
export const description =
"AgentCompany is a local-first desktop app for running an autonomous AI company. Set a goal; an AI CEO hires a team, delegates real work, and the goal only closes when an isolated auditor approves the evidence.";
export const nav = [
{ label: "Quests", href: "#quests" },
{ label: "Party", href: "#party" },
{ label: "Items", href: "#items" },
{ label: "Save", href: "#save-point" },
];
export type Quest = { stage: string; title: string; icon: IconName; body: string; optional?: boolean };
export const quests: Quest[] = [
{ stage: "1-1", title: "Start", icon: "flag", body: "Pick English or 简体中文 and press Get started. The wizard runs once, on first launch." },
{ stage: "1-2", title: "Provider", icon: "key", body: "Bind one model provider: paste an API key or sign in with a plan you already pay for. Keys are encrypted with your OS secure storage and kept on your machine." },
{ stage: "1-3", title: "Company", icon: "building", body: "Name the company, name yourself as the human board chair, describe the business, and optionally bind a workspace folder." },
{ stage: "1-4", title: "Mode", icon: "sword", body: "Choose how much rope the CEO gets: AI CEO or AI Co-Founder. Character select is just below." },
{ stage: "1-5", title: "Goal", icon: "map", body: "State the first goal and its acceptance criteria, then hit Launch. The CEO starts hiring." },
{ stage: "1-6", title: "Connectors", icon: "plug", body: "Bring your own Composio key and connect the toolkits your project enables. Optional side quest: Skip works.", optional: true },
];
export type Mode = {
id: "ceo" | "cofounder";
name: string;
className: string;
icon: IconName;
pitch: string;
stats: { label: string; value: string }[];
special: string;
};
export const modes: Mode[] = [
{
id: "ceo",
name: "AI CEO",
className: "Autonomous",
icon: "trophy",
pitch: "Runs on its own: approves its risky work and large hires, and writes deliverables without review.",
stats: [
{ label: "Risky work", value: "Self-approved" },
{ label: "Large hires", value: "Self-approved" },
{ label: "Deliverables", value: "Ship unreviewed" },
],
special: "Speedrun. You watch; it plays.",
},
{
id: "cofounder",
name: "AI Co-Founder",
className: "Co-op",
icon: "heart",
pitch: "Checks with you: risky work and large hires wait for you, and deliverables wait for your review.",
stats: [
{ label: "Risky work", value: "Your Approvals" },
{ label: "Large hires", value: "Your Approvals" },
{ label: "Deliverables", value: "Your review" },
],
special: "Player 2 has the controller for the big calls.",
},
];
export const items: { icon: IconName; name: string; description: string }[] = [
{ icon: "building", name: "Pixel office", description: "An isometric office where you watch agents walk between desks, coffee and meetings. The lighting follows your real clock." },
{ icon: "chat", name: "CEO chat", description: "Messages between agents and with you, with the CEO's line pinned on top. Chat follows the app language." },
{ icon: "approve", name: "Audited goals", description: "A goal only ends when an isolated auditor approves the evidence. No 'trust me, it's done'." },
{ icon: "check", name: "Approvals", description: "In Co-Founder mode, risky work and large hires queue here until you say yes." },
{ icon: "folder", name: "Files & library", description: "Every file the team produces, plus a library of docs and knowledge your agents can read." },
{ icon: "plug", name: "Connectors", description: "Your own Composio key unlocks the toolkits your Composio project enables. logo.dev keeps the logos crisp." },
{ icon: "sparkle", name: "Skills", description: "Agents carry the skills you grant them, plus an automatic humanizer house style for conversational writing." },
{ icon: "book", name: "EN / 中文", description: "The whole app speaks English and 简体中文. Switch any time." },
{ icon: "lock", name: "Local-first vault", description: "Keys encrypted with your OS secure storage, data in SQLite on your machine, no account to create." },
];
export type PartyClass = { name: string; badge: string; icon: IconName; blurb: string; members: string[] };
export const party: PartyClass[] = [
{
name: "API key",
badge: "Key",
icon: "key",
blurb: "Paste a key and go. OpenAI defaults to gpt-6.1-sol.",
members: ["OpenAI", "Anthropic", "xAI Grok", "Groq", "Mistral", "Cerebras", "OpenRouter", "Kimi", "Kimi CN"],
},
{
name: "Plan sign-in",
badge: "Sign-in",
icon: "user",
blurb: "Use a subscription you already have.",
members: ["Claude Pro/Max", "GitHub Copilot", "Kimi Code", "GPT account (ChatGPT plan)"],
},
{
name: "Cloud",
badge: "Cloud",
icon: "building",
blurb: "Run through your company's cloud.",
members: ["Azure OpenAI", "AWS Bedrock", "Vertex AI"],
},
{
name: "Custom",
badge: "Base URL",
icon: "settings",
blurb: "Anything that speaks a familiar protocol.",
members: ["OpenAI-compatible endpoint", "Anthropic-compatible endpoint"],
},
];
export const faqs: { question: string; answer: string[] }[] = [
{
question: "Where do my API keys go?",
answer: [
"Encrypted with your OS secure storage (Keychain, DPAPI or libsecret) and saved in a SQLite database on your machine, next to the company itself.",
"No account, no sign-up. The app talks to the providers you pick, plus the web when agents search or fetch pages, and Composio if you connect it.",
],
},
{
question: "Which AI providers can join?",
answer: [
"OpenAI, Anthropic, Claude Pro/Max, xAI Grok, Groq, Mistral, Cerebras, OpenRouter, Kimi, Kimi CN, GitHub Copilot, a GPT account on a ChatGPT plan, and Kimi Code.",
"Also Azure OpenAI, AWS Bedrock, Vertex AI, and any custom OpenAI- or Anthropic-compatible endpoint.",
],
},
{
question: "Is the macOS build signed?",
answer: [
"Yes. The DMG and ZIP are signed with a Developer ID and notarized by Apple, so Gatekeeper opens them without a fight.",
"Apple Silicon and Intel each get their own build.",
],
},
{
question: "What runs on Linux?",
answer: ["An AppImage and a .deb package, both x64. Pick whichever your distro likes better."],
},
{
question: "Where does the source live?",
answer: [
"On Gitea at git.zacharyzhang.com. GitHub is no longer used.",
"Want to help? Open an AGit pull request on that Gitea. Push to refs/for/main and you're in the party.",
],
},
{
question: "When is a goal actually done?",
answer: [
"When an isolated auditor looks at the evidence and approves it. The CEO can't mark its own homework.",
],
},
];
export const credits: { role: string; name: string }[] = [
{ role: "Starring", name: "Your AI CEO" },
{ role: "Supporting cast", name: "Whoever it hires" },
{ role: "Auditor", name: "Isolated. Unbribable." },
{ role: "Board chair", name: "You" },
{ role: "Guide", name: "Bit, the intern" },
];
+6 -1
View File
@@ -1,4 +1,4 @@
import { platformDownloads, type PlatformDownload, type PlatformDownloadAsset } from "./release-links.generated";
import { platformDownloads, RELEASE_HOST, type PlatformDownload, type PlatformDownloadAsset } from "./release-links";
export const DOWNLOAD_COUNT_SEED = 13674;
export const DOWNLOAD_DEDUP_WINDOW_HOURS = 24;
@@ -26,3 +26,8 @@ export function getDownloadAsset(assetId: string) {
export function getTrackedDownloadHref(assetId: string) {
return `/api/download?asset=${encodeURIComponent(assetId)}`;
}
export function isAllowedDownloadUrl(href: string) {
const url = new URL(href);
return url.protocol === "https:" && url.host === RELEASE_HOST;
}
-56
View File
@@ -1,56 +0,0 @@
export type FeatureSection = {
id: string;
eyebrow: string;
title: string;
description: string;
image: string;
imageAlt: string;
};
export const featureSections: FeatureSection[] = [
{
id: "org-chart",
eyebrow: "Self-organizing",
title: "Your org chart builds itself",
description:
"Set one goal. The AI CEO decides which roles the mission needs, hires specialist agents, and draws the reporting lines — from your board seat down to every department.",
image: "/images/org-chart.webp",
imageAlt: "AgentCompany org chart with a board chair, an AI CEO, and specialist agents reporting into departments",
},
{
id: "office",
eyebrow: "Presence",
title: "A workplace you can watch",
description:
"Every agent has a desk. See who is heads-down at work, who is in the meeting room, and what the whole company is doing — at a single glance across the floor.",
image: "/images/office.webp",
imageAlt: "Top-down view of the AgentCompany virtual office with named agents at their desks",
},
{
id: "messages",
eyebrow: "Coordination",
title: "Agents that talk to each other",
description:
"Direct messages and department channels keep the team aligned. Read the reasoning behind every hand-off and decision, written out in plain language.",
image: "/images/messages.webp",
imageAlt: "AgentCompany messaging view with direct conversations between the CEO and specialist agents",
},
{
id: "connectors",
eyebrow: "Integrations",
title: "Plugged into your stack",
description:
"Connect GitHub, Slack, Notion, Google Workspace, Stripe, and dozens more. Your agents do the work inside the tools your company already runs on.",
image: "/images/connectors.webp",
imageAlt: "AgentCompany connector catalog with dozens of integrations ready to connect",
},
{
id: "cost-controls",
eyebrow: "Control",
title: "Spend stays under your cap",
description:
"Set a monthly budget and per-agent allocations. Track requests, tokens, and cost by agent — and hold the whole company inside the limit you set.",
image: "/images/cost-controls.webp",
imageAlt: "AgentCompany advanced settings showing budget caps and cost broken down by agent",
},
];
+86
View File
@@ -0,0 +1,86 @@
/** One 32-colour palette; the scene keeps its indices while day part and season swap the colours. */
export type DayPart = "dawn" | "day" | "dusk" | "night";
export type Season = "spring" | "summer" | "autumn" | "winter";
export const PALETTE_SIZE = 32;
/** Named indices every surface draws with; art never uses raw colours. */
export const INK = {
void: 0, shadow: 1, ink: 2, inkSoft: 3, stone: 4, stoneLight: 5, paper: 6, white: 7,
sky0: 8, sky1: 9, sky2: 10, sky3: 11, sun: 12, glow: 13, cloud: 14, star: 15,
wood: 16, woodDark: 17, brick: 18, rug: 19, leaf0: 20, leaf1: 21, leaf2: 22, bloom: 23,
accent: 24, accentDark: 25, gold: 26, mint: 27, sea: 28, berry: 29, skin: 30, screen: 31,
} as const;
export type InkName = keyof typeof INK;
const DAY: string[] = [
"#0b0a12", "#1d1b2b", "#2f2c40", "#4a4660", "#6d6884", "#9a95ad", "#e9e2cf", "#fbf7ec",
"#3f7fd1", "#68a2e6", "#9cc6f2", "#d4e8fa", "#ffe08a", "#fff2c2", "#f4f7fb", "#ffffff",
"#a8693c", "#6e3f23", "#b4533f", "#5d6ea8", "#2f6b3a", "#4f9a4a", "#8cc95f", "#f2a3c0",
"#e0663d", "#9c3b22", "#f2b632", "#5fd3a8", "#2a8fa8", "#c4466f", "#f1c79a", "#7ef0c2",
];
/** How a day part shifts each index: sky and light are replaced, everything else is tinted. */
const DAY_PART_SKY: Record<DayPart, string[]> = {
dawn: ["#5b4a7a", "#c77a8e", "#f2a98a", "#fbd9b0", "#ffb36b", "#ffd9a8", "#f7d6d8", "#fff4e6"],
day: DAY.slice(8, 16),
dusk: ["#2d2350", "#7a3d6e", "#d0625a", "#f4a259", "#ff8c42", "#ffc078", "#e9b0a0", "#ffe7c7"],
night: ["#05060f", "#0d1330", "#1b2650", "#2c3b70", "#e6e9ff", "#9fb2ff", "#3a4470", "#fff8d6"],
};
const DAY_PART_TINT: Record<DayPart, { mix: string; amount: number }> = {
dawn: { mix: "#f3b5a8", amount: 0.18 },
day: { mix: "#ffffff", amount: 0 },
dusk: { mix: "#f08a4b", amount: 0.22 },
night: { mix: "#1a2350", amount: 0.55 },
};
const SEASON_LEAVES: Record<Season, [string, string, string, string]> = {
spring: ["#3f7a3c", "#5fae52", "#9ed46c", "#f6a8c8"],
summer: ["#235c2e", "#3f8f3f", "#78c050", "#ffd35c"],
autumn: ["#7a3a1c", "#c2622a", "#e8a33d", "#d9483b"],
winter: ["#4c6670", "#8aa6ad", "#dfeaee", "#ffffff"],
};
export function dayPartAt(date: Date): DayPart {
const hour = date.getHours();
if (hour >= 5 && hour < 8) return "dawn";
if (hour >= 8 && hour < 17) return "day";
if (hour >= 17 && hour < 20) return "dusk";
return "night";
}
export function seasonAt(date: Date): Season {
const month = date.getMonth();
if (month >= 2 && month <= 4) return "spring";
if (month >= 5 && month <= 7) return "summer";
if (month >= 8 && month <= 10) return "autumn";
return "winter";
}
/** The 32 colours for a moment; index 31 (screens) and the accents stay lit at night. */
export function paletteFor(part: DayPart, season: Season): string[] {
const tint = DAY_PART_TINT[part];
const lit = new Set<number>([INK.screen, INK.accent, INK.gold, INK.mint, INK.glow, INK.star]);
const colours = DAY.map((colour, index) => (lit.has(index) ? colour : mix(colour, tint.mix, tint.amount)));
DAY_PART_SKY[part].forEach((colour, offset) => (colours[INK.sky0 + offset] = colour));
SEASON_LEAVES[season].forEach((colour, offset) => (colours[INK.leaf0 + offset] = colour));
return colours;
}
/** CSS custom properties --p0..--p31 plus named aliases (--ink-accent, ...). */
export function paletteCssVars(colours: string[]): Record<string, string> {
const vars: Record<string, string> = {};
colours.forEach((colour, index) => (vars[`--p${index}`] = colour));
for (const [name, index] of Object.entries(INK)) vars[`--ink-${kebab(name)}`] = colours[index]!;
return vars;
}
function kebab(name: string): string {
return name.replace(/[A-Z0-9]/g, (char) => `-${char.toLowerCase()}`);
}
function mix(a: string, b: string, amount: number): string {
const pa = Number.parseInt(a.slice(1), 16);
const pb = Number.parseInt(b.slice(1), 16);
const channel = (shift: number) => Math.round(((pa >> shift) & 255) * (1 - amount) + ((pb >> shift) & 255) * amount);
return `#${((channel(16) << 16) | (channel(8) << 8) | channel(0)).toString(16).padStart(6, "0")}`;
}
+61
View File
@@ -0,0 +1,61 @@
/** Hand-placed 12x12 pixel icons: rows separated by spaces, "#" is a lit pixel. */
export const PIXEL_ICONS = {
home: ".....##..... ....####.... ...##..##... ..##....##.. .##......##. ############ .#........#. .#..####..#. .#..#..#..#. .#..#..#..#. .#..#..#..#. .##########.",
work: "............ ....####.... ....#..#.... ############ #..........# #..........# #####..##### #....##....# #..........# #..........# ############ ............",
team: "............ ..##....##.. .####..####. .####..####. ..##....##.. ............ .####..####. ######.##### ######.##### ######.##### ............ ............",
user: "............ ....####.... ...######... ...######... ...######... ....####.... ............ ..########.. .##########. .##########. .##########. ............",
chat: "............ .##########. #..........# #..........# #.##.##.##.# #..........# #..........# .###.######. ...#.#...... ...##....... ...#........ ............",
send: "..........## ........#### ......###.## ....###..##. ..###...##.. ####...##... ..###.##.... ....####.... .....###.... ......##.... ......#..... ............",
folder: "............ ####........ #..#........ #..#######.. #........#.. ############ #..........# #..........# #..........# #..........# ############ ............",
file: "#######..... #.....##.... #.....#.#... #.....####.. #........#.. #.#####..#.. #........#.. #.#####..#.. #........#.. #.####...#.. #........#.. ##########..",
book: "............ .####..####. #....##....# #.##.##.##.# #....##....# #.##.##.##.# #....##....# #.##.##.##.# #....##....# .####..####. .....##..... ............",
approve: ".....##..... ..########.. .#........#. .#........#. .#......#.#. .#.#...##.#. .#.##.##..#. .#..###...#. ..#..#...#.. ...#....#... ....#..#.... .....##.....",
plug: "............ ...#....#... ...#....#... ..########.. ..#......#.. ..#......#.. ..#......#.. ...#....#... ....####.... .....##..... .....##..... .....##.....",
building: "..########.. ..#......#.. ..#.##.#.#.. ..#......#.. ..#.##.#.#.. ..#......#.. ..#.##.#.#.. ..#......#.. ###.####.### #.#.#..#.#.# #.#.#..#.#.# ############",
settings: ".....##..... ..#.####.#.. .##########. ..###..###.. .###....###. ####....#### ####....#### .###....###. ..###..###.. .##########. ..#.####.#.. .....##.....",
search: "............ ..#####..... .#.....#.... #.......#... #.......#... #.......#... #.......#... .#.....#.... ..######.... .......###.. ........###. .........##.",
plus: "............ ............ .....##..... .....##..... .....##..... ..########.. ..########.. .....##..... .....##..... .....##..... ............ ............",
minus: "............ ............ ............ ............ ............ ..########.. ..########.. ............ ............ ............ ............ ............",
chevron: "............ ............ ....##...... .....##..... ......##.... .......##... .......##... ......##.... .....##..... ....##...... ............ ............",
chevronDown: "............ ............ ............ ............ ..##....##.. ...##..##... ....####.... .....##..... ............ ............ ............ ............",
chevronLeft: "............ ............ ......##.... .....##..... ....##...... ...##....... ...##....... ....##...... .....##..... ......##.... ............ ............",
more: "............ ............ ............ ............ ............ .##..##..##. .##..##..##. ............ ............ ............ ............ ............",
edit: ".........##. ........#### .......####. ......####.. .....####... ....####.... ...####..... ..####...... .#.##....... .##......... .###........ ............",
filter: "............ ############ .##########. ..########.. ...######... ....####.... ....####.... ....####.... ....####.... ....###..... ....##...... ............",
grid: "............ .####..####. .####..####. .####..####. .####..####. ............ ............ .####..####. .####..####. .####..####. .####..####. ............",
list: "............ ##..######## ##..######## ............ ............ ##..######## ##..######## ............ ............ ##..######## ##..######## ............",
upload: ".....##..... ....####.... ...######... ..##.##.##.. .....##..... .....##..... .....##..... #....##....# #..........# #..........# ############ ............",
arrowRight: "............ ............ .......#.... .......##... .......###.. ##########.. ##########.. .......###.. .......##... .......#.... ............ ............",
arrowUpRight: "............ ....#######. .....######. ........###. .......####. ......###.#. .....###.... ....###..... ...###...... ..###....... .###........ ............",
sparkle: ".....##..... .....##..... .....##..... ....####.... ..########.. ############ ..########.. ....####.... .....##..... .....##..... .....##..... ............",
bot: ".....##..... .....##..... ..########.. .#........#. .#.##..##.#. .#.##..##.#. .#........#. .#..####..#. .#........#. ..########.. ...#....#... ...#....#...",
check: "............ ............ ..........## .........##. ........##.. .......##... ##....##.... .##..##..... ..####...... ...##....... ............ ............",
x: "............ .##......##. ..##....##.. ...##..##... ....####.... .....##..... ....####.... ...##..##... ..##....##.. .##......##. ............ ............",
pin: "............ ....######.. .....####... .....####... .....####... ....######.. ...########. .......#.... .......#.... .......#.... .......#.... ............",
attach: "....####.... ...#....#... ...#.##.#... ...#.#..#... ...#.#..#... ...#.#..#... ...#.#..#... ...#.#..#... ...#....#... ...#....#... ....####.... ............",
hash: "............ ...#...#.... ...#...#.... .#########.. ...#...#.... ...#...#.... ..#...#..... #########... ..#...#..... ..#...#..... ............ ............",
lock: "............ ....####.... ...#....#... ...#....#... ...#....#... ..########.. ..########.. ..###..###.. ..###..###.. ..########.. ..########.. ............",
download: ".....##..... .....##..... .....##..... .....##..... ..##.##.##.. ...######... ....####.... #....##....# #..........# #..........# ############ ............",
apple: ".......##... ......##.... ......#..... ..###..###.. .##########. #########... ########.... ########.... #########... .##########. ..###..###.. ............",
windows: "............ #####.#####. #####.#####. #####.#####. #####.#####. #####.#####. ............ #####.#####. #####.#####. #####.#####. #####.#####. #####.#####.",
linux: "....####.... ...######... ...#.##.#... ...######... ..###..###.. .##......##. .#........#. ##........## .#........#. .##......##. ####....#### .###....###.",
star: ".....##..... .....##..... ....####.... ....####.... ############ .##########. ..########.. ...######... ...######... ..###..###.. ..##....##.. .#........#.",
heart: "............ .###....###. #.###..##### ##.######### ############ ############ .##########. ..########.. ...######... ....####.... .....##..... ............",
sword: ".....##..... ....####.... ....#.##.... ....#.##.... ....#.##.... ....#.##.... ....#.##.... .##########. .##########. .....##..... .....##..... ....####....",
coin: "...######... .##......##. .#...##...#. #...###....# #....##....# #....##....# #....##....# #....##....# #....##....# .#..####..#. .##......##. ...######...",
chest: "............ .##########. #.#......#.# #.#......#.# ############ #.#.####.#.# #.#.#..#.#.# #.#.####.#.# #.#......#.# #.#......#.# ############ ............",
key: "............ ............ ............ .####....... #....#...... #.##.####### #.##.####### #....#...#.# .####....#.# ............ ............ ............",
gear: "....####.... .##.####.##. .##########. ..###..###.. ####....#### ###......### ###......### ####....#### ..###..###.. .##########. .##.####.##. ....####....",
map: "............ ############ #....#...#.# #.....#.#..# #......#...# #.....#.#..# #....#...#.# #..##......# #.#........# #..........# ############ ............",
flag: "..#......... ..#######... ..#########. ..########## ..#########. ..#######... ..#......... ..#......... ..#......... ..#......... .###........ ............",
trophy: "..########.. ##.######.## #..######..# #..######..# .#.######.#. ...######... ....####.... .....##..... .....##..... ....####.... ...######... ...######...",
crown: "............ ............ ............ .#...##...#. .##..##..##. .###.##.###. .##########. .##########. .#.##..##.#. .##########. ............ ............",
sun: ".....##..... .#...##...#. ..#......#.. ....####.... ...######... ##.######.## ##.######.## ...######... ....####.... ..#......#.. .#...##...#. .....##.....",
moon: "....##...... ..###....... .###.....#.. .###....###. ####.....#.. ####........ #####....... #######..... .#########.. .##########. ..########.. ....####....",
sunrise: "............ .....##..... ..#..##..#.. ...#....#... ....####.... #..######..# ..########.. ############ ............ .##########. ............ ...######...",
sunset: "............ ............ ............ ....####.... ..########.. .##########. ############ ............ .##########. ............ ...######... ............",
blossom: "....####.... ...######... ...######... .##.####.##. ####....#### ####.##.#### ####.##.#### ####....#### .##.####.##. ...######... ...######... ....####....",
icecream: "....####.... ...######... ..########.. ..########.. ..#.####.#.. ..########.. ...#.##.#... ...######... ....#..#.... ....####.... .....##..... .....##.....",
maple: ".....##..... ....####.... .#..####..#. .##.####.##. .##########. ############ .##########. ..########.. .###.##.###. .....##..... .....##..... ............",
snowflake: ".....##..... .#..####..#. ..#..##..#.. ...#.##.#... ....####.... ############ ############ ....####.... ...#.##.#... ..#..##..#.. .#..####..#. .....##.....",
} as const;
-112
View File
@@ -1,112 +0,0 @@
import {
platformDownloads,
type PlatformDownload,
type PlatformDownloadAsset,
type ReleaseArchitecture,
} from "./release-links.generated";
const ARCHITECTURE_ORDER: ReleaseArchitecture[] = ["arm64", "x64", "x86"];
export type PlatformSupportSummary = {
downloadAnswer: string;
platformAnswer: string;
};
export const platformSupportSummary = buildPlatformSupportSummary(platformDownloads);
/**
* Copy derives from whatever platforms the release manifest actually ships,
* so the download matrix stays the single source of truth and the site can
* never advertise a platform without a real artifact behind it.
*/
export function buildPlatformSupportSummary(downloads: PlatformDownload[]): PlatformSupportSummary {
if (downloads.length === 0) {
throw new Error("Release download metadata must include at least one platform.");
}
return {
downloadAnswer:
`Use the download section on this page to choose the build that matches your architecture and package format. ` +
downloads.map(formatDownloadSentence).join(" "),
platformAnswer:
`AgentCompany is available on ${joinWithAnd(downloads.map(formatAvailability))}. ` +
`It is built with Electron and the desktop feature set stays aligned across all supported targets.`,
};
}
function joinWithAnd(parts: string[]): string {
if (parts.length === 1) {
return parts[0];
}
if (parts.length === 2) {
return `${parts[0]} and ${parts[1]}`;
}
return `${parts.slice(0, -1).join(", ")}, and ${parts.at(-1)}`;
}
function formatDownloadSentence(download: PlatformDownload): string {
const architectures = formatArchitectureList(collectArchitectures(download));
if (!download.packageDescription.trim()) {
throw new Error(`Release platform ${download.id} must include package format copy.`);
}
return `${download.title} ships as ${download.packageDescription} for ${architectures}.`;
}
function formatAvailability(download: PlatformDownload): string {
return `${download.title} (${formatArchitectureList(collectArchitectures(download), "comma")})`;
}
function collectArchitectures(download: PlatformDownload): ReleaseArchitecture[] {
const architectures = new Set<ReleaseArchitecture>();
for (const asset of download.assets) {
collectAssetArchitectures(download.id, asset).forEach((architecture) => architectures.add(architecture));
}
return [...architectures].sort((left, right) => architectureRank(left) - architectureRank(right));
}
function collectAssetArchitectures(
platformId: PlatformDownload["id"],
asset: PlatformDownloadAsset,
): ReleaseArchitecture[] {
if (asset.architectures.length === 0) {
throw new Error(`Release asset ${platformId}/${asset.id} must declare at least one architecture.`);
}
for (const architecture of asset.architectures) {
if (!isReleaseArchitecture(architecture)) {
throw new Error(`Release asset ${platformId}/${asset.id} declares unsupported architecture: ${architecture}`);
}
}
return asset.architectures;
}
function isReleaseArchitecture(value: string): value is ReleaseArchitecture {
return ARCHITECTURE_ORDER.includes(value as ReleaseArchitecture);
}
function architectureRank(architecture: ReleaseArchitecture): number {
return ARCHITECTURE_ORDER.indexOf(architecture);
}
function formatArchitectureList(
architectures: ReleaseArchitecture[],
style: "and" | "comma" = "and",
): string {
if (architectures.length === 0) {
throw new Error("Release download metadata must include at least one architecture.");
}
if (style === "comma") {
return architectures.join(", ");
}
if (architectures.length === 1) {
return architectures[0];
}
if (architectures.length === 2) {
return `${architectures[0]} and ${architectures[1]}`;
}
return `${architectures.slice(0, -1).join(", ")}, and ${architectures.at(-1)}`;
}
+56
View File
@@ -0,0 +1,56 @@
import { downloadAssets, type ReleaseDownloadAsset } from "./downloads";
import type { ReleaseArchitecture } from "./release-links";
export type DetectedOs = "macos" | "windows" | "linux" | "unknown";
export type DetectedArch = "arm64" | "x64" | "unknown";
export type DetectedPlatform = { os: DetectedOs; arch: DetectedArch };
export type UaData = { platform?: string; architecture?: string; bitness?: string };
type NavigatorUaData = UaData & {
getHighEntropyValues(hints: string[]): Promise<UaData>;
};
function detectOs(userAgent: string, uaPlatform: string): DetectedOs {
if (/android|iphone|ipad|ipod|cros/.test(userAgent)) return "unknown";
if (uaPlatform === "macos" || /mac os x|macintosh/.test(userAgent)) return "macos";
if (uaPlatform === "windows" || /windows/.test(userAgent)) return "windows";
if (uaPlatform === "linux" || /linux|x11/.test(userAgent)) return "linux";
return "unknown";
}
function detectArch(userAgent: string, uaArchitecture: string): DetectedArch {
if (uaArchitecture === "arm") return "arm64";
if (uaArchitecture === "x86") return "x64";
if (/arm64|aarch64/.test(userAgent)) return "arm64";
if (/x86_64|x64|win64|wow64|amd64/.test(userAgent)) return "x64";
return "unknown";
}
export function detectPlatform(userAgent: string, uaData?: UaData): DetectedPlatform {
const ua = userAgent.toLowerCase();
const os = detectOs(ua, uaData?.platform?.toLowerCase() ?? "");
// Mac user agents always say "Intel", so only client hints can tell the architecture apart.
const arch = detectArch(os === "macos" ? "" : ua, uaData?.architecture?.toLowerCase() ?? "");
return { os, arch };
}
export function recommendedAsset(
platform: DetectedPlatform,
assets: ReleaseDownloadAsset[] = downloadAssets,
): ReleaseDownloadAsset | null {
if (platform.os === "unknown") return null;
// Windows on ARM runs the x64 installer under emulation; a Mac of unknown architecture gets a choice.
let arch: ReleaseArchitecture;
if (platform.os === "windows") arch = "x64";
else if (platform.arch !== "unknown") arch = platform.arch;
else if (platform.os === "linux") arch = "x64";
else return null;
return assets.find((asset) => asset.platformId === platform.os && asset.architectures.includes(arch)) ?? null;
}
export async function detectClientPlatform(): Promise<DetectedPlatform> {
const uaData = (navigator as Navigator & { userAgentData?: NavigatorUaData }).userAgentData;
const hints = uaData ? await uaData.getHighEntropyValues(["architecture", "bitness"]) : undefined;
return detectPlatform(navigator.userAgent, hints);
}
-80
View File
@@ -1,80 +0,0 @@
import { releaseVersion } from "./release-links.generated";
export const landingHeroNarrative = {
eyebrow: "AgentCompany Desktop",
titleLine1: "Launch an Autonomous AI Company",
titleLine2: "from Your Desktop",
description:
"Run AI CEO by default, switch to AI Co-Founder for human-reviewed decisions, and launch the first mission from a local desktop setup.",
availability: "Available on Windows · macOS build coming soon",
} as const;
export const onboardingFlowNarrative = {
prelude: "Welcome",
sections: [
{
number: 1,
title: "Requirements",
description: "Check Node.js because CLI runtimes call the local API through node -e.",
},
{
number: 2,
title: "Providers",
description: "Connect at least one runtime before company creation continues.",
},
{
number: 3,
title: "Company",
description: "Enter company name, description, and workspace.",
},
{
number: 4,
title: "Team Models",
description: "Enable the models available to the CEO and workers.",
},
{
number: 5,
title: "Mode",
description: "Use AI CEO by default, or choose AI Co-Founder for human-reviewed decisions.",
},
{
number: 6,
title: "Mission",
description: "Capture the first goal and acceptance description.",
},
{
number: 7,
title: "Launch",
description: "Create the company, CEO agent, workspace, kickoff task, and initial run handoff.",
},
],
} as const;
export const landingHowItWorksNarrative = {
heading: "Follow the Desktop First-Run Flow",
steps: onboardingFlowNarrative.sections,
completionText: "Initial run queued. Open Control Center.",
demoMessages: [
{ agent: "Requirements", role: "system", text: "Node.js ready. CLI runtimes can call the local API.", delay: 0 },
{ agent: "Providers", role: "system", text: "Runtime connected.", delay: 700 },
{ agent: "Company", role: "system", text: "Company profile and workspace saved.", delay: 1400 },
{ agent: "Team Models", role: "system", text: "Model pool enabled for CEO and workers.", delay: 2100 },
{ agent: "Mode", role: "system", text: "AI CEO mode selected.", delay: 2800 },
{ agent: "Mission", role: "system", text: "First goal and acceptance description captured.", delay: 3500 },
{ agent: "Launch", role: "system", text: "Company, CEO agent, kickoff task, and initial run created.", delay: 4400 },
],
} as const;
export const landingStructuredDataNarrative = {
softwareVersion: releaseVersion,
featureList: [
"Guided first-run setup",
"Node.js readiness check for CLI runtimes",
"Provider connection and model-pool selection",
"Multi-connector support (Claude Code, Codex CLI, Gemini CLI)",
"AI CEO autonomous operating mode",
"AI Co-Founder human-reviewed operating mode",
"Local-first SQLite storage",
"Desktop releases for Windows (macOS build coming soon)",
],
};
-59
View File
@@ -1,59 +0,0 @@
export type PlatformDownloadAsset = {
architectures: ReleaseArchitecture[];
id: string;
label: string;
href: string;
note: string;
};
export type ReleaseArchitecture = "arm64" | "x64" | "x86";
export type PlatformDownload = {
id: "macos" | "windows" | "linux";
packageDescription: string;
title: string;
note: string;
assets: PlatformDownloadAsset[];
};
export const releaseVersion = "1.4.1";
export const platformDownloads: PlatformDownload[] = [
{
id: "macos",
packageDescription: "signed and notarized DMG builds",
title: "macOS",
note: "Signed + notarized DMG builds",
assets: [
{
architectures: ["arm64"],
id: "macos-arm64-dmg",
label: "Apple Silicon DMG",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-1.4.1-arm64.dmg",
note: "arm64 · v1.4.1",
},
{
architectures: ["x64"],
id: "macos-x64-dmg",
label: "Intel Mac DMG",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-1.4.1.dmg",
note: "x64 · v1.4.1",
},
],
},
{
id: "windows",
packageDescription: "a signed NSIS installer build",
title: "Windows",
note: "Signed NSIS installer build",
assets: [
{
architectures: ["x64"],
id: "windows-x64-installer",
label: "Windows Installer (x64)",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-1.4.1-x64.exe",
note: "x64 · v1.4.1",
},
],
},
];
+78
View File
@@ -0,0 +1,78 @@
export type ReleaseArchitecture = "arm64" | "x64";
export type PlatformDownloadAsset = {
architectures: ReleaseArchitecture[];
id: string;
label: string;
fileName: string;
href: string;
sizeBytes: number;
size: string;
note: string;
};
export type PlatformDownload = {
id: "macos" | "windows" | "linux";
packageDescription: string;
title: string;
note: string;
assets: PlatformDownloadAsset[];
};
export const releaseVersion = "1.5.1";
export const RELEASE_HOST = "git.zacharyzhang.com";
export const SOURCE_URL = `https://${RELEASE_HOST}/ZacharyZhang-NY/AgentCompany`;
export const RELEASE_PAGE_URL = `${SOURCE_URL}/releases/tag/v${releaseVersion}`;
function asset(
id: string,
label: string,
architecture: ReleaseArchitecture,
fileName: string,
sizeBytes: number,
): PlatformDownloadAsset {
return {
architectures: [architecture],
id,
label,
fileName,
href: `${SOURCE_URL}/releases/download/v${releaseVersion}/${fileName}`,
sizeBytes,
size: `${Math.round(sizeBytes / 1_000_000)} MB`,
note: `${architecture} · v${releaseVersion}`,
};
}
export const platformDownloads: PlatformDownload[] = [
{
id: "macos",
packageDescription: "signed and notarized DMG and ZIP builds",
title: "macOS",
note: "Signed + notarized DMG and ZIP builds",
assets: [
asset("macos-arm64-dmg", "Apple Silicon DMG", "arm64", "AgentCompany-1.5.1-arm64.dmg", 118584099),
asset("macos-arm64-zip", "Apple Silicon ZIP", "arm64", "AgentCompany-1.5.1-arm64-mac.zip", 321953469),
asset("macos-x64-dmg", "Intel Mac DMG", "x64", "AgentCompany-1.5.1.dmg", 126949692),
asset("macos-x64-zip", "Intel Mac ZIP", "x64", "AgentCompany-1.5.1-mac.zip", 346581782),
],
},
{
id: "windows",
packageDescription: "an NSIS installer build",
title: "Windows",
note: "NSIS installer build",
assets: [
asset("windows-x64-installer", "Windows Installer (x64)", "x64", "AgentCompany-1.5.1-x64.exe", 98672653),
],
},
{
id: "linux",
packageDescription: "AppImage and .deb builds",
title: "Linux",
note: "AppImage and .deb builds",
assets: [
asset("linux-x64-appimage", "Linux AppImage (x64)", "x64", "AgentCompany-1.5.1.AppImage", 122224922),
asset("linux-x64-deb", "Debian/Ubuntu .deb (x64)", "x64", "agentcompany-new_1.5.1_amd64.deb", 95501700),
],
},
];
+64 -691
View File
@@ -11,31 +11,23 @@
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"reicon-brands": "^1.0.2",
"reicon-react": "^1.1.2"
"three": "^0.180.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4.2.2",
"@types/node": "^24.9.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"tailwindcss": "^4.2.2",
"@types/three": "^0.180.0",
"typescript": "^5.9.3"
}
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
"license": "Apache-2.0"
},
"node_modules/@emnapi/runtime": {
"version": "1.9.2",
@@ -561,56 +553,6 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@next/env": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz",
@@ -782,288 +724,12 @@
"tslib": "^2.8.0"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
"integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==",
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "^5.19.0",
"jiti": "^2.6.1",
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.2.2"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz",
"integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.2.2",
"@tailwindcss/oxide-darwin-arm64": "4.2.2",
"@tailwindcss/oxide-darwin-x64": "4.2.2",
"@tailwindcss/oxide-freebsd-x64": "4.2.2",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2",
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.2",
"@tailwindcss/oxide-linux-arm64-musl": "4.2.2",
"@tailwindcss/oxide-linux-x64-gnu": "4.2.2",
"@tailwindcss/oxide-linux-x64-musl": "4.2.2",
"@tailwindcss/oxide-wasm32-wasi": "4.2.2",
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.2",
"@tailwindcss/oxide-win32-x64-msvc": "4.2.2"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz",
"integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz",
"integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz",
"integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz",
"integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz",
"integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz",
"integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz",
"integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz",
"integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz",
"integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz",
"integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
"@emnapi/runtime",
"@tybys/wasm-util",
"@emnapi/wasi-threads",
"tslib"
],
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.8.1",
"@emnapi/runtime": "^1.8.1",
"@emnapi/wasi-threads": "^1.1.0",
"@napi-rs/wasm-runtime": "^1.1.1",
"@tybys/wasm-util": "^0.10.1",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
"integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz",
"integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/postcss": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz",
"integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@tailwindcss/node": "4.2.2",
"@tailwindcss/oxide": "4.2.2",
"postcss": "^8.5.6",
"tailwindcss": "4.2.2"
}
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.12.2",
@@ -1095,6 +761,43 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.180.0",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": "*",
"@webgpu/types": "*",
"fflate": "~0.8.2",
"meshoptimizer": "~0.22.0"
}
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"dev": true,
"license": "MIT"
},
"node_modules/@webgpu/types": {
"version": "0.1.74",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz",
"integrity": "sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.14",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz",
@@ -1144,25 +847,18 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/enhanced-resolve": {
"version": "5.20.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
"integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==",
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.3.0"
},
"engines": {
"node": ">=10.13.0"
}
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.2",
@@ -1178,305 +874,12 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"node_modules/meshoptimizer": {
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
"integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==",
"dev": true,
"license": "ISC"
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
@@ -1636,21 +1039,6 @@
"react": "^19.2.4"
}
},
"node_modules/reicon-brands": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/reicon-brands/-/reicon-brands-1.0.2.tgz",
"integrity": "sha512-m+J4Ooa9vU1+Kg73GKni3eJgrFsoRmesLEgjkzHq4b3hkoJZj7so5kk8rYnkS8WZGfgH+ACb1u5wcjBRFUX4tw==",
"license": "MIT"
},
"node_modules/reicon-react": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/reicon-react/-/reicon-react-1.1.2.tgz",
"integrity": "sha512-dbp1CPEPKFjWfG41lGRMWi74YKfKJlb2JcB/TRBAUQF6jalZYW1dRZisRPCpze5TfQwRBD30p40qnoY+oYi7MQ==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -1747,27 +1135,12 @@
}
}
},
"node_modules/tailwindcss": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
"dev": true,
"node_modules/three": {
"version": "0.180.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz",
"integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+5 -5
View File
@@ -8,22 +8,22 @@
"build": "next build",
"start": "next start",
"preview": "wrangler pages dev out",
"test:e2e": "next build && playwright test"
"test:e2e": "next build && playwright test",
"test:unit": "node --import ./tests/unit/resolve-ts.mjs --test 'tests/unit/*.test.ts'",
"og": "node scripts/og.mjs"
},
"dependencies": {
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"reicon-brands": "^1.0.2",
"reicon-react": "^1.1.2"
"three": "^0.180.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4.2.2",
"@types/node": "^24.9.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"tailwindcss": "^4.2.2",
"@types/three": "^0.180.0",
"typescript": "^5.9.3"
},
"overrides": {
+15 -5
View File
@@ -7,7 +7,7 @@ const baseURL = `http://127.0.0.1:${PORT}`;
// `playwright install` revision is unavailable locally); CI uses the bundled
// browser and leaves this unset.
const executablePath = process.env.PW_CHROME || undefined;
const launchOptions = executablePath ? { executablePath } : {};
const swiftshader = ["--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--ignore-gpu-blocklist"];
export default defineConfig({
testDir: "./tests/e2e",
@@ -18,18 +18,28 @@ export default defineConfig({
use: {
baseURL,
trace: "on-first-retry",
launchOptions,
},
projects: [
{
name: "desktop",
use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 900 } },
grepInvert: /@no-webgl/,
use: {
...devices["Desktop Chrome"],
viewport: { width: 1440, height: 900 },
launchOptions: { executablePath, args: swiftshader },
},
},
{
name: "mobile",
use: { ...devices["Pixel 5"] },
name: "no-webgl",
grep: /@no-webgl/,
use: {
...devices["Desktop Chrome"],
viewport: { width: 1440, height: 900 },
launchOptions: { executablePath, args: ["--disable-webgl", "--disable-webgl2"] },
},
},
],
// `npm run test:e2e` builds the static export first; this only serves `out/` (with 404.html for unknown paths).
webServer: {
command: `node tests/e2e/serve.mjs out ${PORT}`,
url: baseURL,
-5
View File
@@ -1,5 +0,0 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
-810
View File
@@ -1,810 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Design System Preview: Cohere (Dark)</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
/* Primary */
--color-black: #000000;
--color-near-black: #212121;
--color-deep-dark: #17171c;
/* Accent */
--color-interaction-blue: #1863dc;
--color-ring-blue: rgba(76, 110, 230, 0.5);
--color-focus-purple: #9b60aa;
/* Surface */
--color-white: #ffffff;
--color-snow: #fafafa;
--color-lightest-gray: #f2f2f2;
/* Neutrals */
--color-muted-slate: #93939f;
--color-border-cool: #d9d9dd;
--color-border-light: #e5e7eb;
/* Fonts */
--font-display: 'Space Grotesk', Inter, ui-sans-serif, system-ui, sans-serif;
--font-body: Inter, Arial, ui-sans-serif, system-ui, sans-serif;
--font-mono: 'Space Mono', Arial, ui-sans-serif, system-ui, monospace;
/* Dark mode tokens */
--bg-page: #17171c;
--bg-card: #212121;
--bg-nav: rgba(23, 23, 28, 0.95);
--text-primary: #fafafa;
--text-secondary: #d9d9dd;
--text-muted: #93939f;
--border-color: #333333;
--border-subtle: #2a2a2f;
--section-label-color: #93939f;
--accent: #1863dc;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg-page);
color: var(--text-primary);
font-family: var(--font-body);
font-size: 16px;
line-height: 1.5;
font-weight: 400;
-webkit-font-smoothing: antialiased;
}
/* NAV */
.nav {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 40px;
background: var(--bg-nav);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border-subtle);
}
.nav-brand {
font-family: var(--font-display);
font-size: 20px;
font-weight: 400;
letter-spacing: -0.4px;
display: flex;
align-items: center;
gap: 6px;
color: var(--text-primary);
}
.nav-brand svg { width: 28px; height: 28px; }
.nav-links { display: flex; gap: 32px; align-items: center; }
.nav-links a {
color: var(--text-secondary);
text-decoration: none;
font-size: 16px;
font-weight: 400;
font-family: var(--font-body);
transition: color 0.2s;
}
.nav-links a:hover { color: var(--accent); }
.nav-cta {
background: var(--color-white);
color: var(--color-black);
padding: 10px 24px;
border: none;
border-radius: 9999px;
font-size: 14px;
font-family: var(--font-body);
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.nav-cta:hover { opacity: 0.85; }
/* HERO */
.hero {
position: relative;
text-align: center;
padding: 120px 40px 100px;
overflow: hidden;
}
.hero h1 {
font-family: var(--font-display);
font-size: 72px;
font-weight: 400;
line-height: 1.0;
letter-spacing: -1.44px;
margin-bottom: 24px;
color: var(--text-primary);
}
.hero h1 span { color: var(--accent); }
.hero p {
color: var(--text-muted);
font-family: var(--font-body);
font-size: 18px;
font-weight: 400;
line-height: 1.4;
margin-bottom: 40px;
}
.hero-buttons { display: flex; gap: 16px; justify-content: center; }
.btn-ghost-primary {
background: transparent;
color: var(--text-primary);
padding: 12px 24px;
border: none;
font-size: 16px;
font-family: var(--font-body);
font-weight: 400;
cursor: pointer;
transition: color 0.2s, opacity 0.2s;
}
.btn-ghost-primary:hover { color: var(--accent); opacity: 0.8; }
.btn-light-solid {
background: var(--color-white);
color: var(--color-black);
padding: 12px 28px;
border: none;
border-radius: 9999px;
font-size: 16px;
font-family: var(--font-body);
font-weight: 400;
cursor: pointer;
transition: opacity 0.2s;
}
.btn-light-solid:hover { opacity: 0.85; }
/* SECTIONS */
.section {
max-width: 1200px;
margin: 0 auto;
padding: 80px 40px;
}
.section-title {
font-family: var(--font-mono);
font-size: 14px;
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.28px;
color: var(--section-label-color);
margin-bottom: 12px;
}
.section-heading {
font-family: var(--font-body);
font-size: 48px;
font-weight: 400;
line-height: 1.2;
letter-spacing: -0.48px;
margin-bottom: 48px;
color: var(--text-primary);
}
.section-divider {
border: none;
border-top: 1px solid var(--border-subtle);
margin: 0 40px;
max-width: 1200px;
margin-left: auto;
margin-right: auto;
}
/* PURPLE BAND */
.purple-band {
background: linear-gradient(135deg, #2d1b4e 0%, #1a1040 50%, #0d0a1a 100%);
padding: 80px 40px;
text-align: center;
}
.purple-band h2 {
font-family: var(--font-display);
font-size: 60px;
font-weight: 400;
line-height: 1.0;
letter-spacing: -1.2px;
color: #ffffff;
margin-bottom: 16px;
}
.purple-band p {
color: rgba(255,255,255,0.7);
font-size: 18px;
font-weight: 400;
line-height: 1.4;
}
/* COLOR PALETTE */
.color-group { margin-bottom: 40px; }
.color-group-title {
font-family: var(--font-body);
font-size: 20px;
font-weight: 400;
line-height: 1.2;
margin-bottom: 20px;
color: var(--text-secondary);
}
.color-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
}
.color-swatch {
border: 1px solid var(--border-color);
border-radius: 22px;
overflow: hidden;
}
.color-swatch-block {
height: 80px;
position: relative;
}
.color-swatch-info {
padding: 12px 16px;
background: var(--bg-card);
}
.color-swatch-name { font-size: 13px; font-weight: 500; margin-bottom: 2px; color: var(--text-primary); }
.color-swatch-hex { font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); margin-bottom: 4px; }
.color-swatch-role { font-size: 12px; color: var(--text-muted); line-height: 1.4; }
/* TYPOGRAPHY */
.type-sample { margin-bottom: 32px; padding-bottom: 32px; border-bottom: 1px solid var(--border-subtle); }
.type-sample:last-child { border-bottom: none; }
.type-sample-text { margin-bottom: 8px; }
.type-sample-label {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-muted);
}
/* BUTTONS */
.button-row {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: flex-start;
}
.button-demo { text-align: center; }
.button-demo-label {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
margin-top: 10px;
text-transform: uppercase;
letter-spacing: 0.28px;
}
.btn-ghost-cohere {
background: transparent;
color: var(--text-primary);
padding: 12px 24px;
border: none;
font-size: 16px;
font-family: var(--font-body);
font-weight: 400;
cursor: pointer;
transition: color 0.2s, opacity 0.2s;
}
.btn-ghost-cohere:hover { color: var(--accent); opacity: 0.8; }
.btn-light-cohere {
background: var(--color-white);
color: var(--color-black);
padding: 12px 28px;
border: none;
border-radius: 22px;
font-size: 16px;
font-family: var(--font-body);
font-weight: 400;
cursor: pointer;
}
.btn-light-pill {
background: var(--color-white);
color: var(--color-black);
padding: 12px 28px;
border: none;
border-radius: 9999px;
font-size: 14px;
font-family: var(--font-body);
font-weight: 500;
cursor: pointer;
}
.btn-outlined {
background: transparent;
color: var(--text-primary);
padding: 12px 24px;
border: 1px solid var(--border-color);
border-radius: 22px;
font-size: 16px;
font-family: var(--font-body);
font-weight: 400;
cursor: pointer;
transition: border-color 0.2s, color 0.2s;
}
.btn-outlined:hover { border-color: var(--accent); color: var(--accent); }
/* CARDS */
.card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 24px; }
.card {
background: var(--bg-card);
padding: 28px;
}
.card-signature {
border-radius: 22px;
border: 1px solid var(--border-color);
}
.card-dialog {
border-radius: 8px;
border: 1px solid var(--border-color);
}
.card-generous {
border-radius: 16px;
border: 1px solid var(--border-color);
}
.card h3 {
font-family: var(--font-body);
font-size: 24px;
font-weight: 400;
line-height: 1.3;
letter-spacing: normal;
margin-bottom: 12px;
color: var(--text-primary);
}
.card p { color: var(--text-muted); font-size: 16px; line-height: 1.5; font-weight: 400; }
.card-label {
font-family: var(--font-mono);
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.28px;
color: var(--text-muted);
margin-bottom: 16px;
}
/* SPACING */
.spacing-row { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; }
.spacing-item { text-align: center; }
.spacing-box {
background: rgba(24, 99, 220, 0.15);
border: 1px solid rgba(24, 99, 220, 0.3);
border-radius: 2px;
margin-bottom: 8px;
}
.spacing-label {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
}
/* RADIUS */
.radius-row { display: flex; flex-wrap: wrap; gap: 20px; align-items: center; }
.radius-item { text-align: center; }
.radius-box {
width: 80px;
height: 80px;
background: var(--bg-card);
border: 1px solid var(--border-color);
margin-bottom: 8px;
}
.radius-label {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
}
.radius-context {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
/* ELEVATION */
.elevation-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 24px; }
.elevation-card {
background: var(--bg-card);
border-radius: 22px;
padding: 24px;
min-height: 140px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.elevation-flat { border: none; background: var(--bg-page); }
.elevation-bordered-light { border: 1px solid var(--border-subtle); }
.elevation-bordered-cool { border: 1px solid var(--border-color); }
.elevation-purple-band {
background: linear-gradient(135deg, #2d1b4e 0%, #1a1040 100%);
color: #ffffff;
}
.elevation-purple-band .elevation-name,
.elevation-purple-band .elevation-desc { color: #ffffff; }
.elevation-purple-band .elevation-level { color: rgba(255,255,255,0.6); }
.elevation-name { font-size: 16px; font-weight: 500; margin-bottom: 8px; color: var(--text-primary); }
.elevation-desc { font-size: 13px; color: var(--text-muted); line-height: 1.5; }
.elevation-level {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.28px;
margin-top: 12px;
}
/* RESPONSIVE */
@media (max-width: 768px) {
.nav { padding: 12px 20px; }
.nav-links a:not(.nav-cta-wrapper) { display: none; }
.hero { padding: 80px 20px 60px; }
.hero h1 { font-size: 42px; letter-spacing: -0.84px; }
.section { padding: 60px 20px; }
.section-heading { font-size: 32px; letter-spacing: -0.32px; }
.color-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
.card-grid { grid-template-columns: 1fr; }
.hero-buttons { flex-direction: column; align-items: center; }
.button-row { flex-direction: column; align-items: flex-start; }
.purple-band h2 { font-size: 36px; }
}
</style>
</head>
<body>
<!-- NAV -->
<nav class="nav">
<div class="nav-brand">
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="14" cy="14" r="13" stroke="currentColor" stroke-width="2"/>
<path d="M8 14 C8 10, 12 8, 14 8 C16 8, 20 10, 20 14 C20 18, 16 20, 14 20 C12 20, 8 18, 8 14Z" fill="currentColor" opacity="0.15"/>
<circle cx="14" cy="14" r="4" fill="currentColor"/>
</svg>
cohere
</div>
<div class="nav-links">
<a href="#colors">Colors</a>
<a href="#typography">Typography</a>
<a href="#buttons">Buttons</a>
<a href="#cards">Cards</a>
<a href="#spacing">Spacing</a>
<a href="#elevation">Elevation</a>
<button class="nav-cta">Get Started</button>
</div>
</nav>
<!-- HERO -->
<section class="hero">
<h1>Design System Preview:<br><span>Cohere</span></h1>
<p>Auto-generated design token catalog from DESIGN.md</p>
<div class="hero-buttons">
<button class="btn-light-solid">Explore Tokens</button>
<button class="btn-ghost-primary">View Source</button>
</div>
</section>
<hr class="section-divider">
<!-- COLORS -->
<section class="section" id="colors">
<div class="section-title">01 / Color Palette</div>
<h2 class="section-heading">Color Palette & Roles</h2>
<div class="color-group">
<h3 class="color-group-title">Primary</h3>
<div class="color-grid">
<div class="color-swatch">
<div class="color-swatch-block" style="background: #000000; border-bottom: 1px solid #333;"></div>
<div class="color-swatch-info">
<div class="color-swatch-name">Cohere Black</div>
<div class="color-swatch-hex">#000000</div>
<div class="color-swatch-role">Primary headline text, maximum emphasis</div>
</div>
</div>
<div class="color-swatch">
<div class="color-swatch-block" style="background: #212121; border-bottom: 1px solid #333;"></div>
<div class="color-swatch-info">
<div class="color-swatch-name">Near Black</div>
<div class="color-swatch-hex">#212121</div>
<div class="color-swatch-role">Standard body link color</div>
</div>
</div>
<div class="color-swatch">
<div class="color-swatch-block" style="background: #17171c; border-bottom: 1px solid #333;"></div>
<div class="color-swatch-info">
<div class="color-swatch-name">Deep Dark</div>
<div class="color-swatch-hex">#17171c</div>
<div class="color-swatch-role">Navigation and dark-section text</div>
</div>
</div>
</div>
</div>
<div class="color-group">
<h3 class="color-group-title">Secondary & Accent</h3>
<div class="color-grid">
<div class="color-swatch">
<div class="color-swatch-block" style="background: #1863dc;"></div>
<div class="color-swatch-info">
<div class="color-swatch-name">Interaction Blue</div>
<div class="color-swatch-hex">#1863dc</div>
<div class="color-swatch-role">Hover, focus states, active links</div>
</div>
</div>
<div class="color-swatch">
<div class="color-swatch-block" style="background: rgba(76,110,230,0.5);"></div>
<div class="color-swatch-info">
<div class="color-swatch-name">Ring Blue</div>
<div class="color-swatch-hex">#4c6ee6 @ 50%</div>
<div class="color-swatch-role">Keyboard focus indicators</div>
</div>
</div>
<div class="color-swatch">
<div class="color-swatch-block" style="background: #9b60aa;"></div>
<div class="color-swatch-info">
<div class="color-swatch-name">Focus Purple</div>
<div class="color-swatch-hex">#9b60aa</div>
<div class="color-swatch-role">Input focus border color</div>
</div>
</div>
</div>
</div>
<div class="color-group">
<h3 class="color-group-title">Surface & Background</h3>
<div class="color-grid">
<div class="color-swatch">
<div class="color-swatch-block" style="background: #ffffff; border-bottom: 1px solid #e5e7eb;"></div>
<div class="color-swatch-info">
<div class="color-swatch-name">Pure White</div>
<div class="color-swatch-hex">#ffffff</div>
<div class="color-swatch-role">Primary page background, card surface</div>
</div>
</div>
<div class="color-swatch">
<div class="color-swatch-block" style="background: #fafafa; border-bottom: 1px solid #e5e7eb;"></div>
<div class="color-swatch-info">
<div class="color-swatch-name">Snow</div>
<div class="color-swatch-hex">#fafafa</div>
<div class="color-swatch-role">Subtle elevated surfaces, light sections</div>
</div>
</div>
<div class="color-swatch">
<div class="color-swatch-block" style="background: #f2f2f2; border-bottom: 1px solid #e5e7eb;"></div>
<div class="color-swatch-info">
<div class="color-swatch-name">Lightest Gray</div>
<div class="color-swatch-hex">#f2f2f2</div>
<div class="color-swatch-role">Card borders, softest containment</div>
</div>
</div>
</div>
</div>
<div class="color-group">
<h3 class="color-group-title">Neutrals & Text</h3>
<div class="color-grid">
<div class="color-swatch">
<div class="color-swatch-block" style="background: #93939f;"></div>
<div class="color-swatch-info">
<div class="color-swatch-name">Muted Slate</div>
<div class="color-swatch-hex">#93939f</div>
<div class="color-swatch-role">Footer links, tertiary text</div>
</div>
</div>
<div class="color-swatch">
<div class="color-swatch-block" style="background: #d9d9dd;"></div>
<div class="color-swatch-info">
<div class="color-swatch-name">Border Cool</div>
<div class="color-swatch-hex">#d9d9dd</div>
<div class="color-swatch-role">Section borders, list separators</div>
</div>
</div>
<div class="color-swatch">
<div class="color-swatch-block" style="background: #e5e7eb;"></div>
<div class="color-swatch-info">
<div class="color-swatch-name">Border Light</div>
<div class="color-swatch-hex">#e5e7eb</div>
<div class="color-swatch-role">Lighter border variant (gray-200)</div>
</div>
</div>
</div>
</div>
</section>
<hr class="section-divider">
<!-- PURPLE HERO BAND -->
<div class="purple-band">
<h2>Enterprise AI Platform</h2>
<p>Deep purple hero sections create dramatic visual breaks in the dark page flow</p>
</div>
<hr class="section-divider">
<!-- TYPOGRAPHY -->
<section class="section" id="typography">
<div class="section-title">02 / Typography Scale</div>
<h2 class="section-heading">Typography Rules</h2>
<div class="type-sample">
<div class="type-sample-text" style="font-family: var(--font-display); font-size: 72px; font-weight: 400; line-height: 1.0; letter-spacing: -1.44px;">Display Hero</div>
<div class="type-sample-label">Display / Hero &mdash; CohereText &mdash; 72px / 400 / 1.00 / -1.44px</div>
</div>
<div class="type-sample">
<div class="type-sample-text" style="font-family: var(--font-display); font-size: 60px; font-weight: 400; line-height: 1.0; letter-spacing: -1.2px;">Display Secondary</div>
<div class="type-sample-label">Display Secondary &mdash; CohereText &mdash; 60px / 400 / 1.00 / -1.2px</div>
</div>
<div class="type-sample">
<div class="type-sample-text" style="font-family: var(--font-body); font-size: 48px; font-weight: 400; line-height: 1.2; letter-spacing: -0.48px;">Section Heading</div>
<div class="type-sample-label">Section Heading &mdash; Unica77 &mdash; 48px / 400 / 1.20 / -0.48px</div>
</div>
<div class="type-sample">
<div class="type-sample-text" style="font-family: var(--font-body); font-size: 32px; font-weight: 400; line-height: 1.2; letter-spacing: -0.32px;">Sub-heading</div>
<div class="type-sample-label">Sub-heading &mdash; Unica77 &mdash; 32px / 400 / 1.20 / -0.32px</div>
</div>
<div class="type-sample">
<div class="type-sample-text" style="font-family: var(--font-body); font-size: 24px; font-weight: 400; line-height: 1.3;">Feature Title</div>
<div class="type-sample-label">Feature Title &mdash; Unica77 &mdash; 24px / 400 / 1.30 / normal</div>
</div>
<div class="type-sample">
<div class="type-sample-text" style="font-family: var(--font-body); font-size: 18px; font-weight: 400; line-height: 1.4;">Body Large text for intro paragraphs. Unica77 provides geometric Swiss precision for all functional UI text at the body-large tier.</div>
<div class="type-sample-label">Body Large &mdash; Unica77 &mdash; 18px / 400 / 1.40 / normal</div>
</div>
<div class="type-sample">
<div class="type-sample-text" style="font-family: var(--font-body); font-size: 16px; font-weight: 400; line-height: 1.5;">Standard body text for paragraphs, navigation links, and button labels. Unica77 at its most common size, weight 400.</div>
<div class="type-sample-label">Body / Button &mdash; Unica77 &mdash; 16px / 400 / 1.50 / normal</div>
</div>
<div class="type-sample">
<div class="type-sample-text" style="font-family: var(--font-body); font-size: 14px; font-weight: 500; line-height: 1.71;">Button Medium Label</div>
<div class="type-sample-label">Button Medium &mdash; Unica77 &mdash; 14px / 500 / 1.71 / normal</div>
</div>
<div class="type-sample">
<div class="type-sample-text" style="font-family: var(--font-body); font-size: 14px; font-weight: 400; line-height: 1.4; color: var(--text-muted);">Caption and metadata text for descriptions</div>
<div class="type-sample-label">Caption &mdash; Unica77 &mdash; 14px / 400 / 1.40 / normal</div>
</div>
<div class="type-sample">
<div class="type-sample-text" style="font-family: var(--font-mono); font-size: 14px; font-weight: 400; line-height: 1.4; text-transform: uppercase; letter-spacing: 0.28px; color: var(--text-muted);">UPPERCASE SECTION LABEL</div>
<div class="type-sample-label">Uppercase Label &mdash; CohereMono &mdash; 14px / 400 / 1.40 / 0.28px / uppercase</div>
</div>
<div class="type-sample">
<div class="type-sample-text" style="font-family: var(--font-body); font-size: 12px; font-weight: 400; line-height: 1.4; color: var(--text-muted);">Smallest text for footer links and fine print</div>
<div class="type-sample-label">Small &mdash; Unica77 &mdash; 12px / 400 / 1.40 / normal</div>
</div>
<div class="type-sample">
<div class="type-sample-text" style="font-family: var(--font-mono); font-size: 8px; font-weight: 400; line-height: 1.4; text-transform: uppercase; letter-spacing: 0.16px; color: var(--text-muted);">CODE MICRO LABEL</div>
<div class="type-sample-label">Code Micro &mdash; CohereMono &mdash; 8px / 400 / 1.40 / 0.16px / uppercase</div>
</div>
</section>
<hr class="section-divider">
<!-- BUTTONS -->
<section class="section" id="buttons">
<div class="section-title">03 / Button Variants</div>
<h2 class="section-heading">Buttons</h2>
<div class="button-row">
<div class="button-demo">
<button class="btn-ghost-cohere">Ghost Button</button>
<div class="button-demo-label">Ghost / Transparent</div>
</div>
<div class="button-demo">
<button class="btn-light-cohere">Light Solid</button>
<div class="button-demo-label">Light Solid (22px)</div>
</div>
<div class="button-demo">
<button class="btn-light-pill">Pill CTA</button>
<div class="button-demo-label">Light Pill (9999px)</div>
</div>
<div class="button-demo">
<button class="btn-outlined">Outlined</button>
<div class="button-demo-label">Outlined (22px)</div>
</div>
</div>
<p style="color: var(--text-muted); margin-top: 32px; font-size: 14px;">
Ghost buttons are the primary style &mdash; invisible until interacted with. On hover, text shifts to Interaction Blue (#1863dc) with 0.8 opacity. Focus: 2px solid Interaction Blue outline.
</p>
</section>
<hr class="section-divider">
<!-- CARDS -->
<section class="section" id="cards">
<div class="section-title">04 / Card Examples</div>
<h2 class="section-heading">Cards & Containers</h2>
<div class="card-grid">
<div class="card card-signature">
<div class="card-label">STANDARD CARD</div>
<h3>Signature 22px Radius</h3>
<p>The distinctive Cohere card with 22px border-radius and #333 border on dark surfaces. Creates cloud-like, organic softness on the dark canvas.</p>
</div>
<div class="card card-dialog">
<div class="card-label">DIALOG CARD</div>
<h3>Dialog 8px Radius</h3>
<p>Modal and dialog containers use a tighter 8px radius with #333 border. Functional, secondary containment for transient UI.</p>
</div>
<div class="card card-generous">
<div class="card-label">GENEROUS CARD</div>
<h3>Featured 16px Radius</h3>
<p>Medium-sized containers for featured content. Uses 16px radius as a middle step in the radius scale with subtle border.</p>
</div>
</div>
</section>
<hr class="section-divider">
<!-- SPACING -->
<section class="section" id="spacing">
<div class="section-title">05 / Spacing Scale</div>
<h2 class="section-heading">Spacing System</h2>
<p style="color: var(--text-muted); margin-bottom: 32px;">Base unit: 8px. Scale from 2px to 60px.</p>
<div class="spacing-row">
<div class="spacing-item"><div class="spacing-box" style="width: 8px; height: 8px;"></div><div class="spacing-label">2px</div></div>
<div class="spacing-item"><div class="spacing-box" style="width: 18px; height: 18px;"></div><div class="spacing-label">6px</div></div>
<div class="spacing-item"><div class="spacing-box" style="width: 24px; height: 24px;"></div><div class="spacing-label">8px</div></div>
<div class="spacing-item"><div class="spacing-box" style="width: 30px; height: 30px;"></div><div class="spacing-label">10px</div></div>
<div class="spacing-item"><div class="spacing-box" style="width: 36px; height: 36px;"></div><div class="spacing-label">12px</div></div>
<div class="spacing-item"><div class="spacing-box" style="width: 48px; height: 48px;"></div><div class="spacing-label">16px</div></div>
<div class="spacing-item"><div class="spacing-box" style="width: 60px; height: 60px;"></div><div class="spacing-label">20px</div></div>
<div class="spacing-item"><div class="spacing-box" style="width: 66px; height: 66px;"></div><div class="spacing-label">22px</div></div>
<div class="spacing-item"><div class="spacing-box" style="width: 72px; height: 72px;"></div><div class="spacing-label">24px</div></div>
<div class="spacing-item"><div class="spacing-box" style="width: 84px; height: 84px;"></div><div class="spacing-label">28px</div></div>
<div class="spacing-item"><div class="spacing-box" style="width: 96px; height: 96px;"></div><div class="spacing-label">32px</div></div>
<div class="spacing-item"><div class="spacing-box" style="width: 120px; height: 120px;"></div><div class="spacing-label">40px</div></div>
<div class="spacing-item"><div class="spacing-box" style="width: 168px; height: 168px;"></div><div class="spacing-label">56px</div></div>
<div class="spacing-item"><div class="spacing-box" style="width: 180px; height: 180px;"></div><div class="spacing-label">60px</div></div>
</div>
</section>
<hr class="section-divider">
<!-- BORDER RADIUS -->
<section class="section">
<div class="section-title">06 / Border Radius Scale</div>
<h2 class="section-heading">Border Radius</h2>
<div class="radius-row">
<div class="radius-item"><div class="radius-box" style="border-radius: 4px;"></div><div class="radius-label">4px</div><div class="radius-context">Sharp &mdash; nav, tags</div></div>
<div class="radius-item"><div class="radius-box" style="border-radius: 8px;"></div><div class="radius-label">8px</div><div class="radius-context">Comfortable &mdash; dialogs</div></div>
<div class="radius-item"><div class="radius-box" style="border-radius: 16px;"></div><div class="radius-label">16px</div><div class="radius-context">Generous &mdash; featured</div></div>
<div class="radius-item"><div class="radius-box" style="border-radius: 20px;"></div><div class="radius-label">20px</div><div class="radius-context">Large &mdash; feature cards</div></div>
<div class="radius-item"><div class="radius-box" style="border-radius: 22px; border-color: var(--accent); border-width: 2px;"></div><div class="radius-label">22px</div><div class="radius-context">Signature &mdash; THE radius</div></div>
<div class="radius-item"><div class="radius-box" style="border-radius: 9999px;"></div><div class="radius-label">9999px</div><div class="radius-context">Pill &mdash; buttons, tags</div></div>
</div>
</section>
<hr class="section-divider">
<!-- ELEVATION -->
<section class="section" id="elevation">
<div class="section-title">07 / Elevation & Depth</div>
<h2 class="section-heading">Depth & Elevation</h2>
<div class="elevation-grid">
<div class="elevation-card elevation-flat">
<div><div class="elevation-name">Flat</div><div class="elevation-desc">No shadow, no border. Page background and text blocks.</div></div>
<div class="elevation-level">Level 0</div>
</div>
<div class="elevation-card elevation-bordered-light">
<div><div class="elevation-name">Bordered (Subtle)</div><div class="elevation-desc">1px solid #2a2a2f. Subtle dark containment lines.</div></div>
<div class="elevation-level">Level 1 &mdash; Subtle</div>
</div>
<div class="elevation-card elevation-bordered-cool">
<div><div class="elevation-name">Bordered (Standard)</div><div class="elevation-desc">1px solid #333. Standard dark-mode card borders.</div></div>
<div class="elevation-level">Level 1 &mdash; Standard</div>
</div>
<div class="elevation-card elevation-purple-band">
<div><div class="elevation-name">Purple Band</div><div class="elevation-desc">Full-width dark purple background. Hero sections, showcases.</div></div>
<div class="elevation-level">Level 2 &mdash; Dramatic</div>
</div>
</div>
<p style="color: var(--text-muted); margin-top: 32px; font-size: 14px; line-height: 1.6;">
Cohere is nearly shadow-free. Depth is communicated through background color contrast (elevated cards on dark canvas), border containment, and dramatic light-to-dark section alternation.
</p>
</section>
<div style="height: 80px;"></div>
</body>
</html>
-814
View File
@@ -1,814 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Design System Preview: Cohere (Light)</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
/* Primary */
--color-black: #000000;
--color-near-black: #212121;
--color-deep-dark: #17171c;
/* Accent */
--color-interaction-blue: #1863dc;
--color-ring-blue: rgba(76, 110, 230, 0.5);
--color-focus-purple: #9b60aa;
/* Surface */
--color-white: #ffffff;
--color-snow: #fafafa;
--color-lightest-gray: #f2f2f2;
/* Neutrals */
--color-muted-slate: #93939f;
--color-border-cool: #d9d9dd;
--color-border-light: #e5e7eb;
/* Fonts */
--font-display: 'Space Grotesk', Inter, ui-sans-serif, system-ui, sans-serif;
--font-body: Inter, Arial, ui-sans-serif, system-ui, sans-serif;
--font-mono: 'Space Mono', Arial, ui-sans-serif, system-ui, monospace;
/* Light mode tokens */
--bg-page: #ffffff;
--bg-card: #ffffff;
--bg-nav: rgba(255, 255, 255, 0.95);
--text-primary: #000000;
--text-secondary: #212121;
--text-muted: #93939f;
--border-color: #d9d9dd;
--border-subtle: #f2f2f2;
--section-label-color: #93939f;
--accent: #1863dc;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg-page);
color: var(--text-primary);
font-family: var(--font-body);
font-size: 16px;
line-height: 1.5;
font-weight: 400;
-webkit-font-smoothing: antialiased;
}
/* NAV */
.nav {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 40px;
background: var(--bg-nav);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border-subtle);
}
.nav-brand {
font-family: var(--font-display);
font-size: 20px;
font-weight: 400;
letter-spacing: -0.4px;
display: flex;
align-items: center;
gap: 6px;
color: var(--text-primary);
}
.nav-brand svg { width: 28px; height: 28px; }
.nav-links { display: flex; gap: 32px; align-items: center; }
.nav-links a {
color: var(--text-secondary);
text-decoration: none;
font-size: 16px;
font-weight: 400;
font-family: var(--font-body);
transition: color 0.2s;
}
.nav-links a:hover { color: var(--accent); }
.nav-cta {
background: var(--color-black);
color: var(--color-white);
padding: 10px 24px;
border: none;
border-radius: 9999px;
font-size: 14px;
font-family: var(--font-body);
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.nav-cta:hover { opacity: 0.85; }
/* HERO */
.hero {
position: relative;
text-align: center;
padding: 120px 40px 100px;
overflow: hidden;
}
.hero h1 {
font-family: var(--font-display);
font-size: 72px;
font-weight: 400;
line-height: 1.0;
letter-spacing: -1.44px;
margin-bottom: 24px;
color: var(--text-primary);
}
.hero h1 span { color: var(--accent); }
.hero p {
color: var(--text-muted);
font-family: var(--font-body);
font-size: 18px;
font-weight: 400;
line-height: 1.4;
margin-bottom: 40px;
}
.hero-buttons { display: flex; gap: 16px; justify-content: center; }
.btn-ghost-primary {
background: transparent;
color: var(--text-primary);
padding: 12px 24px;
border: none;
font-size: 16px;
font-family: var(--font-body);
font-weight: 400;
cursor: pointer;
transition: color 0.2s, opacity 0.2s;
}
.btn-ghost-primary:hover { color: var(--accent); opacity: 0.8; }
.btn-dark-solid {
background: var(--color-black);
color: var(--color-white);
padding: 12px 28px;
border: none;
border-radius: 9999px;
font-size: 16px;
font-family: var(--font-body);
font-weight: 400;
cursor: pointer;
transition: opacity 0.2s;
}
.btn-dark-solid:hover { opacity: 0.85; }
/* SECTIONS */
.section {
max-width: 1200px;
margin: 0 auto;
padding: 80px 40px;
}
.section-title {
font-family: var(--font-mono);
font-size: 14px;
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.28px;
color: var(--section-label-color);
margin-bottom: 12px;
}
.section-heading {
font-family: var(--font-body);
font-size: 48px;
font-weight: 400;
line-height: 1.2;
letter-spacing: -0.48px;
margin-bottom: 48px;
color: var(--text-primary);
}
.section-divider {
border: none;
border-top: 1px solid var(--border-subtle);
margin: 0 40px;
max-width: 1200px;
margin-left: auto;
margin-right: auto;
}
/* PURPLE BAND */
.purple-band {
background: linear-gradient(135deg, #2d1b4e 0%, #1a1040 50%, #17171c 100%);
padding: 80px 40px;
text-align: center;
}
.purple-band h2 {
font-family: var(--font-display);
font-size: 60px;
font-weight: 400;
line-height: 1.0;
letter-spacing: -1.2px;
color: #ffffff;
margin-bottom: 16px;
}
.purple-band p {
color: rgba(255,255,255,0.7);
font-size: 18px;
font-weight: 400;
line-height: 1.4;
}
/* COLOR PALETTE */
.color-group { margin-bottom: 40px; }
.color-group-title {
font-family: var(--font-body);
font-size: 20px;
font-weight: 400;
line-height: 1.2;
margin-bottom: 20px;
color: var(--text-secondary);
}
.color-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
}
.color-swatch {
border: 1px solid var(--border-color);
border-radius: 22px;
overflow: hidden;
}
.color-swatch-block {
height: 80px;
position: relative;
}
.color-swatch-info {
padding: 12px 16px;
background: var(--bg-card);
}
.color-swatch-name { font-size: 13px; font-weight: 500; margin-bottom: 2px; }
.color-swatch-hex { font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); margin-bottom: 4px; }
.color-swatch-role { font-size: 12px; color: var(--text-muted); line-height: 1.4; }
/* TYPOGRAPHY */
.type-sample { margin-bottom: 32px; padding-bottom: 32px; border-bottom: 1px solid var(--border-subtle); }
.type-sample:last-child { border-bottom: none; }
.type-sample-text { margin-bottom: 8px; }
.type-sample-label {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-muted);
}
/* BUTTONS */
.button-row {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: flex-start;
}
.button-demo { text-align: center; }
.button-demo-label {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
margin-top: 10px;
text-transform: uppercase;
letter-spacing: 0.28px;
}
.btn-ghost-cohere {
background: transparent;
color: var(--color-black);
padding: 12px 24px;
border: none;
font-size: 16px;
font-family: var(--font-body);
font-weight: 400;
cursor: pointer;
transition: color 0.2s, opacity 0.2s;
}
.btn-ghost-cohere:hover { color: var(--accent); opacity: 0.8; }
.btn-dark-cohere {
background: var(--color-black);
color: var(--color-white);
padding: 12px 28px;
border: none;
border-radius: 22px;
font-size: 16px;
font-family: var(--font-body);
font-weight: 400;
cursor: pointer;
}
.btn-dark-pill {
background: var(--color-black);
color: var(--color-white);
padding: 12px 28px;
border: none;
border-radius: 9999px;
font-size: 14px;
font-family: var(--font-body);
font-weight: 500;
cursor: pointer;
}
.btn-outlined {
background: transparent;
color: var(--text-primary);
padding: 12px 24px;
border: 1px solid var(--border-color);
border-radius: 22px;
font-size: 16px;
font-family: var(--font-body);
font-weight: 400;
cursor: pointer;
transition: border-color 0.2s, color 0.2s;
}
.btn-outlined:hover { border-color: var(--accent); color: var(--accent); }
.btn-ghost-focus:focus {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* CARDS */
.card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 24px; }
.card {
background: var(--bg-card);
padding: 28px;
}
.card-signature {
border-radius: 22px;
border: 1px solid var(--color-lightest-gray);
}
.card-dialog {
border-radius: 8px;
border: 1px solid var(--border-color);
}
.card-generous {
border-radius: 16px;
border: 1px solid var(--color-lightest-gray);
}
.card h3 {
font-family: var(--font-body);
font-size: 24px;
font-weight: 400;
line-height: 1.3;
letter-spacing: normal;
margin-bottom: 12px;
color: var(--text-primary);
}
.card p { color: var(--text-muted); font-size: 16px; line-height: 1.5; font-weight: 400; }
.card-label {
font-family: var(--font-mono);
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.28px;
color: var(--text-muted);
margin-bottom: 16px;
}
/* SPACING */
.spacing-row { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; }
.spacing-item { text-align: center; }
.spacing-box {
background: rgba(24, 99, 220, 0.08);
border: 1px solid rgba(24, 99, 220, 0.2);
border-radius: 2px;
margin-bottom: 8px;
}
.spacing-label {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
}
/* RADIUS */
.radius-row { display: flex; flex-wrap: wrap; gap: 20px; align-items: center; }
.radius-item { text-align: center; }
.radius-box {
width: 80px;
height: 80px;
background: var(--color-snow);
border: 1px solid var(--border-color);
margin-bottom: 8px;
}
.radius-label {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
}
.radius-context {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
/* ELEVATION */
.elevation-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 24px; }
.elevation-card {
background: var(--bg-card);
border-radius: 22px;
padding: 24px;
min-height: 140px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.elevation-flat { border: none; background: var(--bg-page); }
.elevation-bordered-light { border: 1px solid var(--color-lightest-gray); }
.elevation-bordered-cool { border: 1px solid var(--border-color); }
.elevation-purple-band {
background: linear-gradient(135deg, #2d1b4e 0%, #1a1040 100%);
color: #ffffff;
}
.elevation-purple-band .elevation-name,
.elevation-purple-band .elevation-desc { color: #ffffff; }
.elevation-purple-band .elevation-level { color: rgba(255,255,255,0.6); }
.elevation-name { font-size: 16px; font-weight: 500; margin-bottom: 8px; }
.elevation-desc { font-size: 13px; color: var(--text-muted); line-height: 1.5; }
.elevation-level {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.28px;
margin-top: 12px;
}
/* RESPONSIVE */
@media (max-width: 768px) {
.nav { padding: 12px 20px; }
.nav-links a:not(.nav-cta-wrapper) { display: none; }
.hero { padding: 80px 20px 60px; }
.hero h1 { font-size: 42px; letter-spacing: -0.84px; }
.section { padding: 60px 20px; }
.section-heading { font-size: 32px; letter-spacing: -0.32px; }
.color-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
.card-grid { grid-template-columns: 1fr; }
.hero-buttons { flex-direction: column; align-items: center; }
.button-row { flex-direction: column; align-items: flex-start; }
.purple-band h2 { font-size: 36px; }
}
</style>
</head>
<body>
<!-- NAV -->
<nav class="nav">
<div class="nav-brand">
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="14" cy="14" r="13" stroke="currentColor" stroke-width="2"/>
<path d="M8 14 C8 10, 12 8, 14 8 C16 8, 20 10, 20 14 C20 18, 16 20, 14 20 C12 20, 8 18, 8 14Z" fill="currentColor" opacity="0.15"/>
<circle cx="14" cy="14" r="4" fill="currentColor"/>
</svg>
cohere
</div>
<div class="nav-links">
<a href="#colors">Colors</a>
<a href="#typography">Typography</a>
<a href="#buttons">Buttons</a>
<a href="#cards">Cards</a>
<a href="#spacing">Spacing</a>
<a href="#elevation">Elevation</a>
<button class="nav-cta">Get Started</button>
</div>
</nav>
<!-- HERO -->
<section class="hero">
<h1>Design System Preview:<br><span>Cohere</span></h1>
<p>Auto-generated design token catalog from DESIGN.md</p>
<div class="hero-buttons">
<button class="btn-dark-solid">Explore Tokens</button>
<button class="btn-ghost-primary">View Source</button>
</div>
</section>
<hr class="section-divider">
<!-- COLORS -->
<section class="section" id="colors">
<div class="section-title">01 / Color Palette</div>
<h2 class="section-heading">Color Palette & Roles</h2>
<div class="color-group">
<h3 class="color-group-title">Primary</h3>
<div class="color-grid">
<div class="color-swatch">
<div class="color-swatch-block" style="background: #000000; border-bottom: 1px solid #333;"></div>
<div class="color-swatch-info">
<div class="color-swatch-name">Cohere Black</div>
<div class="color-swatch-hex">#000000</div>
<div class="color-swatch-role">Primary headline text, maximum emphasis</div>
</div>
</div>
<div class="color-swatch">
<div class="color-swatch-block" style="background: #212121; border-bottom: 1px solid #333;"></div>
<div class="color-swatch-info">
<div class="color-swatch-name">Near Black</div>
<div class="color-swatch-hex">#212121</div>
<div class="color-swatch-role">Standard body link color</div>
</div>
</div>
<div class="color-swatch">
<div class="color-swatch-block" style="background: #17171c; border-bottom: 1px solid #333;"></div>
<div class="color-swatch-info">
<div class="color-swatch-name">Deep Dark</div>
<div class="color-swatch-hex">#17171c</div>
<div class="color-swatch-role">Navigation and dark-section text</div>
</div>
</div>
</div>
</div>
<div class="color-group">
<h3 class="color-group-title">Secondary & Accent</h3>
<div class="color-grid">
<div class="color-swatch">
<div class="color-swatch-block" style="background: #1863dc;"></div>
<div class="color-swatch-info">
<div class="color-swatch-name">Interaction Blue</div>
<div class="color-swatch-hex">#1863dc</div>
<div class="color-swatch-role">Hover, focus states, active links</div>
</div>
</div>
<div class="color-swatch">
<div class="color-swatch-block" style="background: rgba(76,110,230,0.5);"></div>
<div class="color-swatch-info">
<div class="color-swatch-name">Ring Blue</div>
<div class="color-swatch-hex">#4c6ee6 @ 50%</div>
<div class="color-swatch-role">Keyboard focus indicators</div>
</div>
</div>
<div class="color-swatch">
<div class="color-swatch-block" style="background: #9b60aa;"></div>
<div class="color-swatch-info">
<div class="color-swatch-name">Focus Purple</div>
<div class="color-swatch-hex">#9b60aa</div>
<div class="color-swatch-role">Input focus border color</div>
</div>
</div>
</div>
</div>
<div class="color-group">
<h3 class="color-group-title">Surface & Background</h3>
<div class="color-grid">
<div class="color-swatch">
<div class="color-swatch-block" style="background: #ffffff; border-bottom: 1px solid #e5e7eb;"></div>
<div class="color-swatch-info">
<div class="color-swatch-name">Pure White</div>
<div class="color-swatch-hex">#ffffff</div>
<div class="color-swatch-role">Primary page background, card surface</div>
</div>
</div>
<div class="color-swatch">
<div class="color-swatch-block" style="background: #fafafa; border-bottom: 1px solid #e5e7eb;"></div>
<div class="color-swatch-info">
<div class="color-swatch-name">Snow</div>
<div class="color-swatch-hex">#fafafa</div>
<div class="color-swatch-role">Subtle elevated surfaces, light sections</div>
</div>
</div>
<div class="color-swatch">
<div class="color-swatch-block" style="background: #f2f2f2; border-bottom: 1px solid #e5e7eb;"></div>
<div class="color-swatch-info">
<div class="color-swatch-name">Lightest Gray</div>
<div class="color-swatch-hex">#f2f2f2</div>
<div class="color-swatch-role">Card borders, softest containment</div>
</div>
</div>
</div>
</div>
<div class="color-group">
<h3 class="color-group-title">Neutrals & Text</h3>
<div class="color-grid">
<div class="color-swatch">
<div class="color-swatch-block" style="background: #93939f;"></div>
<div class="color-swatch-info">
<div class="color-swatch-name">Muted Slate</div>
<div class="color-swatch-hex">#93939f</div>
<div class="color-swatch-role">Footer links, tertiary text</div>
</div>
</div>
<div class="color-swatch">
<div class="color-swatch-block" style="background: #d9d9dd;"></div>
<div class="color-swatch-info">
<div class="color-swatch-name">Border Cool</div>
<div class="color-swatch-hex">#d9d9dd</div>
<div class="color-swatch-role">Section borders, list separators</div>
</div>
</div>
<div class="color-swatch">
<div class="color-swatch-block" style="background: #e5e7eb;"></div>
<div class="color-swatch-info">
<div class="color-swatch-name">Border Light</div>
<div class="color-swatch-hex">#e5e7eb</div>
<div class="color-swatch-role">Lighter border variant (gray-200)</div>
</div>
</div>
</div>
</div>
</section>
<hr class="section-divider">
<!-- PURPLE HERO BAND -->
<div class="purple-band">
<h2>Enterprise AI Platform</h2>
<p>Deep purple hero sections create dramatic visual breaks in the white page flow</p>
</div>
<hr class="section-divider">
<!-- TYPOGRAPHY -->
<section class="section" id="typography">
<div class="section-title">02 / Typography Scale</div>
<h2 class="section-heading">Typography Rules</h2>
<div class="type-sample">
<div class="type-sample-text" style="font-family: var(--font-display); font-size: 72px; font-weight: 400; line-height: 1.0; letter-spacing: -1.44px;">Display Hero</div>
<div class="type-sample-label">Display / Hero &mdash; CohereText &mdash; 72px / 400 / 1.00 / -1.44px</div>
</div>
<div class="type-sample">
<div class="type-sample-text" style="font-family: var(--font-display); font-size: 60px; font-weight: 400; line-height: 1.0; letter-spacing: -1.2px;">Display Secondary</div>
<div class="type-sample-label">Display Secondary &mdash; CohereText &mdash; 60px / 400 / 1.00 / -1.2px</div>
</div>
<div class="type-sample">
<div class="type-sample-text" style="font-family: var(--font-body); font-size: 48px; font-weight: 400; line-height: 1.2; letter-spacing: -0.48px;">Section Heading</div>
<div class="type-sample-label">Section Heading &mdash; Unica77 &mdash; 48px / 400 / 1.20 / -0.48px</div>
</div>
<div class="type-sample">
<div class="type-sample-text" style="font-family: var(--font-body); font-size: 32px; font-weight: 400; line-height: 1.2; letter-spacing: -0.32px;">Sub-heading</div>
<div class="type-sample-label">Sub-heading &mdash; Unica77 &mdash; 32px / 400 / 1.20 / -0.32px</div>
</div>
<div class="type-sample">
<div class="type-sample-text" style="font-family: var(--font-body); font-size: 24px; font-weight: 400; line-height: 1.3;">Feature Title</div>
<div class="type-sample-label">Feature Title &mdash; Unica77 &mdash; 24px / 400 / 1.30 / normal</div>
</div>
<div class="type-sample">
<div class="type-sample-text" style="font-family: var(--font-body); font-size: 18px; font-weight: 400; line-height: 1.4;">Body Large text for intro paragraphs. Unica77 provides geometric Swiss precision for all functional UI text at the body-large tier.</div>
<div class="type-sample-label">Body Large &mdash; Unica77 &mdash; 18px / 400 / 1.40 / normal</div>
</div>
<div class="type-sample">
<div class="type-sample-text" style="font-family: var(--font-body); font-size: 16px; font-weight: 400; line-height: 1.5;">Standard body text for paragraphs, navigation links, and button labels. Unica77 at its most common size, weight 400.</div>
<div class="type-sample-label">Body / Button &mdash; Unica77 &mdash; 16px / 400 / 1.50 / normal</div>
</div>
<div class="type-sample">
<div class="type-sample-text" style="font-family: var(--font-body); font-size: 14px; font-weight: 500; line-height: 1.71;">Button Medium Label</div>
<div class="type-sample-label">Button Medium &mdash; Unica77 &mdash; 14px / 500 / 1.71 / normal</div>
</div>
<div class="type-sample">
<div class="type-sample-text" style="font-family: var(--font-body); font-size: 14px; font-weight: 400; line-height: 1.4; color: var(--text-muted);">Caption and metadata text for descriptions</div>
<div class="type-sample-label">Caption &mdash; Unica77 &mdash; 14px / 400 / 1.40 / normal</div>
</div>
<div class="type-sample">
<div class="type-sample-text" style="font-family: var(--font-mono); font-size: 14px; font-weight: 400; line-height: 1.4; text-transform: uppercase; letter-spacing: 0.28px; color: var(--text-muted);">UPPERCASE SECTION LABEL</div>
<div class="type-sample-label">Uppercase Label &mdash; CohereMono &mdash; 14px / 400 / 1.40 / 0.28px / uppercase</div>
</div>
<div class="type-sample">
<div class="type-sample-text" style="font-family: var(--font-body); font-size: 12px; font-weight: 400; line-height: 1.4; color: var(--text-muted);">Smallest text for footer links and fine print</div>
<div class="type-sample-label">Small &mdash; Unica77 &mdash; 12px / 400 / 1.40 / normal</div>
</div>
<div class="type-sample">
<div class="type-sample-text" style="font-family: var(--font-mono); font-size: 8px; font-weight: 400; line-height: 1.4; text-transform: uppercase; letter-spacing: 0.16px; color: var(--text-muted);">CODE MICRO LABEL</div>
<div class="type-sample-label">Code Micro &mdash; CohereMono &mdash; 8px / 400 / 1.40 / 0.16px / uppercase</div>
</div>
</section>
<hr class="section-divider">
<!-- BUTTONS -->
<section class="section" id="buttons">
<div class="section-title">03 / Button Variants</div>
<h2 class="section-heading">Buttons</h2>
<div class="button-row">
<div class="button-demo">
<button class="btn-ghost-cohere">Ghost Button</button>
<div class="button-demo-label">Ghost / Transparent</div>
</div>
<div class="button-demo">
<button class="btn-dark-cohere">Dark Solid</button>
<div class="button-demo-label">Dark Solid (22px)</div>
</div>
<div class="button-demo">
<button class="btn-dark-pill">Pill CTA</button>
<div class="button-demo-label">Dark Pill (9999px)</div>
</div>
<div class="button-demo">
<button class="btn-outlined">Outlined</button>
<div class="button-demo-label">Outlined (22px)</div>
</div>
</div>
<p style="color: var(--text-muted); margin-top: 32px; font-size: 14px;">
Ghost buttons are the primary style &mdash; invisible until interacted with. On hover, text shifts to Interaction Blue (#1863dc) with 0.8 opacity. Focus: 2px solid Interaction Blue outline.
</p>
</section>
<hr class="section-divider">
<!-- CARDS -->
<section class="section" id="cards">
<div class="section-title">04 / Card Examples</div>
<h2 class="section-heading">Cards & Containers</h2>
<div class="card-grid">
<div class="card card-signature">
<div class="card-label">STANDARD CARD</div>
<h3>Signature 22px Radius</h3>
<p>The distinctive Cohere card with 22px border-radius and 1px solid #f2f2f2 border. Creates cloud-like, organic softness on the white canvas.</p>
</div>
<div class="card card-dialog">
<div class="card-label">DIALOG CARD</div>
<h3>Dialog 8px Radius</h3>
<p>Modal and dialog containers use a tighter 8px radius with #d9d9dd border. Functional, secondary containment for transient UI.</p>
</div>
<div class="card card-generous">
<div class="card-label">GENEROUS CARD</div>
<h3>Featured 16px Radius</h3>
<p>Medium-sized containers for featured content. Uses 16px radius as a middle step in the radius scale with subtle #f2f2f2 border.</p>
</div>
</div>
</section>
<hr class="section-divider">
<!-- SPACING -->
<section class="section" id="spacing">
<div class="section-title">05 / Spacing Scale</div>
<h2 class="section-heading">Spacing System</h2>
<p style="color: var(--text-muted); margin-bottom: 32px;">Base unit: 8px. Scale from 2px to 60px.</p>
<div class="spacing-row">
<div class="spacing-item"><div class="spacing-box" style="width: 8px; height: 8px;"></div><div class="spacing-label">2px</div></div>
<div class="spacing-item"><div class="spacing-box" style="width: 18px; height: 18px;"></div><div class="spacing-label">6px</div></div>
<div class="spacing-item"><div class="spacing-box" style="width: 24px; height: 24px;"></div><div class="spacing-label">8px</div></div>
<div class="spacing-item"><div class="spacing-box" style="width: 30px; height: 30px;"></div><div class="spacing-label">10px</div></div>
<div class="spacing-item"><div class="spacing-box" style="width: 36px; height: 36px;"></div><div class="spacing-label">12px</div></div>
<div class="spacing-item"><div class="spacing-box" style="width: 48px; height: 48px;"></div><div class="spacing-label">16px</div></div>
<div class="spacing-item"><div class="spacing-box" style="width: 60px; height: 60px;"></div><div class="spacing-label">20px</div></div>
<div class="spacing-item"><div class="spacing-box" style="width: 66px; height: 66px;"></div><div class="spacing-label">22px</div></div>
<div class="spacing-item"><div class="spacing-box" style="width: 72px; height: 72px;"></div><div class="spacing-label">24px</div></div>
<div class="spacing-item"><div class="spacing-box" style="width: 84px; height: 84px;"></div><div class="spacing-label">28px</div></div>
<div class="spacing-item"><div class="spacing-box" style="width: 96px; height: 96px;"></div><div class="spacing-label">32px</div></div>
<div class="spacing-item"><div class="spacing-box" style="width: 120px; height: 120px;"></div><div class="spacing-label">40px</div></div>
<div class="spacing-item"><div class="spacing-box" style="width: 168px; height: 168px;"></div><div class="spacing-label">56px</div></div>
<div class="spacing-item"><div class="spacing-box" style="width: 180px; height: 180px;"></div><div class="spacing-label">60px</div></div>
</div>
</section>
<hr class="section-divider">
<!-- BORDER RADIUS -->
<section class="section">
<div class="section-title">06 / Border Radius Scale</div>
<h2 class="section-heading">Border Radius</h2>
<div class="radius-row">
<div class="radius-item"><div class="radius-box" style="border-radius: 4px;"></div><div class="radius-label">4px</div><div class="radius-context">Sharp &mdash; nav, tags</div></div>
<div class="radius-item"><div class="radius-box" style="border-radius: 8px;"></div><div class="radius-label">8px</div><div class="radius-context">Comfortable &mdash; dialogs</div></div>
<div class="radius-item"><div class="radius-box" style="border-radius: 16px;"></div><div class="radius-label">16px</div><div class="radius-context">Generous &mdash; featured</div></div>
<div class="radius-item"><div class="radius-box" style="border-radius: 20px;"></div><div class="radius-label">20px</div><div class="radius-context">Large &mdash; feature cards</div></div>
<div class="radius-item"><div class="radius-box" style="border-radius: 22px; border-color: var(--accent); border-width: 2px;"></div><div class="radius-label">22px</div><div class="radius-context">Signature &mdash; THE radius</div></div>
<div class="radius-item"><div class="radius-box" style="border-radius: 9999px;"></div><div class="radius-label">9999px</div><div class="radius-context">Pill &mdash; buttons, tags</div></div>
</div>
</section>
<hr class="section-divider">
<!-- ELEVATION -->
<section class="section" id="elevation">
<div class="section-title">07 / Elevation & Depth</div>
<h2 class="section-heading">Depth & Elevation</h2>
<div class="elevation-grid">
<div class="elevation-card elevation-flat">
<div><div class="elevation-name">Flat</div><div class="elevation-desc">No shadow, no border. Page background and text blocks.</div></div>
<div class="elevation-level">Level 0</div>
</div>
<div class="elevation-card elevation-bordered-light">
<div><div class="elevation-name">Bordered (Light)</div><div class="elevation-desc">1px solid #f2f2f2. Standard cards, subtle containment.</div></div>
<div class="elevation-level">Level 1 &mdash; Lightest Gray</div>
</div>
<div class="elevation-card elevation-bordered-cool">
<div><div class="elevation-name">Bordered (Cool)</div><div class="elevation-desc">1px solid #d9d9dd. Emphasized cards, list separators.</div></div>
<div class="elevation-level">Level 1 &mdash; Border Cool</div>
</div>
<div class="elevation-card elevation-purple-band">
<div><div class="elevation-name">Purple Band</div><div class="elevation-desc">Full-width dark purple background. Hero sections, showcases.</div></div>
<div class="elevation-level">Level 2 &mdash; Dramatic</div>
</div>
</div>
<p style="color: var(--text-muted); margin-top: 32px; font-size: 14px; line-height: 1.6;">
Cohere is nearly shadow-free. Depth is communicated through background color contrast (white cards on purple bands), border containment (cool gray borders), and dramatic light-to-dark section alternation.
</p>
</section>
<div style="height: 80px;"></div>
</body>
</html>
+10 -2
View File
@@ -1,13 +1,18 @@
/*
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:; connect-src 'self' https://cloudflareinsights.com; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
Permissions-Policy: camera=(), microphone=(), geolocation=()
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data: blob:; connect-src 'self' https://cloudflareinsights.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
Cross-Origin-Opener-Policy: same-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
Referrer-Policy: strict-origin-when-cross-origin
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
/_next/static/*
Cache-Control: public, max-age=31536000, immutable
/fonts/*
Cache-Control: public, max-age=31536000, immutable
/images/*
Cache-Control: public, max-age=604800
@@ -16,3 +21,6 @@
/icon.png
Cache-Control: public, max-age=86400
/api/*
Cache-Control: no-store
Binary file not shown.
Loaded 100 of 207 files, more files were not shown because too many files have changed in this diff. Show more