8 Commits

Author SHA1 Message Date
Jonathan f01d6fbf8d Fix hero SVG overflow clipping in animation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 21:17:51 +02:00
Jonathan 1d92b375ea Remove overflow-hidden from hero background wrapper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 23:00:01 +01:00
Jonathan 8cbf3d42e0 Move overflow-hidden off section onto SVG wrapper
overflow-hidden on the section was clipping the section's own content.
Wrapping just the SVG in an absolute overflow-hidden div contains the
background text without affecting the hero content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:58:46 +01:00
Jonathan a2183b13ce Fix hero name animation: z-index and font size
- Add `isolate` to section + `-z-10` to SVG so background text renders
  behind in-flow content (abs-positioned elements paint over in-flow
  by default without an explicit stacking context)
- Reduce fontSize 22vw → 15vw so "Jonathan" fits within max-w-5xl
  without clipping

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:56:50 +01:00
Jonathan 607e378eb9 Add hero name fill animation
SVG text background of "Jonathan" in outline that slowly fills in on
page load. Uses CSS fill-opacity animation (0 → 1 over 2s, 0.3s delay)
so the stroke appears immediately and the fill bleeds in gradually.

Light mode: zinc-200/300. Dark mode: zinc-800. Both stay recessive so
the effect is atmospheric, not distracting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:53:00 +01:00
Jonathan 8fb170c5e4 Add git workflow and tidy CLAUDE.md
Document development/main branch strategy. Remove stale note about
components/ directory not existing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:43:34 +01:00
Jonathan 6b698c5f58 Build portfolio site with Next.js, Tailwind CSS, and dark mode
- Add CLAUDE.md with project conventions and architecture notes
- Create Navbar (sticky, backdrop blur, mobile menu) and Footer
- Build homepage sections: Hero, FeaturedProjects, Skills, CurrentWork, CallToAction
- Add /projects, /about, and /contact pages
- Create reusable ProjectCard and Badge components
- Centralise project data in content/projects.json with featured flag
- Add class-based dark mode toggle (default dark, persisted to localStorage)
- Refactor globals.css: remove conflicting prefers-color-scheme media query
- Fix two-instance ThemeToggle sync bug in Navbar
- Fix key={index} anti-pattern in timeline, stale closure in setOpen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:34:41 +01:00
Jonathan 4fa9f280e6 Initial commit from Create Next App 2026-03-26 22:34:41 +01:00
33 changed files with 7489 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+43
View File
@@ -0,0 +1,43 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
@AGENTS.md
## Commands
```bash
npm run dev # Start development server
npm run build # Production build
npm run start # Start production server
npm run lint # Run ESLint
```
No test runner is configured.
## Stack
- **Next.js 16** with App Router — read `node_modules/next/dist/docs/` before writing any Next.js code; APIs may differ from training data
- **Tailwind CSS v4** — configured via PostCSS (`postcss.config.mjs`); no `tailwind.config.*` file; theme tokens defined in `app/globals.css` using `@theme inline`
- **TypeScript** — strict mode, path alias `@/*` maps to project root
## Architecture
This is a portfolio site built with the Next.js App Router. All routes live under `app/`. Global styles and theme variables are in `app/globals.css`. Fonts (Geist Sans/Mono) are loaded in `app/layout.tsx` and injected as CSS variables.
Reusable UI components live in `components/`. Project data (e.g. `projects.json`) lives in `content/`.
## Git Workflow
- **`development`** is the working branch — all new work starts here
- **`main`** is deployment-only — never commit directly to `main`
- Merge `development``main` only when code is ready to deploy
## Project Rules
- Functional components only — no class components
- All styling via Tailwind utility classes — no CSS modules or inline styles
- Dark mode by default; use Tailwind dark-mode utilities
- Keep components small and single-purpose
- Prefer readability over cleverness
- Do not add features unless explicitly asked
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+116
View File
@@ -0,0 +1,116 @@
import Badge from '@/components/Badge'
const skills = [
{
category: 'Languages',
items: ['TypeScript', 'JavaScript', 'Go', 'Python', 'SQL', 'Bash'],
},
{
category: 'Frontend',
items: ['React', 'Next.js', 'Tailwind CSS', 'Radix UI', 'Framer Motion'],
},
{
category: 'Backend',
items: ['Node.js', 'PostgreSQL', 'Redis', 'REST', 'GraphQL', 'tRPC'],
},
{
category: 'Tooling',
items: ['Docker', 'Git', 'GitHub Actions', 'Vercel', 'Linux'],
},
{
category: 'Focus Areas',
items: ['Web Performance', 'Developer Experience', 'API Design', 'UI Engineering', 'Accessibility'],
},
]
const timeline = [
{
year: '2024',
title: 'Senior Engineer — Acme Corp',
description:
'Leading frontend infrastructure and design systems. Building core platform features used by millions of users.',
},
{
year: '2022',
title: 'Software Engineer — Startup XYZ',
description:
'Full-stack product work across a SaaS platform. Owned the billing system, onboarding flow, and internal tooling.',
},
{
year: '2021',
title: 'Open Source — Deploy CLI',
description:
'Built and published an open-source CLI tool in Go for automating local dev environment setup.',
},
{
year: '2020',
title: 'Software Engineer — Agency Co.',
description:
'Client work spanning e-commerce, marketing sites, and web apps. Introduced TypeScript and component-driven development.',
},
{
year: '2019',
title: 'BSc Computer Science',
description: 'Graduated with a focus on distributed systems and human-computer interaction.',
},
]
export default function AboutPage() {
return (
<div className="mx-auto max-w-3xl px-6 py-16">
{/* Intro */}
<section className="mb-16">
<h1 className="mb-6 text-3xl font-semibold text-zinc-900 dark:text-white">About</h1>
<p className="text-base leading-7 text-zinc-600 dark:text-zinc-400">
I&apos;m a software engineer with a focus on the web building products
that are fast, accessible, and well-crafted. I care about the details:
clean APIs, readable code, and interfaces that feel good to use. Outside
of work I contribute to open source, write about things I&apos;m learning,
and tinker with side projects.
</p>
</section>
{/* Skills */}
<section className="mb-16">
<h2 className="mb-8 text-xl font-semibold text-zinc-900 dark:text-white">Skills</h2>
<dl className="flex flex-col gap-6">
{skills.map(({ category, items }) => (
<div key={category} className="flex flex-col gap-3 sm:flex-row sm:gap-8">
<dt className="w-32 shrink-0 pt-0.5 text-sm text-zinc-500">{category}</dt>
<dd>
<ul className="flex flex-wrap gap-2">
{items.map((item) => (
<li key={item}>
<Badge label={item} />
</li>
))}
</ul>
</dd>
</div>
))}
</dl>
</section>
{/* Timeline */}
<section>
<h2 className="mb-8 text-xl font-semibold text-zinc-900 dark:text-white">Timeline</h2>
<ol className="flex flex-col">
{timeline.map(({ year, title, description }) => (
<li key={year} className="flex gap-8 pb-10 last:pb-0">
<div className="flex flex-col items-center">
<span className="font-mono text-xs text-zinc-500">{year}</span>
<div className="mt-2 w-px flex-1 bg-zinc-200 dark:bg-zinc-800" />
</div>
<div className="pb-2 pt-0.5">
<p className="text-sm font-medium text-zinc-900 dark:text-white">{title}</p>
<p className="mt-1.5 text-sm leading-6 text-zinc-600 dark:text-zinc-400">{description}</p>
</div>
</li>
))}
</ol>
</section>
</div>
)
}
+37
View File
@@ -0,0 +1,37 @@
const links = [
{
label: 'Email',
value: 'jonathan@example.com',
href: 'mailto:jonathan@example.com',
},
{
label: 'GitHub',
value: 'github.com/jonathan',
href: 'https://github.com/jonathan',
},
]
export default function ContactPage() {
return (
<div className="mx-auto max-w-3xl px-6 py-16">
<h1 className="mb-3 text-3xl font-semibold text-zinc-900 dark:text-white">Contact</h1>
<p className="mb-12 text-sm text-zinc-600 dark:text-zinc-400">
The best way to reach me is by email. I&apos;m also on GitHub.
</p>
<ul className="divide-y divide-zinc-200 border-y border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{links.map(({ label, value, href }) => (
<li key={label} className="flex flex-col gap-1 py-5 sm:flex-row sm:items-center sm:gap-8">
<span className="w-24 shrink-0 text-sm text-zinc-500">{label}</span>
<a
href={href}
className="text-sm text-zinc-700 transition-colors hover:text-zinc-900 dark:text-zinc-300 dark:hover:text-white"
{...(href.startsWith('http') ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
>
{value}
</a>
</li>
))}
</ul>
</div>
)
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+28
View File
@@ -0,0 +1,28 @@
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
body {
font-family: var(--font-sans);
}
@keyframes name-fill-in {
from { fill-opacity: 0; }
to { fill-opacity: 1; }
}
.name-outline-fill {
fill: #e4e4e7; /* zinc-200 */
stroke: #d4d4d8; /* zinc-300 */
fill-opacity: 0;
animation: name-fill-in 2s cubic-bezier(0.4, 0, 0.2, 1) 0.3s forwards;
}
.dark .name-outline-fill {
fill: #27272a; /* zinc-800 */
stroke: #27272a; /* zinc-800 */
}
+56
View File
@@ -0,0 +1,56 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Navbar from "@/components/Navbar";
import Footer from "@/components/Footer";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "jonathan.dev",
description: "Personal portfolio",
};
// Runs before first paint — restores saved theme, defaults to dark
const themeScript = `
(function() {
try {
if (localStorage.getItem('theme') === 'light') {
document.documentElement.classList.remove('dark');
} else {
document.documentElement.classList.add('dark');
}
} catch {}
})();
`;
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} dark h-full antialiased`}
suppressHydrationWarning
>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body className="flex min-h-full flex-col bg-white text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
<Navbar />
<main className="flex-1">{children}</main>
<Footer />
</body>
</html>
);
}
+17
View File
@@ -0,0 +1,17 @@
import Hero from '@/components/Hero'
import FeaturedProjects from '@/components/FeaturedProjects'
import Skills from '@/components/Skills'
import CurrentWork from '@/components/CurrentWork'
import CallToAction from '@/components/CallToAction'
export default function Home() {
return (
<>
<Hero />
<FeaturedProjects />
<Skills />
<CurrentWork />
<CallToAction />
</>
)
}
+20
View File
@@ -0,0 +1,20 @@
import ProjectCard from '@/components/ProjectCard'
import projects from '@/content/projects.json'
export default function ProjectsPage() {
return (
<div className="mx-auto max-w-5xl px-6 py-16">
<h1 className="mb-2 text-3xl font-semibold text-zinc-900 dark:text-white">Projects</h1>
<p className="mb-10 text-sm text-zinc-600 dark:text-zinc-400">
A selection of things I&apos;ve built.
</p>
<ul className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<li key={project.title}>
<ProjectCard {...project} />
</li>
))}
</ul>
</div>
)
}
+11
View File
@@ -0,0 +1,11 @@
type BadgeProps = {
label: string
}
export default function Badge({ label }: BadgeProps) {
return (
<span className="rounded-sm border border-zinc-200 bg-zinc-100 px-2.5 py-1 font-mono text-xs text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800/50 dark:text-zinc-300">
{label}
</span>
)
}
+24
View File
@@ -0,0 +1,24 @@
import Link from 'next/link'
export default function CallToAction() {
return (
<section className="mx-auto max-w-5xl px-6 py-24 text-center">
<p className="font-mono text-xs uppercase tracking-[0.2em] text-zinc-500">
Get in touch
</p>
<h2 className="mt-4 text-3xl font-semibold text-zinc-900 dark:text-white">
Let&apos;s work together.
</h2>
<p className="mx-auto mt-4 max-w-sm text-sm leading-6 text-zinc-600 dark:text-zinc-400">
Open to freelance projects, full-time roles, and interesting
collaborations. I&apos;d love to hear what you&apos;re building.
</p>
<Link
href="/contact"
className="mt-8 inline-block rounded-sm bg-zinc-900 px-8 py-3 text-sm font-semibold text-white transition-colors hover:bg-zinc-700 dark:bg-white dark:text-zinc-950 dark:hover:bg-zinc-200"
>
Say Hello
</Link>
</section>
)
}
+34
View File
@@ -0,0 +1,34 @@
const items = [
{
title: 'Portfolio site',
description: 'Designing and building this site with Next.js and Tailwind CSS.',
},
{
title: 'Open-source CLI tool',
description: 'A developer utility written in Go for automating local environments.',
},
{
title: 'Design system',
description: 'A component library for internal use — exploring composability patterns.',
},
{
title: 'Learning Rust',
description: 'Working through systems programming concepts and small weekend projects.',
},
]
export default function CurrentWork() {
return (
<section className="mx-auto max-w-5xl px-6 py-16">
<h2 className="mb-8 text-2xl font-semibold text-zinc-900 dark:text-white">What I&apos;m Working On</h2>
<ul className="divide-y divide-zinc-200 border-y border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{items.map(({ title, description }) => (
<li key={title} className="flex flex-col gap-1 py-5 transition-colors hover:text-zinc-900 dark:hover:text-white sm:flex-row sm:gap-8">
<span className="w-48 shrink-0 text-sm font-medium text-zinc-900 dark:text-white">{title}</span>
<span className="text-sm text-zinc-600 dark:text-zinc-400">{description}</span>
</li>
))}
</ul>
</section>
)
}
+19
View File
@@ -0,0 +1,19 @@
import ProjectCard from '@/components/ProjectCard'
import projects from '@/content/projects.json'
const featured = projects.filter((p) => p.featured)
export default function FeaturedProjects() {
return (
<section className="mx-auto max-w-5xl px-6 py-16">
<h2 className="mb-8 text-2xl font-semibold text-zinc-900 dark:text-white">Featured Projects</h2>
<ul className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{featured.map((project) => (
<li key={project.title}>
<ProjectCard {...project} />
</li>
))}
</ul>
</section>
)
}
+9
View File
@@ -0,0 +1,9 @@
export default function Footer() {
return (
<footer className="border-t border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
<div className="mx-auto max-w-5xl px-6 py-8 text-center text-sm text-zinc-500">
&copy; {new Date().getFullYear()} jonathan.dev
</div>
</footer>
)
}
+68
View File
@@ -0,0 +1,68 @@
import Link from 'next/link'
export default function Hero() {
return (
<section className="relative isolate mx-auto flex min-h-[72vh] max-w-5xl flex-col items-center justify-center px-6 py-32 text-center">
{/* Background name — outline fills in on load */}
<div className="pointer-events-none absolute inset-0 -z-10">
<svg
aria-hidden="true"
className="h-full w-full overflow-visible"
preserveAspectRatio="xMidYMid meet"
>
<text
x="50%"
y="55%"
textAnchor="middle"
dominantBaseline="middle"
fontFamily="var(--font-geist-sans), sans-serif"
fontSize="15vw"
fontWeight="700"
strokeWidth="1"
className="name-outline-fill"
>
Jonathan
</text>
</svg>
</div>
{/* Eyebrow label */}
<div className="mb-10 flex items-center gap-4">
<span className="h-px w-10 bg-zinc-300 dark:bg-zinc-700" />
<span className="font-mono text-xs uppercase tracking-[0.2em] text-zinc-500">
Software Engineer
</span>
<span className="h-px w-10 bg-zinc-300 dark:bg-zinc-700" />
</div>
{/* Name */}
<h1 className="text-7xl font-bold tracking-tight text-zinc-900 dark:text-white sm:text-8xl">
Jonathan
</h1>
{/* Tagline */}
<p className="mt-6 max-w-md text-base leading-7 text-zinc-600 dark:text-zinc-400">
I build clean, fast web applications focused on great user
experiences and solid engineering.
</p>
{/* Actions */}
<div className="mt-12 flex flex-wrap items-center justify-center gap-4">
<Link
href="/projects"
className="rounded-sm bg-zinc-900 px-8 py-3 text-sm font-semibold text-white transition-colors hover:bg-zinc-700 dark:bg-white dark:text-zinc-950 dark:hover:bg-zinc-200"
>
View Projects
</Link>
<Link
href="/contact"
className="rounded-sm border border-zinc-300 px-8 py-3 text-sm font-medium text-zinc-700 transition-colors hover:border-zinc-400 hover:bg-zinc-100 hover:text-zinc-900 dark:border-zinc-700 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-900 dark:hover:text-white"
>
Contact Me
</Link>
</div>
</section>
)
}
+73
View File
@@ -0,0 +1,73 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import ThemeToggle from '@/components/ThemeToggle'
const links = [
{ href: '/', label: 'Home' },
{ href: '/projects', label: 'Projects' },
{ href: '/about', label: 'About' },
{ href: '/contact', label: 'Contact' },
]
export default function Navbar() {
const [open, setOpen] = useState(false)
return (
<header className="sticky top-0 z-50 border-b border-zinc-200 bg-white/90 backdrop-blur-sm dark:border-zinc-800 dark:bg-zinc-950/90">
<nav className="mx-auto flex max-w-5xl items-center justify-between px-6 py-5">
<Link href="/" className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-white">
jonathan.dev
</Link>
<div className="flex items-center gap-3">
{/* Desktop links */}
<ul className="mr-5 hidden gap-8 sm:flex">
{links.map(({ href, label }) => (
<li key={href}>
<Link
href={href}
className="text-sm text-zinc-500 transition-colors hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white"
>
{label}
</Link>
</li>
))}
</ul>
{/* Single toggle instance — visible on both breakpoints */}
<ThemeToggle />
{/* Mobile hamburger */}
<button
className="flex flex-col gap-1.5 sm:hidden"
onClick={() => setOpen((o) => !o)}
aria-label="Toggle menu"
>
<span className={`block h-px w-6 bg-zinc-500 transition-transform dark:bg-zinc-400 ${open ? 'translate-y-2 rotate-45' : ''}`} />
<span className={`block h-px w-6 bg-zinc-500 transition-opacity dark:bg-zinc-400 ${open ? 'opacity-0' : ''}`} />
<span className={`block h-px w-6 bg-zinc-500 transition-transform dark:bg-zinc-400 ${open ? '-translate-y-2 -rotate-45' : ''}`} />
</button>
</div>
</nav>
{/* Mobile menu */}
{open && (
<ul className="flex flex-col border-t border-zinc-200 px-6 py-4 dark:border-zinc-800 sm:hidden">
{links.map(({ href, label }) => (
<li key={href}>
<Link
href={href}
className="block py-2 text-sm text-zinc-500 transition-colors hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white"
onClick={() => setOpen(false)}
>
{label}
</Link>
</li>
))}
</ul>
)}
</header>
)
}
+38
View File
@@ -0,0 +1,38 @@
import Link from 'next/link'
type ProjectCardProps = {
title: string
description: string
stack: string[]
href?: string
}
export default function ProjectCard({ title, description, stack, href }: ProjectCardProps) {
return (
<div className="group flex h-full flex-col rounded-lg border border-zinc-200 bg-zinc-50 p-6 shadow-sm shadow-black/5 transition-all hover:border-zinc-300 hover:shadow-md hover:shadow-black/10 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-black/40 dark:hover:border-zinc-600 dark:hover:shadow-black/50">
<h3 className="font-medium text-zinc-900 dark:text-white">{title}</h3>
<p className="mt-2 flex-1 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">{description}</p>
<ul className="mt-5 flex flex-wrap gap-2">
{stack.map((tech) => (
<li
key={tech}
className="rounded-sm bg-zinc-200 px-2.5 py-1 font-mono text-xs text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"
>
{tech}
</li>
))}
</ul>
{href && (
<Link
href={href}
className="mt-6 inline-flex items-center gap-1.5 text-sm text-zinc-500 transition-colors hover:text-zinc-900 dark:hover:text-white"
>
View project
<span className="transition-transform group-hover:translate-x-0.5">&rarr;</span>
</Link>
)}
</div>
)
}
+40
View File
@@ -0,0 +1,40 @@
import Badge from '@/components/Badge'
const skills = [
{
category: 'Programming',
items: ['TypeScript', 'JavaScript', 'Go', 'Python', 'SQL'],
},
{
category: 'Tools',
items: ['React', 'Next.js', 'Tailwind CSS', 'Node.js', 'PostgreSQL', 'Docker', 'Git'],
},
{
category: 'Focus Areas',
items: ['Web Performance', 'Developer Experience', 'API Design', 'UI Engineering'],
},
]
export default function Skills() {
return (
<section className="mx-auto max-w-5xl px-6 py-16">
<h2 className="mb-8 text-2xl font-semibold text-zinc-900 dark:text-white">Skills</h2>
<dl className="flex flex-col gap-6">
{skills.map(({ category, items }) => (
<div key={category} className="flex flex-col gap-3 sm:flex-row sm:gap-8">
<dt className="w-36 shrink-0 pt-0.5 text-sm text-zinc-500">{category}</dt>
<dd>
<ul className="flex flex-wrap gap-2">
{items.map((item) => (
<li key={item}>
<Badge label={item} />
</li>
))}
</ul>
</dd>
</div>
))}
</dl>
</section>
)
}
+44
View File
@@ -0,0 +1,44 @@
'use client'
import { useState } from 'react'
function isDark() {
if (typeof window === 'undefined') return true
return document.documentElement.classList.contains('dark')
}
export default function ThemeToggle() {
const [dark, setDark] = useState(isDark)
function toggle() {
const next = !dark
setDark(next)
document.documentElement.classList.toggle('dark', next)
try {
localStorage.setItem('theme', next ? 'dark' : 'light')
} catch {
// ignore
}
}
return (
<button
onClick={toggle}
aria-label="Toggle theme"
className="flex h-8 w-8 items-center justify-center rounded-sm text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
>
{dark ? (
// Sun — switch to light
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
) : (
// Moon — switch to dark
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
)}
</button>
)
}
+40
View File
@@ -0,0 +1,40 @@
[
{
"title": "Portfolio Site",
"description": "This site — a personal portfolio built with Next.js and Tailwind CSS. Focused on clean design and fast load times.",
"stack": ["Next.js", "TypeScript", "Tailwind CSS"],
"href": "#",
"featured": true
},
{
"title": "Deploy CLI",
"description": "An open-source command-line tool written in Go for automating local environment setup and deployments.",
"stack": ["Go", "Docker", "CI/CD"],
"href": "#",
"featured": true
},
{
"title": "Metrics Dashboard",
"description": "A real-time dashboard for monitoring system health and performance metrics across distributed services.",
"stack": ["React", "Node.js", "WebSockets", "Redis"],
"href": "#",
"featured": true
},
{
"title": "Auth Service",
"description": "A standalone authentication service supporting OAuth2, magic links, and session management.",
"stack": ["TypeScript", "PostgreSQL", "REST"]
},
{
"title": "Component Library",
"description": "An internal design system and component library exploring composability and accessibility patterns.",
"stack": ["React", "TypeScript", "Storybook"],
"href": "#"
},
{
"title": "RSS Reader",
"description": "A minimal feed reader with a clean reading mode, keyboard navigation, and offline support via service workers.",
"stack": ["Next.js", "SQLite", "PWA"],
"href": "#"
}
]
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+6593
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "portfolio",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.2.1",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.1",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}