diff --git a/apps/website/app/blog/page.tsx b/apps/website/app/blog/page.tsx index b3cdedea0..8a5da41ae 100644 --- a/apps/website/app/blog/page.tsx +++ b/apps/website/app/blog/page.tsx @@ -1,3 +1,4 @@ +import { DEFAULT_OG_IMAGE, DEFAULT_TWITTER_IMAGE } from "@/lib/social-image"; import { getListedBlogPosts, getFeaturedBlogPost, @@ -34,18 +35,14 @@ export const metadata = { siteName: "xmcp", type: "website", locale: "en_US", - images: { - url: "/xmcp-og.png", - width: 1200, - height: 630, - }, + images: DEFAULT_OG_IMAGE, }, twitter: { card: "summary_large_image", title: "xmcp Blog - MCP Guides, Releases, and Engineering Notes", description: "Read xmcp guides, release notes, and engineering articles about building TypeScript MCP servers and production-ready agent tools.", - images: "/xmcp-og.png", + images: DEFAULT_TWITTER_IMAGE, }, }; diff --git a/apps/website/app/changelog/page.tsx b/apps/website/app/changelog/page.tsx index b21dd09b2..eaf60821e 100644 --- a/apps/website/app/changelog/page.tsx +++ b/apps/website/app/changelog/page.tsx @@ -1,3 +1,4 @@ +import { DEFAULT_OG_IMAGE, DEFAULT_TWITTER_IMAGE } from "@/lib/social-image"; import type { Metadata } from "next"; import { ReleaseNotes } from "@/components/changelog/release-notes"; import { SITE_URL } from "@/lib/base-url"; @@ -22,13 +23,13 @@ export const metadata: Metadata = { description, url: `${SITE_URL}/changelog`, type: "website", - images: "/xmcp-og.png", + images: DEFAULT_OG_IMAGE, }, twitter: { card: "summary_large_image", title, description, - images: "/xmcp-og.png", + images: DEFAULT_TWITTER_IMAGE, }, }; diff --git a/apps/website/app/design-system.css b/apps/website/app/design-system.css index 104a907d8..000703355 100644 --- a/apps/website/app/design-system.css +++ b/apps/website/app/design-system.css @@ -639,10 +639,14 @@ body .hero-specimen + figure.shiki { margin-top: 0; } /* Product mock over the dust backdrop, cut off by the frame's bottom edge. */ -.hero-mock { +.hero-dashboard-player { position: absolute; z-index: 1; inset: 9% 6% -12px; +} +.hero-mock { + position: absolute; + inset: 0; display: flex; flex-direction: column; overflow: hidden; @@ -729,7 +733,7 @@ body .hero-specimen + figure.shiki { .hero-mock-stats { display: grid; /* Keep metric positions independent of the selected tool and its figures. */ - grid-template-columns: minmax(0, 1fr) 4ch minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr) 7ch minmax(0, 1fr); gap: 12px; padding-top: 12px; border-top: 1px solid var(--site-rule); @@ -738,9 +742,12 @@ body .hero-specimen + figure.shiki { min-width: 0; } .hero-mock-stats .hero-mock-muted { + white-space: nowrap; +} +/* Only variable tool names can truncate; the metric labels stay readable. */ +.hero-mock-stats > span:first-child .hero-mock-muted { overflow: hidden; text-overflow: ellipsis; - white-space: nowrap; } .hero-mock-figure { font-size: 19px; @@ -815,9 +822,7 @@ body .hero-specimen + figure.shiki { overflow: hidden; border-radius: 1.5px 1.5px 0 0; opacity: 0.85; - transition: - height 500ms var(--site-ease), - opacity 200ms var(--site-ease); + transition: opacity 200ms var(--site-ease); } .hero-mock-plot[data-hovering] .hero-mock-fill { opacity: 0.25; @@ -885,6 +890,8 @@ a.hero-mock-run:hover { } .hero-scene-size { width: 100%; + /* Keep the tool and middleware rows visible when the frame gets narrow. */ + min-height: 460px; aspect-ratio: 350 / 243; } /* Give the inverted scene an explicit backdrop inside its fixed layer. */ @@ -914,6 +921,21 @@ a.hero-mock-run:hover { padding-block: var(--site-section-gap); text-align: center; } +/* Keep error recovery in one viewport, without the marketing footer. */ +body:has(.site-not-found) :is(.site-prefooter, .site-footer) { + display: none; +} +body .site-not-found { + /* Fill the body's remaining height, including the mobile header's border. */ + flex: 1; + padding-block: 24px; + align-content: center; +} +.site-not-found .prefooter-particles > div { + width: clamp(48px, 16dvh, 127px); + height: auto; + aspect-ratio: 127 / 154; +} body .site-prefooter .site-page-title { margin: 0; font-size: 24px; diff --git a/apps/website/app/faq/page.tsx b/apps/website/app/faq/page.tsx index 2b9e1eae4..b36ed2111 100644 --- a/apps/website/app/faq/page.tsx +++ b/apps/website/app/faq/page.tsx @@ -1,10 +1,11 @@ +import { DEFAULT_OG_IMAGE, DEFAULT_TWITTER_IMAGE } from "@/lib/social-image"; import { Metadata } from "next"; import Link from "next/link"; import { FAQ_ITEMS } from "@/content/faq"; import { JsonLd } from "@/components/seo/json-ld"; import { AccordionItem } from "@/components/ui/accordion-item"; import { getBreadcrumbSchema, getFaqSchema } from "@/lib/structured-data"; -import { getBaseUrl, SITE_URL } from "@/lib/base-url"; +import { SITE_URL } from "@/lib/base-url"; export const dynamic = "force-static"; @@ -13,7 +14,6 @@ const DESCRIPTION = "Answers to common questions about xmcp: what it is, how to create a project, tools, resources and prompts, transports, framework adapters, authentication, deployment, and monetization."; const FAQ_URL = `${SITE_URL}/faq`; -const baseUrl = getBaseUrl(); export const metadata: Metadata = { title: TITLE, @@ -31,21 +31,13 @@ export const metadata: Metadata = { siteName: "xmcp", type: "website", locale: "en_US", - images: { - url: `${baseUrl}/xmcp-og.png`, - width: 1200, - height: 630, - }, + images: DEFAULT_OG_IMAGE, }, twitter: { card: "summary_large_image", title: TITLE, description: DESCRIPTION, - images: { - url: `${baseUrl}/xmcp-og.png`, - width: 1200, - height: 630, - }, + images: DEFAULT_TWITTER_IMAGE, }, }; diff --git a/apps/website/app/layout.tsx b/apps/website/app/layout.tsx index a873bc67e..a7801e886 100644 --- a/apps/website/app/layout.tsx +++ b/apps/website/app/layout.tsx @@ -1,3 +1,4 @@ +import { DEFAULT_OG_IMAGE, DEFAULT_TWITTER_IMAGE } from "@/lib/social-image"; import "./globals.css"; import { PersistentHero } from "@/components/home/hero/persistent-hero"; import { Analytics } from "@vercel/analytics/next"; @@ -73,12 +74,7 @@ export const metadata: Metadata = { }, openGraph: { siteName: "xmcp", - images: { - url: "/xmcp-og.png", - width: 1200, - height: 630, - alt: "xmcp — The TypeScript MCP framework", - }, + images: DEFAULT_OG_IMAGE, url: "https://xmcp.dev", type: "website", locale: "en_US", @@ -93,12 +89,7 @@ export const metadata: Metadata = { title: "xmcp — The TypeScript MCP framework", description: "xmcp is the TypeScript framework for building, shipping, and scaling Model Context Protocol servers — tools, prompts, resources, auth, transports, and monetization out of the box.", - images: { - url: "/xmcp-og.png", - width: 1200, - height: 630, - alt: "xmcp — The TypeScript MCP framework", - }, + images: DEFAULT_TWITTER_IMAGE, }, manifest: "/site.webmanifest", appleWebApp: { diff --git a/apps/website/app/not-found.tsx b/apps/website/app/not-found.tsx index 8a36c3da6..0a32330c0 100644 --- a/apps/website/app/not-found.tsx +++ b/apps/website/app/not-found.tsx @@ -1,28 +1,43 @@ import { Button } from "@/components/ui/button"; +import { PrefooterArtwork } from "@/components/layout/prefooter"; import Link from "next/link"; export const dynamic = "force-static"; -export default async function Page() { +export default function NotFound() { return ( -
-

- 404 - Page Not Found -

-

- The page you are looking for does not exist. -

-
- - -
+
+
+ +
+

+ This page isn’t here. +

+

+ It may have moved, or the link might be incorrect. Head back home or + find what you need in the documentation. +

+
+
+ + +
+
); } diff --git a/apps/website/app/showcase/page.tsx b/apps/website/app/showcase/page.tsx index d32781ce4..5508377ad 100644 --- a/apps/website/app/showcase/page.tsx +++ b/apps/website/app/showcase/page.tsx @@ -1,3 +1,4 @@ +import { DEFAULT_OG_IMAGE, DEFAULT_TWITTER_IMAGE } from "@/lib/social-image"; import { Metadata } from "next"; import { ShowcaseCards } from "@/components/showcase/cards"; import { ShowcaseSubmissionCard } from "@/components/showcase/submission-card"; @@ -24,18 +25,14 @@ export const metadata: Metadata = { siteName: "xmcp", type: "website", locale: "en_US", - images: { - url: "/xmcp-og.png", - width: 1200, - height: 630, - }, + images: DEFAULT_OG_IMAGE, }, twitter: { card: "summary_large_image", title: "Showcase - xmcp", description: "Browse production-ready MCP servers built by the xmcp community and submit your own server to be featured in the showcase alongside other developers building AI tooling.", - images: "/xmcp-og.png", + images: DEFAULT_TWITTER_IMAGE, }, }; diff --git a/apps/website/app/telemetry/page.tsx b/apps/website/app/telemetry/page.tsx index 82ba27788..0e01045f1 100644 --- a/apps/website/app/telemetry/page.tsx +++ b/apps/website/app/telemetry/page.tsx @@ -1,3 +1,4 @@ +import { DEFAULT_OG_IMAGE, DEFAULT_TWITTER_IMAGE } from "@/lib/social-image"; import type { Metadata } from "next"; import { DocsBody } from "@/components/layout/page"; import { JsonLd } from "@/components/seo/json-ld"; @@ -21,18 +22,14 @@ export const metadata: Metadata = { siteName: "xmcp", type: "website", locale: "en_US", - images: { - url: "/xmcp-og.png", - width: 1200, - height: 630, - }, + images: DEFAULT_OG_IMAGE, }, twitter: { card: "summary_large_image", title: "Telemetry - xmcp", description: "xmcp collects anonymous build and run telemetry to prioritize transport, adapter, and compiler fixes. Learn exactly what we collect, why it matters, and how to opt out at any time.", - images: "/xmcp-og.png", + images: DEFAULT_TWITTER_IMAGE, }, }; diff --git a/apps/website/components/blog/artwork.tsx b/apps/website/components/blog/artwork.tsx index af6c1c330..ace6251be 100644 --- a/apps/website/components/blog/artwork.tsx +++ b/apps/website/components/blog/artwork.tsx @@ -1,22 +1,28 @@ +"use client"; + import Image from "next/image"; import ParticleImage from "@/components/particles/image"; -import { getBlogImageProps } from "@/lib/blog-images"; +import type { getBlogImageProps } from "@/lib/blog-images"; +import { useHeroArtworkReady } from "@/components/home/hero/persistent-hero"; /** Keep the original optimized cover available before the shader is ready. */ export function BlogArtwork({ - src, + image, alt, sizes, loading = "lazy", preload = false, + prepareAfterHero = false, }: { - src: string; + image: ReturnType; alt: string; sizes: string; loading?: "eager" | "lazy"; preload?: boolean; + prepareAfterHero?: boolean; }) { - const image = getBlogImageProps(src); + const heroReady = useHeroArtworkReady(); + const prepare = prepareAfterHero && heroReady; const texture = typeof image.src === "string" ? image.src @@ -33,6 +39,7 @@ export function BlogArtwork({ className="h-full w-full" preset="blog" fadeIn + prepare={prepare} fallback={ } diff --git a/apps/website/components/blog/hero.tsx b/apps/website/components/blog/hero.tsx index 4b2760b0e..e1f3dc9e7 100644 --- a/apps/website/components/blog/hero.tsx +++ b/apps/website/components/blog/hero.tsx @@ -1,5 +1,6 @@ import { ContentCard } from "@/components/ui/content-card"; import { BlogArtwork } from "./artwork"; +import { getBlogImageProps } from "@/lib/blog-images"; import { type BlogPost } from "../../utils/blog"; import { Tag } from "../ui/tag"; @@ -18,7 +19,7 @@ export function BlogHero({ featuredPost }: BlogHeroProps) { media={ image ? ( {
{posts.map((post) => ( - + ))}
@@ -31,10 +32,12 @@ export const HomeBlog = () => { export const BlogCard = ({ post, loading = "lazy", + prepareAfterHero = false, sizes = "(min-width: 740px) 338px, (min-width: 640px) calc((100vw - 64px) / 2), calc(100vw - 40px)", }: { post: BlogPost; loading?: "eager" | "lazy"; + prepareAfterHero?: boolean; sizes?: string; }) => { const texture = @@ -55,10 +58,11 @@ export const BlogCard = ({ media={ texture ? ( ) : (
diff --git a/apps/website/components/home/features/schematics.tsx b/apps/website/components/home/features/schematics.tsx index 72eec4e6a..2ddca5f1d 100644 --- a/apps/website/components/home/features/schematics.tsx +++ b/apps/website/components/home/features/schematics.tsx @@ -154,9 +154,6 @@ const glyphs = { ), - box: ( - - ), }; /** A Lucide glyph placed in schematic units, stroked at 1px like the lines. */ @@ -330,8 +327,8 @@ export function ConfigurationSchematic() { ); } -const http = "M24 40C42 40 44 20 64 20H86"; -const stdio = "M24 40C42 40 44 60 64 60H86"; +const http = "M24 40H44V20H86"; +const stdio = "M24 40H44V60H86"; /** One server reaching clients over HTTP (the web) and STDIO (a local shell). */ export function TransportSchematic() { @@ -350,14 +347,14 @@ export function TransportSchematic() { ); } -// Both framework routes merge and enter the app window, where xmcp docks. -const nextApp = "M25 20H30Q38 20 38 28V34Q38 40 46 40H62"; -const expressApp = "M25 60H30Q38 60 38 52V46Q38 40 46 40H62"; +// Orthogonal framework routes merge at the app's input, beside its code lines. +const nextApp = "M25 20H38V40H62"; +const expressApp = "M25 60H38V40H62"; const code = [ - [32, 20], - [38, 14], - [44, 18], - [50, 10], + [32, 34], + [38, 24], + [44, 30], + [50, 20], ]; function CodeLine({ @@ -380,7 +377,7 @@ function CodeLine({ ); return ( {/* The window is structure, so it takes the muted tone, not the ink. */} - + {[65, 69, 73].map((x) => ( ))} - {code.map(([y, length], i) => ( ))} diff --git a/apps/website/components/home/hero/dashboard-player.tsx b/apps/website/components/home/hero/dashboard-player.tsx new file mode 100644 index 000000000..27b6dd04f --- /dev/null +++ b/apps/website/components/home/hero/dashboard-player.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { Player, type PlayerRef } from "@remotion/player"; +import { + DASHBOARD_FPS, + DASHBOARD_FRAMES, + DashboardScene, +} from "./product-mock"; + +const MOTION_QUERY = "(prefers-reduced-motion: reduce)"; +const subscribeToMotion = (onChange: () => void) => { + const query = window.matchMedia(MOTION_QUERY); + query.addEventListener("change", onChange); + return () => query.removeEventListener("change", onChange); +}; +const prefersReducedMotion = () => window.matchMedia(MOTION_QUERY).matches; +const serverReducedMotion = () => true; + +export function ProductMock() { + const container = useRef(null); + const player = useRef(null); + // Match the available CSS pixels instead of scaling desktop text on mobile. + const [size, setSize] = useState({ width: 616, height: 454 }); + const reducedMotion = useSyncExternalStore( + subscribeToMotion, + prefersReducedMotion, + serverReducedMotion + ); + + useEffect(() => { + const observer = new ResizeObserver(([entry]) => { + const width = Math.round(entry.contentRect.width); + const height = Math.round(entry.contentRect.height); + if (width && height) setSize({ width, height }); + }); + observer.observe(container.current!); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + let visible = false; + const updatePlayback = () => { + if (visible && !document.hidden && !reducedMotion) player.current?.play(); + else player.current?.pause(); + }; + const observer = new IntersectionObserver(([entry]) => { + visible = entry.isIntersecting; + updatePlayback(); + }); + observer.observe(container.current!); + document.addEventListener("visibilitychange", updatePlayback); + return () => { + observer.disconnect(); + document.removeEventListener("visibilitychange", updatePlayback); + }; + }, [reducedMotion]); + + return ( + + ); +} diff --git a/apps/website/components/home/hero/hero-particles.tsx b/apps/website/components/home/hero/hero-particles.tsx index 382e56bb0..49db37309 100644 --- a/apps/website/components/home/hero/hero-particles.tsx +++ b/apps/website/components/home/hero/hero-particles.tsx @@ -7,7 +7,13 @@ import DustScene from "@/components/particles/dust-scene"; // Soft folds behind the product mock, drawn as flowing dashes. const PICTURE = "/hero-folds.webp"; -export default function HeroParticles({ active }: { active: boolean }) { +export default function HeroParticles({ + active, + onReady, +}: { + active: boolean; + onReady: () => void; +}) { return ( <> {/* Both renderers and the fallback artwork are monochrome. Inverting the @@ -23,6 +29,7 @@ export default function HeroParticles({ active }: { active: boolean }) { // the other read as a flicker on reload; the scene fades in instead. showLoadingArtwork={false} fadeIn + onReady={onReady} > {(props) => ( import("./hero-particles")); const HeroSlotContext = createContext< ((slot: HTMLDivElement | null) => void) | null >(null); +const HeroArtworkReadyContext = createContext(false); + +export function useHeroArtworkReady() { + return useContext(HeroArtworkReadyContext); +} const LAYER_STYLE: CSSProperties = { position: "fixed", top: 0, @@ -31,6 +36,8 @@ export function PersistentHero({ children }: { children: ReactNode }) { const slot = useRef(null); const [started, setStarted] = useState(false); const [active, setActive] = useState(false); + const [artworkReady, setArtworkReady] = useState(false); + const markArtworkReady = useCallback(() => setArtworkReady(true), []); // Only move the layer's screen position, never its DOM node. Reparenting a // canvas can invalidate the browser's presented GPU surface even when React @@ -81,24 +88,26 @@ export function PersistentHero({ children }: { children: ReactNode }) { return ( - {children} - {/* This element and all renderer ancestors stay in the root layout. + + {children} + {/* This element and all renderer ancestors stay in the root layout. Off-page scenes retain their size and resources, with rendering paused. Both particle renderers already listen for pointer movement on window. */} - + + ); } diff --git a/apps/website/components/home/hero/product-mock.tsx b/apps/website/components/home/hero/product-mock.tsx index 4b602c62f..9886ca1b9 100644 --- a/apps/website/components/home/hero/product-mock.tsx +++ b/apps/website/components/home/hero/product-mock.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { useState } from "react"; +import { Easing, interpolate, useCurrentFrame } from "remotion"; // An illustrative xmcp dashboard for the homepage hero: tool calls per hour, // per tool. The figures are generated sample data; nothing is fetched. @@ -22,6 +23,7 @@ const middleware = [ ["auth", "Better Auth", "/docs/integrations/better-auth"], ["rate-limit", "Upstash", "/docs/integrations/upstash"], ["paywall", "Commet", "/docs/integrations/commet"], + ["email", "Resend", "/docs/integrations/resend"], ["logging", "JSON", "/docs/configuration/observability"], ]; @@ -48,7 +50,7 @@ const clientShares = Array.from({ length: HOURS }, (_, i) => { }); const sum = (values: number[]) => values.reduce((a, b) => a + b, 0); -const format = (n: number) => n.toLocaleString("en-US"); +const format = (n: number) => Math.round(n).toLocaleString("en-US"); const ago = (hour: number) => hour === HOURS - 1 ? "This hour" : `${HOURS - 1 - hour}h ago`; @@ -81,17 +83,101 @@ function Wordmark() { ); } -export function ProductMock() { - const [tool, setTool] = useState(null); - const [hovered, setHovered] = useState(null); - const series = - tool === null - ? traffic.map((_, i) => sum(calls.map((perTool) => perTool[i]))) - : calls[tool]; +export const DASHBOARD_FPS = 60; +// Four seconds per view leaves time to read each tool before the next change. +const VIEW_FRAMES = 4 * DASHBOARD_FPS; +const TRANSITION_FRAMES = Math.round(0.75 * DASHBOARD_FPS); +const TOUR = [null, 0, 1, 2, 3, 4, null] as const; +export const DASHBOARD_FRAMES = TOUR.length * VIEW_FRAMES; +const ease = Easing.bezier(0.4, 0, 0.2, 1); + +type DashboardView = { series: number[]; p95: number }; +const allTools: DashboardView = { + series: traffic.map((_, i) => sum(calls.map((perTool) => perTool[i]))), + p95: 96, +}; +const viewFor = (tool: number | null): DashboardView => + tool === null ? allTools : { series: calls[tool], p95: tools[tool].p95 }; +const progressAt = (frame: number) => + interpolate(frame, [0, TRANSITION_FRAMES], [0, 1], { + easing: ease, + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); +const blend = ( + from: DashboardView, + to: DashboardView, + progress: number +): DashboardView => ({ + series: to.series.map( + (value, i) => from.series[i] + (value - from.series[i]) * progress + ), + p95: from.p95 + (to.p95 - from.p95) * progress, +}); + +type Interaction = { + tool: number | null; + resume: boolean; + from: DashboardView; + frame: number; + settled: boolean; +}; + +export function DashboardScene({ still }: { still: boolean }) { + const frame = useCurrentFrame(); + const [interaction, setInteraction] = useState(null); + const [inspection, setInspection] = useState<{ + hour: number; + view: DashboardView; + tool: number | null; + } | null>(null); + const stage = still ? 0 : Math.floor(frame / VIEW_FRAMES); + const tourTool = TOUR[stage]; + // The last and first stages both show all tools, making the loop seamless. + const tourView = blend( + viewFor(TOUR[Math.max(0, stage - 1)]), + viewFor(tourTool), + still ? 1 : progressAt(frame % VIEW_FRAMES) + ); + const selected = + interaction && !interaction.resume ? interaction.tool : tourTool; + const target = + interaction && !interaction.resume ? viewFor(interaction.tool) : tourView; + const elapsed = interaction + ? (frame - interaction.frame + DASHBOARD_FRAMES) % DASHBOARD_FRAMES + : 0; + const animated = interaction + ? blend( + interaction.from, + target, + still || interaction.settled ? 1 : progressAt(elapsed) + ) + : tourView; + + // Retire completed transitions so an old hover cannot replay at the loop boundary. + // Adjusting this local state during render also handles reduced motion immediately. + if ( + interaction && + !interaction.settled && + (still || elapsed >= TRANSITION_FRAMES) + ) { + setInteraction( + interaction.resume ? null : { ...interaction, settled: true } + ); + } + + // Hold the values while a tooltip is being read, even as the tour clock advances. + const view = inspection?.view ?? animated; + const tool = inspection ? inspection.tool : selected; + const hovered = inspection?.hour ?? null; + const { series, p95 } = view; const peak = Math.max(...series); const total = sum(series); const errors = Math.round(total * 0.006); - const p95 = tool === null ? 96 : tools[tool].p95; + const select = (next: number | null, resume = false) => { + setInspection(null); + setInteraction({ tool: next, resume, from: view, frame, settled: false }); + }; return ( -
setTool(null)}> +
select(null, true)} + > Tool Calls @@ -119,7 +208,7 @@ export function ProductMock() { key={name} className="hero-mock-row hero-mock-run" data-active={tool === i || undefined} - onPointerEnter={() => setTool(i)} + onPointerEnter={() => select(i)} > {name} @@ -165,20 +254,20 @@ export function ProductMock() { p95 latency - {p95} ms + {format(p95)} ms
setHovered(null)} + onPointerLeave={() => select(null, true)} > {series.map((value, i) => ( setHovered(i)} + onPointerEnter={() => setInspection({ hour: i, view, tool })} > ))} @@ -222,7 +312,7 @@ export function ProductMock() { Errors {format(Math.round(series[hovered] * 0.006))} - p95 {p95} ms + p95 {format(p95)} ms )} diff --git a/apps/website/components/home/hero/shader.tsx b/apps/website/components/home/hero/shader.tsx index 56b7607dc..68fcd4160 100644 --- a/apps/website/components/home/hero/shader.tsx +++ b/apps/website/components/home/hero/shader.tsx @@ -1,5 +1,5 @@ import { HeroSlot } from "./persistent-hero"; -import { ProductMock } from "./product-mock"; +import { ProductMock } from "./dashboard-player"; export default function Shader() { return ( diff --git a/apps/website/components/layout/docs-icons.test.tsx b/apps/website/components/layout/docs-icons.test.tsx index 81029abf2..4af986d43 100644 --- a/apps/website/components/layout/docs-icons.test.tsx +++ b/apps/website/components/layout/docs-icons.test.tsx @@ -35,6 +35,26 @@ test("unknown pages have a fallback and page-provided icons take precedence", () assert.equal((explicit.match(/ { + const shapes = new Map(); + for (const [url, Icon] of Object.entries(sidebarIcons)) { + assert(Icon); + const html = renderToStaticMarkup(); + // Compare geometry, not component names: aliases can render the same glyph. + const shape = ( + html.match( + /<(?:path|rect|circle|line|polyline|polygon|ellipse)\b[^>]*>/g + ) ?? [] + ).join(""); + assert(shape, `Missing icon geometry: ${url}`); + assert( + !shapes.has(shape), + `${url} repeats the icon for ${shapes.get(shape)}` + ); + shapes.set(shape, url); + } +}); + test("repeated brand icons use distinct mask IDs in desktop and mobile navigation", () => { const html = renderToStaticMarkup( <> diff --git a/apps/website/components/layout/docs-icons.tsx b/apps/website/components/layout/docs-icons.tsx index 3be26fc34..0bf1b2765 100644 --- a/apps/website/components/layout/docs-icons.tsx +++ b/apps/website/components/layout/docs-icons.tsx @@ -1,21 +1,28 @@ import { type ComponentType, useId } from "react"; import { + ActivityIcon, + AppWindowIcon, ArchiveIcon, AvatarIcon, BarChartIcon, CardStackPlusIcon, ChatBubbleIcon, ColorWheelIcon, + CodeIcon, + Component2Icon, CubeIcon, DownloadIcon, FileTextIcon, + FileInputIcon, + FingerprintIcon, GlobeIcon, GroupIcon, HomeIcon, IdCardIcon, InfoCircledIcon, LayersIcon, + KeyRoundIcon, LightningBoltIcon, Link2Icon, ListBulletIcon, @@ -23,8 +30,7 @@ import { MagicWandIcon, MagnifyingGlassIcon, PaperPlaneIcon, - PersonIcon, - TimerIcon, + ScanLineIcon, TokensIcon, } from "@/components/ui/site-icons"; @@ -43,10 +49,11 @@ export const sidebarIcons: Partial< "/docs/configuration/custom-directories": ArchiveIcon, "/docs/configuration/bundler": CubeIcon, "/docs/configuration/telemetry": BarChartIcon, - "/docs/configuration/observability": BarChartIcon, - "/docs/configuration/components": LayersIcon, - "/docs/core-concepts/request-context": IdCardIcon, - "/docs/core-concepts/mcp-apps": CubeIcon, + "/docs/configuration/observability": ActivityIcon, + "/docs/configuration/components": Component2Icon, + "/docs/configuration/schema-inference": ScanLineIcon, + "/docs/core-concepts/request-context": FingerprintIcon, + "/docs/core-concepts/mcp-apps": AppWindowIcon, "/docs/core-concepts/tools": LightningBoltIcon, "/docs/core-concepts/prompts": ChatBubbleIcon, "/docs/core-concepts/resources": FileTextIcon, @@ -55,7 +62,7 @@ export const sidebarIcons: Partial< "/docs/core-concepts/external-clients": GlobeIcon, "/docs/authentication/api-key": LockClosedIcon, "/docs/authentication/jwt": TokensIcon, - "/docs/authentication/oauth": PersonIcon, + "/docs/authentication/oauth": KeyRoundIcon, "/docs/adapters/tanstack": Icons.tanstack, "/docs/adapters/nextjs": Icons.nextjs, "/docs/adapters/nestjs": Icons.nestjs, @@ -83,17 +90,18 @@ export const sidebarIcons: Partial< "/docs/integrations/profound": IntegrationIcons.profound, "/docs/integrations/resend": IntegrationIcons.resend, "/docs/integrations/scalekit": Icons.scalekit, - "/docs/integrations/sentry": BarChartIcon, + "/docs/integrations/sentry": IntegrationIcons.sentry, "/docs/integrations/supabase": Icons.supabase, "/docs/integrations/billing": Icons.stripe, "/docs/integrations/coinbase": Icons.coinbase, - "/docs/integrations/upstash": TimerIcon, + "/docs/integrations/upstash": IntegrationIcons.upstash, "/docs/integrations/workos": Icons.workos, "/docs/integrations/x402": Icons.x402, "/docs/discoverability/smithery": MagnifyingGlassIcon, "/docs/discoverability/mcp-server-card": IdCardIcon, "/docs/guides/ui-rendering": MagicWandIcon, - "/docs/guides/xmcp-mcp-server": MagicWandIcon, + "/docs/guides/import-openapi": FileInputIcon, + "/docs/guides/xmcp-mcp-server": CodeIcon, "/docs/guides/authentication": AvatarIcon, "/docs/guides/roll-out-to-a-team": GroupIcon, "/docs/guides/monetization": CardStackPlusIcon, diff --git a/apps/website/components/layout/integration-icons.tsx b/apps/website/components/layout/integration-icons.tsx index 5ecb6e8a6..00a7d6c8f 100644 --- a/apps/website/components/layout/integration-icons.tsx +++ b/apps/website/components/layout/integration-icons.tsx @@ -30,6 +30,22 @@ function BrandIcon({ // Official artwork retrieved October 22, 2026; paths retain their geometry. // Marks belong to their respective owners and identify compatible services. export const IntegrationIcons = { + // https://sentry.io/about/media-resources/ (symbol, without the wordmark) + sentry: (props: IconProps) => ( + + + + ), + // https://upstash.com/brand (official icon, rendered monochrome) + upstash: (props: IconProps) => ( + + + + + + + ), + // https://resend.com/brand (black icon) resend: (props: IconProps) => ( diff --git a/apps/website/components/layout/prefooter/index.tsx b/apps/website/components/layout/prefooter/index.tsx index a318ed20d..807fe81db 100644 --- a/apps/website/components/layout/prefooter/index.tsx +++ b/apps/website/components/layout/prefooter/index.tsx @@ -1,18 +1,22 @@ import ParticleImage from "@/components/particles/image"; +export const PrefooterArtwork = () => ( + +); + export const Prefooter = () => (
-
-
- -
-
+

One framework to rule them all

diff --git a/apps/website/components/particles/image.tsx b/apps/website/components/particles/image.tsx index f94b50d7f..484fdecdb 100644 --- a/apps/website/components/particles/image.tsx +++ b/apps/website/components/particles/image.tsx @@ -25,6 +25,7 @@ export default function ParticleImage({ preset = "card", fallback, fadeIn = false, + prepare = false, }: { src: string; width: number; @@ -33,6 +34,7 @@ export default function ParticleImage({ preset?: keyof typeof PRESETS; fallback?: ReactNode; fadeIn?: boolean; + prepare?: boolean; }) { return ( {(props) => ( diff --git a/apps/website/components/particles/viewport.tsx b/apps/website/components/particles/viewport.tsx index 997bcc9d5..e13e597f4 100644 --- a/apps/website/components/particles/viewport.tsx +++ b/apps/website/components/particles/viewport.tsx @@ -28,6 +28,8 @@ export function ParticleViewport({ showLoadingArtwork = true, fadeIn = false, fallback, + prepare = false, + onReady: onArtworkReady, children, }: { className: string; @@ -40,6 +42,9 @@ export function ParticleViewport({ fadeIn?: boolean; /** Preserve optimized, full-bleed artwork when a picture needs its own layout. */ fallback?: ReactNode; + /** Prepare one frame offscreen, then pause until the artwork is visible. */ + prepare?: boolean; + onReady?: () => void; children: (props: ParticleSceneProps) => ReactNode; }) { const container = useRef(null); @@ -51,7 +56,10 @@ export function ParticleViewport({ const [ready, setReady] = useState(false); const [failed, setFailed] = useState(false); const [fallbackSource, setFallbackSource] = useState(texture); - const onReady = useCallback(() => setReady(true), []); + const onReady = useCallback(() => { + setReady(true); + onArtworkReady?.(); + }, [onArtworkReady]); const onError = useCallback(() => setFailed(true), []); useEffect(() => { @@ -111,6 +119,9 @@ export function ParticleViewport({ unoptimized crossOrigin="anonymous" loading={deferred ? "lazy" : "eager"} + onLoad={() => { + if (reducedMotion || failed) onArtworkReady?.(); + }} // Keep the original asset as an independent fallback if the new texture fails. onError={() => setFallbackSource(texture.replace(/\.webp$/, ".png")) @@ -118,12 +129,16 @@ export function ParticleViewport({ className={`absolute inset-0 m-auto h-[90%] w-[90%] object-contain ${ready && !failed ? "invisible" : ""}`} /> ))} - {enabled && nearby && !failed && ( + {enabled && (nearby || prepare) && !failed && (
- {children({ active: visible && pageVisible, onReady, onError })} + {children({ + active: (visible || (prepare && !ready)) && pageVisible, + onReady, + onError, + })}
)} diff --git a/apps/website/components/ui/site-icons.tsx b/apps/website/components/ui/site-icons.tsx index b0e602e29..8c2b97b1c 100644 --- a/apps/website/components/ui/site-icons.tsx +++ b/apps/website/components/ui/site-icons.tsx @@ -2,7 +2,18 @@ import type { ComponentType, Ref, RefAttributes, SVGAttributes } from "react"; import { useEffect, useRef } from "react"; -import { Circle, TriangleAlert, Info, Trash2 } from "lucide-react"; +import { + Activity, + AppWindow, + Circle, + FileInput, + Fingerprint, + KeyRound, + ScanLine, + TriangleAlert, + Info, + Trash2, +} from "lucide-react"; import { bindIconInteraction, type IconAnimation } from "./icon-interaction"; import { AlignLeftIcon as AnimatedAlignLeftIcon } from "./lucide-animated/align-left"; import { ArchiveIcon as AnimatedArchiveIcon } from "./lucide-animated/archive"; @@ -108,6 +119,12 @@ function staticIcon( } export const ArchiveIcon = siteIcon(AnimatedArchiveIcon, "ArchiveIcon"); +export const ActivityIcon = staticIcon(Activity, "ActivityIcon"); +export const AppWindowIcon = staticIcon(AppWindow, "AppWindowIcon"); +export const FingerprintIcon = staticIcon(Fingerprint, "FingerprintIcon"); +export const KeyRoundIcon = staticIcon(KeyRound, "KeyRoundIcon"); +export const FileInputIcon = staticIcon(FileInput, "FileInputIcon"); +export const ScanLineIcon = staticIcon(ScanLine, "ScanLineIcon"); export const ArrowLeftIcon = siteIcon(AnimatedArrowLeftIcon, "ArrowLeftIcon"); export const ArrowRightIcon = siteIcon( AnimatedArrowRightIcon, diff --git a/apps/website/lib/social-image.ts b/apps/website/lib/social-image.ts new file mode 100644 index 000000000..08ff3f6f3 --- /dev/null +++ b/apps/website/lib/social-image.ts @@ -0,0 +1,16 @@ +// Keep the supplied animation intact; card renderers may display its first frame. +export const DEFAULT_OG_IMAGE = { + url: "/xmcp-chrome-og.gif", + type: "image/gif", + width: 480, + height: 270, + alt: "The chrome xmcp logo on a black background", +}; + +// A matching still gives Twitter cards a predictable preview without animation. +export const DEFAULT_TWITTER_IMAGE = { + url: "/xmcp-chrome-og.png", + width: 480, + height: 270, + alt: DEFAULT_OG_IMAGE.alt, +}; diff --git a/apps/website/public/xmcp-chrome-og.gif b/apps/website/public/xmcp-chrome-og.gif new file mode 100644 index 000000000..0614158c3 Binary files /dev/null and b/apps/website/public/xmcp-chrome-og.gif differ diff --git a/apps/website/public/xmcp-chrome-og.png b/apps/website/public/xmcp-chrome-og.png new file mode 100644 index 000000000..d420a5152 Binary files /dev/null and b/apps/website/public/xmcp-chrome-og.png differ