From ab283d34638ab4e278fee00a7dd100574194e21e Mon Sep 17 00:00:00 2001 From: Nicholas Kissel Date: Thu, 11 Jun 2026 23:21:54 -0700 Subject: [PATCH] [SLOP(claude-fable-5)] feat(website): classical art cookbook covers --- website/astro.config.mjs | 4 + .../src/components/cookbook/CookbookCard.tsx | 40 +- .../src/components/cookbook/CookbookCover.tsx | 1231 ----------------- .../cookbook/CookbookPageContent.tsx | 44 +- website/src/data/cookbook/covers.ts | 88 ++ website/src/pages/cookbook/[...slug].astro | 254 ++-- website/src/pages/cookbook/index.astro | 104 +- website/src/styles/fonts.css | 12 +- 8 files changed, 391 insertions(+), 1386 deletions(-) delete mode 100644 website/src/components/cookbook/CookbookCover.tsx create mode 100644 website/src/data/cookbook/covers.ts diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 99625ba63d..4eb2c919ce 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -17,6 +17,10 @@ export default defineConfig({ site: 'https://rivet.dev', output: 'static', trailingSlash: 'ignore', + image: { + // Allow build-time optimization of artwork hosted on the assets CDN. + domains: ['assets.rivet.dev'], + }, // SEO Redirects - Astro generates HTML redirect files for static builds and // serves them on the dev server. The same map drives real HTTP 301s at the // Caddy layer in production (see scripts/generate-caddy-redirects.mjs), so it diff --git a/website/src/components/cookbook/CookbookCard.tsx b/website/src/components/cookbook/CookbookCard.tsx index d4fa2ea264..d2ad60a432 100644 --- a/website/src/components/cookbook/CookbookCard.tsx +++ b/website/src/components/cookbook/CookbookCard.tsx @@ -1,10 +1,17 @@ -import { CookbookCover } from "./CookbookCover"; +export interface CookbookCardCover { + src: string; + objectPosition?: string; + transform?: string; + transformOrigin?: string; + filter?: string; +} export interface CookbookPageCardData { slug: string; title: string; description: string; href: string; + cover?: CookbookCardCover; primaryTemplate?: { name: string; displayName: string; @@ -16,13 +23,38 @@ export interface CookbookPageCardData { }>; } -export function CookbookCard({ page, numeral }: { page: CookbookPageCardData; numeral: string }) { +export function CookbookCard({ page }: { page: CookbookPageCardData }) { return ( - + {page.cover && ( + <> +
+ +
+
+ + )} + {/* Title sizes use container-query units so the lockup scales with the card. */} +

+ {page.title} +

); } diff --git a/website/src/components/cookbook/CookbookCover.tsx b/website/src/components/cookbook/CookbookCover.tsx deleted file mode 100644 index 0455f56f30..0000000000 --- a/website/src/components/cookbook/CookbookCover.tsx +++ /dev/null @@ -1,1231 +0,0 @@ -import type { ReactNode, SVGProps } from "react"; - -// Poster-style cover art for cookbook entries. Every cover shares one surreal -// dusk-landscape composition: a gradient sky, a haze band, a horizon line over -// a dark sea, a soft reflection column, film grain, and a vignette. Per-cover -// identity comes from the palette and from what hangs in the sky, keyed by the -// entry slug in COVER_ART below. Unknown slugs fall back to a generic floating -// orb scene with a palette picked deterministically from the slug. -// -// `CookbookCoverDefs` must be rendered exactly once on any page that renders -// covers; it holds the shared grain filter, vignette, glints, and edge mask -// that every cover SVG references by id. - -export const COVER_SERIF = '"Perfectly Nineties", Georgia, serif'; - -// Horizon y coordinate in the 500x700 viewBox. -const HZ = 510; - -// Deterministic PRNG so server render and hydration always agree. -function mulberry32(a: number) { - return () => { - a |= 0; - a = (a + 0x6d2b79f5) | 0; - let t = Math.imul(a ^ (a >>> 15), 1 | a); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -const f = (n: number) => Math.round(n * 10) / 10; - -function polar(cx: number, cy: number, r: number, deg: number): [number, number] { - const a = (deg * Math.PI) / 180; - return [f(cx + r * Math.cos(a)), f(cy + r * Math.sin(a))]; -} - -export function toRoman(n: number): string { - const table: Array<[number, string]> = [ - [1000, "M"], - [900, "CM"], - [500, "D"], - [400, "CD"], - [100, "C"], - [90, "XC"], - [50, "L"], - [40, "XL"], - [10, "X"], - [9, "IX"], - [5, "V"], - [4, "IV"], - [1, "I"], - ]; - let out = ""; - for (const [value, symbol] of table) { - while (n >= value) { - out += symbol; - n -= value; - } - } - return out; -} - -type Stop = [number, string, number?]; - -function GradStops({ stops }: { stops: Stop[] }) { - return ( - <> - {stops.map(([offset, color, opacity], i) => ( - - ))} - - ); -} - -function LGrad({ - id, - stops, - ...rest -}: { id: string; stops: Stop[] } & SVGProps) { - return ( - - - - ); -} - -function RGrad({ - id, - stops, - ...rest -}: { id: string; stops: Stop[] } & SVGProps) { - return ( - - - - ); -} - -function HazeDef({ id, color, opacity }: { id: string; color: string; opacity: number }) { - return ( - - ); -} - -const Haze = ({ id }: { id: string }) => ( - -); - -const Sea = ({ id }: { id: string }) => ( - -); - -const HorizonLine = ({ color, opacity }: { color: string; opacity: number }) => ( - -); - -const Glints = ({ color }: { color: string }) => ( - - - -); - -function Figure({ x, h = 14, w = 3 }: { x: number; h?: number; w?: number }) { - const y = HZ - h; - return ( - <> - - - - ); -} - -function Stone({ x, h = 12, w = 4 }: { x: number; h?: number; w?: number }) { - return ( - - ); -} - -function Stars({ - seed, - color, - n, - yMin = 36, - yMax = 360, -}: { - seed: number; - color: string; - n: number; - yMin?: number; - yMax?: number; -}) { - const r = mulberry32(seed); - const items: ReactNode[] = []; - for (let i = 0; i < n; i++) { - const x = f(18 + r() * 464); - const y = f(yMin + r() * (yMax - yMin)); - const rad = f(0.6 + r() * 1.0); - const o = f(0.08 + r() * 0.22); - items.push(); - } - return <>{items}; -} - -const GLINT_ROWS: Array<[number, number, number, number]> = [ - [524, 178, 322, 0.5], - [531, 118, 252, 0.42], - [540, 258, 402, 0.36], - [551, 58, 192, 0.3], - [561, 298, 458, 0.27], - [574, 148, 332, 0.23], - [589, 38, 152, 0.19], - [603, 228, 412, 0.16], - [621, 88, 222, 0.13], - [639, 278, 432, 0.1], - [661, 138, 302, 0.08], - [680, 230, 360, 0.06], -]; - -// Shared defs referenced by every cover SVG. Render once per page. -export function CookbookCoverDefs() { - return ( - - ); -} - -// A memory orb raining light streams over a violet sea. -function AiAgentArt() { - const r = mulberry32(11); - const streams: ReactNode[] = []; - for (let i = 0; i < 16; i++) { - const x = f(168 + r() * 164); - const y1 = f(334 + r() * 24); - const y2 = f(y1 + 60 + r() * 120); - streams.push( - , - ); - } - return ( - <> - - - - - - - - - - - - - - - - - - {streams} - - - - -
- - ); -} - -// Slabs ascend in an arc from a grounded monolith toward the upper right. -function WorkspacesArt() { - const slabs: Array<[number, number, number, number]> = [ - [126, 338, 68, 94], - [244, 286, 54, 76], - [312, 224, 42, 60], - [402, 248, 30, 44], - [428, 168, 20, 30], - [180, 420, 28, 40], - ]; - return ( - <> - - - - - - - - - - - - - - {slabs.map(([x, y, w, h], i) => { - const cx = f(x + w / 2); - return ( - - - - - - - - ); - })} - - - - - - - - - - ); -} - -// Broadcast ripple rings with member dots over a teal sea. -function ChatRoomArt() { - const cx = 250; - const cy = 396; - const rings: Array<[number, number]> = [ - [38, 0.5], - [76, 0.33], - [122, 0.2], - [176, 0.12], - [240, 0.07], - ]; - const dots: Array<[number, number, number]> = [ - [76, 205, 3.4], - [76, 332, 3], - [122, 158, 3], - [122, 22, 3.6], - [122, 262, 2.6], - [38, 120, 2.4], - [176, 196, 2.6], - [176, 348, 2.2], - ]; - const ripples: Array<[number, number, number]> = [ - [46, 7, 0.28], - [90, 13, 0.16], - [150, 22, 0.09], - ]; - return ( - <> - - - - - - - - - - - {rings.map(([r, o], i) => ( - - ))} - - - {dots.map(([rr, a, rad], i) => { - const [x, y] = polar(cx, cy, rr, a); - return ; - })} - - - {ripples.map(([rx, ry, o], i) => ( - - ))} - - - - - - - ); -} - -// Rays from every collaborator converge on a single glowing caret. -function CollabEditorArt() { - const pt: [number, number] = [250, 504]; - const edges: Array<[number, number]> = []; - for (let x = 16; x <= 484; x += 52) edges.push([x, 0]); - for (let y = 120; y <= 460; y += 85) { - edges.push([0, y]); - edges.push([500, y]); - } - return ( - <> - - - - - - - - - - - - {edges.map(([x, y], i) => ( - - ))} - - - - - - - - - - - - -
-
- - ); -} - -// A half-risen dial sun with tick marks burning on the horizon. -function CronArt() { - const cx = 250; - const cy = 512; - const ticks: ReactNode[] = []; - for (let a = 184; a <= 356; a += 4) { - const major = a % 20 === 0; - const r1 = major ? 108 : 114; - const r2 = major ? 130 : 123; - const [x1, y1] = polar(cx, cy, r1, a); - const [x2, y2] = polar(cx, cy, r2, a); - ticks.push( - , - ); - } - const [hx1, hy1] = polar(cx, cy, 104, 305); - const [hx2, hy2] = polar(cx, cy, 142, 305); - const stoneXs = [55, 120, 185, 250, 315, 380, 445]; - return ( - <> - - - - - - - - - - - - {ticks} - - - - - - - - - - - {stoneXs.map((x, i) => ( - - ))} - - ); -} - -// A constellation of dart cursors with trails in a slate blue sky. -function CursorsArt() { - const darts: Array<[number, number, number, number, number]> = [ - [140, 252, -32, 1.1, 0.9], - [305, 224, 42, 0.9, 0.72], - [228, 332, 12, 1.35, 0.95], - [352, 318, -16, 0.8, 0.6], - [96, 350, 56, 0.7, 0.5], - [262, 410, -52, 0.9, 0.7], - [180, 434, 26, 0.62, 0.45], - [398, 402, 16, 0.62, 0.4], - [320, 462, -30, 0.5, 0.35], - ]; - const links: Array<[number, number, number, number]> = [ - [140, 252, 305, 224], - [228, 332, 140, 252], - [228, 332, 352, 318], - [262, 410, 228, 332], - ]; - return ( - <> - - - - - - - - - - - - - {links.map(([x1, y1, x2, y2], i) => ( - - ))} - {darts.map(([x, y, rot, s, o], i) => { - const a = ((rot + 90) * Math.PI) / 180; - const len = 26 + s * 30; - const tx1 = f(x + Math.cos(a) * 9 * s); - const ty1 = f(y + Math.sin(a) * 9 * s); - const tx2 = f(x + Math.cos(a) * (9 * s + len)); - const ty2 = f(y + Math.sin(a) * (9 * s + len)); - return ( - - ); - })} - {darts.map(([x, y, rot, s, o], i) => ( - - ))} - - - - -
- - ); -} - -// A ringed sphere with a moon over a perspective grid plain. -function GameArt() { - const horiz: Array<[number, number]> = [ - [514, 0.35], - [521, 0.3], - [531, 0.26], - [545, 0.22], - [564, 0.18], - [590, 0.14], - [624, 0.11], - [668, 0.08], - ]; - const radial = [-140, -40, 40, 120, 200, 250, 300, 380, 460, 540, 640]; - return ( - <> - - - - - - - - - - - - - - - - - - - - {horiz.map(([y, o], i) => ( - - ))} - {radial.map((x, i) => ( - - ))} - - - - - ); -} - -// Five identical sealed vaults, each under its own beam of light. -function TenantsArt() { - const centers = [90, 170, 250, 330, 410]; - return ( - <> - - - - - - - - - - - - - {centers.map((cx) => ( - - ))} - {centers.map((cx) => { - const x = cx - 16; - return ( - - - - - - - ); - })} - - {centers.map((cx) => ( - - ))} - - - - ); -} - -const FALLBACK_PALETTES = [ - { - sky: [ - [0, "#050b11"], - [0.45, "#0c1c28"], - [0.78, "#173448"], - [1, "#22506b"], - ] as Stop[], - body: "#9cc4de", - glow: "#7fb2d4", - haze: "#6f9cc4", - sea: [ - [0, "#13283a"], - [0.5, "#071420"], - [1, "#010204"], - ] as Stop[], - glint: "#2e4f6b", - horizon: "#9dc1dd", - }, - { - sky: [ - [0, "#0a0508"], - [0.45, "#1d0d18"], - [0.78, "#3a1a30"], - [1, "#5b2a48"], - ] as Stop[], - body: "#e0a8c8", - glow: "#c486ab", - haze: "#b07697", - sea: [ - [0, "#311527"], - [0.5, "#130810"], - [1, "#030102"], - ] as Stop[], - glint: "#5e3049", - horizon: "#dba4c4", - }, - { - sky: [ - [0, "#0b0903"], - [0.45, "#1f1a08"], - [0.78, "#3d3411"], - [1, "#5e511c"], - ] as Stop[], - body: "#e6d49a", - glow: "#cdb878", - haze: "#b3a065", - sea: [ - [0, "#2e2810"], - [0.5, "#120f06"], - [1, "#020201"], - ] as Stop[], - glint: "#5c5126", - horizon: "#dccb93", - }, - { - sky: [ - [0, "#040810"], - [0.45, "#0d1430"], - [0.78, "#1b2756"], - [1, "#2c3c7a"], - ] as Stop[], - body: "#aebcf0", - glow: "#8d9fdd", - haze: "#7c8cc8", - sea: [ - [0, "#18204a"], - [0.5, "#0a0d20"], - [1, "#020205"], - ] as Stop[], - glint: "#36406e", - horizon: "#a9b8ea", - }, -]; - -function hashSlug(slug: string): number { - let h = 0; - for (let i = 0; i < slug.length; i++) { - h = (h * 31 + slug.charCodeAt(i)) | 0; - } - return Math.abs(h); -} - -// Generic floating orb scene for entries without bespoke artwork. -function FallbackArt({ slug }: { slug: string }) { - const hash = hashSlug(slug); - const palette = FALLBACK_PALETTES[hash % FALLBACK_PALETTES.length]; - const id = (part: string) => `fb-${slug}-${part}`; - return ( - <> - - - - - - - - - - - - - - - - - - - -
- - ); -} - -const COVER_ART: Record ReactNode> = { - "ai-agent": AiAgentArt, - "ai-agent-workspace": WorkspacesArt, - "chat-room": ChatRoomArt, - "collaborative-text-editor": CollabEditorArt, - "cron-jobs": CronArt, - "live-cursors": CursorsArt, - "multiplayer-game": GameArt, - "per-tenant-database": TenantsArt, -}; - -function RivetMark({ className }: { className?: string }) { - return ( - - ); -} - -export interface CookbookCoverProps { - slug: string; - title: string; - numeral: string; -} - -// Renders the full poster inside a parent that must be `relative`, -// `overflow-hidden`, sized to aspect 5/7, have `container-type: inline-size`, -// and carry the `group` class for the hover treatment. -export function CookbookCover({ slug, title, numeral }: CookbookCoverProps) { - const Art = COVER_ART[slug]; - const longTitle = title.length > 18; - return ( - <> - -
- -

- {title} -

-
-
- -
- - ); -} diff --git a/website/src/components/cookbook/CookbookPageContent.tsx b/website/src/components/cookbook/CookbookPageContent.tsx index 0946b54337..0a108930e0 100644 --- a/website/src/components/cookbook/CookbookPageContent.tsx +++ b/website/src/components/cookbook/CookbookPageContent.tsx @@ -1,10 +1,10 @@ "use client"; import { useMemo, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; import { TECHNOLOGIES, TAGS } from "@/data/templates/shared"; import { CookbookCard, type CookbookPageCardData } from "./CookbookCard"; -import { CookbookCoverDefs, COVER_SERIF, toRoman } from "./CookbookCover"; export interface CookbookPageListItem extends CookbookPageCardData { tags: string[]; @@ -109,37 +109,18 @@ export function CookbookPageContent({ pages, allTags, allTechnologies }: Cookboo const hasActiveFilters = selectedTags.length > 0 || selectedTechnologies.length > 0 || searchQuery.trim().length > 0; - // Numerals come from the full alphabetical list so filtering never renumbers covers. - const numerals = useMemo( - () => new Map(pages.map((p, i) => [p.slug, toRoman(i + 1)])), - [pages], - ); - const toggle = (list: string[], setList: (v: string[]) => void, item: string) => { setList(list.includes(item) ? list.filter((x) => x !== item) : [...list, item]); }; return (
- -
+
-

Rivet

-

+

Cookbooks

-

- Step-by-step guides that build on Rivet Actors, with links to working templates and example code. -

-
-
- -
-
-
+
@@ -182,9 +163,20 @@ export function CookbookPageContent({ pages, allTags, allTechnologies }: Cookboo
No guides found matching your filters
) : (
- {filteredPages.map((page) => ( - - ))} + + {filteredPages.map((page) => ( + + + + ))} +
)}
diff --git a/website/src/data/cookbook/covers.ts b/website/src/data/cookbook/covers.ts new file mode 100644 index 0000000000..593f3918ce --- /dev/null +++ b/website/src/data/cookbook/covers.ts @@ -0,0 +1,88 @@ +// Cover artwork for the cookbook index cards. Every image is a public-domain +// or CC0 artwork hosted on the rivet-assets R2 bucket. Sourcing and license +// details for each work (and verified alternates) are documented in the +// research note "cookbook-cover-image-candidates". +// +// The crop parameters are tuned per artwork so each card centers on the +// painting's focal subject at the tall 5:7 aspect ratio. transform plus +// transformOrigin zooms into a focal point; filter normalizes exposure so the +// covers sit at an even darkness on the black page. + +export interface CookbookCoverArt { + // Artwork title, artist, and date, kept for reference. + artwork: string; + src: string; + width: number; + height: number; + objectPosition?: string; + transform?: string; + transformOrigin?: string; + filter?: string; +} + +export const cookbookCovers: Record = { + "ai-agent": { + artwork: "The Thinker, Auguste Rodin, modeled 1880", + src: "https://assets.rivet.dev/website/images/thinking/thinker.jpg", + width: 3140, + height: 4000, + objectPosition: "50% 28%", + }, + "ai-agent-workspace": { + artwork: + "The Alchymist Discovering Phosphorus, William Pether after Joseph Wright of Derby, 1771", + src: "https://assets.rivet.dev/website/images/cookbook/alchymist-discovering-phosphorus.jpg", + width: 2679, + height: 3400, + transform: "scale(1.55)", + transformOrigin: "26% 74%", + filter: "brightness(1.18)", + }, + "chat-room": { + artwork: "Merry Company on a Terrace, Jan Steen, ca. 1670", + src: "https://assets.rivet.dev/website/images/cookbook/merry-company-on-a-terrace.jpg", + width: 3556, + height: 3829, + transform: "scale(1.12)", + transformOrigin: "45% 42%", + filter: "brightness(0.95)", + }, + "collaborative-text-editor": { + artwork: "Saint Matthew Writing His Gospel, Carlo Dolci, 1640s", + src: "https://assets.rivet.dev/website/images/cookbook/saint-matthew-writing-his-gospel.jpg", + width: 2400, + height: 2897, + objectPosition: "45% 22%", + transform: "scale(1.12)", + }, + "cron-jobs": { + artwork: "The November Meteors, Etienne Leopold Trouvelot, 1881-82", + src: "https://assets.rivet.dev/website/images/cookbook/november-meteors.jpg", + width: 1994, + height: 2560, + objectPosition: "50% 42%", + transform: "scale(1.22)", + }, + "live-cursors": { + artwork: "Curiosity, Gerard ter Borch the Younger, ca. 1660-62", + src: "https://assets.rivet.dev/website/images/thinking/think.jpg", + width: 2935, + height: 3638, + objectPosition: "50% 30%", + }, + "multiplayer-game": { + artwork: "The Card Players, Paul Cezanne, 1890-92", + src: "https://assets.rivet.dev/website/images/cookbook/the-card-players.jpg", + width: 3909, + height: 3112, + objectPosition: "50% 35%", + }, + "per-tenant-database": { + artwork: "Dolls' house of Petronella Oortman, c. 1686-1710", + src: "https://assets.rivet.dev/website/images/cookbook/dollhouse-petronella-oortman.jpg", + width: 2400, + height: 3054, + objectPosition: "50% 40%", + filter: "brightness(0.95)", + }, +}; diff --git a/website/src/pages/cookbook/[...slug].astro b/website/src/pages/cookbook/[...slug].astro index 5816a862ca..0f3033b910 100644 --- a/website/src/pages/cookbook/[...slug].astro +++ b/website/src/pages/cookbook/[...slug].astro @@ -1,7 +1,9 @@ --- import MarketingLayout from "@/layouts/MarketingLayout.astro"; import { getCollection, render } from "astro:content"; +import { getImage } from "astro:assets"; import { templates as allTemplates, type Template } from "@/data/templates/shared"; +import { cookbookCovers } from "@/data/cookbook/covers"; import * as mdxComponents from "@/components/mdx"; import { jsonLdString } from "@/lib/jsonLd"; @@ -99,6 +101,25 @@ function resolveTemplates(names?: string[]): Array