Skip to content

Commit 61e808c

Browse files
committed
feat(blog): theme cover-fallback gradients by library
Blog posts without a header image now generate a gradient palette around their library's primary hue (Virtual → purple, Query → red, etc.) instead of picking one of eight slug-hashed palettes. Posts without a `library` frontmatter keep the existing slug-hash behavior. Also reworked the blob generator so gradients read as soft watercolor washes: bigger radii with ry biased > rx to compensate for wide containers, anchors scattered across the canvas with per-slug jitter, and soft falloff stops so blobs blend into each other rather than rendering as discrete circles. A linear base tint sits underneath so edges never wash out to the wrapper background.
1 parent 8faf458 commit 61e808c

5 files changed

Lines changed: 137 additions & 26 deletions

File tree

src/components/BlogCard.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,11 @@ export function BlogCard({ post, showLibraryBadges = true }: BlogCardProps) {
5858
/>
5959
</div>
6060
) : (
61-
<CoverFallback slug={slug} className="aspect-video w-full" />
61+
<CoverFallback
62+
slug={slug}
63+
library={library}
64+
className="aspect-video w-full"
65+
/>
6266
)}
6367
<div className="p-4 md:p-8 flex flex-col gap-4 flex-1 justify-between">
6468
<div>

src/components/CoverFallback.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,17 @@ import { gradientBackgroundCss } from '~/utils/ogGradient'
33

44
type CoverFallbackProps = {
55
slug: string
6+
library?: string
67
className?: string
78
style?: React.CSSProperties
89
}
910

10-
export function CoverFallback({ slug, className, style }: CoverFallbackProps) {
11+
export function CoverFallback({
12+
slug,
13+
library,
14+
className,
15+
style,
16+
}: CoverFallbackProps) {
1117
return (
1218
<div
1319
aria-hidden="true"
@@ -17,7 +23,7 @@ export function CoverFallback({ slug, className, style }: CoverFallbackProps) {
1723
)}
1824
style={{
1925
...style,
20-
backgroundImage: gradientBackgroundCss(slug),
26+
backgroundImage: gradientBackgroundCss(slug, library),
2127
}}
2228
/>
2329
)

src/routes/blog.$.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export const Route = createFileRoute('/blog/$')({
6262
})
6363

6464
function BlogPost() {
65-
const { contentRsc, filePath, headings, title, headerImage } =
65+
const { contentRsc, filePath, headings, title, headerImage, library } =
6666
Route.useLoaderData()
6767
const { _splat: slug } = Route.useParams()
6868

@@ -161,6 +161,7 @@ function BlogPost() {
161161
{!headerImage && slug ? (
162162
<CoverFallback
163163
slug={slug}
164+
library={library}
164165
className="aspect-[5/2] w-full rounded-2xl mb-6"
165166
/>
166167
) : null}

src/utils/blog.functions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ ${post.content}`
105105
headings,
106106
headerImage: post.headerImage,
107107
isUnpublished,
108+
library: post.library,
108109
published: post.published,
109110
title: post.title,
110111
}

src/utils/ogGradient.ts

Lines changed: 121 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,49 @@
11
// Slug-derived gradient used as a muted cover-image placeholder for blog
22
// posts that ship without a header image. Same input slug always renders
3-
// the same gradient.
3+
// the same gradient. When a library id is provided, the palette is built
4+
// around that library's primary hue so posts feel branded.
45

56
type Blob = {
67
cx: number
78
cy: number
9+
rx: number
10+
ry: number
811
hue: number
912
sat: number
1013
light: number
11-
size: number
1214
alpha: number
15+
stop: number
16+
}
17+
18+
// Base hues per library, chosen to match each library's primary brand color.
19+
// Libraries without a clear chromatic color (ranger, config, devtools, mcp)
20+
// are omitted and fall back to the slug-derived palette.
21+
const LIBRARY_HUES: Record<string, number> = {
22+
query: 0, // red → amber
23+
router: 150, // emerald → lime
24+
start: 180, // teal → cyan
25+
table: 200, // cyan → blue
26+
form: 50, // yellow
27+
virtual: 270, // purple → violet
28+
store: 30, // twine
29+
pacer: 80, // lime
30+
hotkeys: 350, // rose
31+
db: 25, // orange
32+
ai: 330, // pink
33+
intent: 200, // sky
34+
cli: 250, // indigo → violet
35+
}
36+
37+
// Palette mixes hue offsets with lightness/saturation variation so adjacent
38+
// blobs read as separate "regions" rather than blending into one wash.
39+
function paletteFromHue(hue: number): Array<[number, number, number]> {
40+
return [
41+
[(hue - 30 + 360) % 360, 38, 52],
42+
[(hue - 12 + 360) % 360, 30, 70],
43+
[(hue + 360) % 360, 42, 58],
44+
[(hue + 18) % 360, 28, 72],
45+
[(hue + 36) % 360, 40, 55],
46+
]
1347
}
1448

1549
const PALETTES: Array<Array<[number, number, number]>> = [
@@ -98,30 +132,95 @@ function rng(seed: number): () => number {
98132
}
99133
}
100134

101-
function blobsFor(slug: string): Array<Blob> {
135+
function paletteFor(
136+
slug: string,
137+
library?: string,
138+
): Array<[number, number, number]> {
139+
if (library) {
140+
const firstId = library.split(',')[0]?.trim()
141+
const baseHue = firstId ? LIBRARY_HUES[firstId] : undefined
142+
if (baseHue !== undefined) {
143+
return paletteFromHue(baseHue)
144+
}
145+
}
146+
const seed = hash(slug || 'fallback')
147+
return PALETTES[seed % PALETTES.length]
148+
}
149+
150+
// Two layers of blob anchors: large "wash" blobs cover the canvas with
151+
// soft color, and smaller "accent" blobs add organic punch on top. Both
152+
// layers get jittered + asymmetric ellipse radii so no two posts look the
153+
// same and shapes feel hand-placed rather than radially centered.
154+
type Anchor = { cx: number; cy: number; kind: 'wash' | 'accent' }
155+
156+
const ANCHORS: Array<Anchor> = [
157+
{ cx: 18, cy: 22, kind: 'wash' },
158+
{ cx: 78, cy: 18, kind: 'wash' },
159+
{ cx: 25, cy: 78, kind: 'wash' },
160+
{ cx: 72, cy: 82, kind: 'wash' },
161+
{ cx: 50, cy: 12, kind: 'accent' },
162+
{ cx: 8, cy: 88, kind: 'accent' },
163+
{ cx: 88, cy: 92, kind: 'accent' },
164+
{ cx: 42, cy: 38, kind: 'accent' },
165+
{ cx: 60, cy: 65, kind: 'accent' },
166+
{ cx: 32, cy: 92, kind: 'accent' },
167+
]
168+
169+
// Containers using this gradient are wide (5:2 or 16:9), so percentage-based
170+
// ellipse radii get visually squished horizontally. We bias ry > rx so blobs
171+
// read as roughly circular rather than as horizontal bands.
172+
function blobsFor(slug: string, library?: string): Array<Blob> {
102173
const seed = hash(slug || 'fallback')
103174
const rand = rng(seed)
104-
const palette = PALETTES[seed % PALETTES.length]
105-
return palette.map(([hue, sat, light]) => ({
106-
cx: 5 + rand() * 90,
107-
cy: 5 + rand() * 90,
108-
hue,
109-
sat,
110-
light,
111-
size: 55 + Math.floor(rand() * 25),
112-
alpha: 0.4 + rand() * 0.15,
113-
}))
175+
const palette = paletteFor(slug, library)
176+
return ANCHORS.map((anchor, i) => {
177+
const [baseHue, baseSat, baseLight] = palette[i % palette.length]
178+
const hueJitter = (rand() - 0.5) * 14
179+
if (anchor.kind === 'wash') {
180+
const rx = 60 + rand() * 25
181+
return {
182+
cx: anchor.cx + (rand() - 0.5) * 24,
183+
cy: anchor.cy + (rand() - 0.5) * 24,
184+
rx,
185+
ry: rx * (1.5 + rand() * 0.6),
186+
hue: (baseHue + hueJitter + 360) % 360,
187+
sat: baseSat,
188+
light: baseLight,
189+
alpha: 0.6 + rand() * 0.2,
190+
stop: 95 + rand() * 25,
191+
}
192+
}
193+
const rx = 32 + rand() * 18
194+
return {
195+
cx: anchor.cx + (rand() - 0.5) * 18,
196+
cy: anchor.cy + (rand() - 0.5) * 18,
197+
rx,
198+
ry: rx * (1.4 + rand() * 0.6),
199+
hue: (baseHue + hueJitter + 360) % 360,
200+
sat: baseSat,
201+
light: baseLight,
202+
alpha: 0.65 + rand() * 0.2,
203+
stop: 85 + rand() * 20,
204+
}
205+
})
206+
}
207+
208+
function baseTintCss(palette: Array<[number, number, number]>): string {
209+
const [h1, s1, l1] = palette[0]
210+
const [h2, s2, l2] = palette[Math.floor(palette.length / 2)]
211+
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%)`
114212
}
115213

116-
function blobsToCss(blobs: Array<Blob>): string {
117-
return blobs
118-
.map(
119-
(b) =>
120-
`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}%)`,
121-
)
122-
.join(', ')
214+
function blobsToCss(blobs: Array<Blob>, tint: string): string {
215+
const layers = blobs.map(
216+
(b) =>
217+
`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)}%)`,
218+
)
219+
// The base tint sits underneath so edges never wash out to the wrapper bg.
220+
return [...layers, tint].join(', ')
123221
}
124222

125-
export function gradientBackgroundCss(slug: string): string {
126-
return blobsToCss(blobsFor(slug))
223+
export function gradientBackgroundCss(slug: string, library?: string): string {
224+
const palette = paletteFor(slug, library)
225+
return blobsToCss(blobsFor(slug, library), baseTintCss(palette))
127226
}

0 commit comments

Comments
 (0)