From 14a2d2bf2d94a2e1f6c9492a745b6fd939b210fa Mon Sep 17 00:00:00 2001 From: Barry Cape Date: Wed, 23 Sep 2026 01:24:08 -0700 Subject: [PATCH 1/2] Make home, /teams and 404 lighter without changing what they show - Render the lobehub marks these pages use from server-safe copies (components/brand-icons.tsx). Every @lobehub/icons module is 'use client' and its default export pulls in emoji data, ~145 KB of JS per page. A parity test checks the markup matches lobehub exactly. - Lazy-load the 404 backdrop through a next/dynamic client wrapper. The root not-found boundary is part of every route, so its terminal code was in every page's bundle; it still server-renders on a real 404. - Load the hero investor logos eagerly and unoptimized so they no longer pop in after layout. - Serve display-sized WebP copies of the /teams avatars and concept screenshots (scripts/generate-web-images.mjs). Production has no Cloudflare IMAGES binding, so /_next/image returned the 150-800 KB originals. - Pause the timer-driven previews while offscreen (useOnScreen) and replace the chat preview's 250 ms visibility poll with an IntersectionObserver. - Move the docs star badge into its own module so pages with the plain badge don't bundle its client component and the docs nav. - Remove unused CSS-module rules from landing, flows, teams-landing, site-nav and enterprise styles, and the unused AgentSetupPrompt component. Co-Authored-By: Claude Opus 5.5 --- web/app/DurableDeliveryTimeline.tsx | 18 +- web/app/RealtimeEventFeed.tsx | 9 +- web/app/SearchPreviewAnimation.tsx | 16 +- web/app/docs/layout.tsx | 2 +- web/app/enterprise/enterprise.module.css | 14 - web/app/flows/flows.module.css | 851 +-------------- web/app/landing.module.css | 967 +----------------- web/app/not-found.tsx | 2 +- web/app/not-found/LostFieldLazy.tsx | 9 + web/app/teams/page.tsx | 8 +- web/app/teams/teams-landing.module.css | 44 - web/components/AgentSignup.tsx | 2 +- web/components/ChannelMessagesPreview.tsx | 58 +- web/components/DocsGitHubStarsBadgeServer.tsx | 31 + web/components/GitHubStars.tsx | 29 +- web/components/InstallCommand.tsx | 98 +- web/components/InvestorStrip.tsx | 11 +- web/components/brand-icons.tsx | 184 ++++ web/components/home/AgentToolsFeature.tsx | 2 +- web/components/home/ContextCapabilities.tsx | 3 +- web/components/home/DeploymentCall.tsx | 4 +- web/components/home/HeroTerminalMarquee.tsx | 15 +- web/components/home/HowItWorks.tsx | 13 +- web/components/home/QuickStart.tsx | 5 +- web/components/site-nav.module.css | 149 --- web/components/useOnScreen.ts | 25 + web/lib/test/brand-icons.test.tsx | 64 ++ web/lib/test/hero-terminal-marquee.test.tsx | 6 +- web/public/authors/ingrid-128.webp | Bin 0 -> 2606 bytes web/public/authors/khaliq-128.webp | Bin 0 -> 2236 bytes web/public/authors/mary-128.webp | Bin 0 -> 3568 bytes web/public/authors/will-128.webp | Bin 0 -> 2232 bytes web/public/teams/teams-dashboard-concept.webp | Bin 0 -> 98556 bytes .../teams/teams-session-overview-concept.webp | Bin 0 -> 115182 bytes web/scripts/generate-web-images.mjs | 33 + 35 files changed, 443 insertions(+), 2229 deletions(-) create mode 100644 web/app/not-found/LostFieldLazy.tsx create mode 100644 web/components/DocsGitHubStarsBadgeServer.tsx create mode 100644 web/components/brand-icons.tsx create mode 100644 web/components/useOnScreen.ts create mode 100644 web/lib/test/brand-icons.test.tsx create mode 100644 web/public/authors/ingrid-128.webp create mode 100644 web/public/authors/khaliq-128.webp create mode 100644 web/public/authors/mary-128.webp create mode 100644 web/public/authors/will-128.webp create mode 100644 web/public/teams/teams-dashboard-concept.webp create mode 100644 web/public/teams/teams-session-overview-concept.webp create mode 100644 web/scripts/generate-web-images.mjs diff --git a/web/app/DurableDeliveryTimeline.tsx b/web/app/DurableDeliveryTimeline.tsx index 02527def..c054b04c 100644 --- a/web/app/DurableDeliveryTimeline.tsx +++ b/web/app/DurableDeliveryTimeline.tsx @@ -1,9 +1,10 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Bot, Check, CircleAlert, Database, FileOutput, RotateCcw, UserRoundCheck, Webhook } from 'lucide-react'; import { AgentToolLogo, type AgentTool } from '../components/AgentToolLogos'; +import { useOnScreen } from '../components/useOnScreen'; import s from './landing.module.css'; type AgentTimelineItem = { @@ -113,20 +114,24 @@ function getTimelineDelay(index: number) { export function DurableDeliveryTimeline() { const [cursor, setCursor] = useState(INITIAL_TIMELINE_CURSOR); + const rootRef = useRef(null); + const onScreen = useOnScreen(rootRef); const items = Array.from({ length: VISIBLE_TIMELINE_ITEMS }, (_, offset) => getTimelineItem(cursor - VISIBLE_TIMELINE_ITEMS + 1 + offset) ); useEffect(() => { + if (!onScreen) return; + const timeoutId = window.setTimeout(() => { setCursor((current) => current + 1); }, getTimelineDelay(cursor)); return () => window.clearTimeout(timeoutId); - }, [cursor]); + }, [cursor, onScreen]); return ( -
+
{items.map((item) => { if (item.kind === 'agent') { @@ -213,6 +218,8 @@ function WorkflowTraceIcon({ kind }: { kind: WorkflowTraceItem['kind'] }) { export function DurableWorkflowTrace() { const [activeStep, setActiveStep] = useState(0); + const rootRef = useRef(null); + const onScreen = useOnScreen(rootRef); useEffect(() => { const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; @@ -220,16 +227,17 @@ export function DurableWorkflowTrace() { setActiveStep(WORKFLOW_TRACE.length - 1); return undefined; } + if (!onScreen) return undefined; const timeoutId = window.setTimeout(() => { setActiveStep((current) => (current + 1) % WORKFLOW_TRACE.length); }, WORKFLOW_STEP_DELAYS_MS[activeStep]); return () => window.clearTimeout(timeoutId); - }, [activeStep]); + }, [activeStep, onScreen]); return ( -
+
run_01J7 {activeStep === WORKFLOW_TRACE.length - 1 ? 'completed' : 'running'} diff --git a/web/app/RealtimeEventFeed.tsx b/web/app/RealtimeEventFeed.tsx index dff8c3e4..9f8f1719 100644 --- a/web/app/RealtimeEventFeed.tsx +++ b/web/app/RealtimeEventFeed.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react'; import { AgentToolLogo, type AgentTool } from '../components/AgentToolLogos'; +import { useOnScreen } from '../components/useOnScreen'; import s from './landing.module.css'; const REALTIME_EVENT_ACTIVITY = [ @@ -141,8 +142,12 @@ export function RealtimeEventFeed() { const sequenceRef = useRef(INITIAL_EVENT_COUNT); const idRef = useRef(INITIAL_EVENT_COUNT); const stepRef = useRef(0); + const rootRef = useRef(null); + const onScreen = useOnScreen(rootRef); useEffect(() => { + if (!onScreen) return; + let active = true; let timeoutId: number | undefined; @@ -172,10 +177,10 @@ export function RealtimeEventFeed() { active = false; window.clearTimeout(timeoutId); }; - }, []); + }, [onScreen]); return ( -
+
{events.map((event) => (
diff --git a/web/app/SearchPreviewAnimation.tsx b/web/app/SearchPreviewAnimation.tsx index c54f378f..7594db90 100644 --- a/web/app/SearchPreviewAnimation.tsx +++ b/web/app/SearchPreviewAnimation.tsx @@ -1,7 +1,8 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { useOnScreen } from '../components/useOnScreen'; import s from './landing.module.css'; const SEARCH_QUERY = 'handoff token'; @@ -39,6 +40,9 @@ function visibleResultCount(length: number) { export function SearchPreviewAnimation() { const [typedLength, setTypedLength] = useState(0); + const typedLengthRef = useRef(0); + const rootRef = useRef(null); + const onScreen = useOnScreen(rootRef); useEffect(() => { const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; @@ -47,6 +51,8 @@ export function SearchPreviewAnimation() { setTypedLength(SEARCH_QUERY.length); return; } + // Paused while offscreen; resumes from the current keystroke. + if (!onScreen) return; let active = true; let timeoutId: number | undefined; @@ -59,25 +65,25 @@ export function SearchPreviewAnimation() { if (!active) return; const next = atEnd ? 0 : nextLength + 1; + typedLengthRef.current = next; setTypedLength(next); tick(next); }, delay); }; - setTypedLength(0); - tick(0); + tick(typedLengthRef.current); return () => { active = false; window.clearTimeout(timeoutId); }; - }, []); + }, [onScreen]); const query = SEARCH_QUERY.slice(0, typedLength); const resultCount = visibleResultCount(typedLength); return ( -
+
diff --git a/web/app/teams/teams-landing.module.css b/web/app/teams/teams-landing.module.css index c748e2fd..a07d6d0e 100644 --- a/web/app/teams/teams-landing.module.css +++ b/web/app/teams/teams-landing.module.css @@ -9,8 +9,6 @@ background: #0d2638; } -.setupSection { scroll-margin-top: 100px; } - .page .integrationsSection, .page .activitySection { --section-bg: #193650; } @@ -28,43 +26,6 @@ margin-top: 16px; } -.setupSteps { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: clamp(2rem, 5vw, 4.5rem); - margin: 3.5rem 0 0; - padding: 0 1rem; - list-style: none; -} - -.setupSteps > li { min-width: 0; } -.setupSteps svg { width: 28px; height: 28px; color: var(--flows-accent-strong); } -.setupSteps h3 { margin: 1.3rem 0 0.8rem; font-size: 1.15rem; font-weight: 500; } -.setupSteps p { margin: 0; color: var(--flows-muted); font-size: 1rem; line-height: 1.75; } - -.historyFeature { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - align-items: center; - gap: clamp(3rem, 7vw, 7rem); -} - -.journey { display: grid; justify-items: center; padding: 1rem 0; } -.journeySource, .journeyDestination { text-align: center; } -.journeySource > svg, .journeyDestination > svg { width: 42px; height: 42px; margin: 0 auto 1rem; color: var(--flows-accent-strong); stroke-width: 1.3; } -.journeySource > span, .journeyDestination > span { font-size: 1.15rem; font-weight: 500; } -.journey p { margin: 0.6rem 0 0; font-size: 0.9rem; color: var(--flows-muted); } -.journeyLine { height: 45px; width: 1px; background: var(--flows-line-strong); margin: 14px 0; } -.journeyRelay { display: flex; align-items: center; gap: 12px; padding: 16px 26px; border: 1px solid var(--flows-line-strong); border-radius: 8px; background: var(--flows-band-dark); font-size: 1rem; } -.journeyRelay svg { width: 24px; color: var(--flows-accent-strong); } - -.historyCopy h2 { margin: 0; font-family: var(--font-heading), sans-serif; font-size: clamp(2rem, 3.7vw, 3.2rem); font-weight: 500; line-height: 1.12; letter-spacing: -0.03em; } -.historyCopy > p { margin: 1.5rem 0 0; color: var(--flows-muted); font-size: 1.08rem; line-height: 1.75; } -.historyDetails { display: grid; gap: 1.25rem; margin: 2rem 0 0; padding: 0; list-style: none; } -.historyDetails li { display: flex; align-items: flex-start; gap: 14px; color: var(--flows-muted); font-size: 0.95rem; line-height: 1.7; } -.historyDetails svg { flex-shrink: 0; width: 19px; height: 19px; margin-top: 4px; color: var(--flows-accent-strong); } -.finalSection { padding-bottom: 3rem; } - @media (max-width: 960px) { .activitySection { grid-template-columns: 1fr; gap: 2.5rem; } .activityIntro { max-width: 640px; } @@ -75,11 +36,6 @@ @media (max-width: 700px) { .activityIntro h2 { font-size: clamp(1.7rem, 8.4vw, 2.2rem); } .productScreenshot figcaption { font-size: 12px; } - .setupSteps { grid-template-columns: 1fr; gap: 2rem; max-width: 440px; margin: 2.5rem auto 0; padding: 0; } - .setupSteps h3 { margin-top: 1rem; } - .historyFeature { grid-template-columns: 1fr; gap: 3.5rem; } - .historyCopy { order: -1; } - .journey { padding: 0; } } /* Session detail follows the team-wide activity view. */ diff --git a/web/components/AgentSignup.tsx b/web/components/AgentSignup.tsx index 6cabc9ef..37211d14 100644 --- a/web/components/AgentSignup.tsx +++ b/web/components/AgentSignup.tsx @@ -2,7 +2,7 @@ import { useSignupAnalytics } from './useSignupAnalytics'; import Link from 'next/link'; -import Grok from '@lobehub/icons/es/Grok'; +import { Grok } from './brand-icons'; import { AgentToolLogo } from './AgentToolLogos'; import type { AgentSignupProduct } from '../lib/agent-signup'; import s from './agent-signup.module.css'; diff --git a/web/components/ChannelMessagesPreview.tsx b/web/components/ChannelMessagesPreview.tsx index 8b8af636..489abc6c 100644 --- a/web/components/ChannelMessagesPreview.tsx +++ b/web/components/ChannelMessagesPreview.tsx @@ -301,53 +301,25 @@ export function ChannelMessagesPreview() { const stream = streamRef.current; if (!stream) return; - let observer: IntersectionObserver | undefined; - let visibilityPoll: number | undefined; - - const start = () => { + if (!('IntersectionObserver' in window)) { setIsActive(true); - if (visibilityPoll) window.clearInterval(visibilityPoll); - visibilityPoll = undefined; - window.removeEventListener('scroll', maybeStart); - window.removeEventListener('resize', maybeStart); - }; - - const isVisible = () => { - const rect = stream.getBoundingClientRect(); - return rect.top < window.innerHeight * 0.9 && rect.bottom > window.innerHeight * 0.1; - }; - - const maybeStart = () => { - if (isVisible()) start(); - }; - - window.addEventListener('scroll', maybeStart, { passive: true }); - window.addEventListener('resize', maybeStart); - visibilityPoll = window.setInterval(maybeStart, 250); - - if ('IntersectionObserver' in window) { - observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting) { - start(); - observer?.disconnect(); - } - }, - { threshold: 0.35 } - ); - observer.observe(stream); - } else { - start(); + return; } - maybeStart(); + // Start once the stream reaches the middle 80% of the viewport. The + // observer tracks scrolling and resizing itself, so no listeners or polling. + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsActive(true); + observer.disconnect(); + } + }, + { rootMargin: '-10% 0px -10% 0px' } + ); + observer.observe(stream); - return () => { - observer?.disconnect(); - window.removeEventListener('scroll', maybeStart); - window.removeEventListener('resize', maybeStart); - if (visibilityPoll) window.clearInterval(visibilityPoll); - }; + return () => observer.disconnect(); }, []); useEffect(() => { diff --git a/web/components/DocsGitHubStarsBadgeServer.tsx b/web/components/DocsGitHubStarsBadgeServer.tsx new file mode 100644 index 00000000..e700aa60 --- /dev/null +++ b/web/components/DocsGitHubStarsBadgeServer.tsx @@ -0,0 +1,31 @@ +import { productSections } from '../lib/product-docs-nav'; +import { DocsGitHubStarsBadge, type DocsStarRepo } from './DocsGitHubStarsBadge'; +import { DEFAULT_REPO } from './GitHubStars'; + +/** + * Docs header badge that follows the active section: Relayfile under + * `/docs/file`, Relayloop under `/docs/loop`, Agent Relay elsewhere. The + * client loads only the active repo's count, so the docs route stays static. + * + * Lives apart from GitHubStars.tsx so pages that only show the Agent Relay + * badge don't pull this client component and the docs nav into their bundle. + */ +export async function DocsGitHubStarsBadgeServer() { + const targets: { id: string | null; repo: string; label: string }[] = [ + { id: null, repo: DEFAULT_REPO, label: 'Agent Relay' }, + ...productSections.map((section) => ({ + id: section.id, + repo: section.repo, + label: section.label, + })), + ]; + + const repos: DocsStarRepo[] = targets.map((t) => ({ + id: t.id, + repo: t.repo, + href: `https://github.com/${t.repo}`, + label: t.label, + })); + + return ; +} diff --git a/web/components/GitHubStars.tsx b/web/components/GitHubStars.tsx index 961004af..8a317af6 100644 --- a/web/components/GitHubStars.tsx +++ b/web/components/GitHubStars.tsx @@ -1,12 +1,10 @@ -import { productSections } from '../lib/product-docs-nav'; -import { DocsGitHubStarsBadge, type DocsStarRepo } from './DocsGitHubStarsBadge'; import s from './github-stars.module.css'; type GitHubRepoResponse = { stargazers_count?: number; }; -const DEFAULT_REPO = 'agentworkforce/relay'; +export const DEFAULT_REPO = 'agentworkforce/relay'; function GithubIcon() { return ( @@ -39,31 +37,6 @@ async function getGitHubStars(repo: string = DEFAULT_REPO): Promise ({ - id: section.id, - repo: section.repo, - label: section.label, - })), - ]; - - const repos: DocsStarRepo[] = targets.map((t) => ({ - id: t.id, - repo: t.repo, - href: `https://github.com/${t.repo}`, - label: t.label, - })); - - return ; -} - export async function GitHubStarsBadge() { const count = await getGitHubStars(); diff --git a/web/components/InstallCommand.tsx b/web/components/InstallCommand.tsx index 261db98c..0aa51e57 100644 --- a/web/components/InstallCommand.tsx +++ b/web/components/InstallCommand.tsx @@ -1,30 +1,11 @@ 'use client'; -import { type KeyboardEvent, type MouseEvent, useState } from 'react'; -import { Check, Copy, Eye, EyeOff } from 'lucide-react'; +import { useState } from 'react'; +import { Check, Copy } from 'lucide-react'; import s from '../app/landing.module.css'; const INSTALL_COMMAND = 'npm install @agent-relay/sdk'; -const AGENT_SETUP_PROMPT = `Add Agent Relay from https://github.com/AgentWorkforce/relay to this project. - -First inspect the README, package manager files, app entrypoints, worker scripts, existing agent/session/harness code, and test commands. Then propose the smallest integration that fits this project. - -Ask me only for choices you cannot infer: -- Which agents or harnesses should join the workspace? -- Which messages should be channels, direct messages, or thread replies? -- How should inbound messages be delivered: immediate, next-message, next-tool-call, on-idle, or manual? -- Which events should Agent Relay observe: status changes, file edits, terminal output, tool calls, or custom app events? -- Which SDK actions should agents be able to call, and who is allowed to call them? -- Which command should prove the integration works? - -Use the existing package manager to install @agent-relay/sdk. Wire the three Agent Relay surfaces: -1. Messaging: create or join a workspace, register sessions, send one message, and listen for message events. -2. Delivery: implement how Relay messages reach each session and how delivery is accepted, deferred, failed, or acknowledged. -3. Actions: register at least one useful project action with a Zod input schema and a structured result. - -Keep changes minimal, follow existing project patterns, run the build/typecheck/tests, and summarize what works plus any remaining product decisions.`; -const AGENT_SETUP_PROMPT_PREVIEW = `${AGENT_SETUP_PROMPT.replace(/\s+/g, ' ').slice(0, 88)}...`; async function copyText(text: string) { if (navigator.clipboard?.writeText) { @@ -76,78 +57,3 @@ export function InstallCommand() { ); } - -export function AgentSetupPrompt() { - const [copied, setCopied] = useState(false); - const [showPrompt, setShowPrompt] = useState(false); - - async function handleCopy() { - await copyText(AGENT_SETUP_PROMPT); - setCopied(true); - window.setTimeout(() => setCopied(false), 1800); - } - - function handlePromptKeyDown(event: KeyboardEvent) { - if (event.key !== 'Enter' && event.key !== ' ') return; - event.preventDefault(); - void handleCopy(); - } - - function handleShowPrompt(event: MouseEvent) { - event.stopPropagation(); - setShowPrompt((value) => !value); - } - - return ( -
- - {AGENT_SETUP_PROMPT_PREVIEW} - - - {showPrompt ? ( -
event.stopPropagation()} - > - Prompt - {AGENT_SETUP_PROMPT} -
- ) : null} - - - - - {copied ? ( - <> - - -
- ); -} diff --git a/web/components/InvestorStrip.tsx b/web/components/InvestorStrip.tsx index 6a34d62e..e62c09a8 100644 --- a/web/components/InvestorStrip.tsx +++ b/web/components/InvestorStrip.tsx @@ -1,14 +1,19 @@ import Image from 'next/image'; import s from './investor-strip.module.css'; +// The strip sits in the hero, above the fold, so the logos load with the page +// instead of lazily after layout (which made them pop in late). They're small +// and already web-ready, so they skip the /_next/image round trip too. +const LOGO_IMAGE_PROPS = { loading: 'eager', unoptimized: true } as const; + export function InvestorStrip() { const logos = [ - Hustle Fund, - Active Capital, + Hustle Fund, + Active Capital, , - Cortical Ventures, + Cortical Ventures,