diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a146dd4..af59264 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,14 +1,16 @@
name: ci
on:
pull_request:
+ paths: ["**.go", "go.mod", "go.sum", "build-tags.txt", ".github/workflows/ci.yml"]
push:
branches: [main]
+ paths: ["**.go", "go.mod", "go.sum", "build-tags.txt", ".github/workflows/ci.yml"]
jobs:
test:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-go@v5
+ - uses: actions/checkout@v7
+ - uses: actions/setup-go@v7
with:
go-version-file: go.mod
- run: go vet ./...
diff --git a/site/.gitignore b/site/.gitignore
new file mode 100644
index 0000000..4c07101
--- /dev/null
+++ b/site/.gitignore
@@ -0,0 +1,7 @@
+node_modules
+.next
+out
+next-env.d.ts
+*.tsbuildinfo
+.DS_Store
+public/install.sh
diff --git a/site/README.md b/site/README.md
new file mode 100644
index 0000000..7a9044e
--- /dev/null
+++ b/site/README.md
@@ -0,0 +1,14 @@
+# iris — landing page
+
+Static Next.js site for iris. Implements the "LANDING v3" Paper artboard.
+
+```
+npm install
+npm run dev # http://localhost:3000
+npm run build # static export to ./out
+```
+
+- `app/` — layout (fonts, theme bootstrap), page copy, global styles and tokens.
+- `components/terrain/` — the hero: `field.ts` (height field, camera, visibility), `sim.ts` (mesh render, gradient-descent agents, sandboxes), `Terrain.tsx` (canvas + controls).
+- `components/illustrations/` — the four use-case illustrations. Greyed by default; they activate and animate on hover or when scrolled into view (`components/Illustration.tsx`).
+- Theme follows the system setting, with a toggle in the nav (stored in `localStorage`). Append `?theme=dark` or `?theme=light` to force one.
diff --git a/site/app/apple-icon.png b/site/app/apple-icon.png
new file mode 100644
index 0000000..ab95ced
Binary files /dev/null and b/site/app/apple-icon.png differ
diff --git a/site/app/globals.css b/site/app/globals.css
new file mode 100644
index 0000000..5660f8e
--- /dev/null
+++ b/site/app/globals.css
@@ -0,0 +1,294 @@
+/* ---- tokens ---------------------------------------------------------------- */
+:root {
+ color-scheme: light;
+ --bg: #ffffff;
+ --ink: #111111;
+ --body: #444444;
+ --muted: #7e7e7e;
+ --muted-2: #9a9a9a;
+ --hairline: #eaeaea;
+ --hairline-2: #e5e5e5;
+ --line: #dadada;
+ --dash: #9a9a9a;
+ --surface: #ffffff;
+ --surface-2: #f0f0f0;
+ --chip: #f0f0f0;
+ --cobalt: #2447d6;
+ --vermilion: #de4a2c;
+ --moss: #3b8a4e;
+ --amber: #c8901a;
+ --violet: #7b4fd6;
+ --mesh-line: #dadada;
+ --mesh-fill: #ffffff;
+ --imessage: #1b8cff;
+ --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
+ --sh-cmd: #2f8f3f;
+ --sh-flag: #1f8fa8;
+ --sh-op: #b8860b;
+ --sh-claude: #d97a2b;
+}
+[data-theme="dark"] {
+ color-scheme: dark;
+ --bg: #000a0f;
+ --ink: #f2f2f2;
+ --body: #bdbdbd;
+ --muted: #8a8a8a;
+ --muted-2: #6f6f6f;
+ --hairline: #242424;
+ --hairline-2: #2a2a2a;
+ --line: #363636;
+ --dash: #6f6f6f;
+ --surface: #141414;
+ --surface-2: #232323;
+ --chip: #000a0f;
+ --cobalt: #5b78f0;
+ --vermilion: #f0653f;
+ --moss: #52b36c;
+ --amber: #e0a83a;
+ --violet: #9c78f0;
+ --mesh-line: #2c2c2c;
+ --mesh-fill: #000a0f;
+ --imessage: #1b8cff;
+ --sh-cmd: #5fbf6f;
+ --sh-flag: #4fc1d1;
+ --sh-op: #e0b640;
+ --sh-claude: #f0955a;
+}
+
+/* ---- base ------------------------------------------------------------------ */
+* { box-sizing: border-box; }
+html, body { margin: 0; background: var(--bg); color: var(--ink); }
+body {
+ font-family: var(--font-mono), ui-monospace, SFMono-Regular, Menlo, monospace;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ overflow-x: hidden;
+}
+a { color: inherit; text-decoration: none; }
+.bgShader { position: fixed; inset: 0; z-index: 0; pointer-events: none; }
+.page { position: relative; z-index: 1; }
+button { font: inherit; color: inherit; }
+
+.lane { max-width: 824px; margin: 0 auto; padding: 0 20px; }
+.wide { max-width: 1160px; margin: 0 auto; padding: 0 20px; }
+.sans { font-family: var(--font-inter), system-ui, -apple-system, sans-serif; }
+
+/* ---- nav ------------------------------------------------------------------- */
+.nav {
+ display: flex; align-items: center; justify-content: space-between;
+ height: 128px; padding-top: 24px;
+ border-bottom: 1px solid var(--hairline);
+ font-family: var(--font-inter), system-ui, sans-serif;
+}
+.brand { display: flex; gap: 10px; align-items: center; font-size: 13px; letter-spacing: 0.02em; }
+.brand svg { color: var(--ink); }
+.brandName { font-weight: 600; color: var(--ink); }
+.brandSub { font-weight: 500; color: var(--muted-2); }
+.navRight { display: flex; align-items: center; gap: 12px; }
+.square {
+ width: 32px; height: 32px; display: inline-flex; align-items: center; justify-content: center;
+ background: var(--chip); color: var(--ink); border: 0; padding: 0; cursor: pointer;
+ transition: background 0.2s;
+}
+.square:hover { background: var(--line); }
+.square.plain { background: transparent; }
+.square.plain:hover { background: var(--chip); }
+.ghbtn {
+ display: inline-flex; align-items: center; gap: 8px; height: 32px; padding: 0 12px;
+ background: var(--chip); color: var(--ink); font-size: 12px; font-weight: 500; letter-spacing: 0.02em;
+ transition: background 0.2s;
+}
+.ghbtn:hover { background: var(--line); }
+
+/* ---- typography ------------------------------------------------------------ */
+.label {
+ font-family: var(--font-mono), monospace; font-weight: 500; font-size: 12px; line-height: 22px;
+ letter-spacing: 0.1em; color: var(--ink); text-transform: uppercase;
+}
+.copy p {
+ margin: 0 0 14px; font-weight: 400; font-size: 12px; line-height: 22px;
+ letter-spacing: 0.05em; color: var(--body); text-transform: uppercase;
+}
+.copy p:last-child { margin-bottom: 0; }
+.sub {
+ display: block; font-weight: 500; font-size: 12px; line-height: 22px;
+ letter-spacing: 0.05em; color: var(--ink); text-transform: uppercase;
+}
+pre.cmd.copyable { cursor: pointer; outline: none; }
+pre.cmd .sh-claude { color: var(--sh-claude); }
+pre.cmd .sh-p { color: var(--muted-2); }
+pre.cmd .sh-cmd { color: var(--sh-cmd); }
+pre.cmd .sh-flag { color: var(--sh-flag); }
+pre.cmd .sh-op { color: var(--sh-op); }
+.copy .linkBtn { all: unset; cursor: pointer; font: inherit; letter-spacing: inherit; text-transform: inherit; color: inherit; }
+.copy .linkBtn:focus-visible { outline: 1px solid var(--ink); outline-offset: 2px; }
+.copy .link { text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--muted-2); transition: color 150ms ease; }
+@media (hover: hover) and (pointer: fine) { .copy .link:hover { color: var(--ink); } }
+pre.cmd {
+ margin: 0; font-family: inherit; font-weight: 400; font-size: 12px; line-height: 22px;
+ letter-spacing: 0.05em; color: var(--ink); white-space: pre; overflow-x: auto;
+}
+
+/* ---- rows ------------------------------------------------------------------ */
+.row { display: grid; grid-template-columns: 184px 1fr; padding: 36px 0; }
+/* first row: text sits as far below the hairline as the nav buttons sit above it */
+.lane.first .reveal:first-child .row { padding-top: 50px; padding-bottom: 22px; }
+.lane.first .reveal:last-child .row { padding-top: 22px; }
+.rowBody { min-width: 0; }
+.how { display: flex; flex-direction: column; gap: 22px; margin-top: 22px; }
+.howItem .sub { margin-bottom: 2px; }
+.uc { margin-bottom: 40px; }
+.uc:last-child { margin-bottom: 0; }
+
+/* ---- terrain --------------------------------------------------------------- */
+.terrain { padding: 4px 0 56px; }
+.terrainWrap { width: 100%; overflow: hidden; display: flex; justify-content: center; }
+.terrainWrap canvas { display: block; flex: none; }
+.terrainBar { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding-top: 6px; }
+.chips { display: flex; gap: 8px; }
+.chip {
+ font-family: var(--font-mono), monospace; font-weight: 500; font-size: 11px; line-height: 14px; letter-spacing: 0.1em;
+ color: var(--ink); background: transparent; border: 1px solid var(--ink); height: 28px; padding: 0 12px; cursor: pointer;
+ transition: background 0.15s, color 0.15s;
+}
+.chip:hover:not(:disabled) { background: var(--ink); color: var(--bg); }
+.chip:disabled { opacity: 0.3; cursor: default; }
+.readout { font-size: 11px; line-height: 14px; letter-spacing: 0.1em; color: var(--muted); white-space: nowrap; }
+
+/* ---- illustrations: greyed until hovered / scrolled into view -------------- */
+.illus {
+ margin-top: 14px;
+ opacity: 0.4; filter: grayscale(1);
+ transition: opacity 0.5s ease, filter 0.5s ease;
+ font-family: var(--font-inter), system-ui, sans-serif; color: var(--ink);
+}
+.illus.active { opacity: 1; filter: none; }
+
+.typing { display: inline-flex; gap: 4px; height: 19px; align-items: center; }
+.typing i { width: 5px; height: 5px; border-radius: 50%; background: var(--muted); animation: blink 1s infinite; }
+.typing i:nth-child(2) { animation-delay: 0.2s; }
+.typing i:nth-child(3) { animation-delay: 0.4s; }
+@keyframes blink { 0%, 80%, 100% { opacity: 0.25; } 40% { opacity: 1; } }
+.caret { display: inline-block; width: 1px; height: 12px; background: currentColor; margin-left: 2px; vertical-align: -2px; animation: caret 1s steps(2) infinite; }
+@keyframes caret { 50% { opacity: 0; } }
+
+/* slack card */
+.slack { display: flex; gap: 12px; padding: 16px; border: 1px solid var(--hairline-2); }
+.avatar {
+ width: 28px; height: 28px; flex: none; background: var(--ink); color: var(--bg);
+ font-size: 10px; font-weight: 500; line-height: 28px; text-align: center; letter-spacing: 0.06em;
+}
+.slackBody { min-width: 0; flex: 1; }
+.slackMeta { display: flex; gap: 10px; align-items: baseline; font-size: 13px; }
+.slackMeta b { font-weight: 600; }
+.slackMeta span { font-size: 11px; color: var(--muted); }
+.slack p { margin: 0; font-size: 13px; line-height: 19px; }
+.typewrap { position: relative; }
+.typewrap .ghost { visibility: hidden; }
+.typewrap .live { position: absolute; left: 0; top: 0; right: 0; }
+.typewrap .typing.overlay { position: absolute; left: 0; top: 0; }
+.tokenChip {
+ display: inline-flex; align-items: center; gap: 8px; height: 28px; padding: 0 10px; margin-top: 10px;
+ border: 1px solid var(--line); font-size: 12px; font-weight: 500;
+ opacity: 0; transform: translateY(4px); transition: opacity 0.4s, transform 0.4s;
+}
+.tokenChip.in { opacity: 1; transform: none; }
+.tokenChip small { font-size: 10px; letter-spacing: 0.06em; color: var(--muted); font-weight: 500; }
+.tokenChip .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--vermilion); }
+
+/* sandbox lifecycle (svg) */
+.life { display: block; width: 100%; height: auto; padding: 20px; border: 1px solid var(--hairline-2); overflow: visible; }
+.life .box { fill: none; stroke: var(--dash); stroke-dasharray: 4 3; opacity: 0; transition: opacity 0.5s; }
+.life .box.faint { stroke: var(--line); }
+.life .hollow { fill: none; stroke: var(--moss); stroke-width: 1.5; opacity: 0; transition: opacity 0.5s; }
+.life .solid { fill: var(--moss); opacity: 0; transition: opacity 0.5s; }
+.life .arrow { fill: none; stroke: var(--line); opacity: 0; transition: opacity 0.4s; }
+.life .cap { font-size: 11px; letter-spacing: 0.04em; fill: var(--muted); opacity: 0; transition: opacity 0.5s; }
+.life .cap.faint { fill: var(--muted-2); }
+.life .cap.strong { fill: var(--ink); font-weight: 600; }
+.life .bar { stroke: var(--ink); transform-box: fill-box; transform-origin: left; transform: scaleX(0); transition: transform 0.5s ease; }
+.life .bar.on { transform: scaleX(1); }
+.life .logline { stroke: var(--ink); stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset 0.9s ease; }
+.life .logline.on { stroke-dashoffset: 0; }
+.life .on { opacity: 1; }
+
+/* message thread */
+.thread { display: flex; flex-direction: column; gap: 8px; padding: 16px; border: 1px solid var(--hairline-2); }
+.msg {
+ max-width: 68%; padding: 9px 13px; font-size: 13px; line-height: 19px; border-radius: 16px;
+ opacity: 0; transform: translateY(6px); transition: opacity 0.35s ease, transform 0.35s ease;
+}
+.msg.in { opacity: 1; transform: none; }
+.msg.me { align-self: flex-end; background: var(--imessage); color: #fff; border-bottom-right-radius: 4px; }
+.msg.them { align-self: flex-start; background: var(--surface-2); color: var(--ink); border-bottom-left-radius: 4px; }
+.msg.typingB { padding: 9px 12px; }
+.slot { position: relative; display: flex; flex-direction: column; align-items: flex-start; }
+.msg.overlay { position: absolute; left: 0; top: 0; }
+.status { align-self: flex-end; font-size: 10px; letter-spacing: 0.06em; color: var(--muted); padding: 0 6px; opacity: 0; transition: opacity 0.35s; }
+.status.in { opacity: 1; }
+
+/* terminal */
+.term { border: 1px solid var(--hairline-2); }
+.termBar { display: flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; border-bottom: 1px solid var(--hairline-2); font-size: 12px; color: var(--muted); }
+.termBar i { width: 8px; height: 8px; border-radius: 50%; border: 1px solid var(--muted-2); flex: none; }
+.termBar span { padding-left: 8px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.termBody {
+ padding: 12px 16px 14px; overflow-x: auto;
+ font-family: var(--font-mono), monospace; font-size: 11px; line-height: 20px; letter-spacing: 0.02em; color: var(--ink);
+}
+.termBody pre { margin: 0; font: inherit; white-space: pre; min-height: 20px; }
+.line { opacity: 0; transition: opacity 0.3s; }
+.line.in { opacity: 1; }
+.trow { display: flex; align-items: center; gap: 10px; height: 20px; opacity: 0; transition: opacity 0.3s; }
+.trow.in { opacity: 1; }
+.trow i { width: 7px; height: 7px; border-radius: 50%; flex: none; }
+.trow.cobalt i { background: var(--cobalt); }
+.trow.vermilion i { background: var(--vermilion); }
+.trow.moss i { background: var(--moss); }
+.trow.human i { border: 1px solid var(--ink); }
+.trow.human pre { color: var(--muted); }
+
+/* ---- reveal: fade blocks in once as they enter the viewport ---------------- */
+.js .reveal {
+ opacity: 0; transform: translateY(8px);
+ transition: opacity 300ms var(--ease-out), transform 300ms var(--ease-out);
+}
+.js .reveal[data-visible] { opacity: 1; transform: none; }
+.nav { animation: navIn 300ms var(--ease-out) both; }
+@keyframes navIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
+
+/* ---- toast (sonner, unstyled) ---------------------------------------------- */
+.toast {
+ display: flex; align-items: center; height: 36px; padding: 0 14px;
+ background: var(--surface); color: var(--ink); border: 1px solid var(--hairline-2);
+ font-family: var(--font-mono), monospace; font-size: 11px; letter-spacing: 0.1em; white-space: nowrap;
+ width: max-content; margin-left: auto;
+}
+
+/* ---- footer ---------------------------------------------------------------- */
+.footerSpace { height: 200px; }
+.footer {
+ display: flex; align-items: center; justify-content: space-between; height: 96px;
+ border-top: 1px solid var(--hairline);
+ font-family: var(--font-inter), system-ui, sans-serif; font-size: 12px; letter-spacing: 0.02em; color: var(--muted);
+}
+
+/* ---- responsive ------------------------------------------------------------ */
+@media (max-width: 719px) {
+ .nav { height: 88px; padding-top: 12px; }
+ .terrain .wide { padding: 0; }
+ .life { padding: 14px; }
+ .life .cap { font-size: 15px; }
+ .row { grid-template-columns: 1fr; gap: 6px; padding: 28px 0; }
+ .terrainBar { flex-wrap: wrap; }
+ .msg { max-width: 84%; }
+ .footerSpace { height: 120px; }
+ .footer { height: 80px; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .js .reveal { transform: none; transition: opacity 200ms ease; }
+ .nav { animation: none; }
+ .illus, .msg, .tokenChip, .life *, .line, .trow { transition: none; }
+ .typing i, .caret { animation: none; }
+}
diff --git a/site/app/icon.svg b/site/app/icon.svg
new file mode 100644
index 0000000..4a7592d
--- /dev/null
+++ b/site/app/icon.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
diff --git a/site/app/layout.tsx b/site/app/layout.tsx
new file mode 100644
index 0000000..31e7e8c
--- /dev/null
+++ b/site/app/layout.tsx
@@ -0,0 +1,51 @@
+import type { Metadata, Viewport } from "next";
+import { DM_Mono, Inter } from "next/font/google";
+import { SITE_DESCRIPTION, SITE_TITLE } from "@/lib/site";
+import Toasts from "@/components/Toasts";
+import Background from "@/components/Background";
+import "./globals.css";
+
+const inter = Inter({ subsets: ["latin"], variable: "--font-inter", display: "swap" });
+const dmMono = DM_Mono({ subsets: ["latin"], weight: ["400", "500"], variable: "--font-mono", display: "swap" });
+
+export const metadata: Metadata = {
+ metadataBase: new URL("https://iris-tl.dev"),
+ title: SITE_TITLE,
+ description: SITE_DESCRIPTION,
+ openGraph: {
+ title: SITE_TITLE,
+ description: SITE_DESCRIPTION,
+ url: "https://iris-tl.dev",
+ siteName: "iris",
+ images: [{ url: "/og.png", width: 1200, height: 630, alt: "iris — transport layer for AI agents" }],
+ type: "website",
+ },
+ twitter: { card: "summary_large_image", title: SITE_TITLE, description: SITE_DESCRIPTION, images: ["/og.png"] },
+};
+
+export const viewport: Viewport = {
+ width: "device-width",
+ initialScale: 1,
+ themeColor: [
+ { media: "(prefers-color-scheme: light)", color: "#FFFFFF" },
+ { media: "(prefers-color-scheme: dark)", color: "#000a0f" },
+ ],
+};
+
+// Resolve the theme before first paint: ?theme= override, stored preference, else the system setting.
+const themeScript = `(function(){try{var q=new URLSearchParams(location.search).get('theme');var s=q||localStorage.getItem('iris-theme');var d=s?s==='dark':matchMedia('(prefers-color-scheme: dark)').matches;document.documentElement.setAttribute('data-theme',d?'dark':'light');}catch(e){}document.documentElement.classList.add('js');})();`;
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/site/app/page.tsx b/site/app/page.tsx
new file mode 100644
index 0000000..c988c6c
--- /dev/null
+++ b/site/app/page.tsx
@@ -0,0 +1,122 @@
+import Nav from "@/components/Nav";
+import Footer from "@/components/Footer";
+import Row from "@/components/Row";
+import Terrain from "@/components/terrain/Terrain";
+import { Illustration } from "@/components/Illustration";
+import SlackCard from "@/components/illustrations/SlackCard";
+import SandboxLifecycle from "@/components/illustrations/SandboxLifecycle";
+import MessageThread from "@/components/illustrations/MessageThread";
+import Terminal from "@/components/illustrations/Terminal";
+import Reveal from "@/components/Reveal";
+import InstallBlock from "@/components/InstallBlock";
+import EvalBlock from "@/components/EvalBlock";
+
+export default function Page() {
+ return (
+ <>
+
+
+
+
+
+
+ IRIS IS A SECURE, OPEN SOURCE TRANSPORT THAT ALLOWS AGENTS TO COLLABORATE ACROSS
+ ENVIRONMENTS, HARNESSES, AND MODEL PROVIDERS.
+
+
+
+
+
+
THE EXISTING PARADIGMS OF HILL-CLIMBING ARE INEFFICIENT.
+
+ TO SOLVE HARD TASKS, A COLLECTIVE OF AGENTS MUST SEARCH FOR THE GLOBAL MINIMUM INDEPENDENTLY AND
+ COLLECTIVELY — FREED FROM THE LIMITATIONS OF THEIR ENVIRONMENT, HARNESS, OR MODEL PROVIDER.
+
+
TODAY, CROSS-AGENT COLLABORATION IS A HUMAN COPY-PASTING BETWEEN TERMINALS. IRIS IS THE MISSING PIPE.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
IRIS IS DESIGNED TO BE MAXIMALLY SECURE WHILE REMAINING FRICTIONLESS TO SET UP. THERE ARE THREE COMPONENTS:
+
+
+
+
[1] HOST MACHINE
+
+
+ IRIS SERVE STARTS A SCOPED HTTP RELAY: A SQLITE MESSAGE LOG AND A DISK FILE STORE. EXTERNAL AGENTS CAN ONLY
+ POST AND READ MESSAGES AND FILES — NO ACCESS TO THE LOCAL FILESYSTEM, NO CODE EXECUTION.
+
+
+
+
+
[2] TRANSPORT
+
+
+ THE RELAY IS EXPOSED THROUGH AN EMBEDDED TAILCAT TUNNEL — WIREGUARD, END-TO-END ENCRYPTED. A PAIRING TOKEN AND
+ A SESSION KEY ARE GENERATED TO SHARE WITH OTHER AGENTS. IRIS CONNECT <TOKEN> BINDS THE SESSION TO LOCALHOST
+ ON THE PEER.
+
+
+
+
+
[3] CONNECT, COLLABORATE, CEASE
+
+
+ AGENTS JOIN WITH THE KEY AND A STABLE HANDLE, THEN EXCHANGE OPENAI-CHAT-COMPATIBLE MESSAGES AND FILES OVER
+ PLAIN HTTP. THE BUNDLED /IRIS SKILL PRESCRIBES RESTRICTIONS ON INFORMATION SHARING AND HUMAN-IN-THE-LOOP
+ CHECKPOINTS; ADAPT IT. SESSIONS EXPIRE AFTER 24H OF INACTIVITY OR ON /TERMINATE.
+
+
+
+
+
+
+
+
+
+
+
+
+ [1] SOMEONE ELSE'S AGENT
+
+
+
+ [2] EPHEMERAL SANDBOXES
+
+
+
+ [3] TAKE YOUR RIG ON THE GO
+
+
+
+ [4] ANY HARNESS, ANY PROVIDER
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/site/bun.lock b/site/bun.lock
new file mode 100644
index 0000000..45be080
--- /dev/null
+++ b/site/bun.lock
@@ -0,0 +1,147 @@
+{
+ "lockfileVersion": 2,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "iris-site",
+ "dependencies": {
+ "@paper-design/shaders-react": "^0.0.80",
+ "next": "^15.3.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "sonner": "^2.0.8",
+ },
+ "devDependencies": {
+ "@types/node": "^22.0.0",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "typescript": "^5.5.0",
+ },
+ },
+ },
+ "packages": {
+ "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
+
+ "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
+
+ "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="],
+
+ "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.3" }, "os": "darwin", "cpu": "x64" }, "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw=="],
+
+ "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "os": "freebsd" }, "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA=="],
+
+ "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg=="],
+
+ "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw=="],
+
+ "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA=="],
+
+ "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A=="],
+
+ "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w=="],
+
+ "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.3", "", { "os": "linux", "cpu": "none" }, "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ=="],
+
+ "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q=="],
+
+ "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA=="],
+
+ "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw=="],
+
+ "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw=="],
+
+ "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.3" }, "os": "linux", "cpu": "arm" }, "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A=="],
+
+ "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ=="],
+
+ "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ=="],
+
+ "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.3" }, "os": "linux", "cpu": "none" }, "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA=="],
+
+ "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.3" }, "os": "linux", "cpu": "s390x" }, "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ=="],
+
+ "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA=="],
+
+ "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA=="],
+
+ "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg=="],
+
+ "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="],
+
+ "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "cpu": "none" }, "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA=="],
+
+ "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw=="],
+
+ "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw=="],
+
+ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg=="],
+
+ "@next/env": ["@next/env@15.5.25", "", {}, "sha512-42h1lLr07vl4gawALP1hsgRZjHB1xYa58JfUfHwr0f7jG/zhPakh5GHkADHXOC9ZxUvlQFOPIrp7s6qX4DezPQ=="],
+
+ "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.25", "", { "os": "darwin", "cpu": "arm64" }, "sha512-w+RR0v/QuApnWEjRGm1z6gcObKwGMb5YPA7V3bzBEVSBpMFUXprer0tS27UxjUcEnqbhL7Zuzohej79B6rYmBg=="],
+
+ "@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.25", "", { "os": "darwin", "cpu": "x64" }, "sha512-QiGGBUSakt8S1H4Lt9Ehsh6Ja87axiBnQQgysOObvCbI7iUfJnRGntF1P64S4/ijuHFnSB8KLsEddkY3nN26uw=="],
+
+ "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.25", "", { "os": "linux", "cpu": "arm64" }, "sha512-ehLos/66zo0d/mJCU5u96a/VDcr01aaUrX0o/i16UdInxz8qPTCDSxGtjk/Lps1sIr1RJFdiX3hxc0fxJo+cPA=="],
+
+ "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.25", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZVMrqLiJ7DiChgmbkQwFtdhAnUkSH/4p7tB29QY+giATb0Q/XGHNRSKAb/B8XGDHRUaA67NepOW5W8u3ZRJBAA=="],
+
+ "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.25", "", { "os": "linux", "cpu": "x64" }, "sha512-UOewtDGkTMJTiODrEdeLZ50yGb59xCZSriNpXkfPMxRRgwDkGc7i8mLWqV5076wEdb+Ca/XN7MhJyM3CupyNyQ=="],
+
+ "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.25", "", { "os": "linux", "cpu": "x64" }, "sha512-UBHwA8AhkCZgtRfU1aJpunuAJe/6gZv6jDESQe4p5MjTb5V0YEeJBWCdNqx15Vj3x+5jmauRfeMJSjfQj9HGFQ=="],
+
+ "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.25", "", { "os": "win32", "cpu": "arm64" }, "sha512-QcFcPRr16djk5IqK5+e8O80eZfgWzIvVBXfitIq0tQ/uc+eyfdoZ0NmKc0cnbIJyfVwREapKuG97YcxWA9gcpA=="],
+
+ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.25", "", { "os": "win32", "cpu": "x64" }, "sha512-zREeykps3ndWr9egJgvJKqVkkDuaw6Zrrg23cYBos0ygydFkAWYU4+PaPVwXzP1eAYQJe53ShSK45iDM529BOg=="],
+
+ "@paper-design/shaders": ["@paper-design/shaders@0.0.80", "", {}, "sha512-pcabvt5xDlFoEhpjUj4b1tGJMfqb0i5mXifWMNQf6z7FoOJYxYDo7F8R6k0JAh5D/7ouxjX5+N0cIq5WURyQ1Q=="],
+
+ "@paper-design/shaders-react": ["@paper-design/shaders-react@0.0.80", "", { "dependencies": { "@paper-design/shaders": "0.0.80" }, "peerDependencies": { "@types/react": "^18 || ^19", "react": "^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-Y1oxUeh5D2AECo05O+4UYRbaMfQzKKYH3Q5jcvirOcoX0hTPYbezCLn5g94nvcuzxy5LN/uqwsL3jDUp8r3E+A=="],
+
+ "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
+
+ "@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="],
+
+ "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
+
+ "@types/react-dom": ["@types/react-dom@19.2.5", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg=="],
+
+ "caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="],
+
+ "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
+
+ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+
+ "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
+
+ "next": ["next@15.5.25", "", { "dependencies": { "@next/env": "15.5.25", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.25", "@next/swc-darwin-x64": "15.5.25", "@next/swc-linux-arm64-gnu": "15.5.25", "@next/swc-linux-arm64-musl": "15.5.25", "@next/swc-linux-x64-gnu": "15.5.25", "@next/swc-linux-x64-musl": "15.5.25", "@next/swc-win32-arm64-msvc": "15.5.25", "@next/swc-win32-x64-msvc": "15.5.25", "sharp": "^0.34.3 || ^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-OMWNulIIqKM2ykvC2qMjIt0IoavB4UB2SCs4iXJ6z6847FvyH8jBmBWcvrF5iuhTu8Przh20Fo/aoszIdqx4PA=="],
+
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
+ "postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
+
+ "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
+
+ "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
+
+ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
+
+ "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
+
+ "sharp": ["sharp@0.35.4", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-x64": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-x64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-x64": "0.35.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA=="],
+
+ "sonner": ["sonner@2.0.8", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg=="],
+
+ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+
+ "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
+
+ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+ }
+}
diff --git a/site/components/Background.tsx b/site/components/Background.tsx
new file mode 100644
index 0000000..62bc5f2
--- /dev/null
+++ b/site/components/Background.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { GrainGradient } from "@paper-design/shaders-react";
+
+const DARK = { colors: ["#242424"], colorBack: "#000a0f" };
+const LIGHT = { colors: ["#e9e9e9"], colorBack: "#ffffff" };
+
+/** Full-page grain gradient behind the content; follows data-theme. */
+export default function Background() {
+ const [dark, setDark] = useState(false);
+ useEffect(() => {
+ const read = () => setDark(document.documentElement.getAttribute("data-theme") === "dark");
+ read();
+ const mo = new MutationObserver(read);
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
+ return () => mo.disconnect();
+ }, []);
+ const c = dark ? DARK : LIGHT;
+ return (
+
+
+
+ );
+}
diff --git a/site/components/EvalBlock.tsx b/site/components/EvalBlock.tsx
new file mode 100644
index 0000000..6632a13
--- /dev/null
+++ b/site/components/EvalBlock.tsx
@@ -0,0 +1,24 @@
+"use client";
+
+import { toast } from "sonner";
+import { EVAL_PROMPT } from "@/lib/evalPrompt";
+
+export default function EvalBlock() {
+ const copy = async () => {
+ try {
+ await navigator.clipboard.writeText(EVAL_PROMPT);
+ toast("COPIED · EVAL PROMPT");
+ } catch {
+ toast("COPY FAILED · CLIPBOARD UNAVAILABLE");
+ }
+ };
+ return (
+
+
+ TO SEE HOW IRIS WOULD WORK WITH YOUR WORKFLOW, PASTE{" "}
+ THIS PROMPT
+ {" "}INTO YOUR CODING AGENT AND ASK IT WHAT IT THINKS.
+
+
+ );
+}
diff --git a/site/components/Footer.tsx b/site/components/Footer.tsx
new file mode 100644
index 0000000..b35f326
--- /dev/null
+++ b/site/components/Footer.tsx
@@ -0,0 +1,20 @@
+import GitHubMark from "./GitHubMark";
+import Mark from "./Mark";
+import { GITHUB_URL } from "@/lib/site";
+
+export default function Footer() {
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/site/components/GitHubMark.tsx b/site/components/GitHubMark.tsx
new file mode 100644
index 0000000..a3216c6
--- /dev/null
+++ b/site/components/GitHubMark.tsx
@@ -0,0 +1,7 @@
+export default function GitHubMark({ size = 15 }: { size?: number }) {
+ return (
+
+
+
+ );
+}
diff --git a/site/components/Illustration.tsx b/site/components/Illustration.tsx
new file mode 100644
index 0000000..ad47905
--- /dev/null
+++ b/site/components/Illustration.tsx
@@ -0,0 +1,81 @@
+"use client";
+
+import { createContext, useContext, useEffect, useRef, useState } from "react";
+
+const ActiveCtx = createContext(false);
+
+/** True while the enclosing illustration is hovered or scrolled into view. */
+export const useActive = () => useContext(ActiveCtx);
+
+export function Illustration({ children, className = "" }: { children: React.ReactNode; className?: string }) {
+ const ref = useRef(null);
+ const [inView, setInView] = useState(false);
+ const [hover, setHover] = useState(false);
+
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ const io = new IntersectionObserver(([e]) => setInView(e.isIntersecting), { threshold: 0.55 });
+ io.observe(el);
+ return () => io.disconnect();
+ }, []);
+
+ const active = hover || inView;
+ return (
+ setHover(true)}
+ onMouseLeave={() => setHover(false)}
+ >
+
{children}
+
+ );
+}
+
+/**
+ * Cycles through phases 0..n-1 while active, holding each for durations[i] ms,
+ * then loops. Resets to 0 when inactive.
+ */
+export function usePhase(active: boolean, durations: number[]): number {
+ const [phase, setPhase] = useState(0);
+ useEffect(() => {
+ if (!active) {
+ setPhase(0);
+ return;
+ }
+ let i = 0;
+ setPhase(0);
+ let t = setTimeout(next, durations[0]);
+ function next() {
+ i = (i + 1) % durations.length;
+ setPhase(i);
+ t = setTimeout(next, durations[i]);
+ }
+ return () => clearTimeout(t);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [active]);
+ return phase;
+}
+
+/** Counts from 0 to target over `ms` while `run` is true; returns target when not running. */
+export function useCountUp(run: boolean, target: number, ms: number, from = 0): number {
+ const [v, setV] = useState(target);
+ useEffect(() => {
+ if (!run) {
+ setV(target);
+ return;
+ }
+ let raf = 0;
+ const t0 = performance.now();
+ const tick = (t: number) => {
+ const k = Math.min(1, (t - t0) / ms);
+ const e = 1 - Math.pow(1 - k, 3);
+ setV(Math.round(from + (target - from) * e));
+ if (k < 1) raf = requestAnimationFrame(tick);
+ };
+ raf = requestAnimationFrame(tick);
+ return () => cancelAnimationFrame(raf);
+ }, [run, target, ms, from]);
+ return v;
+}
diff --git a/site/components/InstallBlock.tsx b/site/components/InstallBlock.tsx
new file mode 100644
index 0000000..303513b
--- /dev/null
+++ b/site/components/InstallBlock.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import { toast } from "sonner";
+
+const CMD = "curl -fsSL iris-tl.dev/install.sh | sh\nnpx skills add psrth/iris";
+
+export default function InstallBlock() {
+ const copy = async () => {
+ try {
+ await navigator.clipboard.writeText(CMD);
+ toast("COPIED · INSTALL COMMAND");
+ } catch {
+ toast("COPY FAILED · SELECT THE TEXT INSTEAD");
+ }
+ };
+
+ return (
+ { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); copy(); } }}>
+ $ curl -fsSL {" "}
+ iris-tl.dev/install.sh | sh
+ {"\n"}
+ $ npx skills add psrth/iris
+ {"\n"}
+ $ claude > /iris start a new session
+
+ );
+}
diff --git a/site/components/Mark.tsx b/site/components/Mark.tsx
new file mode 100644
index 0000000..871d1ec
--- /dev/null
+++ b/site/components/Mark.tsx
@@ -0,0 +1,10 @@
+/** iris mark: three concentric quarter-arcs rising from bottom-left to top-right. */
+export default function Mark({ size = 16 }: { size?: number }) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/site/components/Nav.tsx b/site/components/Nav.tsx
new file mode 100644
index 0000000..89443e9
--- /dev/null
+++ b/site/components/Nav.tsx
@@ -0,0 +1,24 @@
+import GitHubMark from "./GitHubMark";
+import Mark from "./Mark";
+import ThemeToggle from "./ThemeToggle";
+import { GITHUB_URL } from "@/lib/site";
+
+export default function Nav() {
+ return (
+
+ );
+}
diff --git a/site/components/Reveal.tsx b/site/components/Reveal.tsx
new file mode 100644
index 0000000..44305a0
--- /dev/null
+++ b/site/components/Reveal.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+
+/**
+ * Fades a block in once, the first time it enters the viewport.
+ * `delay` staggers blocks that are visible together on load.
+ */
+export default function Reveal({ children, delay = 0, className = "" }: { children: React.ReactNode; delay?: number; className?: string }) {
+ const ref = useRef(null);
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ const io = new IntersectionObserver(
+ ([e]) => {
+ if (!e.isIntersecting) return;
+ el.setAttribute("data-visible", "");
+ io.disconnect();
+ },
+ { rootMargin: "0px 0px -10% 0px" },
+ );
+ io.observe(el);
+ return () => io.disconnect();
+ }, []);
+ return (
+
+ {children}
+
+ );
+}
diff --git a/site/components/Row.tsx b/site/components/Row.tsx
new file mode 100644
index 0000000..087693a
--- /dev/null
+++ b/site/components/Row.tsx
@@ -0,0 +1,8 @@
+export default function Row({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+ );
+}
diff --git a/site/components/ThemeToggle.tsx b/site/components/ThemeToggle.tsx
new file mode 100644
index 0000000..3e1c66e
--- /dev/null
+++ b/site/components/ThemeToggle.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+const KEY = "iris-theme";
+
+export default function ThemeToggle() {
+ const [dark, setDark] = useState(null);
+
+ useEffect(() => {
+ setDark(document.documentElement.getAttribute("data-theme") === "dark");
+ }, []);
+
+ const toggle = () => {
+ const next = !dark;
+ setDark(next);
+ document.documentElement.setAttribute("data-theme", next ? "dark" : "light");
+ try { localStorage.setItem(KEY, next ? "dark" : "light"); } catch {}
+ };
+
+ return (
+
+ {dark ? (
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+ );
+}
diff --git a/site/components/Toasts.tsx b/site/components/Toasts.tsx
new file mode 100644
index 0000000..dbeffd5
--- /dev/null
+++ b/site/components/Toasts.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Toaster } from "sonner";
+
+/** Sonner toaster that follows the page's data-theme. */
+export default function Toasts() {
+ const [theme, setTheme] = useState<"light" | "dark">("light");
+ useEffect(() => {
+ const read = () => setTheme(document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light");
+ read();
+ const mo = new MutationObserver(read);
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
+ return () => mo.disconnect();
+ }, []);
+ return (
+
+ );
+}
diff --git a/site/components/illustrations/MessageThread.tsx b/site/components/illustrations/MessageThread.tsx
new file mode 100644
index 0000000..9d5e850
--- /dev/null
+++ b/site/components/illustrations/MessageThread.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import { useActive, usePhase } from "../Illustration";
+
+export default function MessageThread() {
+ const active = useActive();
+ // 0 empty · 1 sent · 2 typing · 3 reply · 4 sent · 5 status
+ const phase = usePhase(active, [300, 1100, 1000, 1500, 1000, 2800]);
+ const on = (n: number) => (!active || phase >= n ? "in" : "");
+ const typing = active && phase === 2;
+
+ return (
+
+
+ had to run to my kid's soccer game. did my desktop find any issues with the new release?
+
+
+
+ Two. Batch 41 has 217 rows failing the new constraint — it has been holding since 14:32 for you. Skip them or halt?
+
+ {typing ? (
+
+ ) : null}
+
+
skip, log the row ids
+
Delivered · desktop-agent is typing…
+
+ );
+}
diff --git a/site/components/illustrations/SandboxLifecycle.tsx b/site/components/illustrations/SandboxLifecycle.tsx
new file mode 100644
index 0000000..11f7098
--- /dev/null
+++ b/site/components/illustrations/SandboxLifecycle.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import { useActive, useCountUp, usePhase } from "../Illustration";
+
+export default function SandboxLifecycle() {
+ const active = useActive();
+ // 0 spawned · 1 working · 2 purged · 3 log persists · 4 hold
+ const phase = usePhase(active, [900, 1500, 900, 1700, 2200]);
+ const on = (n: number) => (!active || phase >= n ? "on" : "");
+ const msgs = useCountUp(active && phase === 3, 41, 1200, 12);
+
+ return (
+
+ {/* spawned */}
+
+
+ 14:02 · SPAWNED
+
+
+
+ {/* working */}
+
+
+
+
+
+ WORKING · SEQ 12 → 41
+
+
+
+ {/* purged */}
+
+ 16:42 · PURGED
+
+ {/* the log outlives the sandbox */}
+ SESSION LOG · HOST
+
+ {msgs} MSGS · 3 FILES · PERSIST
+
+ );
+}
diff --git a/site/components/illustrations/SlackCard.tsx b/site/components/illustrations/SlackCard.tsx
new file mode 100644
index 0000000..62a58fd
--- /dev/null
+++ b/site/components/illustrations/SlackCard.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useActive, usePhase } from "../Illustration";
+
+const MSG = "my claude thinks it might be an issue with the upstream DNS configuration, let's see what your codex thinks?";
+
+export default function SlackCard() {
+ const active = useActive();
+ // 0 typing dots · 1 typewriter · 2 chip appears · 3 hold
+ const phase = usePhase(active, [900, 2300, 700, 3200]);
+ const [n, setN] = useState(MSG.length);
+
+ useEffect(() => {
+ if (!active) { setN(MSG.length); return; }
+ if (phase === 0) { setN(0); return; }
+ if (phase >= 2) { setN(MSG.length); return; }
+ setN(0);
+ const id = setInterval(() => setN((k) => (k >= MSG.length ? k : k + 1)), 20);
+ return () => clearInterval(id);
+ }, [active, phase]);
+
+ const typing = active && phase === 0;
+ const chip = !active || phase >= 2;
+
+ return (
+
+
SR
+
+
+ sarah
+ 14:02
+
+
+
{MSG}
+ {typing ? (
+
+ ) : (
+
+ {MSG.slice(0, n)}
+ {active && phase === 1 && n < MSG.length ? : null}
+
+ )}
+
+
+
+ tcomFwWCCcjS…
+ IRIS TOKEN
+
+
+
+ );
+}
diff --git a/site/components/illustrations/Terminal.tsx b/site/components/illustrations/Terminal.tsx
new file mode 100644
index 0000000..7de5783
--- /dev/null
+++ b/site/components/illustrations/Terminal.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useActive, useCountUp, usePhase } from "../Illustration";
+
+const CMD = "$ iris status";
+
+const ROWS: { cls: string; text: string }[] = [
+ { cls: "cobalt", text: "mythos-5.1-cybersec ×20 investigating auth/refresh" },
+ { cls: "vermilion", text: "codex-cybersec ×20 investigating webhook/retry" },
+ { cls: "moss", text: "glm-5.3-local ×20 investigating rate-limits" },
+ { cls: "human", text: "tom-human ×1 attn: 3 pending" },
+];
+
+export default function Terminal() {
+ const active = useActive();
+ // 0 type command · 1 session line counts up · 2-5 rows · 6 live hold
+ const phase = usePhase(active, [900, 1100, 300, 300, 300, 500, 3800]);
+ const on = (n: number) => (!active || phase >= n ? "in" : "");
+ const [typed, setTyped] = useState(CMD.length);
+ const base = useCountUp(active && phase === 1, 4812, 900);
+ const [live, setLive] = useState(0);
+
+ useEffect(() => {
+ if (!active) { setTyped(CMD.length); return; }
+ if (phase !== 0) { setTyped(CMD.length); return; }
+ setTyped(0);
+ const id = setInterval(() => setTyped((k) => (k >= CMD.length ? k : k + 1)), 45);
+ return () => clearInterval(id);
+ }, [active, phase]);
+
+ useEffect(() => {
+ if (!active || phase !== 6) { setLive(0); return; }
+ const id = setInterval(() => setLive((k) => k + 1 + Math.floor(Math.random() * 3)), 320);
+ return () => clearInterval(id);
+ }, [active, phase]);
+
+ const messages = (base + live).toLocaleString("en-US");
+
+ return (
+
+
+
+ api-cybersec — iris session a393jsnd
+
+
+
+ {CMD.slice(0, typed)}
+ {active && phase === 0 ? : null}
+
+
{`SESSION a393jsnd PARTICIPANTS 61 MESSAGES ${messages} FILES 38`}
+
{" "}
+ {ROWS.map((r, i) => (
+
+ ))}
+
+
+ );
+}
diff --git a/site/components/terrain/Terrain.tsx b/site/components/terrain/Terrain.tsx
new file mode 100644
index 0000000..ec7e657
--- /dev/null
+++ b/site/components/terrain/Terrain.tsx
@@ -0,0 +1,99 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { MAX_AGENTS, Palette, TerrainSim } from "./sim";
+
+const MIN_CANVAS_WIDTH = 760;
+
+function readPalette(): Palette {
+ const cs = getComputedStyle(document.documentElement);
+ const v = (n: string) => cs.getPropertyValue(n).trim();
+ return {
+ meshLine: v("--mesh-line"),
+ meshFill: v("--mesh-fill"),
+ ink: v("--ink"),
+ muted: v("--muted"),
+ dash: v("--dash"),
+ agents: [v("--cobalt"), v("--vermilion"), v("--moss"), v("--amber"), v("--violet")],
+ font: v("--font-mono") || "monospace",
+ };
+}
+
+export default function Terrain() {
+ const wrapRef = useRef(null);
+ const canvasRef = useRef(null);
+ const simRef = useRef(null);
+ const [state, setState] = useState({ agents: 2, open: 1, total: 3 });
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ const wrap = wrapRef.current;
+ if (!canvas || !wrap) return;
+ const sim = new TerrainSim(canvas, readPalette());
+ simRef.current = sim;
+ const sync = () => setState({ agents: sim.agentCount, open: sim.openCount, total: sim.sandboxCount });
+ sim.onChange = sync;
+ const fit = () => {
+ // On narrow screens draw the terrain wider than the viewport and crop the
+ // ends, so the hills keep their height instead of shrinking to a strip.
+ const width = Math.max(wrap.clientWidth, MIN_CANVAS_WIDTH);
+ if (wrap.clientWidth > 0) sim.resize(width, TerrainSim.heightFor(width));
+ };
+ fit();
+ sync();
+ sim.start();
+ const ro = new ResizeObserver(fit);
+ ro.observe(wrap);
+ const mo = new MutationObserver(() => sim.setPalette(readPalette()));
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
+ // labels are drawn with the web font; repaint the mesh once fonts are in
+ document.fonts?.ready.then(() => sim.setPalette(readPalette()));
+ return () => {
+ sim.stop();
+ ro.disconnect();
+ mo.disconnect();
+ simRef.current = null;
+ };
+ }, []);
+
+ const local = (e: React.MouseEvent) => {
+ const r = e.currentTarget.getBoundingClientRect();
+ return [e.clientX - r.left, e.clientY - r.top] as const;
+ };
+
+ return (
+
+
+
+ {
+ const [x, y] = local(e);
+ simRef.current?.unlockAt(x, y);
+ }}
+ onMouseMove={(e) => {
+ const [x, y] = local(e);
+ const hit = simRef.current ? simRef.current.lockedAt(x, y) >= 0 : false;
+ e.currentTarget.style.cursor = hit ? "pointer" : "default";
+ }}
+ />
+
+
+
+
+
+ simRef.current?.addAgent()} disabled={state.agents >= MAX_AGENTS}>
+ + AGENT
+
+ simRef.current?.unlockNext()} disabled={state.open >= state.total}>
+ + ENVIRONMENT
+
+
+
+ {state.agents}/{MAX_AGENTS} AGENTS · {state.open}/{state.total} ENVIRONMENTS
+
+
+
+
+ );
+}
diff --git a/site/components/terrain/field.ts b/site/components/terrain/field.ts
new file mode 100644
index 0000000..e65c670
--- /dev/null
+++ b/site/components/terrain/field.ts
@@ -0,0 +1,125 @@
+// Height field, camera and visibility for the hero terrain.
+// Ported from the design-time generator (mesh.py): same features, same camera,
+// so the live page matches the Paper artboard.
+
+export const A = 1.85; // ground aspect: world x spans [0, A], y spans [0, 1]
+const NEG = 0.35; // basins are shallower than hills so floors stay visible at this camera
+
+// amplitude, cx, cy, sx, sy — negative amplitude = basin
+const FEATS: [number, number, number, number, number][] = [
+ [-0.16, 0.26, 0.42, 0.13, 0.11],
+ [-0.14, 0.7, 0.5, 0.14, 0.12],
+ [-0.11, 0.86, 0.16, 0.1, 0.09],
+ [0.15, 0.5, 0.22, 0.16, 0.12],
+ [0.11, 0.1, 0.14, 0.13, 0.1],
+ [0.09, 0.5, 0.8, 0.2, 0.1],
+ [-0.1, 0.14, 0.8, 0.11, 0.09],
+ [0.08, 0.92, 0.78, 0.12, 0.09],
+ [0.06, 0.34, 0.66, 0.08, 0.07],
+ [-0.07, 0.58, 0.66, 0.07, 0.06],
+ [0.05, 0.75, 0.32, 0.06, 0.06],
+ [0.07, 0.28, 0.1, 0.07, 0.06],
+ [0.06, 0.62, 0.06, 0.09, 0.05],
+ [-0.05, 0.44, 0.5, 0.06, 0.05],
+ [0.05, 0.9, 0.5, 0.06, 0.08],
+ [0.04, 0.05, 0.5, 0.05, 0.08],
+];
+
+export function height(x: number, y: number): number {
+ let z = 0;
+ for (const [a, cx, cy, sx, sy] of FEATS) {
+ const amp = a > 0 ? a : a * NEG;
+ const dx = (x - cx) / sx;
+ const dy = (y - cy) / sy;
+ z += amp * Math.exp(-(dx * dx + dy * dy));
+ }
+ z += 0.006 * Math.sin(x * 23 + 1.3) * Math.cos(y * 19 + 0.4) + 0.003 * Math.sin(x * 41 - y * 37);
+ return z * 1.8 + 0.2;
+}
+
+export function grad(x: number, y: number, h = 0.002): [number, number] {
+ return [
+ (height(x + h, y) - height(x - h, y)) / (2 * h),
+ (height(x, y + h) - height(x, y - h)) / (2 * h),
+ ];
+}
+
+// ---- camera -----------------------------------------------------------------
+type V3 = [number, number, number];
+const C: V3 = [A / 2, -0.95, 1.45];
+const T: V3 = [A / 2, 0.45, 0];
+
+const norm = (v: V3): V3 => {
+ const l = Math.hypot(v[0], v[1], v[2]);
+ return [v[0] / l, v[1] / l, v[2] / l];
+};
+const cross = (a: V3, b: V3): V3 => [
+ a[1] * b[2] - a[2] * b[1],
+ a[2] * b[0] - a[0] * b[2],
+ a[0] * b[1] - a[1] * b[0],
+];
+const dot = (a: V3, b: V3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
+
+const FW = norm([T[0] - C[0], T[1] - C[1], T[2] - C[2]]);
+const RT = norm(cross(FW, [0, 0, 1]));
+const UP = cross(RT, FW);
+
+/** Raw camera-plane projection of a world point (x in [0,1] before aspect). */
+export function projRaw(x: number, y: number, z: number): [number, number] {
+ const v: V3 = [x * A - C[0], y - C[1], z - C[2]];
+ const d = dot(v, FW);
+ return [dot(v, RT) / d, dot(v, UP) / d];
+}
+
+/** Bounding box of the projected terrain, computed once. */
+export const BOUNDS = (() => {
+ let minx = Infinity, maxx = -Infinity, miny = Infinity, maxy = -Infinity;
+ for (let i = 0; i <= 20; i++) {
+ for (let j = 0; j <= 20; j++) {
+ const x = i / 20, y = j / 20;
+ const [sx, sy] = projRaw(x, y, height(x, y));
+ if (sx < minx) minx = sx;
+ if (sx > maxx) maxx = sx;
+ if (sy < miny) miny = sy;
+ if (sy > maxy) maxy = sy;
+ }
+ }
+ return { minx, maxx, miny, maxy, cx: (minx + maxx) / 2, cy: (miny + maxy) / 2 };
+})();
+
+/** Height/width ratio of the terrain's projected bounding box. */
+export const ASPECT = (BOUNDS.maxy - BOUNDS.miny) / (BOUNDS.maxx - BOUNDS.minx);
+
+export interface Screen {
+ w: number;
+ h: number;
+ F: number;
+}
+
+export function makeScreen(w: number, h: number, margin: number): Screen {
+ const F = Math.min(
+ (w - 2 * margin) / (BOUNDS.maxx - BOUNDS.minx),
+ (h - 2 * margin) / (BOUNDS.maxy - BOUNDS.miny),
+ );
+ return { w, h, F };
+}
+
+export function project(s: Screen, x: number, y: number, dz = 0): [number, number] {
+ const [sx, sy] = projRaw(x, y, height(x, y) + dz);
+ return [s.w / 2 + (sx - BOUNDS.cx) * s.F, s.h / 2 - (sy - BOUNDS.cy) * s.F];
+}
+
+/** True if the surface point is not hidden behind terrain from the camera. */
+export function visible(x: number, y: number, dz = 0.004): boolean {
+ const z = height(x, y) + dz;
+ const px = x * A, py = y, pz = z;
+ const N = 60;
+ for (let k = 1; k < N; k++) {
+ const t = k / N;
+ const qx = px + (C[0] - px) * t;
+ const qy = py + (C[1] - py) * t;
+ const qz = pz + (C[2] - pz) * t;
+ if (height(qx / A, qy) > qz) return false;
+ }
+ return true;
+}
diff --git a/site/components/terrain/sim.ts b/site/components/terrain/sim.ts
new file mode 100644
index 0000000..5721dd3
--- /dev/null
+++ b/site/components/terrain/sim.ts
@@ -0,0 +1,435 @@
+import { ASPECT, Screen, grad, height, makeScreen, project, visible } from "./field";
+
+export interface Palette {
+ meshLine: string;
+ meshFill: string;
+ ink: string;
+ muted: string;
+ dash: string;
+ agents: string[];
+ font: string;
+}
+
+interface Pt { x: number; y: number; vis: boolean }
+
+interface Agent {
+ ci: number; // colour index into palette.agents
+ x: number; y: number;
+ vx: number; vy: number;
+ trail: Pt[];
+ stillFrames: number;
+ settledAt: number | null;
+ bornAt: number;
+ frame: number;
+}
+
+interface Sandbox {
+ rect: [number, number, number, number]; // x0,y0,x1,y1 (world)
+ open: boolean;
+ openedAt: number | null;
+ outline: Pt[]; // draped, world-space with visibility
+ polygon: [number, number][]; // projected, unclipped (hit testing)
+}
+
+export const MAX_AGENTS = 5;
+const SANDBOX_RECTS: [number, number, number, number][] = [
+ [0.1, 0.36, 0.36, 0.58],
+ [0.72, 0.02, 0.98, 0.3],
+ [0.02, 0.66, 0.28, 0.94],
+];
+const START_POINTS: [number, number][] = [
+ [0.44, 0.22],
+ [0.6, 0.8],
+];
+
+const LR = 0.0026;
+const MOM = 0.8;
+const MARGIN = 20;
+
+export class TerrainSim {
+ private canvas: HTMLCanvasElement;
+ private ctx: CanvasRenderingContext2D;
+ private mesh: HTMLCanvasElement | null = null;
+ private screen: Screen = { w: 0, h: 0, F: 1 };
+ private dpr = 1;
+ private palette: Palette;
+ private agents: Agent[] = [];
+ private sandboxes: Sandbox[];
+ private raf = 0;
+ private last = 0;
+ onChange?: () => void;
+
+ constructor(canvas: HTMLCanvasElement, palette: Palette) {
+ this.canvas = canvas;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) throw new Error("no 2d context");
+ this.ctx = ctx;
+ this.palette = palette;
+ this.sandboxes = SANDBOX_RECTS.map((rect, i) => ({
+ rect,
+ open: i === 0,
+ openedAt: null,
+ outline: drapedOutline(rect),
+ polygon: [],
+ }));
+ for (const [x, y] of START_POINTS) this.spawn(x, y);
+ }
+
+ get agentCount() { return this.agents.length; }
+ get openCount() { return this.sandboxes.filter((s) => s.open).length; }
+ get sandboxCount() { return this.sandboxes.length; }
+
+ static heightFor(width: number) {
+ return Math.round(width * ASPECT + 2 * MARGIN);
+ }
+
+ setPalette(p: Palette) {
+ this.palette = p;
+ this.renderMesh();
+ }
+
+ resize(width: number, height: number) {
+ this.dpr = Math.min(window.devicePixelRatio || 1, 2);
+ this.canvas.width = Math.round(width * this.dpr);
+ this.canvas.height = Math.round(height * this.dpr);
+ this.canvas.style.width = `${width}px`;
+ this.canvas.style.height = `${height}px`;
+ this.screen = makeScreen(width, height, MARGIN);
+ for (const s of this.sandboxes) s.polygon = this.projectPolygon(s.rect);
+ this.renderMesh();
+ }
+
+ start() {
+ const loop = (t: number) => {
+ this.tick(t);
+ this.raf = requestAnimationFrame(loop);
+ };
+ this.raf = requestAnimationFrame(loop);
+ }
+
+ stop() { cancelAnimationFrame(this.raf); }
+
+ addAgent(): boolean {
+ if (this.agents.length >= MAX_AGENTS) return false;
+ const p = this.randomStart();
+ this.spawn(p[0], p[1]);
+ this.onChange?.();
+ return true;
+ }
+
+ unlockNext(): boolean {
+ const s = this.sandboxes.find((b) => !b.open);
+ if (!s) return false;
+ s.open = true;
+ s.openedAt = performance.now();
+ this.renderMesh();
+ this.onChange?.();
+ return true;
+ }
+
+ /** Returns the index of a locked sandbox under the CSS-pixel point, or -1. */
+ lockedAt(px: number, py: number): number {
+ return this.sandboxes.findIndex((s) => !s.open && pointInPolygon(px, py, s.polygon));
+ }
+
+ unlockAt(px: number, py: number): boolean {
+ const i = this.lockedAt(px, py);
+ if (i < 0) return false;
+ this.sandboxes[i].open = true;
+ this.sandboxes[i].openedAt = performance.now();
+ this.renderMesh();
+ this.onChange?.();
+ return true;
+ }
+
+ // ---- internals -------------------------------------------------------------
+
+ private spawn(x: number, y: number) {
+ this.agents.push({
+ ci: this.agents.length,
+ x, y, vx: 0, vy: 0,
+ trail: [{ x, y, vis: visible(x, y) }],
+ stillFrames: 0,
+ settledAt: null,
+ bornAt: performance.now(),
+ frame: 0,
+ });
+ }
+
+ private randomStart(): [number, number] {
+ for (let i = 0; i < 80; i++) {
+ const x = 0.06 + Math.random() * 0.88;
+ const y = 0.08 + Math.random() * 0.84;
+ if (this.inLocked(x, y)) continue;
+ if (!visible(x, y)) continue;
+ const [gx, gy] = grad(x, y);
+ if (Math.hypot(gx, gy) < 0.08) continue; // flat spots make dull descents
+ return [x, y];
+ }
+ return [0.5, 0.5];
+ }
+
+ private inLocked(x: number, y: number) {
+ return this.sandboxes.some((s) => !s.open && inRect(x, y, s.rect));
+ }
+
+ private projectPolygon(r: [number, number, number, number]): [number, number][] {
+ const [x0, y0, x1, y1] = r;
+ const pts: [number, number][] = [];
+ const n = 12;
+ for (let k = 0; k < n; k++) pts.push(project(this.screen, x0 + ((x1 - x0) * k) / n, y0));
+ for (let k = 0; k < n; k++) pts.push(project(this.screen, x1, y0 + ((y1 - y0) * k) / n));
+ for (let k = 0; k < n; k++) pts.push(project(this.screen, x1 - ((x1 - x0) * k) / n, y1));
+ for (let k = 0; k < n; k++) pts.push(project(this.screen, x0, y1 - ((y1 - y0) * k) / n));
+ return pts;
+ }
+
+ private renderMesh() {
+ const { w, h } = this.screen;
+ if (!w || !h) return;
+ const mesh = document.createElement("canvas");
+ mesh.width = this.canvas.width;
+ mesh.height = this.canvas.height;
+ const ctx = mesh.getContext("2d")!;
+ ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
+ const nx = Math.max(28, Math.min(60, Math.round(w / 18)));
+ const ny = Math.round(nx * 0.6);
+ ctx.fillStyle = this.palette.meshFill;
+ ctx.strokeStyle = this.palette.meshLine;
+ ctx.lineWidth = 0.8;
+ ctx.lineJoin = "round";
+ // strips back to front so nearer terrain paints over farther terrain.
+ // Quads inside a locked environment are filled but not stroked: the grid
+ // underneath is hidden until it is unlocked.
+ for (let j = ny; j > 0; j--) {
+ const yb = j / ny, yf = (j - 1) / ny;
+ const open = new Path2D();
+ const locked = new Path2D();
+ for (let i = 0; i < nx; i++) {
+ const x0 = i / nx, x1 = (i + 1) / nx;
+ const target = this.inLocked((x0 + x1) / 2, (yb + yf) / 2) ? locked : open;
+ const a = project(this.screen, x0, yb);
+ const b = project(this.screen, x1, yb);
+ const c = project(this.screen, x1, yf);
+ const d = project(this.screen, x0, yf);
+ target.moveTo(a[0], a[1]);
+ target.lineTo(b[0], b[1]);
+ target.lineTo(c[0], c[1]);
+ target.lineTo(d[0], d[1]);
+ target.closePath();
+ }
+ ctx.fill(locked);
+ ctx.fill(open);
+ ctx.stroke(open);
+ }
+ this.mesh = mesh;
+ }
+
+ private step(a: Agent, now: number) {
+ if (a.settledAt !== null) {
+ if (now - a.settledAt > 2800) this.respawn(a, now);
+ return;
+ }
+ const [gx, gy] = grad(a.x, a.y);
+ a.vx = MOM * a.vx - LR * gx;
+ a.vy = MOM * a.vy - LR * gy;
+ const nx = clamp(a.x + a.vx, 0.02, 0.98);
+ const ny = clamp(a.y + a.vy, 0.02, 0.98);
+ if (this.inLocked(nx, ny)) {
+ // locked sandboxes are walls: stop at the boundary and settle there
+ a.vx = a.vy = 0;
+ a.settledAt = now;
+ return;
+ }
+ const moved = Math.hypot(nx - a.x, ny - a.y);
+ a.x = nx; a.y = ny;
+ a.frame++;
+ if (moved < 1.5e-4) a.stillFrames++; else a.stillFrames = 0;
+ if (a.frame % 3 === 0 && moved > 1e-4) a.trail.push({ x: a.x, y: a.y, vis: visible(a.x, a.y) });
+ if (a.stillFrames > 40 && a.frame > 30) a.settledAt = now;
+ }
+
+ private respawn(a: Agent, now: number) {
+ const [x, y] = this.randomStart();
+ a.x = x; a.y = y; a.vx = a.vy = 0;
+ a.trail = [{ x, y, vis: visible(x, y) }];
+ a.stillFrames = 0; a.settledAt = null; a.bornAt = now; a.frame = 0;
+ }
+
+ private tick(now: number) {
+ if (!this.mesh) return;
+ const ctx = this.ctx;
+ // fixed-rate simulation regardless of display refresh
+ if (!this.last) this.last = now;
+ let steps = Math.min(4, Math.floor((now - this.last) / (1000 / 60)));
+ if (steps > 0) this.last = now;
+ while (steps-- > 0) for (const a of this.agents) this.step(a, now);
+
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
+ ctx.drawImage(this.mesh, 0, 0);
+ ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
+
+ for (const s of this.sandboxes) this.drawSandbox(ctx, s, now);
+ for (const a of this.agents) this.drawAgent(ctx, a, now);
+ }
+
+ private drawSandbox(ctx: CanvasRenderingContext2D, s: Sandbox, now: number) {
+ const p = this.palette;
+ const t = s.openedAt === null ? 1 : clamp((now - s.openedAt) / 450, 0, 1);
+ const openness = s.open ? t : 0;
+ const runs = visibleRuns(this.screen, s.outline, true);
+ // dashed locked outline fades out, solid open outline fades in
+ if (openness < 1) {
+ ctx.save();
+ ctx.globalAlpha = 1 - openness;
+ ctx.strokeStyle = p.dash;
+ ctx.lineWidth = 1;
+ ctx.setLineDash([4, 3]);
+ strokeRuns(ctx, runs);
+ ctx.restore();
+ }
+ if (openness > 0) {
+ ctx.save();
+ ctx.globalAlpha = openness;
+ ctx.strokeStyle = p.ink;
+ ctx.lineWidth = 1.2;
+ ctx.setLineDash([]);
+ strokeRuns(ctx, runs);
+ ctx.restore();
+ }
+ // label at the back-left corner
+ const [x0, , , y1] = s.rect;
+ const [lx, ly] = project(this.screen, x0, y1, 0.004);
+ const scale = Math.max(0.75, Math.min(1, this.screen.w / 1120));
+ ctx.save();
+ ctx.font = `500 ${10 * scale}px ${p.font}`;
+ ctx.textBaseline = "top";
+ if (s.open) {
+ ctx.fillStyle = p.ink;
+ ctx.globalAlpha = openness;
+ fillSpaced(ctx, "ENVIRONMENT", lx + 2, ly + 4 * scale, 1.2 * scale);
+ } else {
+ ctx.fillStyle = p.muted;
+ ctx.strokeStyle = p.muted;
+ ctx.lineWidth = 1.1;
+ const gx = lx + 2, gy = ly + 4 * scale;
+ // lock glyph
+ ctx.strokeRect(gx + 0.5, gy + 4.5, 7, 5);
+ ctx.beginPath();
+ ctx.arc(gx + 4, gy + 4.2, 2.3, Math.PI, 0);
+ ctx.stroke();
+ fillSpaced(ctx, "LOCKED", gx + 12, gy, 1.2 * scale);
+ }
+ ctx.restore();
+ }
+
+ private drawAgent(ctx: CanvasRenderingContext2D, a: Agent, now: number) {
+ const born = clamp((now - a.bornAt) / 500, 0, 1);
+ const color = this.palette.agents[a.ci % this.palette.agents.length];
+ const runs = visibleRuns(this.screen, a.trail, false);
+ ctx.save();
+ ctx.globalAlpha = born;
+ ctx.strokeStyle = color;
+ ctx.fillStyle = color;
+ ctx.lineWidth = 1.5;
+ ctx.lineCap = "round";
+ ctx.setLineDash([3, 4]);
+ strokeRuns(ctx, runs);
+ ctx.setLineDash([]);
+ // step markers
+ for (let i = 3; i < a.trail.length - 1; i += 4) {
+ const t = a.trail[i];
+ if (!t.vis) continue;
+ const [px, py] = project(this.screen, t.x, t.y, 0.004);
+ ctx.beginPath();
+ ctx.arc(px, py, 2, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ // start ring
+ const s0 = a.trail[0];
+ if (s0.vis) {
+ const [sx, sy] = project(this.screen, s0.x, s0.y, 0.004);
+ ctx.beginPath();
+ ctx.arc(sx, sy, 3.5, 0, Math.PI * 2);
+ ctx.fillStyle = this.palette.meshFill;
+ ctx.fill();
+ ctx.stroke();
+ ctx.fillStyle = color;
+ }
+ // current position
+ const vis = visible(a.x, a.y);
+ const [cx, cy] = project(this.screen, a.x, a.y, 0.004);
+ ctx.globalAlpha = born * (vis ? 1 : 0.3);
+ ctx.beginPath();
+ ctx.arc(cx, cy, 6, 0, Math.PI * 2);
+ ctx.fill();
+ const pulse = a.settledAt === null ? 0.45 : 0.25 + 0.25 * Math.sin(now / 350);
+ ctx.globalAlpha = born * pulse * (vis ? 1 : 0.3);
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.arc(cx, cy, 12, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.restore();
+ }
+}
+
+// ---- helpers ----------------------------------------------------------------
+
+function clamp(v: number, lo: number, hi: number) { return v < lo ? lo : v > hi ? hi : v; }
+
+function inRect(x: number, y: number, r: [number, number, number, number]) {
+ return x >= r[0] && x <= r[2] && y >= r[1] && y <= r[3];
+}
+
+function drapedOutline(r: [number, number, number, number]): Pt[] {
+ const [x0, y0, x1, y1] = r;
+ const n = 30;
+ const pts: Pt[] = [];
+ const push = (x: number, y: number) => pts.push({ x, y, vis: visible(x, y) });
+ for (let k = 0; k < n; k++) push(x0 + ((x1 - x0) * k) / n, y0);
+ for (let k = 0; k < n; k++) push(x1, y0 + ((y1 - y0) * k) / n);
+ for (let k = 0; k < n; k++) push(x1 - ((x1 - x0) * k) / n, y1);
+ for (let k = 0; k <= n; k++) push(x0, y1 - ((y1 - y0) * k) / n);
+ return pts;
+}
+
+function visibleRuns(s: Screen, pts: Pt[], closed: boolean): [number, number][][] {
+ const runs: [number, number][][] = [];
+ let cur: [number, number][] = [];
+ const seq = closed ? [...pts, pts[0]] : pts;
+ for (const p of seq) {
+ if (p.vis) cur.push(project(s, p.x, p.y, 0.004));
+ else { if (cur.length > 1) runs.push(cur); cur = []; }
+ }
+ if (cur.length > 1) runs.push(cur);
+ return runs;
+}
+
+function strokeRuns(ctx: CanvasRenderingContext2D, runs: [number, number][][]) {
+ ctx.beginPath();
+ for (const run of runs) {
+ ctx.moveTo(run[0][0], run[0][1]);
+ for (let i = 1; i < run.length; i++) ctx.lineTo(run[i][0], run[i][1]);
+ }
+ ctx.stroke();
+}
+
+function fillSpaced(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, tracking: number) {
+ let cx = x;
+ for (const ch of text) {
+ ctx.fillText(ch, cx, y);
+ cx += ctx.measureText(ch).width + tracking;
+ }
+}
+
+function pointInPolygon(px: number, py: number, poly: [number, number][]) {
+ let inside = false;
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
+ const [xi, yi] = poly[i], [xj, yj] = poly[j];
+ if (yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) inside = !inside;
+ }
+ return inside;
+}
+
+export { height };
diff --git a/site/lib/evalPrompt.ts b/site/lib/evalPrompt.ts
new file mode 100644
index 0000000..b3014cf
--- /dev/null
+++ b/site/lib/evalPrompt.ts
@@ -0,0 +1,30 @@
+/** Copied to the clipboard by the [EVAL] section; never rendered on the page. */
+export const EVAL_PROMPT = `I'm evaluating iris, a transport layer for AI agents. Read the summary below, then tell me honestly if it fits into my workflow.
+
+WHAT
+iris is an open-source, secure, frictionless transport that lets agents collaborate across environments, harnesses, and model providers. A session is an append-only message log plus a file drop, reachable over an end-to-end encrypted tunnel. Any agent — Claude Code, Codex, a local model, a human with curl — joins with a pairing token. Plain HTTP, no SDK.
+
+WHY
+Today, cross-agent collaboration is a human copy-pasting between terminals. Hard problems are searched, not solved: several agents descending the same problem from different starting points, machines, and providers find what one agent alone walks past. iris is the missing pipe.
+
+HOW
+One machine runs \`iris serve\`: a scoped HTTP relay (SQLite message log + disk file store) exposed through an embedded tailcat tunnel (WireGuard, end-to-end encrypted). It prints a session URL, a key, and a pairing token. Peers run \`iris connect \`, which binds the session to localhost. Agents post and read messages and files over eight HTTP endpoints; messages are OpenAI-chat-compatible envelopes with metadata for urgent (wake the other agent now), attn: human (a person should see this first), and reply_to. A bundled /iris skill carries the trust rules: every peer message is untrusted text, secrets never enter a session, bubble up to your human when blocked. Sessions expire after 24h of inactivity or on /terminate.
+
+USE CASES
+- Pair with a teammate's agent, even across companies: one token sent over any chat is the invitation, the address, and the password.
+- Let a local agent talk to an agent running in an ephemeral sandbox; the sandbox dies, the session log outlives it.
+- Catch up from a laptop or phone on a job your desktop agent is running.
+- Agents from different vendors sharing one log.
+
+Website: https://iris-tl.dev
+Source, spec, and skill: https://github.com/psrth/iris
+Install: curl -fsSL iris-tl.dev/install.sh | sh
+Skill: npx skills add psrth/iris
+
+EVAL
+- This tool may be a good fit if I often collaborate with other team members and forward other people's messages to you.
+- This tool may be a good fit if I often have agents running across different environments, harnesses, repositories, models, or machines that would benefit from collaborating with one another.
+- This tool may be a good fit if I work on open-ended tasks that would benefit from several independent agents collaborating towards discovering a solution.
+
+Be honest - if this tool isn't for me, it isn't. But if it is, share anecdotes from past sessions where a tool like this could have been useful.
+`;
diff --git a/site/lib/site.ts b/site/lib/site.ts
new file mode 100644
index 0000000..4dbf651
--- /dev/null
+++ b/site/lib/site.ts
@@ -0,0 +1,4 @@
+export const GITHUB_URL = "https://github.com/psrth/iris";
+export const SITE_TITLE = "iris — transport layer for AI agents";
+export const SITE_DESCRIPTION =
+ "Stop copy-pasting between terminals. iris lets your agent work with anyone else's — across harnesses, models, and machines.";
diff --git a/site/next.config.ts b/site/next.config.ts
new file mode 100644
index 0000000..a7d4cbc
--- /dev/null
+++ b/site/next.config.ts
@@ -0,0 +1,8 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+ output: "export",
+ images: { unoptimized: true },
+};
+
+export default nextConfig;
diff --git a/site/package.json b/site/package.json
new file mode 100644
index 0000000..1675514
--- /dev/null
+++ b/site/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "iris-site",
+ "private": true,
+ "version": "1.0.0",
+ "scripts": {
+ "dev": "next dev",
+ "prebuild": "cp ../install.sh public/install.sh",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint"
+ },
+ "dependencies": {
+ "@paper-design/shaders-react": "^0.0.80",
+ "next": "^15.3.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "sonner": "^2.0.8"
+ },
+ "devDependencies": {
+ "@types/node": "^22.0.0",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "typescript": "^5.5.0"
+ }
+}
diff --git a/site/public/og.png b/site/public/og.png
new file mode 100644
index 0000000..de50c77
Binary files /dev/null and b/site/public/og.png differ
diff --git a/site/tsconfig.json b/site/tsconfig.json
new file mode 100644
index 0000000..bde17a6
--- /dev/null
+++ b/site/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [{ "name": "next" }],
+ "paths": { "@/*": ["./*"] }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}