Skip to content

Commit bf50295

Browse files
committed
Add responsive scientific hero artwork
1 parent d142121 commit bf50295

11 files changed

Lines changed: 234 additions & 12 deletions
1.46 MB
Loading

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"version": "0.0.0",
55
"type": "module",
66
"scripts": {
7+
"assets:hero": "node scripts/build-hero-art.mjs",
78
"assets:partners": "node scripts/build-partner-logos.mjs",
89
"dev": "vite",
910
"test": "node --test scripts/lib/*.test.js",

‎public/hero/README.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Computational biology hero artwork
2+
3+
The landing-page artwork combines a protein ribbon, a genomic heatmap, and a
4+
coverage/gene track in one transparent 4:3 composition.
5+
6+
- Source master: `assets-src/hero/compbio-research-atlas-light-master.png`
7+
- Generated web assets: `compbio-research-atlas-{light,dark}-{800,1600}.webp`
8+
- Regeneration command: `npm run assets:hero`
9+
10+
The light master was created with OpenAI image generation from a
11+
user-supplied scientific-layout reference on 2026-07-29, then isolated onto a
12+
true alpha channel. The dark assets are deterministic palette conversions of
13+
that same master, so theme changes do not shift the composition. California
14+
gold is preserved while blue and steel tones become pearl and ice blue.
15+
16+
Edit the source master or the conversion script, not the generated WebP files.
452 KB
Loading
183 KB
Loading
558 KB
Loading
207 KB
Loading

‎scripts/build-hero-art.mjs‎

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { mkdir } from "node:fs/promises";
2+
import path from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
import sharp from "sharp";
5+
6+
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
7+
const sourcePath = path.join(
8+
projectRoot,
9+
"assets-src/hero/compbio-research-atlas-light-master.png",
10+
);
11+
const outputDirectory = path.join(projectRoot, "public/hero");
12+
const outputWidths = [800, 1600];
13+
14+
const isGoldPixel = (red, green, blue) =>
15+
red > 125
16+
&& red > blue * 1.18
17+
&& green > blue * 1.08
18+
&& red > green * 1.015;
19+
20+
const clearSafeMargin = async (input) => {
21+
const { data, info } = await sharp(input)
22+
.ensureAlpha()
23+
.raw()
24+
.toBuffer({ resolveWithObject: true });
25+
26+
for (let y = 0; y < info.height; y += 1) {
27+
for (let x = 0; x < info.width; x += 1) {
28+
if (x >= 100 && x < info.width - 100 && y >= 100 && y < info.height - 120) {
29+
continue;
30+
}
31+
32+
const index = (y * info.width + x) * 4;
33+
data[index] = 0;
34+
data[index + 1] = 0;
35+
data[index + 2] = 0;
36+
data[index + 3] = 0;
37+
}
38+
}
39+
40+
return sharp(data, {
41+
raw: {
42+
width: info.width,
43+
height: info.height,
44+
channels: 4,
45+
},
46+
})
47+
.png()
48+
.toBuffer();
49+
};
50+
51+
const buildDarkVariant = async (input) => {
52+
const { data, info } = await sharp(input)
53+
.ensureAlpha()
54+
.raw()
55+
.toBuffer({ resolveWithObject: true });
56+
57+
for (let index = 0; index < data.length; index += 4) {
58+
const red = data[index];
59+
const green = data[index + 1];
60+
const blue = data[index + 2];
61+
const alpha = data[index + 3];
62+
63+
if (alpha === 0) continue;
64+
65+
if (isGoldPixel(red, green, blue)) {
66+
data[index] = Math.min(255, Math.round(red * 1.02));
67+
data[index + 1] = Math.min(255, Math.round(green * 1.03));
68+
data[index + 2] = Math.min(255, Math.round(blue * 0.96));
69+
continue;
70+
}
71+
72+
const luminance = Math.max(
73+
0,
74+
Math.min(1, (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255),
75+
);
76+
const pearl = [247, 243, 232];
77+
const iceBlue = [139, 164, 194];
78+
79+
data[index] = Math.round(pearl[0] * (1 - luminance) + iceBlue[0] * luminance);
80+
data[index + 1] = Math.round(
81+
pearl[1] * (1 - luminance) + iceBlue[1] * luminance,
82+
);
83+
data[index + 2] = Math.round(
84+
pearl[2] * (1 - luminance) + iceBlue[2] * luminance,
85+
);
86+
}
87+
88+
return sharp(data, {
89+
raw: {
90+
width: info.width,
91+
height: info.height,
92+
channels: 4,
93+
},
94+
})
95+
.png()
96+
.toBuffer();
97+
};
98+
99+
const writeVariant = async (variant, input, width) => {
100+
const height = Math.round(width * 0.75);
101+
const outputPath = path.join(
102+
outputDirectory,
103+
`compbio-research-atlas-${variant}-${width}.webp`,
104+
);
105+
106+
await sharp(input)
107+
.resize(width, height, {
108+
fit: "fill",
109+
kernel: sharp.kernel.lanczos3,
110+
withoutEnlargement: true,
111+
})
112+
.webp({
113+
quality: 90,
114+
alphaQuality: 100,
115+
effort: 6,
116+
smartSubsample: true,
117+
})
118+
.toFile(outputPath);
119+
120+
console.log(`Wrote ${path.relative(projectRoot, outputPath)}`);
121+
};
122+
123+
const sourceMaster = await sharp(sourcePath).png().toBuffer();
124+
const metadata = await sharp(sourceMaster).metadata();
125+
126+
if (
127+
metadata.width !== 1600
128+
|| metadata.height !== 1200
129+
|| metadata.hasAlpha !== true
130+
) {
131+
throw new Error(
132+
"Hero master must be a 1600x1200 PNG with a transparent alpha channel.",
133+
);
134+
}
135+
136+
await mkdir(outputDirectory, { recursive: true });
137+
138+
const source = await clearSafeMargin(sourceMaster);
139+
const variants = {
140+
light: source,
141+
dark: await buildDarkVariant(source),
142+
};
143+
144+
for (const [variant, input] of Object.entries(variants)) {
145+
for (const width of outputWidths) {
146+
await writeVariant(variant, input, width);
147+
}
148+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { cn } from "@/lib/utils";
2+
import { publicAssetPath } from "@/lib/publicAsset";
3+
import { useTheme } from "next-themes";
4+
5+
const heroAssetPath = (theme: "light" | "dark", width: 800 | 1600) =>
6+
publicAssetPath(`/hero/compbio-research-atlas-${theme}-${width}.webp`);
7+
8+
const heroImageProps = {
9+
alt: "",
10+
width: 1600,
11+
height: 1200,
12+
decoding: "async" as const,
13+
draggable: false,
14+
loading: "eager" as const,
15+
sizes: "(min-width: 1280px) 640px, (min-width: 1024px) 52vw, 92vw",
16+
};
17+
18+
interface HeroScienceGraphicProps {
19+
className?: string;
20+
}
21+
22+
export const HeroScienceGraphic = ({ className }: HeroScienceGraphicProps) => {
23+
const { resolvedTheme } = useTheme();
24+
const documentTheme =
25+
typeof document !== "undefined" && document.documentElement.classList.contains("dark")
26+
? "dark"
27+
: "light";
28+
const theme = resolvedTheme === "dark" || (!resolvedTheme && documentTheme === "dark")
29+
? "dark"
30+
: "light";
31+
32+
return (
33+
<span
34+
className={cn("hero-science-art block", className)}
35+
aria-hidden="true"
36+
>
37+
<img
38+
{...heroImageProps}
39+
src={heroAssetPath(theme, 1600)}
40+
srcSet={`${heroAssetPath(theme, 800)} 800w, ${heroAssetPath(theme, 1600)} 1600w`}
41+
className="h-auto w-full select-none object-contain"
42+
/>
43+
</span>
44+
);
45+
};

‎src/index.css‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,15 @@
191191
background: transparent;
192192
}
193193

194-
.hero-logo-plate {
195-
background: transparent;
194+
.hero-science-art {
195+
transform-origin: center;
196+
filter: drop-shadow(0 18px 26px hsl(var(--primary) / 0.1));
197+
animation: hero-science-drift 10s ease-in-out infinite;
198+
will-change: transform;
199+
}
200+
201+
.dark .hero-science-art {
202+
filter: drop-shadow(0 20px 30px hsl(var(--primary) / 0.12));
196203
}
197204

198205
.site-logo-system-dark {
@@ -265,6 +272,17 @@
265272
}
266273
}
267274

275+
@keyframes hero-science-drift {
276+
0%,
277+
100% {
278+
transform: translate3d(0, 0, 0);
279+
}
280+
281+
50% {
282+
transform: translate3d(0, -5px, 0);
283+
}
284+
}
285+
268286
@media (prefers-reduced-motion: reduce) {
269287
html {
270288
scroll-behavior: auto;

0 commit comments

Comments
 (0)