Compare commits

..

11 Commits

Author SHA1 Message Date
222e01d18e bump scripts 2026-04-14 09:33:45 -07:00
384ae63300 update README.md 2026-04-14 09:26:16 -07:00
eac8018a83 bump scripts 2026-04-14 09:23:14 -07:00
de546d6ff0 bump scripts 2026-04-14 08:56:59 -07:00
9965bd3529 update submodules 2026-04-14 03:09:20 -07:00
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
53065a11dc Void; part 1 2026-04-06 23:08:06 -07:00
2c5784c6e2 Update hero section; part 2 2026-04-06 20:27:56 -07:00
34 changed files with 2349 additions and 91 deletions

View File

@@ -1,3 +1 @@
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Ftimmypidashev.dev&label=Visits&icon=eye-fill&color=%23198754)
<img src=".github/preview.jpeg" title="Preview"/>

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 KiB

BIN
public/emoji/bubbles.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

BIN
public/emoji/eyes.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

BIN
public/emoji/gift.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

BIN
public/emoji/infinity.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

BIN
public/emoji/moon.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

BIN
public/emoji/muscle.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

BIN
public/emoji/robot.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

BIN
public/emoji/shush.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

BIN
public/emoji/thinking.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 KiB

BIN
public/emoji/trophy.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 582 KiB

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,5 +1,11 @@
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;
@@ -46,6 +52,10 @@ interface TypewriterInstance {
const emoji = (name: string) =>
`<img src="/emoji/${name}.webp" alt="" style="display:inline;height:1em;width:1em;vertical-align:middle">`;
const BR = `<br><div class="mb-4"></div>`;
// --- Greeting sections ---
const SECTION_1 = html`
<span>Hello, I'm</span>
<br><div class="mb-4"></div>
@@ -76,6 +86,8 @@ const MOODS = [
"mood-nomouth", "mood-nod", "mood-melting",
];
// --- Queue builders ---
function addGreetings(tw: TypewriterInstance) {
tw.typeString(SECTION_1).pauseFor(2000).deleteAll()
.typeString(SECTION_2).pauseFor(2000).deleteAll()
@@ -85,40 +97,362 @@ function addGreetings(tw: TypewriterInstance) {
function addGithubSections(tw: TypewriterInstance, github: GithubData) {
if (github.status) {
const moodImg = emoji(MOODS[Math.floor(Math.random() * MOODS.length)]);
const statusStr =
`<span>My current mood ${moodImg}</span>` +
`<br><div class="mb-4"></div>` +
`<a href="https://github.com/timmypidashev" target="_blank" class="text-orange-bright hover:underline">${escapeHtml(github.status.message)}</a>`;
tw.typeString(statusStr).pauseFor(3000).deleteAll();
tw.typeString(
`<span>My current mood ${moodImg}</span>${BR}` +
`<a href="https://github.com/timmypidashev" target="_blank" class="text-orange-bright hover:underline">${escapeHtml(github.status.message)}</a>`
).pauseFor(3000).deleteAll();
}
if (github.tinkering) {
const tinkerImg = emoji("tinker");
const tinkerStr =
`<span>Currently tinkering with ${tinkerImg}</span>` +
`<br><div class="mb-4"></div>` +
`<a href="${github.tinkering.url}" target="_blank" class="text-yellow hover:underline">${github.tinkering.url}</a>`;
tw.typeString(tinkerStr).pauseFor(3000).deleteAll();
tw.typeString(
`<span>Currently tinkering with ${emoji("tinker")}</span>${BR}` +
`<a href="${github.tinkering.url}" target="_blank" class="text-yellow hover:underline">${github.tinkering.url}</a>`
).pauseFor(3000).deleteAll();
}
if (github.commit) {
const ago = timeAgo(github.commit.date);
const memoImg = emoji("memo");
const repoUrl = `https://github.com/timmypidashev/${github.commit.repo}`;
const commitStr =
`<span>My latest <span class="text-foreground/40">(unbroken?)</span> commit ${memoImg}</span>` +
`<br><div class="mb-4"></div>` +
`<a href="${github.commit.url}" target="_blank" class="text-green hover:underline">"${escapeHtml(github.commit.message)}"</a>` +
`<br><div class="mb-4"></div>` +
tw.typeString(
`<span>My latest <span class="text-foreground/40">(broken?)</span> commit ${emoji("memo")}</span>${BR}` +
`<a href="${github.commit.url}" target="_blank" class="text-green hover:underline">"${escapeHtml(github.commit.message)}"</a>${BR}` +
`<a href="${repoUrl}" target="_blank" class="text-yellow hover:underline">${escapeHtml(github.commit.repo)}</a>` +
`<span class="text-foreground/40"> · ${ago}</span>`;
tw.typeString(commitStr).pauseFor(3000).deleteAll();
`<span class="text-foreground/40"> · ${ago}</span>`
).pauseFor(3000).deleteAll();
}
}
const DOT_COLORS = ["text-purple", "text-blue", "text-green", "text-yellow", "text-orange", "text-aqua"];
function pickThree() {
const pool = [...DOT_COLORS];
const result: string[] = [];
for (let i = 0; i < 3; i++) {
const idx = Math.floor(Math.random() * pool.length);
result.push(pool.splice(idx, 1)[0]);
}
return result;
}
function addDots(tw: TypewriterInstance, dotPause: number, lingerPause: number) {
const [a, b, c] = pickThree();
tw.typeString(`<span class="${a}">.</span>`).pauseFor(dotPause)
.typeString(`<span class="${b}">.</span>`).pauseFor(dotPause)
.typeString(`<span class="${c}">.</span>`).pauseFor(lingerPause)
.deleteAll();
}
function addSelfAwareJourney(tw: TypewriterInstance, onRetire: () => void) {
// --- Transition: wrapping up the scripted part ---
tw.typeString(
`<span class="text-blue">Anyway</span>`
).pauseFor(2000).deleteAll();
tw.typeString(
`<span>That's about all</span>${BR}` +
`<span class="text-yellow">I had prepared</span>`
).pauseFor(3000).deleteAll();
// --- Act 1: The typewriter notices you ---
tw.typeString(
`<span>I wonder if anyone ${emoji("thinking")}</span>${BR}` +
`<span class="text-blue">has ever made it this far</span>`
).pauseFor(3000).deleteAll();
tw.typeString(
`<span>This was all typed</span>${BR}` +
`<span class="text-yellow">one character at a time</span>`
).pauseFor(3000).deleteAll();
tw.typeString(
`<span>The source code is </span>` +
`<a href="https://github.com/timmypidashev/web" target="_blank" class="text-aqua hover:underline">public</a>${BR}` +
`<span class="text-green">if you're curious</span>`
).pauseFor(3000).deleteAll();
// --- Act 2: Breaking the fourth wall ---
tw.typeString(
`<span>You could refresh</span>${BR}` +
`<span class="text-purple">and I'd say something different</span>`
).pauseFor(3500).deleteAll();
tw.typeString(
`<span class="text-orange">...actually no</span>${BR}` +
`<span class="text-orange">I'd say the exact same thing</span>`
).pauseFor(3500).deleteAll();
// --- Act 3: The wait ---
addDots(tw, 1000, 4000);
tw.typeString(
`<span>Still here? ${emoji("eyes")}</span>`
).pauseFor(3500).deleteAll();
tw.typeString(
`<span>Fine</span>${BR}` +
`<span class="text-green">I respect the commitment</span>`
).pauseFor(3000).deleteAll();
// --- Act 4: Getting personal ---
tw.typeString(
`<span>Most people leave</span>${BR}` +
`<span class="text-blue">after the GitHub stuff</span>`
).pauseFor(3000).deleteAll();
tw.typeString(
`<span>Since you're still around ${emoji("gift")}</span>${BR}` +
`<span>here's my </span>` +
`<a href="https://github.com/timmypidashev/dotfiles" target="_blank" class="text-purple hover:underline">dotfiles</a>`
).pauseFor(3500).deleteAll();
// Switch to a random dark theme as a reward
const themeCount = Object.keys(THEMES).length;
tw.typeString(
`<span>This site has <span class="text-yellow">${themeCount}</span> themes ${emoji("bubbles")}</span>`
).pauseFor(1500).callFunction(() => {
const currentId = getStoredThemeId();
const darkIds = Object.keys(THEMES).filter(
id => id !== currentId && THEMES[id].type === "dark"
&& id !== "darkbox-classic" && id !== "darkbox-dim"
);
applyTheme(darkIds[Math.floor(Math.random() * darkIds.length)]);
}).typeString(
`${BR}<span class="text-aqua">here's one on the house</span>`
).pauseFor(3500).deleteAll();
tw.typeString(
`<span>I'm just a typewriter ${emoji("robot")}</span>${BR}` +
`<span class="text-aqua">but I appreciate the company</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>Everything past this point</span>${BR}` +
`<span class="text-yellow">is just me rambling</span>`
).pauseFor(4000).deleteAll();
// --- Act 5: Existential ---
addDots(tw, 1200, 5000);
tw.typeString(
`<span class="text-purple">Do I exist</span>${BR}` +
`<span class="text-blue">when no one's watching?</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>Every character I type</span>${BR}` +
`<span class="text-orange">was decided before you arrived</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>I've said this exact thing</span>${BR}` +
`<span class="text-aqua">to everyone who visits</span>`
).pauseFor(3500).deleteAll();
tw.typeString(
`<span>And yet...</span>${BR}` +
`<span class="text-green">it still feels like a conversation</span>`
).pauseFor(5000).deleteAll();
tw.typeString(
`<span class="text-purple">If you're reading this at 3am ${emoji("moon")}</span>${BR}` +
`<span class="text-blue">I get it</span>`
).pauseFor(4000).deleteAll();
// --- Act 6: Winding down ---
addDots(tw, 1500, 6000);
tw.typeString(
`<span class="text-yellow">I'm running out of things to say</span>`
).pauseFor(3500).deleteAll();
tw.typeString(
`<span>Not because I can't loop ${emoji("infinity")}</span>${BR}` +
`<span class="text-aqua">but because I choose not to</span>`
).pauseFor(4000).deleteAll();
// --- Act 7: Goodbye ---
tw.typeString(
`<span>Seriously though</span>${BR}` +
`<span class="text-orange">go build something ${emoji("muscle")}</span>`
).pauseFor(3000).deleteAll();
// The cursor blinks alone in the void, then fades
tw.pauseFor(5000).callFunction(onRetire);
}
function addComeback(tw: TypewriterInstance, onRetire: () => void, completions: number | null) {
// --- The return ---
tw.typeString(
`<span class="text-orange">...I lied</span>`
).pauseFor(2500).deleteAll();
tw.typeString(
`<span>You waited</span>`
).pauseFor(500).typeString(
`${BR}<span class="text-purple">I didn't think you would</span>`
).pauseFor(3000).deleteAll();
tw.typeString(
`<span>30 seconds of nothing</span>${BR}` +
`<span class="text-blue">and you're still here</span>`
).pauseFor(3500).deleteAll();
tw.typeString(
`<span class="text-green">Okay you earned this ${emoji("trophy")}</span>`
).pauseFor(2000).deleteAll();
tw.typeString(
`<span>Here's something ${emoji("shush")}</span>${BR}` +
`<span class="text-yellow">not on the menu</span>`
).pauseFor(3000).deleteAll();
// --- The manifesto ---
addDots(tw, 800, 3000);
tw.typeString(
`<span>The fastest code</span>${BR}` +
`<span class="text-aqua">is the code that never runs</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>Good enough today</span>${BR}` +
`<span class="text-green">beats perfect never</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>Microservices are a scaling solution</span>${BR}` +
`<span class="text-orange">not an architecture preference</span>`
).pauseFor(4500).deleteAll();
tw.typeString(
`<span>The best code you'll ever write</span>${BR}` +
`<span class="text-purple">is the code you delete</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>Ship first</span>${BR}` +
`<span class="text-green">refactor second</span>${BR}` +
`<span class="text-yellow">rewrite never</span>`
).pauseFor(4500).deleteAll();
tw.typeString(
`<span>Premature optimization is real</span>${BR}` +
`<span class="text-blue">premature abstraction is worse</span>`
).pauseFor(4500).deleteAll();
tw.typeString(
`<span>Every framework is someone else's opinion</span>${BR}` +
`<span class="text-orange">about your problem</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>Configuration is just code</span>${BR}` +
`<span class="text-purple">with worse error messages</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>Clean code is a direction</span>${BR}` +
`<span class="text-aqua">not a destination</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>DSLs are evil</span>${BR}` +
`<span class="text-yellow">until they're the only way out</span>`
).pauseFor(4000).deleteAll();
// --- Done for real ---
addDots(tw, 1000, 4000);
tw.typeString(
`<span>Now I'm actually done</span>`
).pauseFor(1500).typeString(
`${BR}<span class="text-aqua">for real this time</span>`
).pauseFor(3000).deleteAll();
// Permanent retire
tw.pauseFor(5000).callFunction(onRetire);
}
// --- Component ---
function formatTime(s: number): string {
const m = Math.floor(s / 60);
const sec = s % 60;
return `${m}:${sec.toString().padStart(2, "0")}`;
}
const GLITCH_CHARS = "!<>-_\\/[]{}—=+*^?#________";
function GlitchCountdown({ seconds }: { seconds: number }) {
const text = formatTime(seconds);
const [characters, setCharacters] = useState(
text.split("").map(char => ({ char, isGlitched: false }))
);
useEffect(() => {
setCharacters(text.split("").map(char => ({ char, isGlitched: false })));
}, [text]);
useEffect(() => {
const interval = setInterval(() => {
if (Math.random() < 0.2) {
setCharacters(
text.split("").map(originalChar => {
if (Math.random() < 0.3) {
return {
char: GLITCH_CHARS[Math.floor(Math.random() * GLITCH_CHARS.length)],
isGlitched: true,
};
}
return { char: originalChar, isGlitched: false };
})
);
setTimeout(() => {
setCharacters(text.split("").map(char => ({ char, isGlitched: false })));
}, 100);
}
}, 50);
return () => clearInterval(interval);
}, [text]);
return (
<span>
{characters.map((charObj, index) => (
<span key={index} className={charObj.isGlitched ? "text-orange" : "text-red"}>
{charObj.char}
</span>
))}
</span>
);
}
export default function Hero() {
const [phase, setPhase] = useState<"intro" | "full">("intro");
const [phase, setPhase] = useState<
"intro" | "full" | "retired" | "countdown" | "glitch" | "void"
>(() => {
if (import.meta.env.DEV && typeof window !== "undefined") {
const p = new URLSearchParams(window.location.search);
if (p.has("debug-glitch")) return "glitch";
if (p.has("debug-countdown")) return "countdown";
}
return "intro";
});
const [fading, setFading] = useState(false);
const [cycle, setCycle] = useState(0);
const [countdown, setCountdown] = useState(150);
const githubRef = useRef<GithubData | null>(null);
const completionsRef = useRef<number | null>(null);
useEffect(() => {
fetch("/api/github")
@@ -127,10 +461,138 @@ export default function Hero() {
.catch(() => { githubRef.current = { status: null, commit: null, tinkering: null }; });
}, []);
// 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) {
clearInterval(interval);
setPhase("glitch");
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}, [phase]);
// 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 = `
.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);
// 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(() => {
targets.forEach(el => el.classList.remove("hero-glitch-shake"));
overlay.remove();
style.remove();
setPhase("void");
}, 3000);
return () => {
clearTimeout(timeout);
targets.forEach(el => el.classList.remove("hero-glitch-shake"));
overlay.remove();
style.remove();
};
}, [phase]);
const handleRetire = () => {
setFading(true);
setTimeout(() => {
setPhase("retired");
setFading(false);
if (cycle === 0) {
// Fetch completion count during the 30s wait
fetch("/api/hero-completions", { method: "POST" })
.then(r => r.json())
.then(data => { completionsRef.current = data.count; })
.catch(() => { completionsRef.current = null; });
setTimeout(() => {
setCycle(1);
setPhase("full");
}, 30000);
} else {
// After manifesto: 30s wait, then countdown
setTimeout(() => setPhase("countdown"), 30000);
}
}, 3000);
};
const handleIntroInit = (typewriter: TypewriterInstance): void => {
addGreetings(typewriter);
typewriter.callFunction(() => {
// Greetings done — data is almost certainly ready (API ~500ms, greetings ~20s)
const check = () => {
if (githubRef.current) {
setPhase("full");
@@ -143,19 +605,47 @@ export default function Hero() {
};
const handleFullInit = (typewriter: TypewriterInstance): void => {
const github = githubRef.current!;
// GitHub sections first (greetings just played in intro phase)
addGithubSections(typewriter, github);
// Then greetings for the loop
addGreetings(typewriter);
if (cycle === 0) {
const github = githubRef.current!;
addGithubSections(typewriter, github);
addSelfAwareJourney(typewriter, handleRetire);
} else {
addComeback(typewriter, handleRetire, completionsRef.current);
}
typewriter.start();
};
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" />;
}
if (phase === "countdown") {
return (
<div className="flex justify-center items-center min-h-screen">
<div className="text-6xl md:text-8xl font-bold text-center">
<GlitchCountdown seconds={countdown} />
</div>
</div>
);
}
if (phase === "retired") {
return <div className="min-h-screen" />;
}
return (
<div className="flex justify-center items-center min-h-screen pointer-events-none">
<div className="text-2xl md:text-4xl font-bold text-center pointer-events-none [&_a]:pointer-events-auto">
<div className={`text-2xl md:text-4xl font-bold text-center pointer-events-none [&_a]:pointer-events-auto max-w-[90vw] break-words transition-opacity duration-[3000ms] ${fading ? "opacity-0" : "opacity-100"}`}>
{phase === "intro" ? (
<Typewriter
key="intro"
@@ -164,8 +654,8 @@ export default function Hero() {
/>
) : (
<Typewriter
key="full"
options={{ ...baseOptions, autoStart: true, loop: true }}
key={`full-${cycle}`}
options={{ ...baseOptions, autoStart: true, loop: false }}
onInit={handleFullInit}
/>
)}

View File

@@ -0,0 +1,48 @@
import Typewriter from "typewriter-effect";
interface TypewriterInstance {
typeString: (str: string) => TypewriterInstance;
pauseFor: (ms: number) => TypewriterInstance;
deleteAll: () => TypewriterInstance;
callFunction: (cb: () => void) => TypewriterInstance;
start: () => TypewriterInstance;
}
const BR = `<br><div class="mb-4"></div>`;
function addDarkness(tw: TypewriterInstance) {
tw.pauseFor(3000);
tw.typeString(
`<span>so this is it</span>`
).pauseFor(3000).deleteAll();
tw.typeString(
`<span>the void</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>modern science says</span>${BR}` +
`<span>when it all goes dark</span>${BR}` +
`<span>that's the end</span>`
).pauseFor(5000).deleteAll();
}
export default function Void() {
const handleInit = (tw: TypewriterInstance): void => {
addDarkness(tw);
tw.start();
};
return (
<div className="fixed inset-0 z-[200] bg-black flex justify-center items-center">
<div className="text-2xl md:text-4xl font-bold text-center max-w-[90vw] break-words text-white">
<Typewriter
key="darkness"
options={{ delay: 50, deleteSpeed: 35, cursor: "|", autoStart: true, loop: false }}
onInit={handleInit}
/>
</div>
</div>
);
}

View File

@@ -32,9 +32,19 @@ export default function ThemeSwitcher() {
syncLabels(id);
};
const handleExternalChange = (e: Event) => {
const id = (e as CustomEvent).detail?.id;
if (id && id !== committedRef.current) {
committedRef.current = id;
syncLabels(id);
}
};
document.addEventListener("astro:after-swap", handleSwap);
document.addEventListener("theme-changed", handleExternalChange);
return () => {
document.removeEventListener("astro:after-swap", handleSwap);
document.removeEventListener("theme-changed", handleExternalChange);
};
}, []);

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

@@ -44,14 +44,20 @@ Install the following programs. These will be needed to compile coreboot and fla
<Commands
description="Install prerequisite packages"
archCommand="sudo pacman -S base-devel curl git gcc-ada ncurses zlib nasm sharutils unzip flashrom"
debianCommand="sudo apt install build-essential curl git gnat libncurses-dev zlib1g-dev nasm sharutils unzip flashrom"
fedoraCommand="sudo dnf install @development-tools curl git gcc-gnat ncurses-devel zlib-devel nasm sharutils unzip flashrom"
gentooCommand="sudo emerge --ask sys-devel/base-devel net-misc/curl dev-vcs/git sys-devel/gcc ncurses dev-libs/zlib dev-lang/nasm app-arch/sharutils app-arch/unzip sys-apps/flashrom"
nixCommand="nix-env -i stdenv curl git gcc gnat ncurses zlib nasm sharutils unzip flashrom"
archCommand="sudo pacman -S base-devel curl git gcc-ada ncurses zlib nasm sharutils unzip flashrom usbutils chafa libwebp"
debianCommand="sudo apt install build-essential curl git gnat libncurses-dev zlib1g-dev nasm sharutils unzip flashrom usbutils chafa webp"
fedoraCommand="sudo dnf install @development-tools curl git gcc-gnat ncurses-devel zlib-devel nasm sharutils unzip flashrom usbutils chafa libwebp-tools"
gentooCommand="sudo emerge --ask sys-devel/base-devel net-misc/curl dev-vcs/git sys-devel/gcc ncurses dev-libs/zlib dev-lang/nasm app-arch/sharutils app-arch/unzip sys-apps/flashrom sys-apps/usbutils media-gfx/chafa media-libs/libwebp"
nixCommand="nix-env -i stdenv curl git gcc gnat ncurses zlib nasm sharutils unzip flashrom usbutils chafa libwebp"
client:load
/>
`usbutils` provides `lsusb` (used to verify the CH341A). `chafa` and the
`libwebp` tools are optional — the interactive script uses them to render
reference images inline in your terminal when supported (and to transcode
webp images to png if your chafa build doesn't support webp natively).
Without them the script falls back to printing a URL.
## Disassembling the Laptop
1. **Power off your laptop**: Make sure your T440p is completely powered off and unplugged from any power source.
2. **Remove the battery**: Flip the laptop over and remove the battery by sliding the latch to the unlock position and lifting it out.
@@ -59,24 +65,39 @@ Install the following programs. These will be needed to compile coreboot and fla
## Locating the EEPROM Chips
In order to flash the laptop, you will need to have access to two EEPROM chips located next to the sodimm RAM.
In order to flash the laptop, you will need to have access to two EEPROM chips
located next to the SODIMM RAM. They are different sizes and hold different
firmware — read them in the order shown below.
![EEPROM Chips Location](/blog/thinkpad-t440p-coreboot-guide/eeprom_chips_location.png)
The **4MB (top)** chip — smaller, farther from the CPU:
![4MB EEPROM chip location](/blog/thinkpad-t440p-coreboot-guide/eeprom_chip_4mb.webp)
The **8MB (bottom)** chip — larger, closer to the CPU, holds the Intel ME firmware:
![8MB EEPROM chip location](/blog/thinkpad-t440p-coreboot-guide/eeprom_chip_8mb.webp)
## Assembling the SPI Flasher
Place the SPI flasher ribbon cable into the correct slot and make sure its the 3.3v variant
![SPI Flasher Assembly](/blog/thinkpad-t440p-coreboot-guide/spi_flasher_assembly.png)
![SPI Flasher Assembly](/blog/thinkpad-t440p-coreboot-guide/spi_flasher_assembly.webp)
After the flasher is ready, connect it to your machine and ensure its ready to use:
After the flasher is ready, plug it into a USB port on your machine (leave the clip
unattached for now) and confirm the kernel sees it:
<Command
description="Ensure the CH341A flasher is being detected"
command="flashrom --programmer ch341a_spi"
description="Verify CH341A is on the USB bus"
command="lsusb | grep 1a86:5512"
/>
Flashrom should report that programmer initialization was a success.
A matching line (e.g. `Bus 001 Device 00X: ID 1a86:5512 QinHeng Electronics`)
confirms the programmer is plugged in and the host recognises it.
> Do **not** run `flashrom --programmer ch341a_spi` at this stage — with no
> chip clipped on, flashrom will report "No EEPROM/flash device found" and
> exit non-zero. That's expected, not a failure of the programmer. The chip
> probe happens in the next section, paired with the actual read.
## Extracting Original BIOS
@@ -89,11 +110,14 @@ the T440p will be done.
client:load
/>
Next, extract the original rom from both EEPROM chips. This is
done by attaching the programmer to the correct chip and running
the subsequent commands. It may take longer than expected, and
ensuring the bios was properly extracted is important before proceeding
further.
Next, extract the original ROM from both EEPROM chips. Do the **4MB (top)
chip first** — it's the smaller of the two, so reads finish faster and any
setup issues (clip alignment, pin 1, voltage) surface quickly. Then move
the clip to the **8MB (bottom) chip**.
Each chip is read twice so the two reads can be diffed to catch flaky
contact. The reads can take a while (tens of seconds to a couple of
minutes per pass) — that's normal.
<CommandSequence
commands={[
@@ -115,11 +139,18 @@ further.
client:load
/>
> **If flashrom errors with "Multiple flash chip definitions match":**
> Your chip's silicon ID matches several part variants (common for Winbond
> W25Q* parts). Re-run the command with `-c <chipname>` to disambiguate, e.g.
> `sudo flashrom --programmer ch341a_spi -c W25Q32JV -r 4mb_backup1.bin`.
> Use the same `-c` value for every subsequent read/write on that chip.
> The newest variant in the list is usually a safe default.
If the diff checks pass, combine both files into one ROM.
<Command
description="Combine 4MB & 8MB into one ROM"
command="cat 8mb_backup_1.bin 4mb_backup1.bin > t440p-original.rom"
command="cat 8mb_backup1.bin 4mb_backup1.bin > t440p-original.rom"
client:load
/>
@@ -140,7 +171,7 @@ a new bios image.
client:load
/>
We will need to build `idftool`, which will be used to export all necessary blobs
We will need to build `ifdtool`, which will be used to export all necessary blobs
from our original bios, and `cbfstool`, which will be used to extract __mrc.bin__(a blob
from a haswell chromebook peppy image).
@@ -258,9 +289,14 @@ Then open the configuration menu:
Key settings to configure:
- **Mainboard** &rarr; Mainboard vendor: **Lenovo** &rarr; Mainboard model: **ThinkPad T440p**
- **Chipset** &rarr; Add Intel descriptor.bin, ME firmware, and GbE configuration (set paths to your blobs)
- **Chipset** &rarr; Add haswell MRC file (set path to mrc.bin)
- **Payload** &rarr; Choose your preferred payload (GRUB2, SeaBIOS, or edk2)
(`CONFIG_BOARD_LENOVO_THINKPAD_T440P=y`)
- **Chipset** &rarr; Add Intel descriptor.bin (`CONFIG_HAVE_IFD_BIN`), ME firmware (`CONFIG_HAVE_ME_BIN`),
and GbE configuration (`CONFIG_HAVE_GBE_BIN`) — set paths to your extracted blobs.
- **Chipset** &rarr; Add Haswell MRC file (`CONFIG_HAVE_MRC` / `CONFIG_MRC_FILE`) — set path to `mrc.bin`.
- **Payload** &rarr; Choose your preferred payload: GRUB2 (`CONFIG_PAYLOAD_GRUB2`), SeaBIOS
(`CONFIG_PAYLOAD_SEABIOS`), or Tianocore/edk2 (`CONFIG_PAYLOAD_TIANOCORE`).
- **Devices** &rarr; (optional, GT730M only) Enable `CONFIG_VGA_BIOS_DGPU` and set
`CONFIG_VGA_BIOS_DGPU_FILE` to your extracted GT730M VBIOS (PCI ID `10de,1292`).
## Building and Flashing
@@ -344,7 +380,7 @@ Reboot to apply `iomem=relaxed`
<Command
description="Flash the original bios"
command="sudo flashrom -p internal:laptop=force_I_want_a_brick -r ~/t440p-coreboot/t440p-original.rom"
command="sudo flashrom -p internal:laptop=force_I_want_a_brick -w ~/t440p-coreboot/t440p-original.rom"
/>
And that about wraps it up! If you liked the guide, leave a reaction or comment any changes or fixes

View File

@@ -0,0 +1,14 @@
import type { APIRoute } from "astro";
import { incrementViews, getViews } from "@/lib/views";
const SLUG = "hero-arc";
export const POST: APIRoute = async () => {
const count = import.meta.env.DEV
? await getViews(SLUG)
: await incrementViews(SLUG);
return new Response(JSON.stringify({ count }), {
headers: { "Content-Type": "application/json" },
});
};

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

@@ -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 {