feat: Implement HeroSection with dynamic components and animations

This commit is contained in:
2026-02-22 23:20:48 +05:30
parent 02cc795ae2
commit c3e31fc642
5 changed files with 376 additions and 17 deletions

View File

@@ -1,4 +1,7 @@
import { Activity } from 'lucide-react';
import { Activity, LaptopMinimalCheck } from 'lucide-react';
import { RevolvingPrism } from './prism/RevolvingPrism';
import { HeroTitle } from './HeroTitle';
import './prism/styles.css';
type HeroSectionProps = {
activeThemeName: string;
@@ -15,43 +18,82 @@ export function HeroSection({
}: HeroSectionProps) {
return (
<header className="flex flex-col md:flex-row gap-12 items-center justify-between relative">
<div className="space-y-6 max-w-2xl">
<div className="relative space-y-6 max-w-2xl">
<div className="absolute inset-x-0 -top-10 flex justify-end md:hidden -z-10">
<div className="relative w-48 h-56 sketch-border bg-(--bg-panel) transform rotate-3 opacity-70">
<div
className="absolute inset-2 border-2 border-dashed border-(--border-main) overflow-hidden"
style={{
WebkitMaskImage: 'linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.2) 30%, rgba(0, 0, 0, 1) 85%)',
maskImage: 'linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.2) 30%, rgba(0, 0, 0, 1) 85%)',
}}
>
<img
src="/img/operator.jpg"
alt="Operator portrait"
className="h-full w-full object-cover filter grayscale contrast-125 brightness-90"
loading="lazy"
/>
<div
className="absolute inset-0 opacity-70"
style={{
background: 'linear-gradient(135deg, transparent 10%, var(--color-accent) 100%)',
mixBlendMode: 'multiply',
}}
/>
<div
className="absolute inset-0 opacity-55"
style={{
background: 'var(--color-accent)',
mixBlendMode: 'color',
}}
/>
</div>
</div>
</div>
<div className="relative inline-flex mb-4">
<button
type="button"
onClick={onThemePillClick}
className={`inline-flex items-center gap-2 px-4 py-1.5 sketch-border bg-[var(--bg-surface)] text-xs transform -rotate-1 cursor-pointer select-none ${hasClickedThemePill ? '' : 'animate-bounce'}`}
className={`inline-flex items-center gap-2 px-4 py-1.5 sketch-border bg-(--bg-surface) text-xs transform -rotate-1 cursor-pointer select-none ${hasClickedThemePill ? '' : 'animate-bounce'}`}
aria-label="Theme pill"
>
<Activity className={`w-3 h-3 accent-text ${hasClickedThemePill ? '' : 'animate-pulse'}`} />
<span className="font-sketch text-sm">YOU GET: <span className="text-[var(--text-main)]">{activeThemeName}</span></span>
<span className="font-sketch text-sm">YOU GET: <span className="text-(--text-main)">{activeThemeName}</span></span>
</button>
{showThemePillTooltip && (
<div
role="tooltip"
className="absolute left-1/2 top-full mt-2 -translate-x-1/2 whitespace-nowrap px-3 py-1.5 text-xs font-sketch bg-[var(--bg-panel)] text-[var(--text-main)] sketch-border-subtle shadow-lg"
className="absolute left-1/2 top-full mt-2 -translate-x-1/2 whitespace-nowrap px-3 py-1.5 text-xs font-sketch bg-(--bg-panel) text-(--text-main) sketch-border-subtle shadow-lg"
>
HAHA Made you click
</div>
)}
</div>
<h1 className="text-3xl sm:text-5xl font-bold tracking-tight text-[var(--text-main)] leading-tight">
I overdesign <span className="accent-text relative inline-block">
controllable systems
<svg className="absolute w-full h-3 -bottom-1 left-0 accent-text" viewBox="0 0 100 20" preserveAspectRatio="none" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M2,15 Q50,-5 98,15" /></svg>
</span>,<br /> deconstruct black boxes, and build tools.
</h1>
<div className="md:hidden">
<HeroTitle />
</div>
<div className="hidden md:block">
<RevolvingPrism />
</div>
<p className="text-md text-(--text-muted) leading-relaxed max-w-xl font-mono">
Systems engineer in the making. From window managers and game engines to self-hosted infrastructure.
</p>
<div className="md:hidden">
<div className="sketch-border bg-(--bg-panel) px-4 py-3 text-sm text-(--text-muted) font-mono max-w-sm flex items-start gap-2">
<LaptopMinimalCheck className="h-4 w-4 mt-0.5 text-(--color-accent) animate-pulse" />
<span>This looks a lot cooler on bigger screens.</span>
</div>
</div>
</div>
<div className="relative w-48 h-56 sm:w-64 sm:h-72 shrink-0 sketch-border bg-[var(--bg-panel)] transform rotate-[3deg] hover:rotate-0 transition-transform duration-500 group flex flex-col items-center justify-center mt-8 md:mt-0">
<div className="absolute -top-3 -right-4 w-12 h-6 bg-[var(--color-accent-muted)] transform rotate-45 opacity-60 backdrop-blur-sm sketch-border-subtle border-none" />
<div className="absolute -bottom-3 -left-4 w-12 h-6 bg-[var(--color-accent-muted)] transform rotate-45 opacity-60 backdrop-blur-sm sketch-border-subtle border-none" />
<div className="hidden md:flex relative w-48 h-56 sm:w-64 sm:h-72 shrink-0 sketch-border bg-(--bg-panel) transform rotate-3 hover:rotate-0 transition-transform duration-500 group flex-col items-center justify-center mt-8 md:mt-0">
<div className="absolute -top-3 -right-4 w-12 h-6 bg-(--color-accent-muted) transform rotate-45 opacity-60 backdrop-blur-sm sketch-border-subtle border-none" />
<div className="absolute -bottom-3 -left-4 w-12 h-6 bg-(--color-accent-muted) transform rotate-45 opacity-60 backdrop-blur-sm sketch-border-subtle border-none" />
<div className="absolute inset-2 border-2 border-dashed border-[var(--border-main)] overflow-hidden">
<div className="absolute inset-2 border-2 border-dashed border-(--border-main) overflow-hidden">
<img
src="/img/operator.jpg"
alt="Operator portrait"
@@ -74,7 +116,7 @@ export function HeroSection({
/>
</div>
<div className="absolute -bottom-8 -left-12 font-sketch text-xs accent-text -rotate-[15deg] hidden md:flex items-center gap-2 opacity-80">
<div className="absolute -bottom-8 -left-12 font-sketch text-xs accent-text -rotate-15 hidden md:flex items-center gap-2 opacity-80">
<span className="whitespace-nowrap">the operator</span>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="transform -scale-y-100 rotate-45"><path d="M5 12h14" /><path d="m12 5 7 7-7 7" /></svg>
</div>

View File

@@ -0,0 +1,10 @@
export function HeroTitle() {
return (
<h1 className="text-3xl sm:text-5xl font-bold tracking-tight text-(--text-main) leading-tight">
I overdesign <span className="accent-text relative inline-block">
controllable systems
<svg className="absolute w-full h-3 -bottom-1 left-0 accent-text" viewBox="0 0 100 20" preserveAspectRatio="none" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M2,15 Q50,-5 98,15" /></svg>
</span>,<br /> deconstruct black boxes, and build tools.
</h1>
);
}

View File

@@ -0,0 +1,171 @@
import { useRef, useState, useEffect } from "react";
import { Activity } from "lucide-react";
export function RevolvingPrism() {
const faces = [
{
id: 0,
title: "01_INFRA",
text: "Personal Infrastructure",
desc: "Self-hosting with Docker, Traefik, Gitea, S3 stacks and Linux. Owning deployment, networking, and storage end-to-end."
},
{
id: 1,
title: "02_DESKTOP",
text: "Desktop & Workflow Engineering",
desc: "KDE, KWin scripting, Wayland, Tauri, tiling logic, automation. Modifying the environment instead of adapting to it."
},
{
id: 2,
title: "03_ENGINES",
text: "Simulation & Engine Design",
desc: "TypeScript and Rust. Structured world state, deterministic intent systems, LLM-assisted orchestration."
},
{
id: 3,
title: "04_HARDWARE",
text: "Hardware & Embedded Experiments",
desc: "ESP32, relays, ACPI quirks, eDP controller boards, audio chains, and soldered prototypes."
}
];
const rotationRef = useRef(0);
const isHoveredRef = useRef(false);
const isDraggingRef = useRef(false);
const lastMouseX = useRef(0);
const [, setRenderTick] = useState(0);
// animation loop
useEffect(() => {
let animationFrameId: number | null = null;
const loop = () => {
if (!isDraggingRef.current) {
if (isHoveredRef.current) {
// SNAP: Magnetically lerp to the nearest 90-degree angle
const nearest = Math.round(rotationRef.current / 90) * 90;
rotationRef.current += (nearest - rotationRef.current) * 0.08;
} else {
// CONTINUOUS: Rotate slowly (approx 16s per revolution at 60fps)
rotationRef.current -= 0.375;
}
}
// force a re-render to apply inline transform and check active states
setRenderTick(t => t + 1);
animationFrameId = requestAnimationFrame(loop);
};
animationFrameId = requestAnimationFrame(loop);
return () => {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
}
};
}, []);
// Drag Interactions
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
isDraggingRef.current = true;
lastMouseX.current = e.clientX;
e.currentTarget.setPointerCapture(e.pointerId);
};
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!isDraggingRef.current) return;
const delta = e.clientX - lastMouseX.current;
rotationRef.current += delta * 0.8; // Adjust drag sensitivity
lastMouseX.current = e.clientX;
};
const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
isDraggingRef.current = false;
e.currentTarget.releasePointerCapture(e.pointerId);
};
return (
<div
className="w-full h-64 flex items-center justify-start perspective-1000 z-30 relative touch-none group"
onMouseEnter={() => isHoveredRef.current = true}
onMouseLeave={() => {
isHoveredRef.current = false;
isDraggingRef.current = false;
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{/* Annotation for hover interaction */}
<div className="absolute top-0 right-10 hidden sm:flex items-center gap-2 text-(--color-accent-muted) font-sketch text-xs animate-pulse opacity-80 pointer-events-none">
<Activity className="w-3 h-3" />
<span>[ hover to snap | drag to rotate ]</span>
</div>
<div className="relative w-14 h-55">
<div
className="prism-container cursor-grab active:cursor-grabbing w-full h-full"
style={{ transform: `rotateY(${rotationRef.current}deg)` }}
>
<div className="prism-cap prism-cap-top" />
<div className="prism-cap prism-cap-bottom" />
{faces.map((face, index) => {
// Calculate the true world rotation of this specific face
const worldRotation = rotationRef.current + (index * 90);
// Normalize between -180 and +180
let normalized = worldRotation % 360;
if (normalized > 180) normalized -= 360;
if (normalized < -180) normalized += 360;
// Face is considered "Active" if it is within ~42 degrees of perfectly parallel
const isActive = Math.abs(normalized) < 42;
// Calculate staircase offset for dots and routing line
const dotY = 20 + index * 30;
const dy = index * 30; // Distance between the dot and the text
return (
<div key={face.id} className={`prism-face prism-face-${index} ${isActive ? 'active' : ''}`}>
{/* Dynamically assign height to create the staircase */}
<div className="glowing-dot" style={{ top: `${dotY}px` }} />
<div className="prism-popout">
{/* Architectural dynamic routing line bridging the gap */}
<svg className="prism-line-svg" width="40" height={dy + 2} style={{ position: 'absolute', top: '50%', left: 0, overflow: 'visible' }}>
<path d={`M 0,${dy} L 20,${dy} L 20,0 L 40,0`} fill="none" stroke="var(--color-accent)" strokeWidth="2" strokeDasharray="4 4" />
</svg>
<div className="prism-text">
<div className="font-mono text-xs text-(--text-dim) mb-1 leading-none prism-3d-text">{face.title}</div>
<div className="tracking-tight text-4xl accent-text whitespace-nowrap max-w-292 prism-3d-text leading-tight">{face.text}</div>
</div>
</div>
</div>
);
})}
</div>
{/* Static Descriptions Container */}
<div className="absolute left-28 top-18 w-100 sm:w-120 pointer-events-none">
{faces.map((face, index) => {
const worldRotation = rotationRef.current + (index * 90);
let normalized = worldRotation % 360;
if (normalized > 180) normalized -= 360;
if (normalized < -180) normalized += 360;
const isActive = Math.abs(normalized) < 42;
return (
<div
key={`desc-${face.id}`}
className={`absolute top-0 left-0 transition-opacity duration-300 ease-in-out ${isActive ? 'opacity-100 delay-100' : 'opacity-0'}`}
>
<p className="font-mono text-md text-(--text-muted) leading-relaxed border-l-2 border-(--color-accent-muted) pl-3">
{face.desc}
</p>
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,136 @@
/* 3D Prism Animation Styles */
.perspective-1000 {
perspective: 1000px;
}
.prism-container {
--prism-width: 56px;
--prism-height: 220px;
--prism-depth: 28px;
--prism-cap-size: 56px;
width: var(--prism-width);
height: var(--prism-height);
position: relative;
transform-style: preserve-3d;
}
.prism-face {
position: absolute;
width: var(--prism-width);
height: var(--prism-height);
background: var(--bg-panel);
border: 2px solid var(--border-main);
transform-style: preserve-3d;
}
.prism-face-0 {
transform: rotateY(0deg) translateZ(var(--prism-depth));
}
.prism-face-1 {
transform: rotateY(90deg) translateZ(var(--prism-depth));
}
.prism-face-2 {
transform: rotateY(180deg) translateZ(var(--prism-depth));
}
.prism-face-3 {
transform: rotateY(270deg) translateZ(var(--prism-depth));
}
.prism-cap {
position: absolute;
left: 0;
top: calc((var(--prism-height) - var(--prism-cap-size)) / 2);
width: var(--prism-cap-size);
height: var(--prism-cap-size);
background: var(--bg-surface);
border: 2px solid var(--border-main);
}
.prism-cap-top {
transform: rotateX(90deg) translateZ(calc(var(--prism-height) / 2));
}
.prism-cap-bottom {
transform: rotateX(-90deg) translateZ(calc(var(--prism-height) / 2));
}
.glowing-dot {
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--border-main);
transition: all 0.3s ease;
}
.prism-face.active .glowing-dot {
background: var(--color-accent);
box-shadow: 0 0 8px var(--color-accent), 0 0 16px var(--color-accent);
}
.prism-popout {
position: absolute;
left: 100%;
top: 32px;
/* Aligns with the fixed height of the text */
transform: translateY(-50%);
display: flex;
align-items: center;
pointer-events: none;
transform-style: preserve-3d;
}
/* SVG Line wiping animation */
.prism-line-svg {
-webkit-mask-image: linear-gradient(to right, #000 40%, transparent 60%);
mask-image: linear-gradient(to right, #000 40%, transparent 60%);
-webkit-mask-size: 300% 100%;
mask-size: 300% 100%;
-webkit-mask-position: 100% 0;
mask-position: 100% 0;
transition: -webkit-mask-position 0.3s ease-out, mask-position 0.3s ease-out;
}
.prism-face.active .prism-line-svg {
-webkit-mask-position: 0 0;
mask-position: 0 0;
transition: -webkit-mask-position 0.4s cubic-bezier(0.25, 1, 0.5, 1);
}
.prism-text {
margin-left: var(--prism-width);
/* Reserves space for the absolute-positioned SVG line */
padding-left: 10px;
transform-style: preserve-3d;
/* Masking for directional fade (wipes left/right) */
-webkit-mask-image: linear-gradient(to right, #000 40%, transparent 60%);
mask-image: linear-gradient(to right, #000 40%, transparent 60%);
-webkit-mask-size: 300% 100%;
mask-size: 300% 100%;
-webkit-mask-position: 100% 0;
mask-position: 100% 0;
opacity: 0;
/* Fade out right-to-left (sped up to clear out earlier) */
transition: -webkit-mask-position 0.2s ease-out, mask-position 0.2s ease-out, opacity 0s linear 0.2s;
}
.prism-face.active .prism-text {
-webkit-mask-position: 0 0;
mask-position: 0 0;
opacity: 1;
/* Fade in left-to-right (slight delay lets the line draw first) */
transition: -webkit-mask-position 0.5s ease-out 0.1s, mask-position 0.5s ease-out 0.1s, opacity 0s linear 0.1s;
}
/* Simulates 3D extrusion/thickness on flat text */
.prism-3d-text {
text-shadow: 1px 1px 0 var(--border-main), 2px 2px 0 var(--bg-base);
backface-visibility: visible;
}

View File

@@ -1,4 +1,4 @@
function deterministicIpValue(ip: string, x: number): number {
export function deterministicIpValue(ip: string, x: number): number {
if (!Number.isInteger(x) || x <= 0) {
throw new Error("x must be a positive integer");
}