diff --git a/src/components/marketing/ProductMotif.tsx b/src/components/marketing/ProductMotif.tsx index da65d9e..d9fe18e 100644 --- a/src/components/marketing/ProductMotif.tsx +++ b/src/components/marketing/ProductMotif.tsx @@ -43,7 +43,8 @@ const workflowLanePath = (offset: number, rightX = 262, leftX = 58) => { ].join(" "); }; -// Card motifs draw one 320×400 cluster stretched over the 4/5 plate. Heroes +// Card motifs draw one 320×400 cluster on a fixed 4/5 canvas. Compact cards +// crop that canvas instead of squeezing its shapes and animation paths. Heroes // are wide and short, so slicing that same viewBox magnifies the artwork ~4× // and the center cut-out mask swallows it — the shapes stop reading as the // card imagery. Instead heroes repeat the cluster across a 1600×400 field so @@ -485,7 +486,13 @@ export const ProductMotif = ({ productId, surface }: ProductMotifProps) => { return ( ); }; diff --git a/src/components/marketing/sections/LatestPostCallout.module.css b/src/components/marketing/sections/LatestPostCallout.module.css new file mode 100644 index 0000000..4e49b90 --- /dev/null +++ b/src/components/marketing/sections/LatestPostCallout.module.css @@ -0,0 +1,86 @@ +.story { + --post-width: min(72rem, calc(100vw - 2rem)); + position: relative; + z-index: 20; + padding: 3rem 1rem 4rem; +} + +.track { + position: relative; + width: 100%; +} + +.card { + position: relative; + display: block; + width: 100%; + max-width: 72rem; + margin-inline: auto; + overflow: hidden; + border-radius: 24px; +} + +.card::before { + content: ""; + position: absolute; + z-index: 1; + inset: 0; + border-top: 1px solid rgb(255 255 255 / 60%); + border-radius: inherit; + pointer-events: none; +} + +.pill { + display: none; +} + +.content { + position: relative; + width: var(--post-width); +} + +/* A 48px sticky carrier keeps the bottom edge stable as the card grows up. + Its track ends at the callout's resting position, so it releases into the + page naturally, without fixed positioning, reparenting or scroll locking. */ +.story[data-enhanced] { + margin-top: -80px; + padding-top: 0; +} + +.story[data-enhanced] .track { + height: calc(var(--post-height, 320px) + 144px); +} + +.story[data-enhanced] .sticky { + position: sticky; + top: calc(var(--post-viewport-height, 100svh) - 80px); + height: 48px; +} + +.story[data-enhanced] .card { + position: absolute; + inset-inline: 0; + bottom: 0; +} + +.story[data-enhanced] .pill { + position: absolute; + inset: 0 0 auto; + display: flex; + height: 46px; + align-items: center; +} + +@media (min-width: 48rem) { + .story { + --post-width: min(72rem, calc(100vw - 6rem)); + padding-inline: 3rem; + } +} + +@media (min-width: 1681px) { + .story { + --post-width: min(72rem, calc(100vw - 7rem)); + padding-inline: 3.5rem; + } +} diff --git a/src/components/marketing/sections/LatestPostCallout.tsx b/src/components/marketing/sections/LatestPostCallout.tsx new file mode 100644 index 0000000..9877128 --- /dev/null +++ b/src/components/marketing/sections/LatestPostCallout.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { motion, useMotionValue, useScroll, useTransform } from "framer-motion"; +import { ArrowRight } from "lucide-react"; +import { canonicalizeInternalHref } from "@/lib/internalHref"; +import { EYEBROW_CLASS, SECTION_H2_CLASS } from "../typography"; +import styles from "./LatestPostCallout.module.css"; + +export interface LatestPost { + title: string; + description: string; + href: string; + image: { src: string; width: number; height: number; alt: string } | null; +} + +export function LatestPostCallout({ post }: { post: LatestPost }) { + const [reducedMotion, setReducedMotion] = useState(false); + const storyRef = useRef(null); + const trackRef = useRef(null); + const contentRef = useRef(null); + const [enhanced, setEnhanced] = useState(false); + const expandedHeight = useMotionValue(320); + const { scrollYProgress } = useScroll({ + target: storyRef, + offset: ["start end", "end end"], + }); + const progress = useTransform(() => { + const fullHeight = expandedHeight.get(); + // The story includes the track's 144px spacing and 64px bottom padding. + const travel = scrollYProgress.get() * (fullHeight + 208) - 80; + // Follow as a pill first. Finish expanding exactly where sticky releases. + const t = Math.max(0, Math.min(1, (travel - 128) / (fullHeight - 32))); + const eased = t * t * (3 - 2 * t); + // Never let the growing top edge cross the hero: reserve at least 24px. + const clearance = Math.max(0, (travel - 104) / (fullHeight - 48)); + return Math.min(eased, clearance, 1); + }); + const height = useTransform( + () => 48 + (expandedHeight.get() - 48) * progress.get(), + ); + const maxWidth = useTransform(progress, [0, 1], [896, 1152]); + const pillOpacity = useTransform(progress, [0, 0.18], [1, 0]); + const contentOpacity = useTransform(progress, [0.22, 0.72], [0, 1]); + const contentY = useTransform(progress, [0.22, 1], [12, 0]); + const imageOpacity = useTransform(progress, [0.38, 0.86], [0, 1]); + const imageScale = useTransform(progress, [0.22, 1], [1.025, 1]); + + useEffect(() => { + const content = contentRef.current; + const track = trackRef.current; + if (!content || !track) return; + const motionPreference = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ); + + const measure = () => { + const measuredHeight = content.offsetHeight + 2; + const viewportHeight = document.documentElement.clientHeight; + expandedHeight.set(measuredHeight); + track.style.setProperty("--post-height", `${measuredHeight}px`); + track.style.setProperty("--post-viewport-height", `${viewportHeight}px`); + setReducedMotion(motionPreference.matches); + // Short screens, reduced motion and no-JS get a normal in-flow card. + setEnhanced( + !motionPreference.matches && viewportHeight > measuredHeight + 160, + ); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(content); + window.addEventListener("resize", measure); + motionPreference.addEventListener("change", measure); + return () => { + observer.disconnect(); + window.removeEventListener("resize", measure); + motionPreference.removeEventListener("change", measure); + }; + }, [expandedHeight]); + + return ( +
+
+
+ + + +
+ +
+

Latest update

+

{post.title}

+ {post.description ? ( +

+ {post.description} +

+ ) : null} + + Read update + +
+ {post.image ? ( + + + + ) : null} +
+
+
+
+
+
+ ); +} + +function CalloutArrow() { + return ( + + ); +} diff --git a/src/components/marketing/sections/RedesignedHero.tsx b/src/components/marketing/sections/RedesignedHero.tsx index ebac966..477df28 100644 --- a/src/components/marketing/sections/RedesignedHero.tsx +++ b/src/components/marketing/sections/RedesignedHero.tsx @@ -3,8 +3,9 @@ import { productAccent } from "@/lib/product-accent"; import { canonicalizeInternalHref } from "@/lib/internalHref"; import { useEffect, useState } from "react"; -import { Terminal, Check, ArrowRight } from "lucide-react"; +import { Terminal, Check } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; +import { LatestPostCallout, type LatestPost } from "./LatestPostCallout"; import { HERO_H1_CLASS, PRODUCT_HERO_PRIMARY_BUTTON_CLASS, @@ -18,12 +19,6 @@ interface ThinkingImage { date: string; } -interface LatestPost { - title: string; - href: string; - imageSrc: string | null; -} - const ThinkingImageCycler = ({ images }: { images: ThinkingImage[] }) => { const reducedMotion = useReducedMotion(); const [currentIndex, setCurrentIndex] = useState(0); @@ -296,113 +291,74 @@ function ProductVerb({ ); } -const LatestPostWindow = ({ post }: { post: LatestPost }) => { - return ( - - {post.imageSrc ? ( - - ) : null} - - - - Latest update - - - - {post.title} - - - - ); -}; - export const RedesignedHero = ({ latestPost, thinkingImages, }: RedesignedHeroProps) => { return ( -
-
-
-
-

- Infrastructure for the
- agentic era. -

- -

- - Orchestrate - {" "} - agents.{" "} - - Operate - {" "} - their environment.{" "} - - Automate - {" "} - their work.{" "} - - Deploy - {" "} - what they build. -

+
+
+
+
+
+

+ Infrastructure for the
+ agentic era. +

+ +

+ + Orchestrate + {" "} + agents.{" "} + + Operate + {" "} + their environment.{" "} + + Automate + {" "} + their work.{" "} + + Deploy + {" "} + what they build. +

+ + +
-
- -
-
- - {/* Mobile: Image */} -
-
- + {/* Mobile: Image */} +
+
+ +
-
- {/* Ungutted rail matching the floating header's max width, so the card's - own gutter offsets (right-4/-12/-14) land on the same right edge as the - nav. A padded rail would not inset an absolutely-positioned child. */} - {latestPost ? ( -
- -
- ) : null} -
+
+ {latestPost ? : null} + ); }; diff --git a/src/components/marketing/sections/StackSection.tsx b/src/components/marketing/sections/StackSection.tsx index cb2c31c..f576314 100644 --- a/src/components/marketing/sections/StackSection.tsx +++ b/src/components/marketing/sections/StackSection.tsx @@ -2,7 +2,11 @@ import { Icon } from "@rivet-gg/icons"; import { visibleProducts, type Product } from "@/sitemap/products"; import { productLogos } from "@/sitemap/productLogos"; import { productAccent, wordmarkMaskStyle } from "@/lib/product-accent"; -import { BODY_CLASS, CARD_TITLE_BASE_CLASS, SectionHeading } from "../typography"; +import { + BODY_CLASS, + CARD_TITLE_BASE_CLASS, + SectionHeading, +} from "../typography"; import { ProductMotif, type ProductMotifId } from "../ProductMotif"; import { SITE_SECTION_CLASS, SITE_STANDARD_RAIL_CLASS } from "../layout"; import { canonicalizeInternalHref } from "@/lib/internalHref"; @@ -58,7 +62,7 @@ export const StackSection = () => (
{stack.map(({ product, accent }) => ( ( product color. Each product uses its own quiet background field without competing with the foreground content. */}
{hasProductMotif(product.id) ? ( @@ -82,20 +86,29 @@ export const StackSection = () => (
-
{product.name}
+
+ {product.name} +
{product.premise && ( -

+

{product.premise}

)}
- + Explore {product.name} - + {product.badge && ( diff --git a/src/pages/index.astro b/src/pages/index.astro index 6b98d26..b9d374c 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,6 +1,7 @@ --- import { getCollection } from 'astro:content'; import { getImage } from 'astro:assets'; +import { getPostImage } from '@/lib/postImage'; import MarketingLayout from '@/layouts/MarketingLayout.astro'; import { RedesignedHero } from '@/components/marketing/sections/RedesignedHero'; import { StackSection } from '@/components/marketing/sections/StackSection'; @@ -11,7 +12,6 @@ import { HostingSection } from '@/components/marketing/sections/HostingSection'; import { OnPremSection } from '@/components/marketing/sections/OnPremSection'; import { ClosingCtaPanel } from '@/components/marketing/ClosingCtaPanel'; import { SectionRule } from '@/components/marketing/SectionRule'; -import { getPostImage } from '@/lib/postImage'; import { INK_PANEL_GHOST_BUTTON_CLASS, INK_PANEL_LIGHT_BUTTON_CLASS, @@ -82,20 +82,32 @@ const latestEntry = posts .filter((post) => post.data.category === 'changelog' && !post.data.unpublished) .sort((a, b) => b.data.published.getTime() - a.data.published.getTime())[0]; const latestImage = latestEntry ? getPostImage(latestEntry) : null; +const latestImageWidth = latestImage ? Math.min(1000, latestImage.width) : 0; +const latestImageHeight = latestImage + ? Math.round((latestImage.height / latestImage.width) * latestImageWidth) + : 0; const optimizedLatestImage = latestImage ? await getImage({ src: latestImage.src, - width: 320, - height: 160, + width: latestImageWidth, + height: latestImageHeight, format: 'webp', - quality: 80, + quality: 85, }) : null; const latestPost = latestEntry ? { title: latestEntry.data.title, + description: latestEntry.data.description, href: `/changelog/${latestEntry.id.replace(/\/page$/, '')}/`, - imageSrc: optimizedLatestImage?.src ?? null, + image: optimizedLatestImage + ? { + src: optimizedLatestImage.src, + width: latestImageWidth, + height: latestImageHeight, + alt: latestEntry.data.title, + } + : null, } : null;