Compare commits

..

4 Commits

Author SHA1 Message Date
f0ae0b9ce1 hero bugfixes/improvements 2026-04-08 22:25:26 -07:00
87d3b3bfa6 hero bugfixes/improvements 2026-04-08 16:29:13 -07:00
f6873546df Remove debug url params 2026-04-08 16:10:32 -07:00
e7ada63431 Void; part 2 2026-04-08 16:08:06 -07:00
16 changed files with 1787 additions and 84 deletions

View File

@@ -13,6 +13,7 @@
"@tailwindcss/typography": "^0.5.19",
"@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7",
"@types/three": "^0.175.0",
"astro": "^6.1.2",
"tailwindcss": "^3.4.19"
},
@@ -24,6 +25,9 @@
"@giscus/react": "^3.1.0",
"@pilcrowjs/object-parser": "^0.0.4",
"@react-hook/intersection-observer": "^3.1.2",
"@react-three/drei": "^9.122.0",
"@react-three/fiber": "^8.18.0",
"@react-three/postprocessing": "^2.19.1",
"@rehype-pretty/transformers": "^0.13.2",
"@vercel/analytics": "^2.0.1",
"@vercel/speed-insights": "^2.0.0",
@@ -31,6 +35,7 @@
"ioredis": "^5.10.1",
"lucide-react": "^0.468.0",
"marked": "^15.0.12",
"postprocessing": "^6.39.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.6.0",
@@ -40,6 +45,7 @@
"rehype-slug": "^6.0.0",
"schema-dts": "^1.1.5",
"shiki": "^3.23.0",
"three": "^0.175.0",
"typewriter-effect": "^2.22.0",
"unist-util-visit": "^5.1.0"
}

728
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -351,8 +351,6 @@ const Background: React.FC<BackgroundProps> = ({
style={{ cursor: "default" }}
/>
<div className="absolute inset-0 bg-background/30 backdrop-blur-sm pointer-events-none" />
<div className="crt-scanlines absolute inset-0 pointer-events-none" />
<div className="crt-bloom absolute inset-0 pointer-events-none" />
</div>
);
};

View File

@@ -1,8 +1,12 @@
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, Suspense, lazy } from "react";
import Typewriter from "typewriter-effect";
import { THEMES } from "@/lib/themes";
import { applyTheme, getStoredThemeId } from "@/lib/themes/engine";
// Preload void component — starts downloading when countdown begins
const voidImport = () => import("@/components/void");
const VoidExperience = lazy(voidImport);
interface GithubData {
status: { message: string } | null;
commit: { message: string; repo: string; date: string; url: string } | null;
@@ -366,16 +370,6 @@ function addComeback(tw: TypewriterInstance, onRetire: () => void, completions:
`<span class="text-yellow">until they're the only way out</span>`
).pauseFor(4000).deleteAll();
// --- Visitor count ---
if (completions !== null && completions > 0) {
tw.typeString(
`<span>You're visitor </span>` +
`<span class="text-yellow">#${completions.toLocaleString()}</span>${BR}` +
`<span class="text-aqua">to make it this far</span>`
).pauseFor(5000).deleteAll();
}
// --- Done for real ---
addDots(tw, 1000, 4000);
@@ -445,12 +439,12 @@ function GlitchCountdown({ seconds }: { seconds: number }) {
export default function Hero() {
const [phase, setPhase] = useState<
"intro" | "full" | "retired" | "countdown" | "glitch"
"intro" | "full" | "retired" | "countdown" | "glitch" | "void"
>(() => {
if (typeof window !== "undefined") {
if (import.meta.env.DEV && typeof window !== "undefined") {
const p = new URLSearchParams(window.location.search);
if (p.has("debug-countdown")) return "countdown";
if (p.has("debug-glitch")) return "glitch";
if (p.has("debug-countdown")) return "countdown";
}
return "intro";
});
@@ -467,9 +461,20 @@ export default function Hero() {
.catch(() => { githubRef.current = { status: null, commit: null, tinkering: null }; });
}, []);
// Countdown timer
// Void token + preload during countdown
const voidTokenRef = useRef<string | null>(null);
useEffect(() => {
if (phase !== "countdown") return;
// Preload the void component bundle
voidImport();
// Fetch a signed token for the void visit
fetch("/api/void-token")
.then(r => r.json())
.then(data => { voidTokenRef.current = data.token; })
.catch(() => { voidTokenRef.current = null; });
const interval = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
@@ -483,37 +488,82 @@ export default function Hero() {
return () => clearInterval(interval);
}, [phase]);
// Glitch → navigate to /enlighten
// Glitch → transition into void
// Apply animation directly to each visible element (works on both desktop + mobile)
// On mobile, filter/transform on <body> doesn't reach fixed-position children,
// so we target the elements themselves
useEffect(() => {
if (phase !== "glitch") return;
const style = document.createElement("style");
style.textContent = `
@keyframes hero-glitch {
0% { filter: none; transform: none; }
5% { filter: hue-rotate(90deg) saturate(3); transform: skewX(2deg); }
10% { filter: invert(1); transform: skewX(-3deg) translateX(5px); }
15% { filter: hue-rotate(180deg) brightness(1.5); transform: scale(1.02); }
20% { filter: saturate(5) contrast(2); transform: skewX(1deg) translateY(-2px); }
25% { filter: invert(1) hue-rotate(270deg); transform: skewX(-2deg); }
30% { filter: brightness(2) saturate(0); transform: scale(0.98); }
40% { filter: hue-rotate(45deg) contrast(3); transform: translateX(-3px); }
50% { filter: invert(1) brightness(0.5); transform: skewX(4deg) skewY(1deg); }
60% { filter: saturate(0) brightness(1.8); transform: scale(1.01); }
70% { filter: hue-rotate(180deg) brightness(0.3); transform: none; }
80% { filter: contrast(5) saturate(0); transform: skewX(-1deg); }
90% { filter: brightness(0.1); transform: scale(0.99); }
100% { filter: brightness(0); transform: none; }
.hero-glitch-shake {
animation: hero-glitch-shake 3s ease-in forwards !important;
}
@keyframes hero-glitch-shake {
0% { transform: none; }
5% { transform: skewX(2deg); }
10% { transform: skewX(-3deg) translateX(5px); }
15% { transform: scale(1.02); }
20% { transform: skewX(1deg) translateY(-2px); }
25% { transform: skewX(-2deg); }
30% { transform: scale(0.98); }
40% { transform: translateX(-3px); }
50% { transform: skewX(4deg) skewY(1deg); }
60% { transform: scale(1.01); }
70% { transform: none; }
80% { transform: skewX(-1deg); }
90% { transform: none; }
100% { transform: none; }
}
.hero-glitch-filter {
animation: hero-glitch-filter 3s ease-in forwards !important;
position: fixed !important;
inset: 0 !important;
z-index: 99999 !important;
pointer-events: none !important;
}
@keyframes hero-glitch-filter {
0% { backdrop-filter: none; background: transparent; }
5% { backdrop-filter: hue-rotate(90deg) saturate(3); }
10% { backdrop-filter: invert(1); }
15% { backdrop-filter: hue-rotate(180deg) brightness(1.5); }
20% { backdrop-filter: saturate(5) contrast(2); }
25% { backdrop-filter: invert(1) hue-rotate(270deg); }
30% { backdrop-filter: brightness(2) saturate(0); }
40% { backdrop-filter: hue-rotate(45deg) contrast(3); }
50% { backdrop-filter: invert(1) brightness(0.5); }
60% { backdrop-filter: saturate(0) brightness(1.8); }
70% { backdrop-filter: hue-rotate(180deg) brightness(0.3); }
80% { backdrop-filter: contrast(5) saturate(0); }
90% { backdrop-filter: brightness(0); background: #000; }
100% { backdrop-filter: brightness(0); background: #000; }
}
`;
document.head.appendChild(style);
document.documentElement.style.animation = "hero-glitch 3s ease-in forwards";
// Overlay for backdrop-filter (color distortion — works on all platforms)
const overlay = document.createElement("div");
overlay.className = "hero-glitch-filter";
document.body.appendChild(overlay);
// Shake transforms on all layout elements
const targets = document.querySelectorAll<HTMLElement>(
"header, main, footer, nav"
);
targets.forEach(el => el.classList.add("hero-glitch-shake"));
const timeout = setTimeout(() => {
window.location.href = "/enlighten";
targets.forEach(el => el.classList.remove("hero-glitch-shake"));
overlay.remove();
style.remove();
setPhase("void");
}, 3000);
return () => {
clearTimeout(timeout);
document.documentElement.style.animation = "";
targets.forEach(el => el.classList.remove("hero-glitch-shake"));
overlay.remove();
style.remove();
};
}, [phase]);
@@ -567,6 +617,14 @@ export default function Hero() {
const baseOptions = { delay: 35, deleteSpeed: 35, cursor: "|" };
if (phase === "void") {
return (
<Suspense fallback={<div className="fixed inset-0 bg-black" />}>
<VoidExperience token={voidTokenRef.current || ""} />
</Suspense>
);
}
if (phase === "glitch") {
return <div className="min-h-screen" />;
}

View File

@@ -0,0 +1,233 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { Canvas } from "@react-three/fiber";
import VoidTypewriter from "./typewriter";
import VoidWater from "./scenes/void-water";
// Canvas glitch: transforms + filters (physical shake + color corruption)
// Text glitch: filters only (color corruption, no position shift)
const GLITCH_CSS = `
.void-glitch-subtle {
animation: void-glitch-subtle 2s ease-in-out infinite;
}
.void-glitch-intense {
animation: void-glitch-intense 1.2s ease-in-out infinite;
}
.void-glitch-dissolve {
animation: void-glitch-dissolve 2s ease-in forwards;
}
.void-text-glitch-subtle {
animation: void-text-glitch-subtle 2s ease-in-out infinite;
}
.void-text-glitch-intense {
animation: void-text-glitch-intense 1.2s ease-in-out infinite;
}
.void-text-glitch-dissolve {
animation: void-text-glitch-dissolve 2s ease-in forwards;
}
@keyframes void-glitch-subtle {
0%, 100% { transform: none; filter: none; }
3% { transform: skewX(0.5deg); filter: hue-rotate(15deg); }
6% { transform: none; filter: none; }
15% { transform: translateX(1px) skewX(-0.2deg); }
17% { transform: none; }
30% { transform: skewX(-0.3deg) translateY(0.5px); filter: saturate(1.5); }
32% { transform: none; filter: none; }
50% { transform: translateY(-1px); }
52% { transform: none; }
70% { transform: skewX(0.2deg) translateX(-0.5px); filter: hue-rotate(-10deg); }
72% { transform: none; filter: none; }
85% { transform: translateX(-1px) skewY(0.1deg); }
87% { transform: none; }
}
@keyframes void-text-glitch-subtle {
0%, 100% { filter: none; }
3% { filter: hue-rotate(15deg); }
6% { filter: none; }
30% { filter: saturate(1.5); }
32% { filter: none; }
70% { filter: hue-rotate(-10deg); }
72% { filter: none; }
}
@keyframes void-glitch-intense {
0%, 100% { transform: none; filter: none; }
2% { transform: skewX(2deg) translateX(2px); filter: hue-rotate(60deg) saturate(3); }
5% { transform: skewX(-1.5deg) translateY(-1px); filter: none; }
8% { transform: none; }
12% { transform: translateY(-3px) skewX(0.5deg); filter: hue-rotate(-90deg); }
15% { transform: none; filter: none; }
25% { transform: skewX(1.5deg) scale(1.005) translateX(-2px); filter: saturate(4); }
28% { transform: none; filter: none; }
40% { transform: skewX(-2deg) translateY(2px); filter: hue-rotate(120deg) saturate(2); }
42% { transform: none; filter: none; }
55% { transform: translateX(-3px) skewY(0.3deg); }
58% { transform: none; }
70% { transform: scale(1.01) skewX(1deg); filter: hue-rotate(-45deg) saturate(3); }
73% { transform: none; filter: none; }
85% { transform: skewX(-1deg) translateX(2px) translateY(-1px); filter: saturate(5); }
88% { transform: none; filter: none; }
}
@keyframes void-text-glitch-intense {
0%, 100% { filter: none; }
2% { filter: hue-rotate(60deg) saturate(3); }
5% { filter: none; }
12% { filter: hue-rotate(-90deg); }
15% { filter: none; }
25% { filter: saturate(4); }
28% { filter: none; }
40% { filter: hue-rotate(120deg) saturate(2); }
42% { filter: none; }
70% { filter: hue-rotate(-45deg) saturate(3); }
73% { filter: none; }
85% { filter: saturate(5); }
88% { filter: none; }
}
@keyframes void-glitch-dissolve {
0% { transform: none; filter: none; opacity: 1; }
3% { transform: skewX(3deg) translateX(4px); filter: hue-rotate(90deg) saturate(4); }
6% { transform: skewX(-2deg) translateY(-3px); opacity: 0.95; }
10% { filter: hue-rotate(-120deg) saturate(3); opacity: 0.9; }
15% { transform: translateX(-5px) skewX(2deg); filter: none; opacity: 0.85; }
20% { transform: skewX(-3deg) scale(1.02); filter: hue-rotate(180deg) saturate(5); opacity: 0.8; }
25% { transform: translateY(4px) skewX(1deg); opacity: 0.75; }
30% { filter: saturate(6) hue-rotate(60deg); opacity: 0.7; }
40% { transform: skewX(2deg) translateX(-3px); filter: hue-rotate(-90deg); opacity: 0.55; }
50% { transform: skewX(-4deg) translateY(2px); filter: saturate(3); opacity: 0.4; }
60% { filter: hue-rotate(150deg); opacity: 0.3; }
70% { transform: scale(1.03) skewX(2deg); opacity: 0.2; }
80% { transform: translateX(-2px); opacity: 0.1; }
100% { transform: none; filter: none; opacity: 0; }
}
@keyframes void-text-glitch-dissolve {
0% { filter: none; opacity: 1; }
3% { filter: hue-rotate(90deg) saturate(4); }
10% { filter: hue-rotate(-120deg) saturate(3); opacity: 0.9; }
20% { filter: hue-rotate(180deg) saturate(5); opacity: 0.8; }
30% { filter: saturate(6) hue-rotate(60deg); opacity: 0.7; }
40% { filter: hue-rotate(-90deg); opacity: 0.55; }
50% { filter: saturate(3); opacity: 0.4; }
60% { filter: hue-rotate(150deg); opacity: 0.3; }
80% { filter: none; opacity: 0.1; }
100% { filter: none; opacity: 0; }
}
`;
function getCorruption(segment: number): number {
if (segment < 8) return 0;
if (segment === 8) return 0.05;
if (segment === 9) return 0.08;
if (segment === 10) return 0.1;
if (segment === 11) return 0.13;
if (segment === 12) return 0.1;
if (segment === 13) return 0.3;
if (segment === 14) return 0.6;
if (segment === 15) return 0.75;
if (segment === 16) return 0.9;
return 1.0;
}
function getCanvasGlitch(segment: number, dissolving: boolean): string {
if (dissolving) return "void-glitch-dissolve";
if (segment < 8) return "";
if (segment <= 14) return "void-glitch-subtle";
return "void-glitch-intense";
}
function getTextGlitch(segment: number, dissolving: boolean): string {
if (dissolving) return "void-text-glitch-dissolve";
if (segment < 8) return "";
if (segment <= 14) return "void-text-glitch-subtle";
return "void-text-glitch-intense";
}
interface VoidExperienceProps {
token: string;
}
export default function VoidExperience({ token }: VoidExperienceProps) {
const [activeSegment, setActiveSegment] = useState(0);
const [visitCount, setVisitCount] = useState<number | null>(null);
const [dissolving, setDissolving] = useState(false);
// Inject CSS + hide cursor + hide layout chrome underneath
useEffect(() => {
const style = document.createElement("style");
style.textContent = GLITCH_CSS;
document.head.appendChild(style);
document.body.style.cursor = "none";
document.documentElement.style.overflow = "hidden";
document.body.style.overflow = "hidden";
return () => {
style.remove();
document.body.style.cursor = "";
document.documentElement.style.overflow = "";
document.body.style.overflow = "";
};
}, []);
// Fetch + increment visit count on mount (with token verification)
useEffect(() => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
fetch("/api/void-visits", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
signal: controller.signal,
})
.then(r => r.json())
.then(data => setVisitCount(data.count ?? 1))
.catch(() => setVisitCount(1))
.finally(() => clearTimeout(timeout));
return () => {
controller.abort();
clearTimeout(timeout);
};
}, []);
const handlePhaseComplete = useCallback(() => {
setDissolving(true);
setTimeout(() => {
window.location.href = "/about";
}, 2000);
}, []);
const handleSegmentChange = useCallback((index: number) => {
setActiveSegment(index);
}, []);
const corruption = getCorruption(activeSegment);
return (
<div className="fixed inset-0 bg-black z-[9999]" style={{ height: "100dvh" }}>
{/* 3D Canvas — full glitch (transforms + filters) */}
<div className={`fixed inset-0 z-[10] ${getCanvasGlitch(activeSegment, dissolving)}`}>
<Canvas
camera={{ position: [0, 0, 8], fov: 60 }}
dpr={[1, 1.5]}
gl={{ antialias: false, alpha: true }}
style={{ background: "transparent" }}
>
<VoidWater segment={activeSegment} corruption={corruption} />
</Canvas>
</div>
{/* Typewriter — glitch class applied to inner text, not the fixed container */}
{visitCount !== null && (
<VoidTypewriter
startSegment={0}
onPhaseComplete={handlePhaseComplete}
onSegmentChange={handleSegmentChange}
visitCount={visitCount}
corruption={corruption}
glitchClass={getTextGlitch(activeSegment, dissolving)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,15 @@
export const VOID = {
bg: "#000000",
text: "#FFFFFF",
red: "#CC2420",
dim: "#BDAE93",
gold: "#D79921",
} as const;
export const VOID_RGB = {
bg: [0, 0, 0] as const,
text: [1, 1, 1] as const,
red: [0.8, 0.14, 0.13] as const,
dim: [0.74, 0.68, 0.58] as const,
gold: [0.84, 0.6, 0.13] as const,
};

View File

@@ -0,0 +1,8 @@
import type { Phase } from "../types";
import { addVoidPhase, VOID_SEGMENT_COUNT } from "./void";
export { addVoidPhase };
export const PHASE_SEGMENT_COUNTS: Record<Phase, number> = {
void: VOID_SEGMENT_COUNT,
};

View File

@@ -0,0 +1,132 @@
import type { TypewriterInstance, Segment } from "../types";
import { buildSegments, T1 } from "../types";
import { VOID } from "../palette";
export function createVoidSegments(visitCount: number): Segment[] {
return [
// 0
{
html: `<span>so this is it</span>`,
pause: 3500,
delay: T1,
},
// 1
{
html: `<span>the void</span>`,
pause: 4000,
delay: T1,
},
// 2
{
html: `<span>not much here</span>`,
pause: 3000,
},
// 3
{
html: `<span>just dark water</span>`,
pause: 4000,
delay: T1,
},
// 4
{
html: `<span>you sat through the whole thing though</span>`,
pause: 3500,
prePause: 1500,
},
// 5
{
html: `<span>the countdown and everything</span>`,
pause: 3000,
},
// 6
{
html: `<span>imagine if you took that energy</span>`,
pause: 3000,
prePause: 1500,
},
// 7
{
html: `<span>and pointed it at something that matters</span>`,
pause: 3500,
delay: T1,
},
// 8 — the line that lands
{
html: `<span>you'd be <span style="color:${VOID.red}">dangerous</span></span>`,
pause: 4500,
delay: T1,
prePause: 1000,
},
// 9
{
html: `<span>seriously</span>`,
pause: 2500,
delay: T1,
prePause: 1500,
},
// 10
{
html: `<span>don't waste that potential</span>`,
pause: 3000,
},
// 11
{
html: `<span>go build something cool</span>`,
pause: 4000,
delay: T1,
},
// 12 — deflection
{
html: `<span>anyway</span>`,
pause: 3000,
delay: T1,
prePause: 2000,
},
// 13 — visitor count (corruption picks up)
{
html: `<span>you're visitor <span style="color:${VOID.gold}">#${Math.max(visitCount, 1)}</span></span>`,
pause: 4000,
delay: T1,
prePause: 1500,
},
// 14 — unstable
{
html: `<span>this void is pretty unstable though</span>`,
pause: 3000,
prePause: 1000,
},
// 15 — resigned
{
html: `<span>ah well</span>`,
pause: 2500,
delay: T1,
prePause: 1000,
},
// 16 — goodbye
{
html: `<span>it's been nice knowing ya</span>`,
pause: 2500,
delay: T1,
},
// 17 — cut off, void wins
{
html: `<span>see you on the other si</span>`,
pause: 500,
delay: T1,
deleteMode: "none",
},
];
}
export const VOID_SEGMENT_COUNT = createVoidSegments(0).length;
export function addVoidPhase(
tw: TypewriterInstance,
onComplete: () => void,
startSegment: number = 0,
onSegmentChange?: (index: number) => void,
visitCount: number = 0,
) {
const segments = createVoidSegments(visitCount);
buildSegments(tw, segments, onComplete, startSegment, 4000, onSegmentChange);
}

View File

@@ -0,0 +1,171 @@
import { useRef, useMemo } from "react";
import { useFrame } from "@react-three/fiber";
import * as THREE from "three";
import { SIMPLEX_3D, PLANE_VERT } from "../shaders/noise";
interface VoidWaterProps {
segment: number;
corruption: number; // 0-1, drives RGB split + color noise
}
const waterFrag = `
${SIMPLEX_3D}
uniform float uTime;
uniform float uOpacity;
uniform float uCorruption;
varying vec2 vUv;
// Sample the water height field — broad, slow waves
float waterHeight(vec2 p) {
float t = uTime;
// Large primary waves — slow, dominant
float h = snoise(vec3(p * 0.4, t * 0.08)) * 0.6;
// Medium secondary swell — different direction via offset
h += snoise(vec3(p.yx * 0.7 + 2.0, t * 0.12)) * 0.3;
// Small surface detail
h += snoise(vec3(p * 1.5 + 5.0, t * 0.2)) * 0.1;
return h;
}
// Compute lighting for a given UV position
float computeLight(vec2 p) {
float eps = 0.08;
float h = waterHeight(p);
float hx = waterHeight(p + vec2(eps, 0.0));
float hy = waterHeight(p + vec2(0.0, eps));
vec3 normal = normalize(vec3(
(h - hx) / eps * 2.0,
(h - hy) / eps * 2.0,
1.0
));
vec3 viewDir = vec3(0.0, 0.0, 1.0);
vec3 lightDir = normalize(vec3(0.4, 0.3, 1.0));
vec3 halfDir = normalize(lightDir + viewDir);
float diffuse = max(dot(normal, lightDir), 0.0);
float spec1 = pow(max(dot(normal, halfDir), 0.0), 12.0);
float spec2 = pow(max(dot(normal, halfDir), 0.0), 40.0);
float tilt = 1.0 - normal.z;
return tilt * 0.12 + diffuse * 0.2 + spec1 * 0.5 + spec2 * 0.6;
}
void main() {
vec2 p = (vUv - 0.5) * 4.0;
// Circular vignette
float dist = length(vUv - 0.5) * 2.0;
float vignette = 1.0 - smoothstep(0.5, 1.0, dist);
if (uCorruption < 0.01) {
// Clean path — original water
float light = computeLight(p);
float intensity = light * vignette * uOpacity;
vec3 color = vec3(0.3, 0.38, 0.5) * intensity;
gl_FragColor = vec4(color, intensity);
} else {
// Corrupted path — RGB channel separation + color noise
// Chromatic offset increases with corruption
float offset = uCorruption * 0.15;
// Sample lighting at offset positions for each channel
float lightR = computeLight(p + vec2(offset, offset * 0.5));
float lightG = computeLight(p);
float lightB = computeLight(p - vec2(offset * 0.7, offset));
// Base water color per channel
vec3 baseColor = vec3(0.3, 0.38, 0.5);
float r = lightR * baseColor.r;
float g = lightG * baseColor.g;
float b = lightB * baseColor.b;
// Color static — high-frequency noise injecting random color
float staticR = snoise(vec3(vUv * 80.0, uTime * 3.0)) * 0.5 + 0.5;
float staticG = snoise(vec3(vUv * 80.0 + 50.0, uTime * 3.5)) * 0.5 + 0.5;
float staticB = snoise(vec3(vUv * 80.0 + 100.0, uTime * 4.0)) * 0.5 + 0.5;
float staticMix = uCorruption * 0.3;
r = mix(r, staticR * 0.4, staticMix);
g = mix(g, staticG * 0.3, staticMix);
b = mix(b, staticB * 0.5, staticMix);
// Scan line glitch — horizontal bands that flicker
float scanline = step(0.92, snoise(vec3(0.0, vUv.y * 40.0, uTime * 5.0)));
r += scanline * uCorruption * 0.15;
float avgLight = (lightR + lightG + lightB) / 3.0;
float intensity = avgLight * vignette * uOpacity;
gl_FragColor = vec4(vec3(r, g, b) * vignette * uOpacity, intensity);
}
}
`;
function getOpacityTarget(segment: number): number {
if (segment < 2) return 0;
if (segment === 2) return 0.5;
if (segment === 3) return 0.7;
if (segment === 4) return 0.85;
return 1.0;
}
export default function VoidWater({ segment, corruption }: VoidWaterProps) {
const meshRef = useRef<THREE.Mesh>(null!);
const opacityRef = useRef(0);
const corruptionRef = useRef(0);
const uniforms = useMemo(() => ({
uTime: { value: 0 },
uOpacity: { value: 0 },
uCorruption: { value: 0 },
}), []);
const material = useMemo(() => new THREE.ShaderMaterial({
vertexShader: PLANE_VERT,
fragmentShader: waterFrag,
uniforms,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
}), [uniforms]);
useFrame((state, delta) => {
const target = getOpacityTarget(segment);
opacityRef.current = THREE.MathUtils.lerp(opacityRef.current, target, delta * 0.4);
corruptionRef.current = THREE.MathUtils.lerp(corruptionRef.current, corruption, delta * 2.0);
const mesh = meshRef.current;
if (!mesh) return;
if (opacityRef.current < 0.001) {
mesh.visible = false;
return;
}
mesh.visible = true;
const t = state.clock.elapsedTime;
uniforms.uTime.value = t;
// Gentle pulse — slow breathing modulation on opacity
const pulse = 1.0 + Math.sin(t * 0.4) * 0.08 + Math.sin(t * 0.7) * 0.04;
uniforms.uOpacity.value = opacityRef.current * pulse;
uniforms.uCorruption.value = corruptionRef.current;
});
return (
<mesh
ref={meshRef}
position={[0, 0, 0]}
visible={false}
material={material}
>
<planeGeometry args={[20, 20, 1, 1]} />
</mesh>
);
}

View File

@@ -0,0 +1,75 @@
// Shared GLSL noise functions for void experience shaders
// 3D Simplex noise (Ashima Arts / Stefan Gustavson, MIT)
export const SIMPLEX_3D = `
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 permute(vec4 x) { return mod289(((x * 34.0) + 1.0) * x); }
vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
float snoise(vec3 v) {
const vec2 C = vec2(1.0/6.0, 1.0/3.0);
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
vec3 i = floor(v + dot(v, C.yyy));
vec3 x0 = v - i + dot(i, C.xxx);
vec3 g = step(x0.yzx, x0.xyz);
vec3 l = 1.0 - g;
vec3 i1 = min(g.xyz, l.zxy);
vec3 i2 = max(g.xyz, l.zxy);
vec3 x1 = x0 - i1 + C.xxx;
vec3 x2 = x0 - i2 + C.yyy;
vec3 x3 = x0 - D.yyy;
i = mod289(i);
vec4 p = permute(permute(permute(
i.z + vec4(0.0, i1.z, i2.z, 1.0))
+ i.y + vec4(0.0, i1.y, i2.y, 1.0))
+ i.x + vec4(0.0, i1.x, i2.x, 1.0));
float n_ = 0.142857142857;
vec3 ns = n_ * D.wyz - D.xzx;
vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
vec4 x_ = floor(j * ns.z);
vec4 y_ = floor(j - 7.0 * x_);
vec4 x = x_ * ns.x + ns.yyyy;
vec4 y = y_ * ns.x + ns.yyyy;
vec4 h = 1.0 - abs(x) - abs(y);
vec4 b0 = vec4(x.xy, y.xy);
vec4 b1 = vec4(x.zw, y.zw);
vec4 s0 = floor(b0) * 2.0 + 1.0;
vec4 s1 = floor(b1) * 2.0 + 1.0;
vec4 sh = -step(h, vec4(0.0));
vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
vec3 p0 = vec3(a0.xy, h.x);
vec3 p1 = vec3(a0.zw, h.y);
vec3 p2 = vec3(a1.xy, h.z);
vec3 p3 = vec3(a1.zw, h.w);
vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3)));
p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
m = m * m;
return 42.0 * dot(m*m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3)));
}
`;
// Standard passthrough vertex shader used by all scene planes
export const PLANE_VERT = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;

View File

@@ -0,0 +1,83 @@
export interface TypewriterInstance {
typeString: (str: string) => TypewriterInstance;
pasteString: (str: string, node?: HTMLElement | null) => TypewriterInstance;
pauseFor: (ms: number) => TypewriterInstance;
deleteAll: (speed?: number | "natural") => TypewriterInstance;
deleteChars: (amount: number) => TypewriterInstance;
changeDelay: (delay: number | "natural") => TypewriterInstance;
changeDeleteSpeed: (speed: number | "natural") => TypewriterInstance;
callFunction: (cb: () => void) => TypewriterInstance;
start: () => TypewriterInstance;
}
export type Phase = "void";
export const PHASE_ORDER: Phase[] = ["void"];
export const T1 = 55;
export const T2 = 35;
export const DELETE_SPEED = 15;
export interface Segment {
html: string;
pause: number;
method?: "type" | "paste";
delay?: number;
prePause?: number;
deleteMode?: "all" | "none";
deleteSpeed?: number;
}
export type PhaseBuilder = (
tw: TypewriterInstance,
onComplete: () => void,
startSegment?: number,
onSegmentChange?: (index: number) => void,
) => void;
export function buildSegments(
tw: TypewriterInstance,
segments: Segment[],
onComplete: () => void,
startSegment: number = 0,
initialPause: number = 0,
onSegmentChange?: (index: number) => void,
) {
if (startSegment === 0 && initialPause > 0) {
tw.pauseFor(initialPause);
}
for (let i = startSegment; i < segments.length; i++) {
const seg = segments[i];
const idx = i;
tw.callFunction(() => onSegmentChange?.(idx));
if (seg.prePause && seg.prePause > 0) {
tw.pauseFor(seg.prePause);
}
if (seg.delay !== undefined) {
tw.changeDelay(seg.delay);
}
if (seg.method === "paste") {
tw.pasteString(seg.html, null);
} else {
tw.typeString(seg.html);
}
tw.pauseFor(seg.pause);
if (seg.delay !== undefined) {
tw.changeDelay(T2);
}
const mode = seg.deleteMode ?? "all";
if (mode === "all") {
tw.deleteAll(seg.deleteSpeed ?? DELETE_SPEED);
}
}
tw.callFunction(onComplete);
}

View File

@@ -0,0 +1,105 @@
import { useRef, useEffect } from "react";
import Typewriter from "typewriter-effect";
import type { TypewriterInstance } from "./types";
import { addVoidPhase } from "./phases";
const GLITCH_CHARS = "!<>-_\\/[]{}—=+*^?#________";
interface VoidTypewriterProps {
startSegment: number;
onPhaseComplete: () => void;
onSegmentChange: (index: number) => void;
visitCount: number;
corruption: number;
glitchClass: string;
}
function getTextNodes(node: Node): Text[] {
const nodes: Text[] = [];
const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
let current: Node | null;
while ((current = walker.nextNode())) {
if (current.textContent && current.textContent.trim().length > 0) {
nodes.push(current as Text);
}
}
return nodes;
}
export default function VoidTypewriter({ startSegment, onPhaseComplete, onSegmentChange, visitCount, corruption, glitchClass }: VoidTypewriterProps) {
const containerRef = useRef<HTMLDivElement>(null);
const corruptionRef = useRef(corruption);
corruptionRef.current = corruption;
const handleInit = (tw: TypewriterInstance): void => {
addVoidPhase(tw, onPhaseComplete, startSegment, onSegmentChange, visitCount);
tw.start();
};
// 404-style character replacement glitch — intensity scales with corruption
useEffect(() => {
const pendingResets: ReturnType<typeof setTimeout>[] = [];
const interval = setInterval(() => {
const c = corruptionRef.current;
if (c <= 0 || !containerRef.current) return;
const triggerChance = c * 0.4;
if (Math.random() > triggerChance) return;
const textNodes = getTextNodes(containerRef.current);
if (textNodes.length === 0) return;
const originals = textNodes.map(n => n.textContent || "");
const charChance = c * 0.4;
textNodes.forEach((node, i) => {
const text = originals[i];
const glitched = text.split("").map(char => {
if (char === " ") return char;
if (Math.random() < charChance) {
return GLITCH_CHARS[Math.floor(Math.random() * GLITCH_CHARS.length)];
}
return char;
}).join("");
node.textContent = glitched;
});
const resetMs = Math.max(40, 120 - c * 80);
const id = setTimeout(() => {
textNodes.forEach((node, i) => {
if (node.parentNode) {
node.textContent = originals[i];
}
});
}, resetMs);
pendingResets.push(id);
}, 60);
return () => {
clearInterval(interval);
pendingResets.forEach(clearTimeout);
};
}, []);
return (
<div className="fixed inset-0 z-[50] flex justify-center items-center pointer-events-none">
<div
ref={containerRef}
className={`text-xl md:text-3xl font-bold text-center max-w-[85vw] md:max-w-[70vw] break-words text-white leading-relaxed ${glitchClass}`}
>
<Typewriter
key={`void-${startSegment}-${visitCount}`}
options={{
delay: 35,
deleteSpeed: 15,
cursor: "",
autoStart: true,
loop: false,
}}
onInit={handleInit}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import type { APIRoute } from "astro";
const SECRET = import.meta.env.VOID_SECRET || process.env.VOID_SECRET;
async function sign(timestamp: string): Promise<string> {
const data = new TextEncoder().encode(timestamp + SECRET);
const hash = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, "0")).join("");
}
export const GET: APIRoute = async () => {
if (!SECRET) {
return new Response(JSON.stringify({ token: "dev" }), {
headers: { "Content-Type": "application/json" },
});
}
const timestamp = Date.now().toString();
const signature = await sign(timestamp);
const token = `${timestamp}:${signature}`;
return new Response(JSON.stringify({ token }), {
headers: { "Content-Type": "application/json" },
});
};

View File

@@ -0,0 +1,114 @@
import type { APIRoute } from "astro";
import Redis from "ioredis";
const SECRET = import.meta.env.VOID_SECRET || process.env.VOID_SECRET;
const TOKEN_WINDOW_MS = 10 * 60 * 1000; // 10 minutes
let redis: Redis | null = null;
function getRedis(): Redis | null {
if (redis) return redis;
const url = import.meta.env.REDIS_URL || process.env.REDIS_URL;
if (!url) return null;
redis = new Redis(url);
return redis;
}
async function sign(timestamp: string): Promise<string> {
const data = new TextEncoder().encode(timestamp + SECRET);
const hash = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, "0")).join("");
}
async function hashIp(ip: string): Promise<string> {
const data = new TextEncoder().encode(ip + SECRET);
const hash = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 32);
}
function getClientIp(request: Request): string {
// x-vercel-forwarded-for is Vercel's trusted header (can't be spoofed)
// Fall back to last entry in x-forwarded-for (Vercel appends real IP at end)
return request.headers.get("x-vercel-forwarded-for")
|| request.headers.get("x-forwarded-for")?.split(",").at(-1)?.trim()
|| request.headers.get("x-real-ip")
|| "unknown";
}
export const POST: APIRoute = async ({ request }) => {
const r = getRedis();
// No secret or no Redis — dev mode, return 1
if (!SECRET || !r) {
return new Response(JSON.stringify({ count: 1 }), {
headers: { "Content-Type": "application/json" },
});
}
// Parse body
let body: { token?: string } = {};
try { body = await request.json(); } catch {}
const token = body.token;
if (!token || token === "dev") {
return new Response(JSON.stringify({ error: "missing token" }), { status: 400 });
}
const [ts, sig] = token.split(":");
if (!ts || !sig) {
return new Response(JSON.stringify({ error: "invalid token" }), { status: 400 });
}
// Check token age
const age = Date.now() - parseInt(ts, 10);
if (isNaN(age) || age < 0 || age > TOKEN_WINDOW_MS) {
return new Response(JSON.stringify({ error: "expired token" }), { status: 400 });
}
// Verify signature
const expected = await sign(ts);
if (sig !== expected) {
return new Response(JSON.stringify({ error: "invalid token" }), { status: 400 });
}
try {
// Atomic one-time-use check: SET NX returns "OK" only if key didn't exist
const tokenKey = `void:token:${sig.slice(0, 16)}`;
const isNew = await r.set(tokenKey, "pending", "EX", 600, "NX");
if (!isNew) {
// Token already used — return the stored count
const storedCount = await r.get(tokenKey);
const count = storedCount && storedCount !== "pending" ? parseInt(storedCount, 10) : 1;
return new Response(JSON.stringify({ count }), {
headers: { "Content-Type": "application/json" },
});
}
// Check IP dedup
const ip = getClientIp(request);
const ipKey = `void:ip:${await hashIp(ip)}`;
const existingCount = await r.get(ipKey);
let count: number;
if (existingCount) {
// Same IP — return their existing number
count = parseInt(existingCount, 10);
} else {
// New visitor — increment global counter
count = await r.incr("void:count");
await r.set(ipKey, count.toString());
}
// Update token key with the actual count (for replay lookups)
await r.set(tokenKey, count.toString(), "EX", 600);
return new Response(JSON.stringify({ count }), {
headers: { "Content-Type": "application/json" },
});
} catch {
return new Response(JSON.stringify({ count: 1 }), {
headers: { "Content-Type": "application/json" },
});
}
};

View File

@@ -1,17 +0,0 @@
---
export const prerender = false;
import "@/style/globals.css"
import Void from "@/components/hero/void";
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>...</title>
</head>
<body class="bg-black text-white overflow-hidden h-screen m-0">
<Void client:only="react" />
</body>
</html>

View File

@@ -41,37 +41,6 @@
@apply bg-purple/50
}
}
/* CRT overlay — canvas only */
.crt-scanlines {
background: repeating-linear-gradient(
to bottom,
transparent 0px,
transparent 2px,
rgb(var(--color-foreground) / 0.06) 2px,
rgb(var(--color-foreground) / 0.06) 4px
);
animation: crt-scroll 12s linear infinite;
z-index: 1;
}
.crt-bloom {
box-shadow: inset 0 0 100px 30px rgb(var(--color-background) / 0.3);
background: radial-gradient(
ellipse at center,
transparent 50%,
rgb(var(--color-background) / 0.25) 100%
);
z-index: 2;
}
@keyframes crt-scroll {
0% {
background-position: 0 0;
}
100% {
background-position: 0 200px;
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {