Skip to content

Commit 2a2cd38

Browse files
committed
Add subtle starfield to homepage hero
1 parent 0a9d3b2 commit 2a2cd38

1 file changed

Lines changed: 104 additions & 2 deletions

File tree

‎src/pages/index.astro‎

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ const reports = (await getCollection('reports'))
1313
bodyClass="home-page"
1414
>
1515
<section class="minimal-hero">
16-
<div>
16+
<canvas class="starfield" aria-hidden="true"></canvas>
17+
<div class="hero-copy">
1718
<div class="overline">Accelerated systems engineering</div>
1819
<h1>Open systems,<br /><span>accelerated.</span></h1>
1920
<p class="intro">GPU software adaptation and performance research, published with reproducible evidence.</p>
@@ -79,8 +80,109 @@ const reports = (await getCollection('reports'))
7980
</section>
8081
</BaseLayout>
8182

83+
<script>
84+
const canvas = document.querySelector<HTMLCanvasElement>('.starfield');
85+
const hero = canvas?.closest<HTMLElement>('.minimal-hero');
86+
const context = canvas?.getContext('2d');
87+
88+
if (canvas && hero && context) {
89+
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
90+
let width = 0;
91+
let height = 0;
92+
let stars: { x: number; y: number; radius: number; phase: number; color: string }[] = [];
93+
let animationFrame = 0;
94+
95+
const resize = () => {
96+
window.cancelAnimationFrame(animationFrame);
97+
const bounds = hero.getBoundingClientRect();
98+
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
99+
width = bounds.width;
100+
height = bounds.height;
101+
canvas.width = Math.round(width * pixelRatio);
102+
canvas.height = Math.round(height * pixelRatio);
103+
context!.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
104+
105+
const count = Math.max(72, Math.round((width * height) / 6200));
106+
stars = Array.from({ length: count }, () => ({
107+
x: Math.random() * width,
108+
y: Math.random() * height,
109+
radius: Math.random() * 1.25 + 0.35,
110+
phase: Math.random() * Math.PI * 2,
111+
color: Math.random() > 0.82 ? '155,234,52' : Math.random() > 0.5 ? '178,220,255' : '232,242,255',
112+
}));
113+
draw(0);
114+
};
115+
116+
const draw = (time: number) => {
117+
context!.clearRect(0, 0, width, height);
118+
119+
// Connect only nearby stars to form sparse, quiet constellations.
120+
for (let i = 0; i < stars.length; i += 1) {
121+
const a = stars[i];
122+
for (let j = i + 1; j < stars.length; j += 1) {
123+
const b = stars[j];
124+
const dx = a.x - b.x;
125+
const dy = a.y - b.y;
126+
const distance = Math.hypot(dx, dy);
127+
if (distance < 92) {
128+
context!.strokeStyle = `rgba(171, 205, 224, ${(1 - distance / 92) * 0.12})`;
129+
context!.lineWidth = 0.6;
130+
context!.beginPath();
131+
context!.moveTo(a.x, a.y);
132+
context!.lineTo(b.x, b.y);
133+
context!.stroke();
134+
}
135+
}
136+
}
137+
138+
stars.forEach((star) => {
139+
const twinkle = reducedMotion.matches ? 0.72 : 0.48 + Math.sin(time * 0.0007 + star.phase) * 0.24;
140+
context!.beginPath();
141+
context!.fillStyle = `rgba(${star.color}, ${twinkle})`;
142+
context!.shadowColor = `rgba(${star.color}, ${twinkle * 0.55})`;
143+
context!.shadowBlur = star.radius * 5;
144+
context!.arc(star.x, star.y, star.radius, 0, Math.PI * 2);
145+
context!.fill();
146+
});
147+
context!.shadowBlur = 0;
148+
149+
// A small periodic meteor adds motion without competing with the headline.
150+
if (!reducedMotion.matches) {
151+
const cycle = (time % 9200) / 9200;
152+
if (cycle > 0.68 && cycle < 0.76) {
153+
const progress = (cycle - 0.68) / 0.08;
154+
const x = width * (0.72 - progress * 0.42);
155+
const y = height * (0.12 + progress * 0.28);
156+
const trail = context!.createLinearGradient(x + 90, y - 58, x, y);
157+
trail.addColorStop(0, 'rgba(155,234,52,0)');
158+
trail.addColorStop(1, 'rgba(206,246,255,0.78)');
159+
context!.strokeStyle = trail;
160+
context!.lineWidth = 1.2;
161+
context!.beginPath();
162+
context!.moveTo(x + 90, y - 58);
163+
context!.lineTo(x, y);
164+
context!.stroke();
165+
}
166+
}
167+
168+
if (!reducedMotion.matches) animationFrame = window.requestAnimationFrame(draw);
169+
};
170+
171+
resize();
172+
const observer = new ResizeObserver(resize);
173+
observer.observe(hero);
174+
reducedMotion.addEventListener('change', () => {
175+
window.cancelAnimationFrame(animationFrame);
176+
draw(0);
177+
});
178+
}
179+
</script>
180+
82181
<style>
83-
.minimal-hero { min-height: 540px; display: grid; grid-template-columns: minmax(0, 1fr) 250px; gap: 64px; align-items: end; padding: 74px 0 54px; }
182+
.minimal-hero { position: relative; isolation: isolate; min-height: 540px; display: grid; grid-template-columns: minmax(0, 1fr) 250px; gap: 64px; align-items: end; padding: 74px 0 54px; overflow: hidden; }
183+
.minimal-hero::before { position: absolute; z-index: -1; inset: -8% -8% 2% 36%; content: ""; pointer-events: none; background: radial-gradient(ellipse at 68% 40%, rgba(45,91,121,.16), transparent 55%), radial-gradient(ellipse at 76% 34%, rgba(155,234,52,.08), transparent 42%); }
184+
.starfield { position: absolute; z-index: -1; inset: 0; width: 100%; height: 100%; pointer-events: none; }
185+
.hero-copy, .profile, .signal-line { position: relative; z-index: 1; }
84186
.overline { color: var(--accent); font: 700 10px/1.4 monospace; letter-spacing: .18em; text-transform: uppercase; }
85187
.minimal-hero h1 { max-width: 900px; margin: 20px 0 24px; font-size: clamp(58px, 8vw, 112px); font-weight: 610; line-height: .88; letter-spacing: -.075em; }
86188
.minimal-hero h1 span { color: var(--muted); }

0 commit comments

Comments
 (0)