Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
7d03a19
Add contact page with minimalist URL-based design
jakebodea Feb 10, 2026
294560e
Redesign contact page with shell command aesthetic
jakebodea Feb 10, 2026
8386c93
Add intro text back to contact page
jakebodea Feb 10, 2026
6b2b782
Add LinkedIn easter egg to contact page
jakebodea Feb 10, 2026
37a1389
Fix LinkedIn easter egg hover jitter
jakebodea Feb 10, 2026
c6cb3a5
Align easter egg comment indentation with $ prompts
jakebodea Feb 10, 2026
b5f026c
Add GitHub easter egg to contact page
jakebodea Feb 10, 2026
80f68c6
Extract ContactLink component to reuse easter egg logic
jakebodea Feb 10, 2026
5c1bade
Shorten GitHub easter egg message
jakebodea Feb 10, 2026
ea78716
Make easter egg comments clickable
jakebodea Feb 10, 2026
73ad85d
Style comments like active terminal text with blinking cursor
jakebodea Feb 10, 2026
138559b
Add character-by-character typing animation with block cursor
jakebodea Feb 10, 2026
afa8834
Make typing animation more char-by-char and snappy
jakebodea Feb 10, 2026
e8b4f70
Make easter egg comments quiet links
jakebodea Feb 10, 2026
76df6ca
Fix typing animation initial render and add randomized character timing
jakebodea Feb 10, 2026
bb299c2
Implement truly randomized typing animation
jakebodea Feb 10, 2026
c59eb59
Fix randomization - ensure truly unique animations each hover
jakebodea Feb 10, 2026
83ada3a
Implement truly variable typing animations
jakebodea Feb 10, 2026
edc9f5a
Fix animation width scaling - normalize increments to reach 100%
jakebodea Feb 10, 2026
7c57684
Remove debug console logs
jakebodea Feb 10, 2026
303eea1
Refactor keyframe generation to use Array.from and reduce
jakebodea Feb 10, 2026
08d37cc
Swap X and LinkedIn order, add 'no spam pls' easter egg to email
jakebodea Feb 10, 2026
158604b
Bump nav desktop/mobile breakpoint from md to lg to prevent title ove…
jakebodea Feb 10, 2026
e0c88dc
Fix unescaped apostrophes in contact page text
jakebodea Feb 10, 2026
0f56348
Add circular reveal animation for theme transitions
jakebodea Feb 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions app/contact/contact-link.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
@keyframes blink {
0%, 49% {
opacity: 1;
}
50%, 100% {
opacity: 0;
}
}

.cursor-block {
display: inline-block;
width: 0.6em;
height: 0.9em;
margin-left: 0.1em;
background-color: currentColor;
animation: blink 1s infinite;
}
128 changes: 128 additions & 0 deletions app/contact/contact-link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"use client"

import { useState, useEffect, useRef } from "react"
import Link from "next/link"
import { motion, AnimatePresence } from "framer-motion"
import "./contact-link.css"

function generateRandomKeyframes(): string {
const numSteps = Math.floor(Math.random() * 15) + 15
const increments = Array.from({ length: numSteps }, () => Math.random() * 4 + 1)
const total = increments.reduce((a, b) => a + b, 0)
const normalized = increments.map(inc => (inc / total) * 100)

let keyframes = "0% { width: 0; }"
let currentWidth = 0
normalized.forEach((increment, index) => {
currentWidth += increment
const keyframePercent = ((index + 1) / numSteps) * 100
keyframes += ` ${keyframePercent.toFixed(1)}% { width: ${currentWidth.toFixed(1)}%; }`
})

return keyframes
}

interface ContactLinkProps {
url: string
display: string
easterEgg?: string
}

export function ContactLink({ url, display, easterEgg }: ContactLinkProps) {
const [isHovered, setIsHovered] = useState(false)
const styleRef = useRef<HTMLStyleElement | null>(null)
const animationIdRef = useRef<string>("")

useEffect(() => {
if (isHovered && easterEgg) {
const animationId = `typing-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
animationIdRef.current = animationId

const keyframes = generateRandomKeyframes()
const css = `@keyframes ${animationId} { ${keyframes} }`

// Remove old style element if it exists
if (styleRef.current) {
styleRef.current.remove()
}

// Create new style element each time
styleRef.current = document.createElement("style")
styleRef.current.textContent = css
document.head.appendChild(styleRef.current)
}

return () => {
// Cleanup on unmount
if (styleRef.current) {
styleRef.current.remove()
styleRef.current = null
}
}
}, [isHovered, easterEgg])

return (
<div
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="flex items-center gap-2">
<span className="text-accent/60">$</span>
<Link
href={url}
target="_blank"
rel="noopener noreferrer"
className="group text-muted-foreground hover:text-accent transition-colors"
>
<span className="text-accent/70 group-hover:text-accent">open</span>
<span className="ml-2 group-hover:underline">{display}</span>
</Link>
</div>
<AnimatePresence>
{isHovered && easterEgg && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<Link
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 py-1 text-muted-foreground/70 text-xs font-mono"
>
<span>#</span>
<span className="flex items-center">
<motion.span
initial={{ width: 0 }}
animate={{ width: "auto" }}
transition={{
duration: 0.6,
ease: "linear",
}}
className="inline-block overflow-hidden whitespace-nowrap"
style={{
animation: isHovered && easterEgg ? `${animationIdRef.current} 0.6s linear forwards` : "none",
}}
>
{easterEgg}
</motion.span>
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.1,
delay: 0.1,
}}
className="cursor-block"
/>
</span>
</Link>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
54 changes: 54 additions & 0 deletions app/contact/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"use client"

import { PageTitle } from "@/components/layout/page-title"
import { ContactLink } from "./contact-link"

const contactLinks = [
{
url: "mailto:jakebodea@gmail.com",
display: "jakebodea@gmail.com",
easterEgg: "no spam pls"
},
{
url: "https://x.com/jakebodea",
display: "x.com/jakebodea"
},
{
url: "https://www.linkedin.com/in/jakebodea/",
display: "linkedin.com/in/jakebodea",
easterEgg: "unfortunately i still need this platform"
},
{
url: "https://github.com/jakebodea",
display: "github.com/jakebodea",
easterEgg: "don't DM me here but check out my commit history map"
}
]

export default function ContactPage() {

return (
<div className="min-h-[calc(100vh-3.5rem)] flex items-center">
<div className="container max-w-2xl mx-auto px-6 py-12">
<PageTitle>contact</PageTitle>

<p className="text-lg md:text-xl text-muted-foreground leading-relaxed mb-12">
i&apos;m always interested in connecting. here&apos;s where you can find me:
</p>

<div className="rounded-lg border border-accent/10 bg-muted/20 p-4 font-mono text-sm">
<div className="space-y-2">
{contactLinks.map((link) => (
<ContactLink
key={link.url}
url={link.url}
display={link.display}
easterEgg={link.easterEgg}
/>
))}
</div>
</div>
</div>
</div>
)
}
27 changes: 27 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,33 @@
}
}

/* Theme transition - circular reveal from toggle button */
@property --reveal-size {
syntax: "<length>";
initial-value: 0px;
inherits: false;
}

::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}

::view-transition-old(root) {
z-index: 1;
}

::view-transition-new(root) {
z-index: 9999;
mask: radial-gradient(
circle at var(--reveal-x, 50%) var(--reveal-y, 50%),
black 0,
black calc(var(--reveal-size) - 160px),
transparent var(--reveal-size)
);
}

@layer base {
* {
@apply border-border;
Expand Down
1 change: 0 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ export default function RootLayout({
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>
<NavigationProvider>
{process.env.VERCEL_GIT_COMMIT_REF === "dev" && (
Expand Down
2 changes: 1 addition & 1 deletion components/layout/mobile-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export function MobileMenu({ navItems, isOpen, onClose }: MobileMenuProps) {
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { duration: 0.15 } }}
exit={{ opacity: 0, transition: { duration: 0.1, delay: 0.1 } }}
className="fixed inset-0 z-[70] bg-background md:hidden"
className="fixed inset-0 z-[70] bg-background lg:hidden"
>
{/* Spacer for nav header */}
<div className="h-14" />
Expand Down
2 changes: 1 addition & 1 deletion components/layout/page-transition.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ function useIsMobile() {
const [isMobile, setIsMobile] = useState(false)

useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768)
const check = () => setIsMobile(window.innerWidth < 1024)
check()
window.addEventListener("resize", check)
return () => window.removeEventListener("resize", check)
Expand Down
88 changes: 82 additions & 6 deletions components/layout/theme-toggle.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client"

import { useState, useEffect, useRef } from "react"
import { useState, useEffect, useRef, useCallback } from "react"
import { flushSync } from "react-dom"
import { useTheme } from "next-themes"
import { AnimatePresence, motion } from "framer-motion"
import { Moon, Sun, Monitor } from "lucide-react"
Expand Down Expand Up @@ -46,26 +47,96 @@ export function ThemeToggle({
const [open, setOpen] = useState(false)
const [mounted, setMounted] = useState(false)
const leaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const triggerRectRef = useRef<DOMRect | null>(null)

// Capture button position on pointer down, before dropdown interaction moves things
const capturePosition = useCallback(() => {
if (triggerRef.current) {
triggerRectRef.current = triggerRef.current.getBoundingClientRect()
}
}, [])

const setThemeWithTransition = useCallback(
(newTheme: string) => {
const supportsViewTransition =
typeof document !== "undefined" &&
"startViewTransition" in document

if (!supportsViewTransition) {
setTheme(newTheme)
return
}

// Use captured position, fall back to live measurement
const rect =
triggerRectRef.current ??
triggerRef.current?.getBoundingClientRect()

if (!rect || (rect.x === 0 && rect.y === 0 && rect.width === 0)) {
setTheme(newTheme)
return
}

const x = rect.left + rect.width / 2
const y = rect.top + rect.height / 2
const endRadius = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y),
)

// Set the circle origin for the CSS mask
const root = document.documentElement
root.style.setProperty("--reveal-x", `${x}px`)
root.style.setProperty("--reveal-y", `${y}px`)

const transition = (document as any).startViewTransition(() => {
flushSync(() => {
setTheme(newTheme)
})
})

transition.ready
.then(() => {
root.animate(
{ "--reveal-size": [`0px`, `${endRadius + 80}px`] },
{
duration: 350,
easing: "ease-out",
fill: "forwards",
pseudoElement: "::view-transition-new(root)",
},
)
})
.catch(() => {})

// Clear captured position after use
triggerRectRef.current = null
},
[setTheme],
)

useEffect(() => {
setMounted(true)
}, [])

// 't' keyboard shortcut for quick toggle
// 't' keyboard shortcut for quick toggle (only one instance registers)
useEffect(() => {
if (!shortcut) return

const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") return

if (e.key === "t") {
setTheme(resolvedTheme === "dark" ? "light" : "dark")
setThemeWithTransition(resolvedTheme === "dark" ? "light" : "dark")
setOpen(false)
}
}

window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [resolvedTheme, setTheme])
}, [shortcut, resolvedTheme, setThemeWithTransition])

// Cleanup leave timer on unmount
useEffect(() => {
Expand All @@ -91,9 +162,14 @@ export function ThemeToggle({
const trigger = (
<DropdownMenuTrigger asChild>
<button
ref={triggerRef}
className="p-2 text-muted-foreground hover:text-foreground transition-colors rounded-md"
aria-label="Toggle theme"
onPointerEnter={handlePointerEnter}
onPointerDown={capturePosition}
onPointerEnter={(e) => {
capturePosition()
handlePointerEnter(e)
}}
onPointerLeave={handlePointerLeave}
>
<AnimatePresence mode="wait">
Expand Down Expand Up @@ -132,7 +208,7 @@ export function ThemeToggle({
return (
<DropdownMenuItem
key={option.value}
onSelect={() => setTheme(option.value)}
onSelect={() => setThemeWithTransition(option.value)}
className={
isActive
? "text-accent focus:text-accent bg-accent/10 focus:bg-accent/10"
Expand Down
Loading