|
1 | 1 | // Slug-derived gradient used as a muted cover-image placeholder for blog |
2 | 2 | // 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. |
4 | 5 |
|
5 | 6 | type Blob = { |
6 | 7 | cx: number |
7 | 8 | cy: number |
| 9 | + rx: number |
| 10 | + ry: number |
8 | 11 | hue: number |
9 | 12 | sat: number |
10 | 13 | light: number |
11 | | - size: number |
12 | 14 | 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 | + ] |
13 | 47 | } |
14 | 48 |
|
15 | 49 | const PALETTES: Array<Array<[number, number, number]>> = [ |
@@ -98,30 +132,95 @@ function rng(seed: number): () => number { |
98 | 132 | } |
99 | 133 | } |
100 | 134 |
|
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> { |
102 | 173 | const seed = hash(slug || 'fallback') |
103 | 174 | 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%)` |
114 | 212 | } |
115 | 213 |
|
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(', ') |
123 | 221 | } |
124 | 222 |
|
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)) |
127 | 226 | } |
0 commit comments