Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/components/BlogCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,11 @@ export function BlogCard({ post, showLibraryBadges = true }: BlogCardProps) {
/>
</div>
) : (
<CoverFallback slug={slug} className="aspect-video w-full" />
<CoverFallback
slug={slug}
library={library}
className="aspect-video w-full"
/>
)}
<div className="p-4 md:p-8 flex flex-col gap-4 flex-1 justify-between">
<div>
Expand Down
10 changes: 8 additions & 2 deletions src/components/CoverFallback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,17 @@ import { gradientBackgroundCss } from '~/utils/ogGradient'

type CoverFallbackProps = {
slug: string
library?: string
className?: string
style?: React.CSSProperties
}

export function CoverFallback({ slug, className, style }: CoverFallbackProps) {
export function CoverFallback({
slug,
library,
className,
style,
}: CoverFallbackProps) {
return (
<div
aria-hidden="true"
Expand All @@ -17,7 +23,7 @@ export function CoverFallback({ slug, className, style }: CoverFallbackProps) {
)}
style={{
...style,
backgroundImage: gradientBackgroundCss(slug),
backgroundImage: gradientBackgroundCss(slug, library),
}}
/>
)
Expand Down
3 changes: 2 additions & 1 deletion src/routes/blog.$.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export const Route = createFileRoute('/blog/$')({
})

function BlogPost() {
const { contentRsc, filePath, headings, title, headerImage } =
const { contentRsc, filePath, headings, title, headerImage, library } =
Route.useLoaderData()
const { _splat: slug } = Route.useParams()

Expand Down Expand Up @@ -161,6 +161,7 @@ function BlogPost() {
{!headerImage && slug ? (
<CoverFallback
slug={slug}
library={library}
className="aspect-[5/2] w-full rounded-2xl mb-6"
/>
) : null}
Expand Down
1 change: 1 addition & 0 deletions src/utils/blog.functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ ${post.content}`
headings,
headerImage: post.headerImage,
isUnpublished,
library: post.library,
published: post.published,
title: post.title,
}
Expand Down
143 changes: 121 additions & 22 deletions src/utils/ogGradient.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,49 @@
// Slug-derived gradient used as a muted cover-image placeholder for blog
// posts that ship without a header image. Same input slug always renders
// the same gradient.
// the same gradient. When a library id is provided, the palette is built
// around that library's primary hue so posts feel branded.

type Blob = {
cx: number
cy: number
rx: number
ry: number
hue: number
sat: number
light: number
size: number
alpha: number
stop: number
}

// Base hues per library, chosen to match each library's primary brand color.
// Libraries without a clear chromatic color (ranger, config, devtools, mcp)
// are omitted and fall back to the slug-derived palette.
const LIBRARY_HUES: Record<string, number> = {
query: 0, // red → amber
router: 150, // emerald → lime
start: 180, // teal → cyan
table: 200, // cyan → blue
form: 50, // yellow
virtual: 270, // purple → violet
store: 30, // twine
pacer: 80, // lime
hotkeys: 350, // rose
db: 25, // orange
ai: 330, // pink
intent: 200, // sky
cli: 250, // indigo → violet
}

// Palette mixes hue offsets with lightness/saturation variation so adjacent
// blobs read as separate "regions" rather than blending into one wash.
function paletteFromHue(hue: number): Array<[number, number, number]> {
return [
[(hue - 30 + 360) % 360, 38, 52],
[(hue - 12 + 360) % 360, 30, 70],
[(hue + 360) % 360, 42, 58],
[(hue + 18) % 360, 28, 72],
[(hue + 36) % 360, 40, 55],
]
}

const PALETTES: Array<Array<[number, number, number]>> = [
Expand Down Expand Up @@ -98,30 +132,95 @@ function rng(seed: number): () => number {
}
}

function blobsFor(slug: string): Array<Blob> {
function paletteFor(
slug: string,
library?: string,
): Array<[number, number, number]> {
if (library) {
const firstId = library.split(',')[0]?.trim()
const baseHue = firstId ? LIBRARY_HUES[firstId] : undefined
if (baseHue !== undefined) {
return paletteFromHue(baseHue)
}
}
const seed = hash(slug || 'fallback')
return PALETTES[seed % PALETTES.length]
}

// Two layers of blob anchors: large "wash" blobs cover the canvas with
// soft color, and smaller "accent" blobs add organic punch on top. Both
// layers get jittered + asymmetric ellipse radii so no two posts look the
// same and shapes feel hand-placed rather than radially centered.
type Anchor = { cx: number; cy: number; kind: 'wash' | 'accent' }

const ANCHORS: Array<Anchor> = [
{ cx: 18, cy: 22, kind: 'wash' },
{ cx: 78, cy: 18, kind: 'wash' },
{ cx: 25, cy: 78, kind: 'wash' },
{ cx: 72, cy: 82, kind: 'wash' },
{ cx: 50, cy: 12, kind: 'accent' },
{ cx: 8, cy: 88, kind: 'accent' },
{ cx: 88, cy: 92, kind: 'accent' },
{ cx: 42, cy: 38, kind: 'accent' },
{ cx: 60, cy: 65, kind: 'accent' },
{ cx: 32, cy: 92, kind: 'accent' },
]

// Containers using this gradient are wide (5:2 or 16:9), so percentage-based
// ellipse radii get visually squished horizontally. We bias ry > rx so blobs
// read as roughly circular rather than as horizontal bands.
function blobsFor(slug: string, library?: string): Array<Blob> {
const seed = hash(slug || 'fallback')
const rand = rng(seed)
const palette = PALETTES[seed % PALETTES.length]
return palette.map(([hue, sat, light]) => ({
cx: 5 + rand() * 90,
cy: 5 + rand() * 90,
hue,
sat,
light,
size: 55 + Math.floor(rand() * 25),
alpha: 0.4 + rand() * 0.15,
}))
const palette = paletteFor(slug, library)
return ANCHORS.map((anchor, i) => {
const [baseHue, baseSat, baseLight] = palette[i % palette.length]
const hueJitter = (rand() - 0.5) * 14
if (anchor.kind === 'wash') {
const rx = 60 + rand() * 25
return {
cx: anchor.cx + (rand() - 0.5) * 24,
cy: anchor.cy + (rand() - 0.5) * 24,
rx,
ry: rx * (1.5 + rand() * 0.6),
hue: (baseHue + hueJitter + 360) % 360,
sat: baseSat,
light: baseLight,
alpha: 0.6 + rand() * 0.2,
stop: 95 + rand() * 25,
}
}
const rx = 32 + rand() * 18
return {
cx: anchor.cx + (rand() - 0.5) * 18,
cy: anchor.cy + (rand() - 0.5) * 18,
rx,
ry: rx * (1.4 + rand() * 0.6),
hue: (baseHue + hueJitter + 360) % 360,
sat: baseSat,
light: baseLight,
alpha: 0.65 + rand() * 0.2,
stop: 85 + rand() * 20,
}
})
}

function baseTintCss(palette: Array<[number, number, number]>): string {
const [h1, s1, l1] = palette[0]
const [h2, s2, l2] = palette[Math.floor(palette.length / 2)]
return `linear-gradient(135deg, hsla(${h1}, ${s1}%, ${Math.max(35, l1 - 8)}%, 0.35) 0%, hsla(${h2}, ${s2}%, ${Math.max(35, l2 - 8)}%, 0.35) 100%)`
}

function blobsToCss(blobs: Array<Blob>): string {
return blobs
.map(
(b) =>
`radial-gradient(circle at ${b.cx.toFixed(2)}% ${b.cy.toFixed(2)}%, hsla(${b.hue}, ${b.sat}%, ${b.light}%, ${b.alpha.toFixed(2)}) 0%, hsla(${b.hue}, ${b.sat}%, ${b.light}%, 0) ${b.size}%)`,
)
.join(', ')
function blobsToCss(blobs: Array<Blob>, tint: string): string {
const layers = blobs.map(
(b) =>
`radial-gradient(ellipse ${b.rx.toFixed(1)}% ${b.ry.toFixed(1)}% at ${b.cx.toFixed(2)}% ${b.cy.toFixed(2)}%, hsla(${b.hue.toFixed(1)}, ${b.sat.toFixed(1)}%, ${b.light.toFixed(1)}%, ${b.alpha.toFixed(2)}) 0%, hsla(${b.hue.toFixed(1)}, ${b.sat.toFixed(1)}%, ${b.light.toFixed(1)}%, 0) ${b.stop.toFixed(1)}%)`,
)
// The base tint sits underneath so edges never wash out to the wrapper bg.
return [...layers, tint].join(', ')
}

export function gradientBackgroundCss(slug: string): string {
return blobsToCss(blobsFor(slug))
export function gradientBackgroundCss(slug: string, library?: string): string {
const palette = paletteFor(slug, library)
return blobsToCss(blobsFor(slug, library), baseTintCss(palette))
}
Loading