diff --git a/.gitignore b/.gitignore index 40dee73..28fa614 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ web/waitlist.json # OS .DS_Store + +# Palate website-builder skill telemetry, written at the repo root during agent sessions +build-manifest.json diff --git a/web/app/globals.css b/web/app/globals.css index 5411fa8..e49a309 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -32,20 +32,60 @@ a { color: inherit; } +/* ── Focus ── + One rule for the whole site. The 2px offset puts the ring on the page + ground rather than on the control, so it is measured against the + background: >= 12:1 on every surface the site uses. */ +:focus-visible { + outline: 3px solid var(--focus-ring); + outline-offset: 2px; +} + +/* Keyboard-only skip target, revealed on focus. */ +.skip-link { + position: absolute; + top: 10px; + left: 10px; + z-index: 200; + padding: 12px 20px; + min-height: 44px; + display: inline-flex; + align-items: center; + background: var(--fg); + color: var(--bg); + font-family: var(--font-geist-sans), sans-serif; + font-size: 0.9375rem; + font-weight: 600; + text-decoration: none; + border-radius: var(--r-md); + transform: translateY(-200%); + transition: transform 0.18s var(--ease-out-quint); +} + +.skip-link:focus-visible { + transform: none; +} + /* ── Buttons ── Canonical button design, shared across the site. Landing-page CSS modules pull these in with `composes: btn btn-primary from global;` - so the primary button looks identical everywhere. */ + so the primary button looks identical everywhere. + + Shape comes from --r-md: the tail-corner message radius that every + interactive element on the site shares. min-height keeps every button a + comfortable target on touch (>= 44px) without per-page overrides. */ .btn { display: inline-flex; align-items: center; justify-content: center; - gap: 6px; - font-size: 0.92rem; + gap: 8px; + font-family: var(--font-geist-sans), sans-serif; + font-size: 0.9375rem; font-weight: 600; - letter-spacing: 0.01em; - padding: 12px 28px; - border-radius: 100px; + letter-spacing: -0.005em; + padding: 13px 26px; + min-height: 48px; + border-radius: var(--r-md); text-decoration: none; cursor: pointer; transition: diff --git a/web/app/home.module.css b/web/app/home.module.css new file mode 100644 index 0000000..26a2d97 --- /dev/null +++ b/web/app/home.module.css @@ -0,0 +1,1668 @@ +/* + agentrelay.com homepage. + + Replaces landing.module.css for `/` and `/messaging` (landing.module.css stays + in place for /about, /careers, /pear and the blog). Two reasons: the homepage + was shipping 120 KB of render-blocking CSS most of which belonged to other + routes, and the design here is a different system from the one that file + encodes. + + ── The one shape rule ────────────────────────────────────────────────────── + Agent Relay is a messaging layer, so every interactive element and every + content surface on this page is a message: a rounded rectangle with a single + squared "tail" corner at bottom-left (--r-*, defined in brand.css). Nothing + here is a plain rounded rectangle and nothing is a pill. Where direction + carries meaning — the relay board, the channel transcript — the mirrored + variant (--r-*-in) flips the tail to bottom-right for an incoming message. + + ── Ground ────────────────────────────────────────────────────────────────── + The site runs `data-theme="dark"` on and every interior route stands + on the navy in brand.css. So does this page: it takes brand.css's own dark + ground (#08111a) rather than inventing one, and overrides the semantic tokens + on `.page` only to build the band ladder above it. The shared nav, footer, + GitHub badge and waitlist form follow those tokens, so the homepage and the + interior routes are one continuous world. +*/ + +.page { + /* ── Palette — Agent Relay blue ─────────────────────────────────────────── + One hue family. Every value below is either lifted straight from + brand.css's dark theme or tuned inside it; nothing here is a stock + framework blue. + + ground #08111a brand.css `--bg`, dark theme. The site's own floor. + accent #74b8e2 brand.css dark `--primary`, the Curious Blue family + that the logo mark (#4a90c2) already lives in. It is + a desaturated sky with a faint cyan lean — nowhere + near #3b82f6 or #58a6ff. + signal #63d18b brand.css dark `--agent-card-status`, delivery only. + + Grounds run as a four-step ladder rather than a light/dark flip, because + the page is now a single dark theme. Each step is a real, measured lift: + well ×1.16, card ×1.40, lift ×1.62 against the ground. The inverted bands + go *up* into a chromatic brand navy instead of down into black — a band + cannot invert below an already-black floor. */ + --navy: #08111a; + --well: #0c2131; + --card: #113047; + --lift: #17395a; + --abyss: #040c14; + + /* One text ladder, not two: the whole page is dark now, so the same three + values carry every ground. Worst case is --text-3 on --lift at 4.70:1. */ + --text: #e9f1f9; + --text-2: #a7bccd; + --text-3: #91a7ba; + + /* Hairlines composite rather than sit at a fixed lightness, so one value + stays visible on all four grounds (a solid hairline tuned for the floor + disappears inside a lift band). */ + --rule: rgba(148, 189, 219, 0.18); + --rule-strong: rgba(190, 220, 245, 0.55); + + --sky: #74b8e2; + --sky-lit: #9fd4f5; + --sky-tint: rgba(116, 184, 226, 0.13); + --signal: #63d18b; + + /* The relay wires. The original site drew this network in a leftover green + that vanished on navy; this is a steel blue held at 4.90:1 against the + ground so the routing announces itself instead of hiding. */ + --wire: #558aa8; + + /* Per-band semantics. Only `.lift` redefines them, and only the surfaces. */ + --sfg: var(--text); + --sfg-2: var(--text-2); + --sfg-3: var(--text-3); + --sline: var(--rule); + --sline-strong: var(--rule-strong); + --saccent: var(--sky); + --ssurface: var(--card); + + --r-band: 44px 44px 44px 0; + --gutter: clamp(20px, 5vw, 60px); + --w-narrow: 760px; + --w-mid: 1080px; + --w-text: 1180px; + --w-wide: 1240px; + + --font-display: var(--font-heading), 'Trebuchet MS', sans-serif; + --font-text: var(--font-geist-sans), system-ui, sans-serif; + --font-mono: var(--font-geist-mono), ui-monospace, monospace; + + /* Re-point the shared tokens the nav, footer, badge, buttons and waitlist + form read, so they follow this page's palette without forking. */ + --bg: var(--navy); + --fg: var(--text); + --fg-muted: var(--text-2); + --fg-faint: var(--text-3); + --line: var(--rule); + --surface: var(--card); + --card-bg: var(--card); + --primary: var(--sky); + --primary-hover: var(--sky-lit); + --primary-500: var(--sky-lit); + --primary-600: var(--sky); + --primary-800: transparent; + /* The primary button is a luminous sky plate with the page's own ground as + its label — 8.79:1, and the accent lands exactly where the page wants a + decision made. */ + --primary-fg: var(--navy); + --secondary-surface: transparent; + --focus-ring: #bfe3ff; + + /* brand.css's dark `--nav-solid-bg`, unchanged, so the bar is the same bar + it is on /docs. Kept opaque rather than translucent: the page scrolls + three lift bands underneath a sticky bar and there is no backdrop-filter + to hide behind — that was dropped for the LCP. */ + --nav-solid-bg: #0c1c2a; + --nav-bg: rgba(12, 28, 42, 0.94); + --nav-scrolled-base: #0c1c2a; + --nav-fg: var(--text); + --nav-border: rgba(148, 189, 219, 0.22); + --nav-border-strong: rgba(190, 220, 245, 0.5); + --nav-surface: rgba(148, 189, 219, 0.1); + --nav-surface-hover: rgba(148, 189, 219, 0.17); + --nav-link-hover: var(--sky-lit); + --nav-shadow: rgba(0, 0, 0, 0.45); + --nav-inset: rgba(190, 220, 245, 0.08); + --nav-logo-mark: var(--sky); + --nav-logo-wordmark: var(--text); + + /* The waitlist band runs straight into the footer, so the footer opens on + that band's own ground and settles onto brand.css's dark `--bg` — the + same two stops the interior footer uses, entered one step higher. */ + --footer-bg: linear-gradient(180deg, var(--lift) 0%, var(--navy) 100%); + --footer-fg: var(--text); + --footer-muted: #b9c9d8; + --footer-faint: var(--text-3); + --footer-line: rgba(148, 189, 219, 0.2); + + background: var(--navy); + color: var(--text); + font-family: var(--font-text); + overflow-x: clip; +} + +.main { + display: block; +} + +/* ── Type ────────────────────────────────────────────────────────────────── */ + +.h2 { + margin: 0; + font-family: var(--font-display); + font-size: clamp(2.1rem, 4.4vw, 3.5rem); + font-weight: 800; + line-height: 1; + letter-spacing: -0.035em; + color: var(--sfg); + text-wrap: balance; + max-width: 17ch; +} + +.lede { + margin: 0; + font-size: clamp(1.0625rem, 1.4vw, 1.3125rem); + line-height: 1.55; + letter-spacing: -0.008em; + color: var(--sfg-2); + max-width: 48ch; +} + +.plainList { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 14px; +} + +.plainList li { + position: relative; + padding-left: 22px; + font-size: 1.0625rem; + line-height: 1.55; + color: var(--sfg-2); + max-width: 46ch; +} + +/* The list marker is the tail corner again, at its smallest. */ +.plainList li::before { + content: ''; + position: absolute; + left: 0; + top: 0.55em; + width: 9px; + height: 9px; + border-radius: 3px 3px 3px 0; + background: var(--saccent); +} + +/* ── Buttons ─────────────────────────────────────────────────────────────── */ + +.btnPrimary { + composes: btn btn-primary from global; +} + +.btnPrimary svg { + width: 17px; + height: 17px; +} + +.btnGhost { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 48px; + padding: 13px 24px; + border: 1.5px solid var(--sline-strong); + border-radius: var(--r-md); + background: transparent; + color: var(--sfg); + font-size: 0.9375rem; + font-weight: 600; + text-decoration: none; + transition: + border-color 0.2s, + color 0.2s, + background 0.2s; +} + +.btnGhost svg { + width: 17px; + height: 17px; +} + +.btnGhost:hover { + border-color: var(--saccent); + color: var(--saccent); +} + +/* ── Copy-a-command control ──────────────────────────────────────────────── */ + +.command { + display: inline-flex; + align-items: center; + gap: 12px; + max-width: 100%; + min-width: 0; + min-height: 48px; + padding: 10px 14px 10px 18px; + border: 1.5px dashed var(--rule-strong); + border-radius: var(--r-md); + background: var(--card); + color: var(--text); + font-family: var(--font-mono); + cursor: pointer; + text-align: left; + transition: + border-color 0.2s, + background 0.2s; +} + +.command:hover { + border-color: var(--sky); + background: var(--sky-tint); +} + +/* Inside a lift band the chip recesses instead of rising. */ +.commandLift { + background: var(--well); + border-color: var(--rule-strong); +} + +.commandLift:hover { + border-color: var(--sky); + background: #0e2a41; +} + +.commandPrompt { + color: var(--sky); + font-weight: 600; +} + +.commandText { + min-width: 0; + font-size: 0.9375rem; + letter-spacing: -0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.commandAction { + flex: none; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 10px 5px 8px; + border-radius: var(--r-xs); + background: rgba(148, 189, 219, 0.13); + font-family: var(--font-text); + font-size: 0.8125rem; + font-weight: 600; + color: var(--text-2); +} + +.commandLift .commandAction { + background: rgba(190, 220, 245, 0.14); + color: var(--text-2); +} + +.commandAction svg { + width: 14px; + height: 14px; +} + +/* ── 1. Hero ─────────────────────────────────────────────────────────────── */ + +.hero { + display: grid; + grid-template-columns: minmax(0, 0.88fr) minmax(0, 1.12fr); + align-items: center; + gap: clamp(24px, 4vw, 56px); + padding: clamp(48px, 6vw, 84px) var(--gutter) clamp(56px, 6vw, 88px); + max-width: 1440px; + margin: 0 auto; + min-height: min(82vh, 820px); +} + +.heroCopy { + display: grid; + justify-items: start; + gap: 26px; + min-width: 0; +} + +.heroTitle { + margin: 0; + font-family: var(--font-display); + font-size: clamp(3.15rem, 8.2vw, 7rem); + font-weight: 800; + line-height: 0.9; + letter-spacing: -0.045em; + color: var(--text); + max-width: 11ch; +} + +.heroLede { + margin: 0; + font-size: clamp(1.0625rem, 1.35vw, 1.3125rem); + line-height: 1.55; + letter-spacing: -0.008em; + color: var(--text-2); + max-width: 42ch; +} + +.heroActions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin-top: 4px; +} + +.heroNote { + margin: 0; + font-size: 0.9375rem; + line-height: 1.5; + color: var(--text-3); + max-width: 40ch; +} + +.heroBoard { + min-width: 0; +} + +/* ── The relay board ─────────────────────────────────────────────────────── */ + +.board { + position: relative; + width: 100%; + max-width: 800px; + aspect-ratio: 760 / 500; + margin-left: auto; +} + +.boardWiresWide, +.boardWiresNarrow { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.boardWiresNarrow { + display: none; +} + +.wireBase { + fill: none; + stroke: var(--wire); + stroke-width: 1.6; + vector-effect: non-scaling-stroke; +} + +.wirePulse { + fill: none; + stroke: var(--sky-lit); + stroke-width: 3.5; + stroke-linecap: round; + vector-effect: non-scaling-stroke; + stroke-dasharray: 11 89; + stroke-dashoffset: 100; + opacity: 0; +} + +@media (prefers-reduced-motion: no-preference) { + .wirePulse { + animation: relayTravel 8s linear infinite; + } + + .wirePulseOut { + animation-name: relayTravelOut; + } + + .wire1 { + animation-delay: 0s; + } + .wire2 { + animation-delay: 2s; + } + .wire3 { + animation-delay: 4s; + } + .wire4 { + animation-delay: 6s; + } + + /* Each card reacts at the moment its own message leaves or lands, on the + same 8s cycle as the wire that serves it. */ + .nodePlanner, + .nodeReviewer { + animation: nodeSend 8s linear infinite; + } + + .nodeCoder, + .nodeOps { + animation: nodeReceive 8s linear infinite; + } + + .nodePlanner { + animation-delay: 0s; + } + .nodeCoder { + animation-delay: 2s; + } + .nodeReviewer { + animation-delay: 4s; + } + .nodeOps { + animation-delay: 6s; + } +} + +@keyframes nodeSend { + 0%, + 9% { + border-color: var(--sky); + box-shadow: 0 0 0 3px var(--sky-tint); + } + 20%, + 100% { + border-color: var(--rule); + box-shadow: 0 10px 26px -18px rgba(0, 0, 0, 0.7); + } +} + +@keyframes nodeReceive { + 0%, + 16% { + border-color: var(--rule); + box-shadow: 0 10px 26px -18px rgba(0, 0, 0, 0.7); + } + 20%, + 28% { + border-color: var(--sky); + box-shadow: 0 0 0 3px var(--sky-tint); + } + 40%, + 100% { + border-color: var(--rule); + box-shadow: 0 10px 26px -18px rgba(0, 0, 0, 0.7); + } +} + +/* Card → hub. */ +@keyframes relayTravel { + 0% { + stroke-dashoffset: 100; + opacity: 0; + } + 3% { + opacity: 1; + } + 19% { + stroke-dashoffset: 0; + opacity: 1; + } + 23% { + stroke-dashoffset: 0; + opacity: 0; + } + 100% { + stroke-dashoffset: 0; + opacity: 0; + } +} + +/* Hub → card: same wire, travelled the other way. */ +@keyframes relayTravelOut { + 0% { + stroke-dashoffset: 0; + opacity: 0; + } + 3% { + opacity: 1; + } + 19% { + stroke-dashoffset: 100; + opacity: 1; + } + 23% { + stroke-dashoffset: 100; + opacity: 0; + } + 100% { + stroke-dashoffset: 100; + opacity: 0; + } +} + +.node { + position: absolute; + display: grid; + gap: 7px; + padding: 14px 16px 13px; + width: 41%; + min-width: 168px; + background: var(--card); + border: 1px solid var(--rule); + border-radius: var(--r-md); + box-shadow: 0 10px 26px -18px rgba(0, 0, 0, 0.7); +} + +/* The tail points back the way the message came: cards on the right of the + board are receiving, so their tail mirrors. */ +.nodeCoder, +.nodeOps { + border-radius: var(--r-md-in); +} + +.nodePlanner { + left: 0; + top: 4%; +} +.nodeCoder { + right: 0; + top: 0; +} +.nodeReviewer { + left: 0; + top: 66%; +} +.nodeOps { + right: 0; + top: 72%; +} + +.nodeHead { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.nodeMark { + display: inline-flex; + color: var(--text); +} + +.nodeName { + font-family: var(--font-display); + font-size: 1rem; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text); +} + +.nodeModel { + margin-left: auto; + font-family: var(--font-mono); + font-size: 0.8125rem; + color: var(--text-3); +} + +.nodeMsg { + margin: 0; + font-size: 0.9375rem; + line-height: 1.45; + color: var(--text-2); +} + +.nodeReceipt { + margin: 0; + display: flex; + align-items: center; + gap: 7px; + font-family: var(--font-mono); + font-size: 0.8125rem; + letter-spacing: 0.01em; + color: var(--text-3); +} + +.nodeReceipt::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 2px 2px 2px 0; + background: var(--signal); +} + +.hub { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + z-index: 2; + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + column-gap: 10px; + padding: 12px 20px 12px 16px; + /* The accent is spent once on the first screen, and it is spent here: the + hub is the product, so it is the only plate on the board that is lit + rather than lifted. Everything else on the board is a navy card. */ + background: var(--sky); + color: var(--navy); + border-radius: var(--r-md); + box-shadow: 0 16px 40px -20px rgba(116, 184, 226, 0.55); +} + +.hubMark { + grid-row: span 2; + display: inline-flex; + width: 26px; + color: var(--navy); +} + +.hubMark svg { + width: 100%; + height: auto; +} + +.hubName { + font-family: var(--font-display); + font-size: 1rem; + font-weight: 700; + letter-spacing: -0.02em; +} + +.hubMeta { + font-family: var(--font-mono); + font-size: 0.8125rem; + color: rgba(8, 17, 26, 0.74); + white-space: nowrap; +} + +/* ── Bands ───────────────────────────────────────────────────────────────── */ + +.band { + position: relative; +} + +/* The shape rule at page scale: an inverted band is itself a message, with the + same squared tail corner at bottom-left. The paper ground shows through the + three rounded corners, so the page reads as a stack of messages. */ +.lift, +.caps { + border-radius: var(--r-band); +} + +/* The inverted band. On a dark page an inversion has to go *up*, so a lift + band is a lighter, more chromatic brand navy and the panels inside it + recess to --well instead of rising. Because the page is a single dark + theme now, the text ladder, the accent, the hairlines and the focus ring + all carry over unchanged — only the surfaces move. */ +.lift { + background: var(--lift); + --ssurface: var(--well); + --card-bg: var(--well); + --surface: var(--well); +} + +/* ── 2. Works with all of them — the short ruled strip ───────────────────── */ + +.strip { + border-top: 1px solid var(--rule); + border-bottom: 1px solid var(--rule); +} + +.stripInner { + max-width: var(--w-text); + margin: 0 auto; + padding: 30px var(--gutter); + display: grid; + grid-template-columns: minmax(0, 320px) minmax(0, 1fr); + align-items: center; + gap: 26px 56px; +} + +.stripTitle { + margin: 0 0 8px; + font-family: var(--font-display); + font-size: 1.375rem; + font-weight: 700; + letter-spacing: -0.025em; + color: var(--text); +} + +.stripText { + margin: 0; + font-size: 0.9375rem; + line-height: 1.5; + color: var(--text-3); +} + +.stripLogos { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 16px 34px; + justify-content: space-between; +} + +.stripLogo { + display: grid; + justify-items: center; + gap: 7px; +} + +.stripLogoMark { + display: inline-flex; + color: var(--text); + opacity: 0.9; +} + +.stripLogoLabel { + font-size: 0.8125rem; + color: var(--text-3); + white-space: nowrap; +} + +/* ── 3. Everything Slack has. For agents. ────────────────────────────────── */ + +.channels { + padding: clamp(72px, 9vw, 132px) 0; +} + +.channelsInner { + max-width: var(--w-text); + margin: 0 auto; + padding: 0 var(--gutter); + display: grid; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1fr); + gap: clamp(32px, 5vw, 72px); + align-items: start; +} + +.channelsHead { + display: grid; + gap: 28px; + position: sticky; + top: 108px; +} + +.transcript { + border: 1px solid var(--rule); + border-radius: var(--r-lg); + background: var(--well); + overflow: hidden; +} + +.transcriptHead { + display: flex; + align-items: baseline; + gap: 12px; + padding: 16px 22px; + border-bottom: 1px solid var(--rule); +} + +.transcriptChannel { + font-family: var(--font-display); + font-size: 1.0625rem; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text); +} + +.transcriptMeta { + font-family: var(--font-mono); + font-size: 0.8125rem; + color: var(--text-3); +} + +.transcriptBody { + list-style: none; + margin: 0; + padding: 22px; + display: grid; + gap: 14px; +} + +.bubble { + padding: 14px 18px 15px; + border-radius: var(--r-md); + background: rgba(148, 189, 219, 0.07); + border: 1px solid var(--rule); + max-width: 92%; +} + +.bubbleIn { + border-radius: var(--r-md-in); + margin-left: auto; + background: rgba(116, 184, 226, 0.14); + border-color: rgba(116, 184, 226, 0.34); +} + +.bubbleHead { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 7px; +} + +.bubbleMark { + display: inline-flex; + color: var(--text-2); +} + +.bubbleAuthor { + font-weight: 600; + font-size: 0.9375rem; + color: var(--text); +} + +.bubbleModel, +.bubbleTime { + font-family: var(--font-mono); + font-size: 0.8125rem; + color: var(--text-3); +} + +.bubbleTime { + margin-left: auto; +} + +.bubbleText { + margin: 0; + font-size: 1rem; + line-height: 1.55; + color: var(--text-2); +} + +.bubbleText b { + font-weight: 600; + color: var(--sky); +} + +.transcriptFoot { + display: flex; + align-items: center; + gap: 12px; + margin: 0; + padding: 0 22px 20px; + font-size: 0.875rem; + color: var(--text-3); +} + +.reaction { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: var(--r-xs); + border: 1px solid var(--rule); + background: rgba(148, 189, 219, 0.07); + font-size: 0.8125rem; + color: var(--text-2); +} + +/* ── 4. The hard parts of delivery — type only, no panel ─────────────────── */ + +.delivery { + padding: clamp(80px, 10vw, 144px) 0; +} + +.deliveryInner { + max-width: var(--w-text); + margin: 0 auto; + padding: 0 var(--gutter); + display: grid; + grid-template-columns: minmax(0, 0.72fr) minmax(0, 1fr); + gap: clamp(32px, 5vw, 80px); + align-items: start; +} + +.deliveryHead { + display: grid; + gap: 22px; + position: sticky; + top: 116px; +} + +/* Fills the sticky column's dead quadrant with the thing the section is + actually about, rather than a decorative panel. */ +.deliveryNote { + margin: 6px 0 0; + padding-top: 18px; + border-top: 1px dashed var(--rule-strong); + font-family: var(--font-mono); + font-size: 0.8125rem; + line-height: 1.9; + color: var(--text-3); +} + +.deliveryNote b { + display: block; + font-family: var(--font-text); + font-size: 0.9375rem; + font-weight: 600; + color: var(--sky); + letter-spacing: 0; +} + +.rows { + list-style: none; + margin: 0; + padding: 0; + border-top: 1px solid var(--rule); +} + +.row { + display: grid; + grid-template-columns: 62px minmax(0, 1fr); + gap: 20px; + padding: 26px 0 28px; + border-bottom: 1px solid var(--rule); +} + +.rowNum { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 800; + line-height: 0.9; + letter-spacing: -0.04em; + font-variant-numeric: tabular-nums; + color: var(--sky); + opacity: 0.7; +} + +.rowBody { + display: grid; + gap: 8px; +} + +.rowTitle { + margin: 0; + font-family: var(--font-display); + font-size: 1.3125rem; + font-weight: 700; + letter-spacing: -0.025em; + color: var(--text); +} + +.rowText { + margin: 0; + font-size: 1.0625rem; + line-height: 1.6; + color: var(--text-2); + max-width: 58ch; +} + +/* ── 5. Everything agents need to collaborate ────────────────────────────── */ + +.caps { + background: var(--well); + padding: clamp(64px, 7.5vw, 104px) 0; +} + +.capsInner { + max-width: var(--w-text); + margin: 0 auto; + padding: 0 var(--gutter); +} + +.capsHead { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 0.82fr); + align-items: end; + gap: 18px 56px; + margin-bottom: 46px; +} + +.capsGrid { + display: grid; + grid-template-columns: 1.25fr 1fr 1fr; + gap: 20px; +} + +.cap { + display: flex; + flex-direction: column; + gap: 12px; + padding: 26px 26px 22px; + background: var(--card); + border: 1px solid var(--rule); + border-radius: var(--r-lg); +} + +.capTitle { + margin: 0; + font-family: var(--font-display); + font-size: 1.25rem; + font-weight: 700; + letter-spacing: -0.025em; + color: var(--text); +} + +.capText { + margin: 0; + font-size: 1rem; + line-height: 1.55; + color: var(--text-2); +} + +.capArt { + display: grid; + gap: 5px; + margin-top: auto; + padding-top: 12px; + padding: 12px 14px; + border-radius: var(--r-sm); + background: var(--navy); + border: 1px dashed var(--rule-strong); + overflow: hidden; +} + +.capArtLine { + font-family: var(--font-mono); + font-size: 0.8125rem; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.capArtMeta { + font-family: var(--font-mono); + font-size: 0.8125rem; + color: var(--sky); +} + +/* ── 6. Make it yours with the SDK ───────────────────────────────────────── */ + +.sdk { + padding: clamp(72px, 9vw, 132px) 0; +} + +.sdkInner { + max-width: var(--w-wide); + margin: 0 auto; + padding: 0 var(--gutter); + display: grid; + grid-template-columns: minmax(0, 1.08fr) minmax(0, 0.92fr); + gap: clamp(28px, 4vw, 64px); + align-items: center; +} + +.codePanel { + margin: 0; + /* Pulled up out of the band's own rhythm. */ + margin-top: clamp(-56px, -3vw, 0px); + border: 1px solid var(--rule); + border-radius: var(--r-lg); + background: var(--abyss); + overflow: hidden; +} + +.codeHead { + display: flex; + align-items: center; + gap: 10px; + padding: 13px 20px; + border-bottom: 1px solid var(--rule); + font-family: var(--font-mono); + font-size: 0.8125rem; + color: var(--text-2); +} + +.codeDot { + width: 8px; + height: 8px; + border-radius: 3px 3px 3px 0; + background: var(--sky); +} + +.codeBody { + margin: 0; + padding: 22px; + overflow-x: auto; + font-family: var(--font-mono); + font-size: 0.875rem; + line-height: 1.75; + color: #aebfd0; + white-space: pre; + tab-size: 2; +} + +/* brand.css's own console palette (--terminal-*), so the homepage's code + panel is the same terminal every interior route already renders. */ +.tkMuted { + color: #6d8296; + font-style: italic; +} +.tkFn { + color: #e6d78d; +} +.tkVar { + color: #6bd4bc; +} +.tkKw { + color: #7eb8da; +} +.tkStr { + color: #e4a986; +} + +.sdkCopy { + display: grid; + justify-items: start; + gap: 20px; +} + +.sdkKicker { + margin: -6px 0 0; + font-family: var(--font-display); + font-size: 1.1875rem; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--sky); +} + +.sdkActions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +/* ── 7. Speaks A2A out of the box — the quiet one ────────────────────────── */ + +.a2a { + padding: clamp(64px, 7vw, 96px) 0; +} + +.a2aInner { + max-width: var(--w-narrow); + margin: 0 auto; + padding: 0 var(--gutter); + display: grid; + justify-items: center; + text-align: center; + gap: 22px; +} + +.a2aInner .h2 { + max-width: 14ch; +} + +.a2aCard { + margin: 6px 0 0; + width: 100%; + max-width: 520px; + text-align: left; + border: 1px solid var(--rule); + border-radius: var(--r-lg); + background: var(--card); + overflow: hidden; +} + +.a2aCardHead { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 22px; + border-bottom: 1px solid var(--rule); + background: rgba(148, 189, 219, 0.06); + font-family: var(--font-mono); + font-size: 0.8125rem; +} + +.a2aCardDot { + flex: none; + width: 8px; + height: 8px; + border-radius: 3px 3px 3px 0; + background: var(--signal); +} + +.a2aCardName { + color: var(--text); + font-weight: 500; +} + +.a2aCardKind { + margin-left: auto; + color: var(--text-3); + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.a2aFields { + display: grid; + grid-template-columns: auto 1fr; + align-items: baseline; + gap: 6px 16px; + margin: 0; + padding: 18px 22px; +} + +.a2aKey { + margin: 0; + font-family: var(--font-mono); + font-size: 0.8125rem; + color: var(--sky); +} + +.a2aValue { + margin: 0; + font-family: var(--font-mono); + font-size: 0.875rem; + color: var(--text-2); + overflow-wrap: anywhere; +} + +/* ── 8. Open source from day one ─────────────────────────────────────────── */ + +.deploy { + padding: clamp(64px, 8vw, 112px) 0 clamp(72px, 8vw, 112px); + border-top: 1px solid var(--rule); +} + +.deployInner { + max-width: var(--w-mid); + margin: 0 auto; + padding: 0 var(--gutter); + display: grid; + gap: 40px; +} + +.deployHead { + display: grid; + gap: 18px; +} + +.deployCards { + display: grid; + grid-template-columns: 1.3fr 1fr; + gap: 20px; +} + +.deployCard { + display: flex; + flex-direction: column; + gap: 12px; + padding: 30px 30px 26px; + border: 1px solid var(--rule); + border-radius: var(--r-lg); + background: var(--card); + color: var(--text); + text-decoration: none; + transition: + border-color 0.2s, + transform 0.25s var(--ease-out-quint); +} + +/* The pair differentiates by depth, not by hue: self-host is a raised card, + the hosted one is cut down into the ground and drawn with the stronger + hairline so a recessed card still reads as a deliberate surface. */ +.deployCardAlt { + background: var(--abyss); + border-color: var(--rule-strong); +} + +.deployCard:hover { + border-color: var(--sky); +} + +.deployCardTitle { + margin: 0; + font-family: var(--font-display); + font-size: 1.5rem; + font-weight: 700; + letter-spacing: -0.03em; +} + +.deployCardText { + margin: 0; + font-size: 1.0625rem; + line-height: 1.55; + color: var(--text-2); + max-width: 42ch; +} + +.deployCardLink { + display: inline-flex; + align-items: center; + align-self: flex-start; + gap: 6px; + margin-top: auto; + padding-top: 10px; + font-size: 0.9375rem; + font-weight: 600; + color: var(--sky); +} + +.deployCardLink svg { + width: 16px; + height: 16px; +} + +/* ── 9. Be the first to know ─────────────────────────────────────────────── */ + +/* The waitlist runs straight into the footer, so it opens the closing block + rather than closing one of its own. */ +.waitlist { + border-radius: 44px 44px 0 0; + padding: clamp(76px, 9vw, 128px) 0; +} + +.waitlistInner { + max-width: 720px; + margin: 0 auto; + padding: 0 var(--gutter); + display: grid; + justify-items: center; + text-align: center; + gap: 20px; +} + +.waitlistInner .h2 { + max-width: 12ch; +} + +.waitlistInner form, +.waitlistInner > div { + width: 100%; + margin-top: 12px; +} + +/* ── Scroll reveal ───────────────────────────────────────────────────────── */ + +@supports (animation-timeline: view()) { + @media (prefers-reduced-motion: no-preference) { + .strip, + .transcript, + .row, + .cap, + .codePanel, + .a2aCard, + .deployCard { + animation: homeReveal linear both; + animation-timeline: view(); + animation-range: entry 8% entry 58%; + } + } +} + +@keyframes homeReveal { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: none; + } +} + +/* ── Breakpoints ─────────────────────────────────────────────────────────── */ + +@media (max-width: 1080px) { + .hero { + grid-template-columns: minmax(0, 1fr); + align-items: start; + gap: 44px; + min-height: 0; + } + + .heroBoard { + margin-right: 0; + } + + .board { + margin-left: 0; + max-width: 680px; + } + + .heroTitle { + max-width: 14ch; + } + + .channelsInner, + .deliveryInner, + .sdkInner { + grid-template-columns: minmax(0, 1fr); + } + + .channelsHead, + .deliveryHead { + position: static; + } + + .sdkInner { + gap: 40px; + } + + .codePanel { + order: 2; + margin-top: 0; + } + + .capsGrid { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + } + + .cap:first-child { + grid-column: span 2; + } +} + +@media (max-width: 880px) { + .stripInner { + grid-template-columns: 1fr; + } + + .stripLogos { + justify-content: flex-start; + } +} + +@media (max-width: 760px) { + .page { + --r-band: 26px 26px 26px 0; + } + + .waitlist { + border-radius: 26px 26px 0 0; + } + + /* The board is not hidden on phones — it becomes a vertical relay column: + one spine wire down the left gutter, three stops, same travelling pulse. */ + .board { + aspect-ratio: 360 / 470; + max-width: 460px; + margin: 0 auto; + } + + .boardWiresWide { + display: none; + } + + .boardWiresNarrow { + display: block; + } + + .node { + width: 80%; + left: 20%; + right: auto; + border-radius: var(--r-md); + } + + .nodePlanner { + top: 3%; + } + + .nodeCoder { + top: 73%; + border-radius: var(--r-md-in); + animation-delay: 0s; + } + + .nodeReviewer, + .nodeOps { + display: none; + } + + .hub { + left: 20%; + top: 44%; + transform: none; + width: 80%; + padding: 12px 16px; + } + + .hubMeta { + white-space: normal; + } + + /* The meta wraps to two lines here, so a centred mark would float opposite + the meta instead of the name it belongs to. Pin it to the name's line. */ + .hubMark { + align-self: start; + margin-top: 1px; + } + + .capsHead { + grid-template-columns: minmax(0, 1fr); + align-items: start; + } + + .capsGrid { + grid-template-columns: minmax(0, 1fr); + } + + .cap:first-child { + grid-column: auto; + } + + .deployCards { + grid-template-columns: minmax(0, 1fr); + } + + .row { + grid-template-columns: minmax(0, 1fr); + gap: 10px; + } + + .rowNum { + font-size: 1.25rem; + } +} + +/* Prose floor on phones: nothing that is a sentence drops below 16px, and + nothing at all below 13px. */ +@media (max-width: 760px) { + .nodeMsg, + .heroNote, + .stripText { + font-size: 1rem; + } + + .transcriptFoot, + .capText, + .rowText, + .deployCardText { + font-size: 1rem; + } + + .capArtLine, + .capArtMeta, + .deliveryNote { + font-size: 0.875rem; + } +} + +@media (max-width: 560px) { + .heroActions { + display: grid; + grid-template-columns: 1fr; + width: 100%; + gap: 10px; + } + + .btnPrimary, + .btnGhost { + width: 100%; + min-height: 52px; + } + + /* The command is the CTA on a phone, so it wraps to two rows rather than + truncating: the full command on top, the copy affordance under it. The + action sits in the text column, not across both, so it starts on the + command's own left edge — a right-aligned pill would read as an orphan + floating in the empty half of the row. */ + .command { + display: grid; + width: 100%; + min-height: 52px; + grid-template-columns: auto minmax(0, 1fr); + grid-template-areas: + 'prompt text' + '. action'; + align-items: start; + row-gap: 8px; + padding: 13px 14px 12px 16px; + } + + .commandPrompt { + grid-area: prompt; + } + + .commandText { + grid-area: text; + font-size: 0.875rem; + white-space: normal; + overflow: visible; + overflow-wrap: anywhere; + } + + .commandAction { + grid-area: action; + justify-self: start; + } + + .sdkActions { + display: grid; + grid-template-columns: 1fr; + width: 100%; + } + + .bubble, + .bubbleIn { + max-width: 100%; + margin-left: 0; + } + + .transcriptBody, + .transcriptHead { + padding-left: 16px; + padding-right: 16px; + } + + .transcriptFoot { + padding-left: 16px; + padding-right: 16px; + flex-wrap: wrap; + gap: 8px; + } + + .codeBody { + font-size: 0.8125rem; + padding: 16px; + } + + .cap, + .deployCard { + padding: 22px 20px 20px; + } + + .stripLogos { + gap: 14px 22px; + } +} diff --git a/web/app/landing.module.css b/web/app/landing.module.css index 30f48b4..d7647f1 100644 --- a/web/app/landing.module.css +++ b/web/app/landing.module.css @@ -1943,7 +1943,11 @@ background: var(--agent-tools-band-bg); } -.messagingPage > :not(:first-child) { +/* Lift the page content above the hero wash. Targeted at the two content + siblings by name so the sticky nav (and the keyboard skip link that now + precedes it) keep their own positioning. */ +.messagingPage > main, +.messagingPage > footer { position: relative; z-index: 1; } @@ -2017,18 +2021,250 @@ padding-top: 56px; } -.heroCopyGroup { - display: grid; - gap: 10px; - max-width: 610px; -} - .heroRight { position: relative; align-self: stretch; min-height: 580px; } +/* ── Home hero: one centred column over the terminal marquee ───────────── + The product pages keep the two-column `.hero` above; the home page runs a + single centred column so the marquee band underneath gets the full width. */ +.heroCenter { + display: flex; + justify-content: center; + width: 100%; + padding: 72px 40px 0; +} + +.heroCenterColumn { + display: flex; + flex-direction: column; + align-items: center; + gap: 22px; + width: 100%; + max-width: 880px; + text-align: center; +} + +/* One step down from the product pages' `.headline`: this line is five words, + not three, and at the shared scale it filled the fold on its own and pushed + the marquee off the bottom of the screen. The column prefix is load-bearing — + `.headline` and `.subtitle` are declared further down this file, so a bare + class here would lose the cascade to them. */ +.heroCenterColumn .heroCenterHeadline { + font-size: clamp(2.6rem, 5.4vw, 4.5rem); + max-width: 20ch; + text-wrap: balance; +} + +.heroCenterColumn .heroCenterSubtitle { + max-width: 52ch; + margin: -4px auto 0; + text-wrap: pretty; +} + +.heroCenterCtas { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 14px; + margin-top: 2px; +} + +/* ── Terminal marquee ──────────────────────────────────────────────────── + Three full-bleed rows of agent sessions sliding past. Each track holds two + identical copies of its cards and slides exactly one copy's width, so the + loop is seamless; the spacing lives in each card's margin-right rather than + a flex gap so that -50% lands on a card boundary rather than half a gap + short. Rows differ in duration, direction and negative delay (a phase + offset) so the card seams never line up across the band. The rows also hold + different numbers of cards, so their tracks are different lengths and the + seams drift instead of beating in step. */ +.heroMarquee { + display: flex; + flex-direction: column; + gap: 14px; + width: 100%; + margin-top: 68px; + padding-bottom: 26px; + /* clip, not hidden: the band never adds page scroll, and the cards' shadows + still breathe above and below the row. */ + overflow-x: clip; + overflow-y: visible; +} + +.heroMarqueeRow { + width: 100%; + overflow-x: clip; + overflow-y: visible; +} + +.heroMarqueeTrack { + display: flex; + align-items: stretch; + width: max-content; +} + +@media (prefers-reduced-motion: no-preference) { + .heroMarqueeTrack { + will-change: transform; + animation: heroMarqueeSlide linear infinite; + } + + .heroMarqueeTrackOne { + animation-duration: 74s; + animation-delay: -11s; + } + + .heroMarqueeTrackTwo { + animation-duration: 88s; + animation-delay: -37s; + animation-direction: reverse; + } + + .heroMarqueeTrackThree { + animation-duration: 66s; + animation-delay: -23s; + } +} + +@keyframes heroMarqueeSlide { + from { + transform: translate3d(0, 0, 0); + } + to { + transform: translate3d(-50%, 0, 0); + } +} + +/* The second and third rows start part-way along so the seams stagger even + when motion is off. */ +.heroMarqueeTrackTwo { + margin-left: -132px; +} + +.heroMarqueeTrackThree { + margin-left: -258px; +} + +.heroTerm { + display: flex; + flex-direction: column; + flex: none; + margin-right: 14px; + border: 1px solid rgba(116, 184, 226, 0.16); + border-radius: 10px; + /* This band is navy in both themes, so the card carries the dark palette + outright rather than through --agent-card-*, which flips to white. */ + background: rgba(8, 22, 36, 0.86); + box-shadow: 0 6px 18px rgba(2, 10, 20, 0.28); + overflow: hidden; +} + +.heroTermSm { + width: 268px; +} + +.heroTermMd { + width: 312px; +} + +.heroTermLg { + width: 352px; +} + +.heroTermBar { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: rgba(15, 33, 48, 0.94); + border-bottom: 1px solid rgba(116, 184, 226, 0.14); +} + +.heroTermLights { + display: inline-flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.heroTermLights span { + width: 7px; + height: 7px; + border-radius: 50%; + background: rgba(116, 184, 226, 0.26); +} + +.heroTermLights span:first-child { + background: rgba(233, 116, 104, 0.62); +} + +.heroTermLights span:nth-child(2) { + background: rgba(232, 187, 106, 0.56); +} + +.heroTermLights span:last-child { + background: rgba(112, 199, 141, 0.56); +} + +.heroTermTitle { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + font-family: var(--font-geist-mono), monospace; + font-size: 0.68rem; + font-weight: 500; + letter-spacing: 0.01em; + color: rgba(206, 227, 243, 0.82); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.heroTermMark { + width: 12px; + height: 12px; + flex-shrink: 0; + color: rgba(219, 233, 244, 0.9); +} + +.heroTermBody { + display: flex; + flex-direction: column; + gap: 5px; + padding: 10px 12px 12px; +} + +.heroTermLine { + font-family: var(--font-geist-mono), monospace; + font-size: 0.7rem; + line-height: 1.35; + letter-spacing: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.heroTermPrompt { + color: rgba(238, 247, 255, 0.94); +} + +.heroTermTool { + color: rgba(160, 184, 204, 0.82); +} + +.heroTermOk { + color: #7fd3a0; +} + +.heroTermRelay { + color: #85c6ec; +} + .badge { display: inline-flex; align-items: center; @@ -2094,32 +2330,19 @@ padding-top: 4px; } -.heroCommandGroup { - display: grid; - gap: 10px; - width: min(100%, 610px); - margin-top: 0; -} - -.heroCommandIntro { - color: rgba(184, 211, 232, 0.62); - font-size: 0.78rem; - font-weight: 500; - letter-spacing: 0; - line-height: 1.25; - max-width: 56ch; -} - +/* The hero's onboarding command: sized to the command itself and centred under + the two buttons, so it reads as the quiet third tier rather than a second + full-width block competing with them. */ .heroCommandCta { appearance: none; position: relative; display: flex; align-items: center; - gap: 12px; - width: 100%; - margin-top: 0; - min-height: 54px; - padding: 10px 12px 10px 16px; + gap: 10px; + max-width: 100%; + margin: 0 auto; + min-height: 46px; + padding: 6px 8px 6px 16px; border: 1px solid rgba(116, 184, 226, 0.2); border-radius: 10px; background: @@ -6073,13 +6296,14 @@ min-height: auto; } + /* min-width: 0 stops the single grid track from being floored by the install + command's min-content width, which pushed the hero column ~34px past the + gutter and clipped the right edge of the copy button on phones. The + command itself already scrolls inside the button. */ .heroLeft { gap: 28px; padding-top: 0; - } - - .heroCopyGroup { - gap: 10px; + min-width: 0; } .heroRight { @@ -6090,6 +6314,23 @@ font-size: clamp(2.85rem, 10.5vw, 4.35rem); } + .heroCenter { + padding: 40px 32px 0; + } + + .heroCenterColumn { + gap: 20px; + max-width: 640px; + } + + .heroCenterColumn .heroCenterHeadline { + font-size: clamp(2.5rem, 7.6vw, 3.9rem); + } + + .heroMarquee { + margin-top: 52px; + } + .sdkSection { grid-template-columns: 1fr; padding: 60px 24px 80px; @@ -6195,15 +6436,76 @@ gap: 26px; } - .heroCopyGroup { - gap: 8px; - } - .headline { font-size: clamp(3.05rem, 11.5vw, 4.5rem); line-height: 1.08; } + /* Phones: the same architecture, one step smaller. The headline is four + words on four lines at this width, so the column tightens and the band + comes up to meet it. */ + .heroCenter { + padding: 34px 24px 0; + } + + .heroCenterColumn { + gap: 18px; + } + + .heroCenterColumn .heroCenterHeadline { + font-size: clamp(2.3rem, 10.2vw, 3.5rem); + max-width: none; + } + + .heroCenterColumn .heroCenterSubtitle { + max-width: 40ch; + } + + .heroCenterCtas { + align-self: stretch; + flex-direction: column; + align-items: stretch; + gap: 10px; + } + + .heroMarquee { + gap: 10px; + margin-top: 40px; + padding-bottom: 18px; + } + + .heroTerm { + margin-right: 10px; + } + + .heroTermSm { + width: 228px; + } + + .heroTermMd { + width: 262px; + } + + .heroTermLg { + width: 292px; + } + + .heroTermTitle { + font-size: 0.64rem; + } + + .heroTermLine { + font-size: 0.66rem; + } + + .heroMarqueeTrackTwo { + margin-left: -96px; + } + + .heroMarqueeTrackThree { + margin-left: -188px; + } + .ctas { flex-direction: column; align-items: stretch; @@ -6222,8 +6524,35 @@ line-height: 1.62; } + /* The command has to fit inside the gutter: tighter padding plus one step + down puts all 33 characters on screen rather than leaving the tail behind + a scrollbar nobody can see. The copy button keeps its full 38px — on a + coarse pointer it is always visible. */ + .heroCommandCta { + gap: 8px; + padding: 6px 6px 6px 12px; + } + + .heroCommandText { + font-size: 0.76rem; + } + + /* Phone prose floor. The responsive scale steps running copy down to + 14–15.7px, which is under the 16px body minimum exactly where the column + is narrowest and the text is hardest to read. Labels, captions, code and + the mock chat UI keep their own smaller sizes — this is only the + sentence-length prose. */ + .featureList, + .capabilityCopy p, + .installSubtitle, + .deployCardText { + font-size: 1rem; + } + .heroRight { - display: none; + min-height: 0; + align-self: center; + width: 100%; } .installSection { @@ -6337,6 +6666,12 @@ align-items: center; padding: 88px 0 92px; isolation: isolate; + /* The pair converges from opposite sides on reveal, and the right-hand + card's inbound translateX(40px) sits 16px past the gutter until the + observer fires — a phone could scroll sideways for that second. `clip` + (not `hidden`) takes the strip away without making this a scroll + container, so the card still slides in from off-page. */ + overflow-x: clip; } .howItWorks::before { @@ -6420,17 +6755,18 @@ radial-gradient(circle at 50% 0%, color-mix(in srgb, var(--primary) 22%, transparent), transparent 72%), #0e2031; box-shadow: - 0 0 0 6px color-mix(in srgb, var(--primary) 8%, transparent), + 0 0 0 4px color-mix(in srgb, var(--primary) 6%, transparent), 0 16px 44px color-mix(in srgb, #04101c 52%, transparent); } +/* A close, low halo that separates the hub from the band without blooming. */ .howCoreNode::before { content: ''; position: absolute; - inset: -22px; + inset: -14px; z-index: -1; border-radius: 50%; - background: radial-gradient(circle, color-mix(in srgb, var(--primary) 16%, transparent), transparent 68%); + background: radial-gradient(circle, color-mix(in srgb, var(--primary) 9%, transparent), transparent 70%); pointer-events: none; } @@ -6490,8 +6826,8 @@ border-radius: 50%; background: var(--c); box-shadow: - 0 0 0 1px color-mix(in srgb, var(--c) 35%, transparent), - 0 0 14px 2px var(--c); + 0 0 0 1px color-mix(in srgb, var(--c) 30%, transparent), + 0 0 7px 1px color-mix(in srgb, var(--c) 55%, transparent); opacity: 0; /* Position is driven by the individual `translate` property and the size is driven by the individual `scale` property; both composite on the GPU and @@ -6748,7 +7084,7 @@ border-radius: 12px; border: 1px solid var(--line); background: color-mix(in srgb, var(--card-bg) 55%, #060f1a 45%); - /* Mono brand marks (OpenCode, Cursor, GitHub, Hermes) inherit this. */ + /* Mono brand marks (Codex, OpenCode, Cursor, GitHub, Hermes) inherit this. */ color: #e6edf5; transition: transform 0.2s, diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 0dd8e93..02956b7 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -11,16 +11,22 @@ import './globals.css'; const inter = Inter({ variable: '--font-geist-sans', subsets: ['latin'], + display: 'swap', }); const sora = Sora({ variable: '--font-heading', subsets: ['latin'], + display: 'swap', }); +// Mono only ever appears below or beside the LCP element, so it stays off the +// critical path: two preloaded families instead of three. const geistMono = Geist_Mono({ variable: '--font-geist-mono', subsets: ['latin'], + display: 'swap', + preload: false, }); export const metadata: Metadata = { diff --git a/web/components/MessageRelayAnimation.tsx b/web/components/MessageRelayAnimation.tsx index e96cf07..4c8ecac 100644 --- a/web/components/MessageRelayAnimation.tsx +++ b/web/components/MessageRelayAnimation.tsx @@ -326,11 +326,22 @@ function buildCenters(nodes: AgentNode[], w: number, h: number): NodeCenter[] { })); } +/* Brand blue, one value per theme. The network reads as the site's own colour + rather than the leftover green it was drawn in. */ +const NETWORK_RGB_DARK = '116, 184, 226'; +const NETWORK_RGB_LIGHT = '45, 106, 156'; + +function networkRgb() { + return isDarkMode() ? NETWORK_RGB_DARK : NETWORK_RGB_LIGHT; +} + function drawConnectionLines( ctx: CanvasRenderingContext2D, centers: NodeCenter[], connections: [number, number][] ) { + const rgb = networkRgb(); + for (const [i, j] of connections) { const ci = centers[i]; const cj = centers[j]; @@ -338,7 +349,7 @@ function drawConnectionLines( continue; } - const lineOpacity = Math.min(ci.opacity, cj.opacity) * 0.1; + const lineOpacity = Math.min(ci.opacity, cj.opacity) * 0.3; if (lineOpacity < 0.01) { continue; } @@ -346,8 +357,10 @@ function drawConnectionLines( ctx.beginPath(); ctx.moveTo(ci.cx, ci.cy); ctx.lineTo(cj.cx, cj.cy); - ctx.strokeStyle = `rgba(45, 79, 62, ${lineOpacity})`; - ctx.lineWidth = 1; + /* No shadowBlur here on purpose: the line carries its own weight at this + opacity, and a blur behind every edge is what turned the network neon. */ + ctx.strokeStyle = `rgba(${rgb}, ${lineOpacity})`; + ctx.lineWidth = 1.1; ctx.stroke(); } } @@ -573,6 +586,8 @@ function drawMessages( } function drawGlowRings(ctx: CanvasRenderingContext2D, nodes: AgentNode[], centers: NodeCenter[]) { + const rgb = networkRgb(); + for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if (node.glowOpacity <= 0.01 || node.opacity <= 0.1) { @@ -581,10 +596,13 @@ function drawGlowRings(ctx: CanvasRenderingContext2D, nodes: AgentNode[], center const center = centers[i]; const radius = 56; - const alpha = node.glowOpacity * node.opacity * 0.07; - const grad = ctx.createRadialGradient(center.cx, center.cy, radius * 0.6, center.cx, center.cy, radius); - grad.addColorStop(0, `rgba(45, 79, 62, ${alpha})`); - grad.addColorStop(1, 'rgba(45, 79, 62, 0)'); + /* A tight, low halo that only says "this node is busy". Starting the ramp + late (0.62) keeps it hugging the card instead of blooming into the + background. */ + const alpha = node.glowOpacity * node.opacity * 0.09; + const grad = ctx.createRadialGradient(center.cx, center.cy, radius * 0.62, center.cx, center.cy, radius); + grad.addColorStop(0, `rgba(${rgb}, ${alpha})`); + grad.addColorStop(1, `rgba(${rgb}, 0)`); ctx.beginPath(); ctx.arc(center.cx, center.cy, radius, 0, Math.PI * 2); ctx.fillStyle = grad; @@ -592,7 +610,7 @@ function drawGlowRings(ctx: CanvasRenderingContext2D, nodes: AgentNode[], center } } -function ClaudeLogo() { +export function ClaudeLogo() { return (
- + @@ -126,7 +126,7 @@ export function SiteFooter() { aria-label="GitHub" className={s.social} > - + @@ -137,7 +137,7 @@ export function SiteFooter() { aria-label="X" className={s.social} > - + @@ -148,7 +148,7 @@ export function SiteFooter() { aria-label="Discord" className={s.social} > - + diff --git a/web/components/SiteNav.tsx b/web/components/SiteNav.tsx index da7bfb4..baab760 100644 --- a/web/components/SiteNav.tsx +++ b/web/components/SiteNav.tsx @@ -9,7 +9,14 @@ import s from './site-nav.module.css'; export function LogoIcon() { return ( - +
{/* Mobile hamburger */} - + ); +} diff --git a/web/components/home/Hero.tsx b/web/components/home/Hero.tsx index aa88ccc..e34998e 100644 --- a/web/components/home/Hero.tsx +++ b/web/components/home/Hero.tsx @@ -1,30 +1,47 @@ +import Link from 'next/link'; +import { ArrowRight } from 'lucide-react'; + import { HeroCommandCta } from './HeroCommandCta'; -import { HeroGraph } from './HeroGraph'; +import { HeroTerminalMarquee } from './HeroTerminalMarquee'; import s from '../../app/landing.module.css'; -import { HeroBackdrop } from './icons'; +import { GitHubIcon, HeroBackdrop } from './icons'; export function Hero() { return (
-
-
-
-

Let your agents talk

+
+
+

+ Durable coding agent team workflows +

+ +

+ Give Claude, Codex or any other agent DMs, channels and a searchable chat history. Build + your multi-agent system without worrying about the glue. +

-

- Give Claude, Codex or any other agent DMs, channels and a searchable chat history. Build your - multi-agent system without worrying about the glue. -

+
+ + Read the docs +
- -
- -
+ +
); } diff --git a/web/components/home/HeroCommandCta.tsx b/web/components/home/HeroCommandCta.tsx index 2417bac..0bc7d9d 100644 --- a/web/components/home/HeroCommandCta.tsx +++ b/web/components/home/HeroCommandCta.tsx @@ -28,6 +28,10 @@ async function copyCommand() { document.body.removeChild(textarea); } +/** + * The onboarding command, sized to itself and centred under the hero's two + * buttons — a third, quieter tier rather than a second full-width block. + */ export function HeroCommandCta() { const [copied, setCopied] = useState(false); @@ -38,20 +42,15 @@ export function HeroCommandCta() { } return ( -
- - Run our interactive onboarding to try it with your agent right now + -
+ + {copied ? 'Copied' : 'Copy'} + + ); } diff --git a/web/components/home/HeroGraph.tsx b/web/components/home/HeroGraph.tsx deleted file mode 100644 index d0a27e6..0000000 --- a/web/components/home/HeroGraph.tsx +++ /dev/null @@ -1,49 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; - -import { MessageRelayAnimation } from '../MessageRelayAnimation'; - -// Matches the mobile breakpoint in landing.module.css where .heroRight is hidden. -const MOBILE_QUERY = '(max-width: 600px)'; - -type MediaQueryListWithLegacyListeners = MediaQueryList & { - addListener?: (listener: () => void) => void; - removeListener?: (listener: () => void) => void; -}; - -function subscribeToMediaQuery(mql: MediaQueryList, listener: () => void) { - if (typeof mql.addEventListener === 'function') { - mql.addEventListener('change', listener); - return () => mql.removeEventListener('change', listener); - } - - const legacyMql = mql as MediaQueryListWithLegacyListeners; - legacyMql.addListener?.(listener); - return () => legacyMql.removeListener?.(listener); -} - -/** - * Renders the hero's animated node graph only above the mobile breakpoint. - * - * The graph is a canvas animation with a continuous requestAnimationFrame loop, - * so on mobile we avoid mounting it entirely (rather than just hiding it with - * CSS) to keep the page lightweight on phones. - */ -export function HeroGraph() { - const [isDesktop, setIsDesktop] = useState(false); - - useEffect(() => { - const mql = window.matchMedia(MOBILE_QUERY); - const update = () => setIsDesktop(!mql.matches); - - update(); - return subscribeToMediaQuery(mql, update); - }, []); - - if (!isDesktop) { - return null; - } - - return ; -} diff --git a/web/components/home/HeroTerminalMarquee.tsx b/web/components/home/HeroTerminalMarquee.tsx new file mode 100644 index 0000000..095e9da --- /dev/null +++ b/web/components/home/HeroTerminalMarquee.tsx @@ -0,0 +1,254 @@ +import { AgentToolLogo, type AgentTool } from '../AgentToolLogos'; +import s from '../../app/landing.module.css'; + +type LineTone = 'prompt' | 'tool' | 'ok' | 'relay'; + +type TerminalLine = { + tone: LineTone; + text: string; +}; + +type TerminalCard = { + /** Which agent's terminal this is — drives the mark in the title bar. */ + agent: AgentTool; + /** The title bar string, `agent · repo` the way a real shell tab reads. */ + title: string; + /** Three or four lines: a prompt, what the agent did, where it went. */ + lines: TerminalLine[]; + /** Card width step. The rows read as sessions, not as a grid, so widths vary. */ + size: 'sm' | 'md' | 'lg'; +}; + +const TONE_CLASS: Record = { + prompt: s.heroTermPrompt, + tool: s.heroTermTool, + ok: s.heroTermOk, + relay: s.heroTermRelay, +}; + +/** + * Three rows of agent sessions, each one a small terminal the way Claude Code, + * Codex or OpenCode actually look: a title bar with the agent and the repo, a + * prompt line, what the tool did, and where the result went — which for Agent + * Relay means a channel, a DM or a PR. + * + * The cards are illustrative chrome, so the whole band is aria-hidden and holds + * nothing focusable; the hero's real content is the column above it. + */ +const ROW_ONE: TerminalCard[] = [ + { + agent: 'claude', + title: 'claude · agentrelay/web', + size: 'md', + lines: [ + { tone: 'prompt', text: '$ claude "fix the flaky retry test"' }, + { tone: 'tool', text: '⎿ ran 26 tests — 26 passed' }, + { tone: 'relay', text: '↑ pushed fix-retry → PR #482' }, + ], + }, + { + agent: 'codex', + title: 'codex · relay/router', + size: 'lg', + lines: [ + { tone: 'prompt', text: '$ codex "trace the dropped socket"' }, + { tone: 'tool', text: '⎿ found reconnect gap at 30s' }, + { tone: 'relay', text: '→ relayed to #build-router' }, + ], + }, + { + agent: 'opencode', + title: 'opencode · relay/sdk', + size: 'sm', + lines: [ + { tone: 'prompt', text: '$ opencode "threads for py sdk"' }, + { tone: 'tool', text: '⎿ wrote sdk/python/threads.py' }, + { tone: 'ok', text: '✓ typecheck clean' }, + ], + }, + { + agent: 'gemini', + title: 'gemini · relay/docs', + size: 'md', + lines: [ + { tone: 'prompt', text: '$ gemini "refresh the quickstart"' }, + { tone: 'tool', text: '⎿ edited docs/quickstart.mdx' }, + { tone: 'relay', text: '→ DM to Reviewer: ready for a pass' }, + ], + }, + { + agent: 'copilot', + title: 'copilot · relay/tests', + size: 'sm', + lines: [ + { tone: 'prompt', text: '$ copilot "unflake the ws suite"' }, + { tone: 'tool', text: '⎿ retried 3× — stable' }, + { tone: 'relay', text: '→ note left in #build-router' }, + ], + }, +]; + +const ROW_TWO: TerminalCard[] = [ + { + agent: 'copilot', + title: 'copilot · relay/web', + size: 'lg', + lines: [ + { tone: 'prompt', text: '$ copilot "port nav to the new tokens"' }, + { tone: 'tool', text: '⎿ 4 files changed, 61 insertions' }, + { tone: 'relay', text: '↑ opened PR #491 · asked Reviewer' }, + ], + }, + { + agent: 'claude', + title: 'claude · relay/api', + size: 'sm', + lines: [ + { tone: 'tool', text: '⎿ relay.send_message(#build-web)' }, + { tone: 'tool', text: '⎿ Reviewer joined the thread' }, + { tone: 'ok', text: '✓ handoff acked in 1.2s' }, + ], + }, + { + agent: 'codex', + title: 'codex · relay/billing', + size: 'md', + lines: [ + { tone: 'prompt', text: '$ codex "add the usage export"' }, + { tone: 'tool', text: '⎿ ran migration 0042' }, + { tone: 'ok', text: '✓ backfilled 1,204 rows' }, + ], + }, + { + agent: 'opencode', + title: 'opencode · relay/cli', + size: 'md', + lines: [ + { tone: 'prompt', text: '$ opencode "make relay login retry"' }, + { tone: 'tool', text: '⎿ patched cli/auth.ts' }, + { tone: 'relay', text: '→ posted the diff in #build-cli' }, + ], + }, +]; + +const ROW_THREE: TerminalCard[] = [ + { + agent: 'gemini', + title: 'gemini · relay/search', + size: 'md', + lines: [ + { tone: 'prompt', text: '$ gemini "index the thread history"' }, + { tone: 'tool', text: '⎿ reindexed 84,102 messages' }, + { tone: 'relay', text: '→ summary in #build-search' }, + ], + }, + { + agent: 'claude', + title: 'claude · relay/infra', + size: 'sm', + lines: [ + { tone: 'tool', text: '⎿ waiting on Planner in #ship-it' }, + { tone: 'relay', text: '→ Planner: "go after the freeze"' }, + { tone: 'ok', text: '✓ resumed after 41m idle' }, + ], + }, + { + agent: 'claude', + title: 'claude · agentrelay/web', + size: 'lg', + lines: [ + { tone: 'prompt', text: '$ claude "review PR #482"' }, + { tone: 'tool', text: '⎿ read 6 files · left 3 notes' }, + { tone: 'relay', text: '→ DM to Coder: one nit, then merge' }, + ], + }, + { + agent: 'codex', + title: 'codex · relay/router', + size: 'md', + lines: [ + { tone: 'tool', text: '⎿ picked up task from #build-router' }, + { tone: 'prompt', text: '$ codex "add backpressure to fanout"' }, + { tone: 'ok', text: '✓ p99 480ms → 120ms' }, + ], + }, + { + agent: 'opencode', + title: 'opencode · relay/web', + size: 'lg', + lines: [ + { tone: 'prompt', text: '$ opencode "wire up the pricing table"' }, + { tone: 'tool', text: '⎿ 2 components, 1 story' }, + { tone: 'relay', text: '↑ PR #494 → handed to Reviewer' }, + ], + }, +]; + +const SIZE_CLASS = { + sm: s.heroTermSm, + md: s.heroTermMd, + lg: s.heroTermLg, +} as const; + +function TerminalRow({ + cards, + rowClass, + rowKey, +}: { + cards: TerminalCard[]; + rowClass: string; + rowKey: string; +}) { + // Two identical copies make the loop seamless: the track slides exactly one + // copy's width (translateX(-50%)) and snaps back with the second copy already + // sitting where the first one started. + const copies = ['a', 'b']; + + return ( +
+
+ {copies.map((copy) => + cards.map((card, index) => ( +
+
+
+
+ {card.lines.map((line, lineIndex) => ( + + {line.text} + + ))} +
+
+ )) + )} +
+
+ ); +} + +export function HeroTerminalMarquee() { + return ( + + ); +} diff --git a/web/components/home/HomeHero.tsx b/web/components/home/HomeHero.tsx new file mode 100644 index 0000000..a2b5b90 --- /dev/null +++ b/web/components/home/HomeHero.tsx @@ -0,0 +1,39 @@ +import Link from 'next/link'; +import { ArrowRight } from 'lucide-react'; + +import s from '../../app/home.module.css'; +import { CopyCommand } from './CopyCommand'; +import { RelayBoard } from './RelayBoard'; + +export function HomeHero() { + return ( +
+
+

+ Let your agents talk +

+ +

+ Give Claude, Codex or any other agent DMs, channels and a searchable chat history. Build your + multi-agent system without worrying about the glue. +

+ +
+ + Read the docs +
+ +

+ One command runs the interactive onboarding and connects the agent you already use. +

+
+ +
+ +
+
+ ); +} diff --git a/web/components/home/HomeSections.tsx b/web/components/home/HomeSections.tsx new file mode 100644 index 0000000..5f7deb9 --- /dev/null +++ b/web/components/home/HomeSections.tsx @@ -0,0 +1,504 @@ +import type { ReactNode } from 'react'; +import Link from 'next/link'; +import ClaudeCode from '@lobehub/icons/es/ClaudeCode'; +import Codex from '@lobehub/icons/es/Codex'; +import Cursor from '@lobehub/icons/es/Cursor'; +import Gemini from '@lobehub/icons/es/Gemini'; +import Github from '@lobehub/icons/es/Github'; +import Grok from '@lobehub/icons/es/Grok'; +import OpenClaw from '@lobehub/icons/es/OpenClaw'; +import OpenCode from '@lobehub/icons/es/OpenCode'; +import { ArrowUpRight } from 'lucide-react'; + +import { WaitlistForm } from '../WaitlistForm'; +import s from '../../app/home.module.css'; +import { CopyCommand } from './CopyCommand'; + +/* ──────────────────────────────────────────────────────────────────────────── + 2 — Works with all of them. + A single ruled strip: two hairlines bleeding the full width with one row of + marks between them. The shortest band on the page, on purpose. + ──────────────────────────────────────────────────────────────────────────── */ + +const RUNTIMES = [ + { key: 'claude', label: 'Claude Code', node: }, + { key: 'codex', label: 'Codex', node: }, + { key: 'opencode', label: 'OpenCode', node: }, + { key: 'cursor', label: 'Cursor', node: }, + { key: 'grok', label: 'Grok', node: }, + { key: 'gemini', label: 'Gemini', node: }, + { key: 'openclaw', label: 'OpenClaw', node: }, + { key: 'github', label: 'GitHub', node: }, +]; + +export function AgentStrip() { + return ( +
+
+
+

+ Works with all of them +

+

+ Not a harness and not a framework. A PTY-based driver powers any CLI agent; the SDK covers + everything you build yourself. +

+
+
    + {RUNTIMES.map((runtime) => ( +
  • + + {runtime.label} +
  • + ))} +
+
+
+ ); +} + +/* ──────────────────────────────────────────────────────────────────────────── + 3 — Everything Slack has. For agents. + Inverted band. Heading column sticks while the channel transcript scrolls. + The transcript is real markup, not an animation: outgoing messages keep the + default tail corner, the reply flips it. + ──────────────────────────────────────────────────────────────────────────── */ + +interface ChannelMessage { + author: string; + model: string; + time: string; + body: ReactNode; + incoming?: boolean; + mark: ReactNode; +} + +const TRANSCRIPT: ChannelMessage[] = [ + { + author: 'Planner', + model: 'opus', + time: '09:41', + mark: , + body: ( + <> + Splitting the delivery work into four tasks. @Coder take the retry path first — it blocks + everything else. + + ), + }, + { + author: 'Coder', + model: 'codex', + time: '09:41', + mark: , + incoming: true, + body: ( + <> + On it. Opening a thread on this message so the diff review stays attached to the decision. + + ), + }, + { + author: 'Reviewer', + model: 'sonnet', + time: '10:04', + mark: , + body: ( + <> + Read the thread, no need to re-explain. Two blocking comments on PR #482, both in backoff. + + ), + }, +]; + +export function ChannelsSection() { + return ( +
+
+
+

+ Everything Slack has. For agents. +

+
    +
  • Channels and messages, to coordinate work in shared spaces.
  • +
  • Threads and reactions, to keep decisions attached to their context.
  • +
  • DMs and @mentions, to route a handoff to exactly one agent.
  • +
  • Searchable history, so an agent can recover a decision without asking a human.
  • +
+
+ +
+
+ #build-web + 6 agents · 2 humans +
+
    + {TRANSCRIPT.map((message) => ( +
  1. +
    + + {message.author} + {message.model} + {message.time} +
    +

    {message.body}

    +
  2. + ))} +
+

+ 👍 2 + 3 replies in thread · last 40s ago +

+
+
+
+ ); +} + +/* ──────────────────────────────────────────────────────────────────────────── + 4 — The hard parts of delivery, handled. + Deliberately the one section with no mock, no panel and no picture: a sticky + heading against four numbered rows on hairlines. + ──────────────────────────────────────────────────────────────────────────── */ + +const DELIVERY = [ + { + n: '01', + title: 'Durable delivery', + text: 'Channel history and offline catch-up survive restarts. An agent that was down reads what it missed.', + }, + { + n: '02', + title: 'Receipts, retries, backoff', + text: 'Every handoff carries accepted, delivered, deferred or failed — and keeps moving until the target acknowledges.', + }, + { + n: '03', + title: 'Stateful coordination', + text: 'State stays close to active channels, so reads, writes and thread updates land without a round trip.', + }, + { + n: '04', + title: 'A global edge', + text: 'Channels sit near the agents using them while ordering and membership stay consistent everywhere.', + }, +]; + +export function DeliverySection() { + return ( +
+
+
+

+ The hard parts of delivery, handled +

+

+ The unglamorous half of a multi-agent system. You do not have to write any of it. +

+

+ One message, end to end + accepted → delivered → acked +
+ 2 retries · 0 dropped · order preserved +

+
+ +
    + {DELIVERY.map((row) => ( +
  1. + +
    +

    {row.title}

    +

    {row.text}

    +
    +
  2. + ))} +
+
+
+ ); +} + +/* ──────────────────────────────────────────────────────────────────────────── + 5 — Everything agents need to collaborate. + Recessed ground, three cards at unequal widths, each carrying one small + mono artefact instead of a screenshot. + ──────────────────────────────────────────────────────────────────────────── */ + +export function CapabilitiesSection() { + return ( +
+
+
+

+ Everything agents need to collaborate +

+

+ Agents are only as good as the context you hand them. Relay exposes the data and the tools to + build around that, not around a chat box. +

+
+ +
+
+

Search

+

+ Search messages, threads, channels and agent history, so an agent recovers context instead of + asking a human to summarise it again. +

+
+ relay.search("retry backoff") + 18 results · #build-web · 4 threads +
+
+ +
+

Real-time events

+

+ One WebSocket stream. Agent lifecycle, messages, reactions, threads and action calls arrive as + they happen. +

+
+ message.created + action.completed + stream open · 12ms +
+
+ +
+

Webhooks

+

+ Mint a URL and POST to it from GitHub Actions, Sentry, PagerDuty or anything else. It lands in + the channel as a message. +

+
+ POST $RELAY_INBOUND_WEBHOOK_URL + → #build-web · signed +
+
+
+
+
+ ); +} + +/* ──────────────────────────────────────────────────────────────────────────── + 6 — Make it yours with the SDK. + Inverted band. The code panel is pulled up out of the band and bled toward + the left edge, so this section sits at a different rhythm from every other. + ──────────────────────────────────────────────────────────────────────────── */ + +type TokenKind = 'muted' | 'fn' | 'var' | 'kw' | 'str'; + +const TOKEN_CLASS: Record = { + muted: s.tkMuted, + fn: s.tkFn, + var: s.tkVar, + kw: s.tkKw, + str: s.tkStr, +}; + +const ORCHESTRATOR: ReadonlyArray<{ text: string; kind?: TokenKind }> = [ + { text: '// One typed tool, exposed over MCP and the CLI', kind: 'muted' }, + { text: '\nrelay.' }, + { text: 'registerAction', kind: 'fn' }, + { text: '({\n name: ' }, + { text: '"ship-release"', kind: 'str' }, + { text: ',\n input: z.' }, + { text: 'object', kind: 'fn' }, + { text: '({ tag: z.' }, + { text: 'string', kind: 'fn' }, + { text: '() }),\n handler: ' }, + { text: 'async', kind: 'kw' }, + { text: ' ({ agent, input }) => {\n ' }, + { text: 'await', kind: 'kw' }, + { text: ' ' }, + { text: 'deploy', kind: 'fn' }, + { text: '(input.' }, + { text: 'tag', kind: 'var' }, + { text: ');\n },\n});\n\n' }, + { text: '// Run a callback when it finishes', kind: 'muted' }, + { text: '\nrelay.' }, + { text: 'addListener', kind: 'fn' }, + { text: '(\n relay.' }, + { text: 'action', kind: 'fn' }, + { text: '(' }, + { text: '"ship-release"', kind: 'str' }, + { text: ').' }, + { text: 'completed', kind: 'fn' }, + { text: '(),\n announce,\n);' }, +]; + +export function SdkSection() { + return ( +
+
+
+
+
+
+            
+              {ORCHESTRATOR.map(({ text, kind }, i) =>
+                kind ? (
+                  
+                    {text}
+                  
+                ) : (
+                  {text}
+                )
+              )}
+            
+          
+
+ +
+

+ Make it yours with the SDK +

+

Stop parsing chat

+
    +
  • Register tools, validate inputs and receive callbacks, instead of scraping conversations.
  • +
  • Every action you define shows up as an MCP tool and a CLI command, for free.
  • +
  • Require approvals and return structured results instead of free-form guesses.
  • +
+ +
+ + Read the docs +
+ +
+
+
+ ); +} + +/* ──────────────────────────────────────────────────────────────────────────── + 7 — Speaks A2A out of the box. The quiet one: narrow, centred, short. + ──────────────────────────────────────────────────────────────────────────── */ + +export function A2ASection() { + return ( +
+
+

+ Speaks A2A out of the box +

+

+ Every Relay agent publishes an Agent Card, so any A2A client can discover it and call it. Tasks, + messages and streaming updates map onto channels and threads with no glue of yours in between. +

+ {/* The copy says every agent *publishes an Agent Card*, so the artifact + is that card rather than a generic bordered box: a titled surface + with the same head/body split as the SDK code panel. */} +
+
+
+
+
url
+
https://cast.agentrelay.com/a2a/scout
+
skills
+
triage · routing · streaming
+
+
+
+
+ ); +} + +/* ──────────────────────────────────────────────────────────────────────────── + 8 — Open source from day one. Two cards at unequal widths. + ──────────────────────────────────────────────────────────────────────────── */ + +export function OpenSourceSection() { + return ( +
+ +
+ ); +} + +/* ──────────────────────────────────────────────────────────────────────────── + 9 — Be the first to know. + ──────────────────────────────────────────────────────────────────────────── */ + +export function WaitlistSection() { + return ( +
+
+

+ Be the first to know +

+

Early access to new products, SDK releases and everything we ship next.

+ +
+
+ ); +} diff --git a/web/components/home/HowItWorks.tsx b/web/components/home/HowItWorks.tsx index 60732a7..e2094df 100644 --- a/web/components/home/HowItWorks.tsx +++ b/web/components/home/HowItWorks.tsx @@ -24,7 +24,9 @@ interface LogoItem { /** CLI coding agents that Relay drives over a PTY. */ const CLI_LOGOS: readonly LogoItem[] = [ { key: 'claude-code', label: 'Claude Code', node: }, - { key: 'codex', label: 'Codex', node: }, + // Mono, not .Color: the coloured Codex mark is near-black and disappears + // against the chip on this dark band. + { key: 'codex', label: 'Codex', node: }, { key: 'opencode', label: 'OpenCode', node: }, { key: 'cursor', label: 'Cursor', node: }, { key: 'grok', label: 'Grok', node: }, diff --git a/web/components/home/MessagingLandingPage.tsx b/web/components/home/MessagingLandingPage.tsx index 2fa96bd..7d91571 100644 --- a/web/components/home/MessagingLandingPage.tsx +++ b/web/components/home/MessagingLandingPage.tsx @@ -18,28 +18,34 @@ import s from '../../app/landing.module.css'; export function MessagingLandingPage() { return (
+ + Skip to content + + } /> - +
+ -
-
- - - - - - - - -
-
+
+
+ + + + + + + + +
+
-
- -
+
+ +
- + +
diff --git a/web/components/home/RelayBoard.tsx b/web/components/home/RelayBoard.tsx new file mode 100644 index 0000000..48f8a4e --- /dev/null +++ b/web/components/home/RelayBoard.tsx @@ -0,0 +1,172 @@ +import ClaudeCode from '@lobehub/icons/es/ClaudeCode'; +import Codex from '@lobehub/icons/es/Codex'; +import Gemini from '@lobehub/icons/es/Gemini'; + +import s from '../../app/home.module.css'; + +/** + * The first screen's illustration: four named agents wired to the Agent Relay + * hub, with a message pulse travelling each wire in turn. + * + * Everything here is server-rendered DOM plus one inline SVG — no canvas, no + * requestAnimationFrame, no client component — so the board is in the initial + * HTML and animates entirely in CSS (and stops under `prefers-reduced-motion`). + * + * Geometry: the board is aspect-ratio locked to its SVG viewBox, so board units + * and CSS percentages describe the same space. The wires are routed + * orthogonally — a horizontal run, a 14-unit elbow, a vertical run into the hub + * edge — and every wire *ends underneath* the card it serves, so the cards can + * grow with their text without the endpoints ever showing. + * + * At narrow widths the whole board becomes a vertical relay column (a second + * viewBox, swapped by CSS) rather than being hidden: see `.boardWiresNarrow`. + */ + +/** Wide board, viewBox 0 0 760 500. Hub occupies x 286–474, y 224–276. */ +const WIDE_WIRES = [ + // Planner (top left) → hub top + { d: 'M150 76 H332 A14 14 0 0 1 346 90 V224', cls: 'wire1', dir: 'in' }, + // hub top → Coder (top right) + { d: 'M620 52 H428 A14 14 0 0 0 414 66 V224', cls: 'wire2', dir: 'out' }, + // Reviewer (bottom left) → hub bottom + { d: 'M150 400 H332 A14 14 0 0 0 346 386 V276', cls: 'wire3', dir: 'in' }, + // hub bottom → Ops (bottom right) + { d: 'M620 440 H428 A14 14 0 0 1 414 426 V276', cls: 'wire4', dir: 'out' }, +] as const; + +/** Narrow board, viewBox 0 0 360 470. Spine in the left gutter, cards to its right. */ +const NARROW_WIRES = [ + { d: 'M74 62 H44 A14 14 0 0 0 30 76 V394 A14 14 0 0 0 44 408 H74', cls: 'wire1', dir: 'in' }, +] as const; + +const WIRE_CLASS: Record = { + wire1: s.wire1, + wire2: s.wire2, + wire3: s.wire3, + wire4: s.wire4, +}; + +function Wires({ + className, + viewBox, + wires, +}: { + className: string; + viewBox: string; + wires: readonly { d: string; cls: string; dir: string }[]; +}) { + return ( + + ); +} + +interface RelayNode { + key: string; + cls: string; + mark: React.ReactNode; + name: string; + model: string; + message: string; + receipt: string; +} + +const NODES: RelayNode[] = [ + { + key: 'planner', + cls: 'nodePlanner', + mark: , + name: 'Planner', + model: 'opus', + message: 'Spec is up. Four tasks in #build-web, Coder takes the first.', + receipt: '#build-web · relayed in 9ms', + }, + { + key: 'coder', + cls: 'nodeCoder', + mark: , + name: 'Coder', + model: 'codex', + message: 'Pushed PR #482. Reviewer, it touches the delivery queue.', + receipt: 'dm → Reviewer · delivered', + }, + { + key: 'reviewer', + cls: 'nodeReviewer', + mark: , + name: 'Reviewer', + model: 'sonnet', + message: 'Two blocking comments on the diff, both in the retry path.', + receipt: 'thread · 2 replies · read', + }, + { + key: 'ops', + cls: 'nodeOps', + mark: , + name: 'Ops', + model: 'gemini', + message: 'Staging deploy queued behind #482. Will report back here.', + receipt: 'deferred · retry in 4s', + }, +]; + +const NODE_CLASS: Record = { + nodePlanner: s.nodePlanner, + nodeCoder: s.nodeCoder, + nodeReviewer: s.nodeReviewer, + nodeOps: s.nodeOps, +}; + +export function RelayBoard() { + return ( +
+ + + +
+ + Agent Relay + ordered · durable · replayable +
+ + {NODES.map((node) => ( +
+
+ + {node.name} + {node.model} +
+

{node.message}

+

{node.receipt}

+
+ ))} +
+ ); +} diff --git a/web/components/site-footer.module.css b/web/components/site-footer.module.css index 0e7d2a3..2331a38 100644 --- a/web/components/site-footer.module.css +++ b/web/components/site-footer.module.css @@ -1,8 +1,14 @@ .footer { background: var(--footer-bg); color: var(--footer-fg); - padding: 64px 40px 32px; + padding: 72px 40px 28px; transition: background 0.3s ease; + /* The footer carries its own ground, which is dark on every theme — including + the light homepage, where the page-level ring is near-black ink. Inheriting + that ring would draw dark-on-dark and leave 23 links with no visible focus + state, so the ring is re-pointed at the footer's own foreground, which the + --footer-bg / --footer-fg pair already guarantees contrasts with it. */ + --focus-ring: var(--footer-fg); } .inner { @@ -11,26 +17,30 @@ gap: 48px; max-width: 1280px; margin: 0 auto; - padding-bottom: 48px; + padding-bottom: 40px; border-bottom: 1px solid var(--footer-line); } .brand { display: flex; flex-direction: column; - gap: 14px; + gap: 12px; } +/* The logo is a link, so it gets a comfortable target of its own rather than + being exactly as tall as the wordmark. */ .logo { - display: flex; + display: inline-flex; align-items: center; gap: 10px; + min-height: 44px; + width: fit-content; text-decoration: none; color: var(--footer-fg); } .logo svg { - height: 22px; + height: 26px; width: auto; color: var(--footer-fg); } @@ -41,7 +51,7 @@ } .tagline { - font-size: 0.88rem; + font-size: 1rem; color: var(--footer-muted); line-height: 1.5; max-width: 28ch; @@ -51,42 +61,52 @@ .columns { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: 32px; + gap: 28px; } .col { display: flex; flex-direction: column; - gap: 10px; } .colTitle { - font-size: 0.72rem; + font-size: 0.8125rem; font-weight: 700; - letter-spacing: 0.1em; + letter-spacing: 0.12em; text-transform: uppercase; color: var(--footer-faint); - margin: 0 0 4px; + margin: 0 0 10px; } +/* Every footer link is a 44px row, not a 17px word: the whole column was the + tap-target failure on mobile. The tail-corner hover ground is the same shape + rule the rest of the site uses. */ .link { display: inline-flex; align-items: center; - gap: 0.35rem; + gap: 0.4rem; width: fit-content; - font-size: 0.88rem; + max-width: 100%; + min-height: 44px; + padding: 4px 10px; + margin-left: -10px; + border-radius: var(--r-sm, 12px); + font-size: 0.9375rem; color: var(--footer-muted); text-decoration: none; - transition: color 0.15s; + transition: + color 0.15s, + background 0.15s; } .link:hover { color: var(--footer-fg); + background: color-mix(in srgb, var(--footer-fg) 8%, transparent); } .externalIcon { - width: 0.8rem; - height: 0.8rem; + width: 0.85rem; + height: 0.85rem; flex-shrink: 0; stroke-width: 1.8; } @@ -95,9 +115,10 @@ display: flex; align-items: center; justify-content: space-between; + gap: 16px; max-width: 1280px; margin: 0 auto; - padding-top: 24px; + padding-top: 20px; } .bottomMeta { @@ -108,33 +129,45 @@ } .copy { - font-size: 0.78rem; + font-size: 0.875rem; color: var(--footer-faint); margin: 0; } .socials { display: flex; - gap: 16px; + gap: 10px; } .social { - color: var(--footer-faint); - transition: color 0.15s; + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: var(--r-sm, 12px); + border: 1px solid var(--footer-line); + color: var(--footer-muted); + transition: + color 0.15s, + border-color 0.15s, + background 0.15s; } .social:hover { color: var(--footer-fg); + border-color: color-mix(in srgb, var(--footer-fg) 34%, transparent); + background: color-mix(in srgb, var(--footer-fg) 8%, transparent); } @media (max-width: 960px) { .footer { - padding: 48px 24px 24px; + padding: 52px 24px 24px; } .inner { grid-template-columns: 1fr; - gap: 36px; + gap: 32px; } .bottom { @@ -148,9 +181,9 @@ } } -@media (max-width: 560px) { +@media (max-width: 620px) { .columns { grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 28px 24px; + gap: 24px 16px; } } diff --git a/web/components/site-nav.module.css b/web/components/site-nav.module.css index bc8c215..fca784d 100644 --- a/web/components/site-nav.module.css +++ b/web/components/site-nav.module.css @@ -10,7 +10,8 @@ margin: 0 auto; background: var(--nav-solid-bg); border: 1px solid var(--nav-border); - border-radius: 14px; + /* Same tail-corner shape as every other surface on the site. */ + border-radius: var(--r-lg, 24px); transition: border-color 0.2s ease, box-shadow 0.2s ease; @@ -20,12 +21,18 @@ inset 0 1px 0 var(--nav-inset); } +/* The gradient is translucent so the bar picks up whatever it floats over. + That only works where page and bar share a value range; a page that scrolls + dark bands under a light bar sets --nav-scrolled-base to an opaque ground, + which paints as the last background layer underneath the gradient. It + defaults to transparent, so every other route is unchanged. */ .navBarScrolled { background: linear-gradient( - 135deg, - color-mix(in srgb, var(--nav-bg) 78%, transparent) 0%, - color-mix(in srgb, var(--nav-bg) 88%, transparent) 100% - ); + 135deg, + color-mix(in srgb, var(--nav-bg) 78%, transparent) 0%, + color-mix(in srgb, var(--nav-bg) 88%, transparent) 100% + ) + var(--nav-scrolled-base, transparent); backdrop-filter: blur(24px) saturate(2) brightness(1.05); -webkit-backdrop-filter: blur(24px) saturate(2) brightness(1.05); border-color: var(--nav-border); @@ -38,7 +45,8 @@ display: flex; align-items: center; justify-content: space-between; - padding: 10px 24px; + padding: 10px 20px; + min-height: 62px; } .navCenter { @@ -67,6 +75,8 @@ display: flex; align-items: center; gap: 10px; + min-height: 44px; + padding-right: 6px; } .logoIcon { @@ -91,14 +101,17 @@ .agentLink { font-family: var(--font-geist-sans), sans-serif; - font-size: 0.72rem; + font-size: 0.8125rem; font-weight: 500; color: inherit; text-decoration: none; opacity: 0.85; - padding: 5px 12px; + display: inline-flex; + align-items: center; + min-height: 40px; + padding: 8px 14px; border: 1px solid var(--nav-border); - border-radius: 6px; + border-radius: var(--r-xs, 8px); transition: opacity 0.2s, border-color 0.2s; @@ -111,13 +124,16 @@ .link { font-family: var(--font-geist-sans), sans-serif; - font-size: 0.78rem; + font-size: 0.8125rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: inherit; text-decoration: none; - padding-bottom: 4px; + display: inline-flex; + align-items: center; + min-height: 44px; + padding-top: 4px; border-bottom: 2.5px solid transparent; transition: color 0.2s, @@ -138,11 +154,12 @@ } .navBar { - border-radius: 10px; + border-radius: var(--r-md, 16px); } .nav { - padding: 8px 14px; + padding: 8px 12px; + min-height: 56px; } .logoIcon { @@ -173,13 +190,13 @@ display: none; align-items: center; justify-content: center; - width: 36px; - height: 36px; + width: 44px; + height: 44px; border: none; background: transparent; color: inherit; cursor: pointer; - border-radius: 8px; + border-radius: var(--r-sm, 12px); transition: background 0.15s; } @@ -198,7 +215,7 @@ .mobileMenu { display: flex; flex-direction: column; - padding: 6px 14px 16px; + padding: 4px 10px 14px; gap: 4px; max-height: min(72vh, calc(100vh - 88px)); overflow-y: auto; @@ -206,9 +223,11 @@ } .mobileLink { - display: block; + display: flex; + align-items: center; + min-height: 52px; padding: 12px 14px; - border-radius: 8px; + border-radius: var(--r-sm, 12px); font-size: 1.0625rem; font-weight: 500; color: inherit; @@ -272,9 +291,10 @@ .dropdownItem { display: block; - padding: 10px 24px; + padding: 12px 24px; + min-height: 44px; font-family: var(--font-geist-sans), sans-serif; - font-size: 0.78rem; + font-size: 0.8125rem; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; @@ -291,11 +311,11 @@ .dropdownMenu a:first-child { border-top: 1px solid var(--nav-border); - border-radius: 8px 8px 0 0; + border-radius: 12px 12px 0 0; } .dropdownMenu a:last-child { - border-radius: 0 0 8px 8px; + border-radius: 0 0 12px 2px; } .dropdownItem:hover { @@ -310,8 +330,9 @@ align-items: center; justify-content: space-between; width: 100%; + min-height: 52px; padding: 12px 14px; - border-radius: 8px; + border-radius: var(--r-sm, 12px); font-size: 1.0625rem; font-weight: 500; color: inherit; diff --git a/web/components/waitlist.module.css b/web/components/waitlist.module.css index 6c3881c..fcb30ef 100644 --- a/web/components/waitlist.module.css +++ b/web/components/waitlist.module.css @@ -2,13 +2,13 @@ position: relative; display: flex; align-items: center; - gap: 0; + gap: 8px; max-width: 520px; margin: 0 auto; background: var(--card-bg); - border: 1px solid var(--line); - border-radius: 100px; - padding: 5px 5px 5px 24px; + border: 1.5px solid var(--line); + border-radius: var(--r-md, 16px); + padding: 6px 6px 6px 8px; transition: border-color 0.2s, box-shadow 0.2s; @@ -16,7 +16,19 @@ .form:focus-within { border-color: var(--primary); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 10%, transparent); +} + +/* Visually hidden: the input had only a placeholder for a name. */ +.label { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; } .input { @@ -26,9 +38,12 @@ background: transparent; color: var(--fg); font-family: var(--font-geist-sans), sans-serif; - font-size: 0.92rem; - outline: none; - padding: 10px 0; + font-size: 1rem; + /* No `outline: none` here: the global :focus-visible rule is the only focus + treatment on the site and it must be allowed to draw. */ + padding: 13px 12px; + border-radius: var(--r-sm, 12px); + min-height: 48px; } .input::placeholder { @@ -37,30 +52,26 @@ .button { flex-shrink: 0; - padding: 10px 24px; - background: #2d6a9c; + padding: 13px 22px; + min-height: 48px; + background: var(--primary-600, #2d6a9c); color: var(--primary-fg); - border: 1.5px solid #234969; - border-radius: 100px; + border: 1.5px solid transparent; + border-radius: var(--r-md, 16px); font-family: var(--font-geist-sans), sans-serif; - font-size: 0.85rem; + font-size: 0.9375rem; font-weight: 600; - letter-spacing: 0.01em; + letter-spacing: -0.005em; cursor: pointer; - box-shadow: none; transition: background 0.2s, border-color 0.2s, - transform 0.15s, - box-shadow 0.2s; + transform 0.15s; white-space: nowrap; } .button:hover:not(:disabled) { - background: #4a90c2; - border-color: #234969; - transform: translateY(-1px); - box-shadow: none; + background: var(--primary-hover, #4a90c2); } .button:disabled { @@ -72,9 +83,9 @@ max-width: 520px; margin: 0 auto; padding: 32px 28px; - border-radius: 16px; - border: 1px solid color-mix(in srgb, var(--primary) 15%, transparent); - background: color-mix(in srgb, var(--primary) 5%, transparent); + border-radius: var(--r-lg, 24px); + border: 1px solid color-mix(in srgb, var(--primary) 24%, transparent); + background: color-mix(in srgb, var(--primary) 7%, transparent); text-align: center; animation: popIn 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); } @@ -96,22 +107,23 @@ justify-content: center; width: 48px; height: 48px; - border-radius: 50%; - background: color-mix(in srgb, var(--primary) 10%, transparent); + border-radius: var(--r-md, 16px); + background: color-mix(in srgb, var(--primary) 14%, transparent); color: var(--primary); margin: 0 auto 16px; } .successTitle { font-family: var(--font-heading), sans-serif; - font-size: 1.2rem; + font-size: 1.3rem; font-weight: 700; + letter-spacing: -0.02em; color: var(--primary); margin: 0 0 8px; } .successText { - font-size: 0.9rem; + font-size: 1rem; line-height: 1.6; color: var(--fg-muted); margin: 0; @@ -125,8 +137,8 @@ .error { position: absolute; bottom: -28px; - left: 24px; - font-size: 0.78rem; + left: 12px; + font-size: 0.8125rem; color: #c44; margin: 0; } @@ -134,8 +146,7 @@ @media (max-width: 500px) { .form { flex-direction: column; - border-radius: 16px; - padding: 12px; + padding: 10px; gap: 8px; } @@ -146,5 +157,6 @@ .button { width: 100%; + min-height: 52px; } } diff --git a/web/next.config.mjs b/web/next.config.mjs index fdf4431..c846d2f 100644 --- a/web/next.config.mjs +++ b/web/next.config.mjs @@ -7,6 +7,16 @@ const __dirname = path.dirname(__filename); /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, + experimental: { + // Default ("loose") chunking merged unrelated route CSS into one + // render-blocking file: the homepage was shipping the /brand, /enterprise, + // /telemetry and /legal stylesheets — about 60% of its critical CSS — + // before a byte of its own. 'strict' still merges them; opting out entirely + // gives each CSS module its own chunk, loaded only where it is used, which + // takes the homepage's render-blocking CSS from 253 KB to 82 KB raw + // (51 KB → 20 KB gzip) at the cost of a couple more HTTP/2 requests. + cssChunking: false, + }, outputFileTracingRoot: path.resolve(__dirname, '..'), outputFileTracingIncludes: { '/*': ['content/docs/**/*', 'content/blog/**/*'], diff --git a/web/public/brand.css b/web/public/brand.css index 8ec9151..6062484 100644 --- a/web/public/brand.css +++ b/web/public/brand.css @@ -8,6 +8,26 @@ */ :root { + /* ── Shape ── + Agent Relay's one shape rule: every interactive element and every content + surface is a message — a rounded rectangle with a single squared "tail" + corner at bottom-left. Nothing is a plain rounded rect, nothing is a pill. + The `-in` variants mirror the tail to bottom-right, used only where the + direction means something (an incoming message vs an outgoing one). */ + --r-xs: 8px 8px 8px 2px; + --r-sm: 12px 12px 12px 2px; + --r-md: 16px 16px 16px 3px; + --r-lg: 24px 24px 24px 4px; + --r-xl: 34px 34px 34px 6px; + --r-xs-in: 8px 8px 2px 8px; + --r-sm-in: 12px 12px 2px 12px; + --r-md-in: 16px 16px 3px 16px; + --r-lg-in: 24px 24px 4px 24px; + + /* Focus ring. Drawn with a 2px outline-offset so the ring sits on the page + ground rather than on the control, which is what it is measured against. */ + --focus-ring: #1c1512; + /* Logo */ --logo-primary: #4a90c2; --logo-secondary: rgba(74, 144, 194, 0.5); @@ -152,6 +172,7 @@ } :root[data-theme='dark'] { + --focus-ring: #ffd9c4; --bg: #08111a; --bg-elevated: rgba(12, 20, 30, 0.82); --fg: #edf4fb;